mirror of
https://github.com/openclassify/openclassify.git
synced 2026-04-17 20:52:12 -05:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
239fd0d2bf | ||
|
|
f06943ce9d | ||
|
|
057620b715 |
@ -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 {}
|
||||
}
|
||||
@ -11,9 +11,11 @@ use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Str;
|
||||
use Modules\Category\Models\Category;
|
||||
use Modules\Conversation\App\Models\Conversation;
|
||||
use Modules\Listing\States\ListingStatus;
|
||||
use Modules\Listing\Support\ListingImageViewData;
|
||||
use Modules\Listing\Support\ListingPanelHelper;
|
||||
use Modules\Site\App\Support\LocalMedia;
|
||||
use Modules\User\App\Models\User;
|
||||
use Modules\Video\Enums\VideoStatus;
|
||||
use Modules\Video\Models\Video;
|
||||
@ -63,23 +65,23 @@ class Listing extends Model implements HasMedia
|
||||
|
||||
public function category()
|
||||
{
|
||||
return $this->belongsTo(\Modules\Category\Models\Category::class);
|
||||
return $this->belongsTo(Category::class);
|
||||
}
|
||||
|
||||
public function user()
|
||||
{
|
||||
return $this->belongsTo(\Modules\User\App\Models\User::class);
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
public function favoritedByUsers()
|
||||
{
|
||||
return $this->belongsToMany(\Modules\User\App\Models\User::class, 'favorite_listings')
|
||||
return $this->belongsToMany(User::class, 'favorite_listings')
|
||||
->withTimestamps();
|
||||
}
|
||||
|
||||
public function conversations()
|
||||
{
|
||||
return $this->hasMany(\Modules\Conversation\App\Models\Conversation::class);
|
||||
return $this->hasMany(Conversation::class);
|
||||
}
|
||||
|
||||
public function videos()
|
||||
@ -317,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()
|
||||
@ -453,6 +503,7 @@ class Listing extends Model implements HasMedia
|
||||
return;
|
||||
}
|
||||
|
||||
$disk = $this->mediaDisk();
|
||||
$targetFileName = trim((string) ($fileName ?: basename($absolutePath)));
|
||||
$existingMediaItems = $this->getMedia('listing-images');
|
||||
|
||||
@ -462,7 +513,7 @@ class Listing extends Model implements HasMedia
|
||||
if (
|
||||
$existingMedia
|
||||
&& (string) $existingMedia->file_name === $targetFileName
|
||||
&& (string) $existingMedia->disk === 'public'
|
||||
&& (string) $existingMedia->disk === $disk
|
||||
) {
|
||||
try {
|
||||
if (is_file($existingMedia->getPath())) {
|
||||
@ -474,12 +525,25 @@ class Listing extends Model implements HasMedia
|
||||
}
|
||||
|
||||
$this->clearMediaCollection('listing-images');
|
||||
$this->attachListingImage($absolutePath, $targetFileName, $disk);
|
||||
}
|
||||
|
||||
public function attachListingImage(string $absolutePath, string $fileName, ?string $disk = null): void
|
||||
{
|
||||
if (! is_file($absolutePath)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$targetDisk = is_string($disk) && trim($disk) !== ''
|
||||
? trim($disk)
|
||||
: $this->mediaDisk();
|
||||
|
||||
$this
|
||||
->addMedia($absolutePath)
|
||||
->usingFileName($targetFileName)
|
||||
->usingFileName(trim($fileName))
|
||||
->withCustomProperties(self::mediaCustomProperties())
|
||||
->preservingOriginal()
|
||||
->toMediaCollection('listing-images', 'public');
|
||||
->toMediaCollection('listing-images', $targetDisk);
|
||||
}
|
||||
|
||||
public function statusValue(): string
|
||||
@ -512,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([
|
||||
@ -571,7 +645,7 @@ class Listing extends Model implements HasMedia
|
||||
|
||||
public function registerMediaCollections(): void
|
||||
{
|
||||
$this->addMediaCollection('listing-images')->useDisk('public');
|
||||
$this->addMediaCollection('listing-images')->useDisk($this->mediaDisk());
|
||||
}
|
||||
|
||||
public function registerMediaConversions(?Media $media = null): void
|
||||
@ -644,6 +718,37 @@ class Listing extends Model implements HasMedia
|
||||
return str_contains($argv, 'db:seed') || str_contains($argv, '--seed');
|
||||
}
|
||||
|
||||
private function mediaDisk(): string
|
||||
{
|
||||
return LocalMedia::disk();
|
||||
}
|
||||
|
||||
public static function mediaCustomProperties(): array
|
||||
{
|
||||
$scope = static::mediaPathScope();
|
||||
|
||||
return $scope !== null
|
||||
? ['path_scope' => $scope]
|
||||
: [];
|
||||
}
|
||||
|
||||
public static function mediaPathScope(): ?string
|
||||
{
|
||||
$connection = (string) config('database.default', 'pgsql');
|
||||
$searchPath = config("database.connections.{$connection}.search_path");
|
||||
$value = is_array($searchPath)
|
||||
? implode('_', $searchPath)
|
||||
: (string) $searchPath;
|
||||
$scope = (string) Str::of($value)
|
||||
->before(',')
|
||||
->trim()
|
||||
->lower()
|
||||
->replaceMatches('/[^a-z0-9_]+/', '_')
|
||||
->trim('_');
|
||||
|
||||
return $scope !== '' ? $scope : null;
|
||||
}
|
||||
|
||||
protected function location(): Attribute
|
||||
{
|
||||
return Attribute::make(
|
||||
|
||||
@ -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
|
||||
{}
|
||||
}
|
||||
|
||||
@ -23,6 +23,7 @@ use Filament\Tables\Filters\SelectFilter;
|
||||
use Filament\Tables\Filters\TernaryFilter;
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Support\Str;
|
||||
use Modules\Admin\Support\Filament\ResourceTableActions;
|
||||
use Modules\Category\Models\Category;
|
||||
use Modules\Listing\Models\Listing;
|
||||
@ -31,6 +32,7 @@ use Modules\Listing\Support\ListingPanelHelper;
|
||||
use Modules\Location\Models\City;
|
||||
use Modules\Location\Models\Country;
|
||||
use Modules\Location\Support\CountryCodeManager;
|
||||
use Modules\Site\App\Support\LocalMedia;
|
||||
use Modules\Video\Support\Filament\VideoFormSchema;
|
||||
use Ysfkaya\FilamentPhoneInput\Forms\PhoneInput;
|
||||
|
||||
@ -39,7 +41,7 @@ class AdminListingResourceSchema
|
||||
public static function form(): array
|
||||
{
|
||||
return [
|
||||
TextInput::make('title')->required()->maxLength(255)->live(onBlur: true)->afterStateUpdated(fn ($state, $set) => $set('slug', \Illuminate\Support\Str::slug($state).'-'.\Illuminate\Support\Str::random(4))),
|
||||
TextInput::make('title')->required()->maxLength(255)->live(onBlur: true)->afterStateUpdated(fn ($state, $set) => $set('slug', Str::slug($state).'-'.Str::random(4))),
|
||||
TextInput::make('slug')->required()->maxLength(255)->unique(ignoreRecord: true),
|
||||
Textarea::make('description')->rows(4),
|
||||
TextInput::make('price')
|
||||
@ -101,6 +103,8 @@ class AdminListingResourceSchema
|
||||
->columnSpanFull(),
|
||||
SpatieMediaLibraryFileUpload::make('images')
|
||||
->collection('listing-images')
|
||||
->disk(fn (): string => LocalMedia::disk())
|
||||
->customProperties(fn (): array => Listing::mediaCustomProperties())
|
||||
->multiple()
|
||||
->image()
|
||||
->reorderable(),
|
||||
|
||||
@ -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
|
||||
{}
|
||||
}
|
||||
|
||||
@ -18,7 +18,7 @@ use Modules\Listing\Support\ListingPanelHelper;
|
||||
use Modules\Listing\Support\QuickListingCategorySuggester;
|
||||
use Modules\Location\Models\City;
|
||||
use Modules\Location\Models\Country;
|
||||
use Modules\S3\Support\MediaStorage;
|
||||
use Modules\Site\App\Support\LocalMedia;
|
||||
use Modules\User\App\Models\Profile;
|
||||
use Modules\Video\Models\Video;
|
||||
use Throwable;
|
||||
@ -641,10 +641,11 @@ class PanelQuickListingForm extends Component
|
||||
continue;
|
||||
}
|
||||
|
||||
$listing
|
||||
->addMedia($photo->getRealPath())
|
||||
->usingFileName($photo->getClientOriginalName())
|
||||
->toMediaCollection('listing-images', $mediaDisk);
|
||||
$listing->attachListingImage(
|
||||
$photo->getRealPath(),
|
||||
$photo->getClientOriginalName(),
|
||||
$mediaDisk
|
||||
);
|
||||
}
|
||||
|
||||
foreach ($this->videos as $index => $video) {
|
||||
@ -757,7 +758,7 @@ class PanelQuickListingForm extends Component
|
||||
|
||||
private function frontendMediaDisk(): string
|
||||
{
|
||||
return (string) config('media_storage.local_disk', MediaStorage::diskFromDriver(MediaStorage::DRIVER_LOCAL));
|
||||
return LocalMedia::disk();
|
||||
}
|
||||
|
||||
private function handlePublishValidationFailure(ValidationException $exception): 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>
|
||||
|
||||
@ -1,13 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\S3\Providers;
|
||||
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
|
||||
class S3ServiceProvider extends ServiceProvider
|
||||
{
|
||||
public function register(): void
|
||||
{
|
||||
$this->mergeConfigFrom(module_path('S3', 'config/s3.php'), 'media_storage');
|
||||
}
|
||||
}
|
||||
@ -1,145 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\S3\Support;
|
||||
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Modules\Site\App\Settings\GeneralSettings;
|
||||
use Throwable;
|
||||
|
||||
final class MediaStorage
|
||||
{
|
||||
public const DRIVER_LOCAL = 'local';
|
||||
|
||||
public const DRIVER_S3 = 's3';
|
||||
|
||||
public static function options(): array
|
||||
{
|
||||
return [
|
||||
self::DRIVER_S3 => 'S3 Object Storage',
|
||||
self::DRIVER_LOCAL => 'Local Storage',
|
||||
];
|
||||
}
|
||||
|
||||
public static function defaultDriver(): string
|
||||
{
|
||||
return self::coerceDriver(config('media_storage.default_driver'))
|
||||
?? self::DRIVER_S3;
|
||||
}
|
||||
|
||||
public static function activeDriver(): string
|
||||
{
|
||||
if (! self::hasSettingsTable()) {
|
||||
return self::defaultDriver();
|
||||
}
|
||||
|
||||
try {
|
||||
return self::normalizeDriver(app(GeneralSettings::class)->media_disk ?? null);
|
||||
} catch (Throwable) {
|
||||
return self::defaultDriver();
|
||||
}
|
||||
}
|
||||
|
||||
public static function normalizeDriver(mixed $driver): string
|
||||
{
|
||||
return self::coerceDriver($driver) ?? self::defaultDriver();
|
||||
}
|
||||
|
||||
public static function diskFromDriver(mixed $driver = null): string
|
||||
{
|
||||
return self::normalizeDriver($driver) === self::DRIVER_LOCAL
|
||||
? (string) config('media_storage.local_disk', 'public')
|
||||
: (string) config('media_storage.cloud_disk', 's3');
|
||||
}
|
||||
|
||||
public static function activeDisk(): string
|
||||
{
|
||||
return self::diskFromDriver(self::activeDriver());
|
||||
}
|
||||
|
||||
public static function storedDisk(mixed $disk = null, mixed $driver = null): string
|
||||
{
|
||||
if (is_string($disk) && trim($disk) !== '') {
|
||||
return self::diskFromDriver(trim($disk) === 'public' ? self::DRIVER_LOCAL : trim($disk));
|
||||
}
|
||||
|
||||
return self::diskFromDriver($driver);
|
||||
}
|
||||
|
||||
public static function managesPath(mixed $path): bool
|
||||
{
|
||||
$path = is_string($path) ? trim($path) : '';
|
||||
|
||||
if ($path === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return ! self::isExternalUrl($path) && ! self::isAssetPath($path);
|
||||
}
|
||||
|
||||
public static function url(mixed $path, mixed $disk = null): ?string
|
||||
{
|
||||
$path = is_string($path) ? trim($path) : '';
|
||||
|
||||
if ($path === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (self::isExternalUrl($path)) {
|
||||
return $path;
|
||||
}
|
||||
|
||||
if (self::isAssetPath($path)) {
|
||||
return asset($path);
|
||||
}
|
||||
|
||||
return Storage::disk(self::storedDisk($disk))->url($path);
|
||||
}
|
||||
|
||||
public static function applyRuntimeConfig(): void
|
||||
{
|
||||
$disk = self::activeDisk();
|
||||
|
||||
config([
|
||||
'filesystems.default' => $disk,
|
||||
'filemanager.disk' => $disk,
|
||||
'filament.default_filesystem_disk' => $disk,
|
||||
'media-library.disk_name' => $disk,
|
||||
'video.disk' => $disk,
|
||||
]);
|
||||
}
|
||||
|
||||
private static function coerceDriver(mixed $driver): ?string
|
||||
{
|
||||
if (! is_string($driver)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return match (strtolower(trim($driver))) {
|
||||
self::DRIVER_LOCAL, 'public' => self::DRIVER_LOCAL,
|
||||
self::DRIVER_S3 => self::DRIVER_S3,
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
|
||||
private static function hasSettingsTable(): bool
|
||||
{
|
||||
try {
|
||||
return Schema::hasTable('settings');
|
||||
} catch (Throwable) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static function isAssetPath(string $path): bool
|
||||
{
|
||||
return str_starts_with($path, 'images/');
|
||||
}
|
||||
|
||||
private static function isExternalUrl(string $path): bool
|
||||
{
|
||||
return str_starts_with($path, 'http://')
|
||||
|| str_starts_with($path, 'https://')
|
||||
|| str_starts_with($path, '//');
|
||||
}
|
||||
}
|
||||
@ -1,7 +0,0 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
'default_driver' => env('MEDIA_DISK', env('FILESYSTEM_DISK', 's3')),
|
||||
'local_disk' => env('LOCAL_MEDIA_DISK', 'public'),
|
||||
'cloud_disk' => env('CLOUD_MEDIA_DISK', 's3'),
|
||||
];
|
||||
@ -1,12 +0,0 @@
|
||||
{
|
||||
"name": "S3",
|
||||
"alias": "s3",
|
||||
"description": "Media storage selection and S3 object storage integration",
|
||||
"keywords": [],
|
||||
"priority": 0,
|
||||
"providers": [
|
||||
"Modules\\S3\\Providers\\S3ServiceProvider"
|
||||
],
|
||||
"aliases": {},
|
||||
"files": []
|
||||
}
|
||||
@ -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(
|
||||
|
||||
19
Modules/Site/App/Http/Controllers/PublicMediaController.php
Normal file
19
Modules/Site/App/Http/Controllers/PublicMediaController.php
Normal file
@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Site\App\Http\Controllers;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Modules\Site\App\Support\LocalMedia;
|
||||
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||
|
||||
class PublicMediaController extends Controller
|
||||
{
|
||||
public function show(Request $request, string $path): StreamedResponse
|
||||
{
|
||||
abort_unless(Storage::disk(LocalMedia::disk())->exists($path), 404);
|
||||
|
||||
return Storage::disk(LocalMedia::disk())->serve($request, $path);
|
||||
}
|
||||
}
|
||||
@ -4,13 +4,29 @@ namespace Modules\Site\App\Providers;
|
||||
|
||||
use Illuminate\Support\Facades\View;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
use Modules\Site\App\Support\LocalMedia;
|
||||
use Modules\Site\App\Support\ScopedMediaPathGenerator;
|
||||
|
||||
class SiteServiceProvider extends ServiceProvider
|
||||
{
|
||||
public function register(): void
|
||||
{
|
||||
config([
|
||||
'filesystems.default' => LocalMedia::disk(),
|
||||
'filament.default_filesystem_disk' => LocalMedia::disk(),
|
||||
'filemanager.storage_mode.disk' => LocalMedia::disk(),
|
||||
'filemanager.upload.disk' => LocalMedia::disk(),
|
||||
'media-library.disk_name' => LocalMedia::disk(),
|
||||
'media-library.path_generator' => ScopedMediaPathGenerator::class,
|
||||
'video.disk' => LocalMedia::disk(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function boot(): void
|
||||
{
|
||||
$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);
|
||||
|
||||
@ -10,12 +10,8 @@ class GeneralSettings extends Settings
|
||||
|
||||
public string $site_description;
|
||||
|
||||
public string $media_disk;
|
||||
|
||||
public ?string $site_logo;
|
||||
|
||||
public ?string $site_logo_disk;
|
||||
|
||||
public string $default_language;
|
||||
|
||||
public string $default_country_code;
|
||||
|
||||
@ -3,7 +3,6 @@
|
||||
namespace Modules\Site\App\Support;
|
||||
|
||||
use Illuminate\Support\Arr;
|
||||
use Modules\S3\Support\MediaStorage;
|
||||
|
||||
final class HomeSlideDefaults
|
||||
{
|
||||
@ -17,7 +16,6 @@ final class HomeSlideDefaults
|
||||
'primary_button_text' => 'Browse Listings',
|
||||
'secondary_button_text' => 'Post Listing',
|
||||
'image_path' => 'images/home-slides/slide-marketplace.svg',
|
||||
'disk' => null,
|
||||
],
|
||||
[
|
||||
'badge' => 'Fresh Categories',
|
||||
@ -26,7 +24,6 @@ final class HomeSlideDefaults
|
||||
'primary_button_text' => 'See Categories',
|
||||
'secondary_button_text' => 'Start Now',
|
||||
'image_path' => 'images/home-slides/slide-categories.svg',
|
||||
'disk' => null,
|
||||
],
|
||||
[
|
||||
'badge' => 'Local Shopping',
|
||||
@ -35,12 +32,11 @@ final class HomeSlideDefaults
|
||||
'primary_button_text' => 'Nearby Deals',
|
||||
'secondary_button_text' => 'Sell for Free',
|
||||
'image_path' => 'images/home-slides/slide-local.svg',
|
||||
'disk' => null,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
public static function normalize(mixed $slides, ?string $defaultDisk = null): array
|
||||
public static function normalize(mixed $slides): array
|
||||
{
|
||||
$defaults = self::defaults();
|
||||
$source = is_array($slides) ? $slides : [];
|
||||
@ -48,7 +44,7 @@ final class HomeSlideDefaults
|
||||
$normalized = collect($source)
|
||||
->filter(fn ($slide): bool => is_array($slide))
|
||||
->values()
|
||||
->map(function (array $slide, int $index) use ($defaults, $defaultDisk): ?array {
|
||||
->map(function (array $slide, int $index) use ($defaults): ?array {
|
||||
$fallback = $defaults[$index] ?? $defaults[array_key_last($defaults)];
|
||||
$badge = trim((string) ($slide['badge'] ?? ''));
|
||||
$title = trim((string) ($slide['title'] ?? ''));
|
||||
@ -56,9 +52,6 @@ final class HomeSlideDefaults
|
||||
$primaryButtonText = trim((string) ($slide['primary_button_text'] ?? ''));
|
||||
$secondaryButtonText = trim((string) ($slide['secondary_button_text'] ?? ''));
|
||||
$imagePath = self::normalizeImagePath($slide['image_path'] ?? null);
|
||||
$disk = MediaStorage::managesPath($imagePath)
|
||||
? MediaStorage::storedDisk($slide['disk'] ?? null, $defaultDisk)
|
||||
: null;
|
||||
|
||||
if ($title === '') {
|
||||
return null;
|
||||
@ -71,7 +64,6 @@ final class HomeSlideDefaults
|
||||
'primary_button_text' => $primaryButtonText !== '' ? $primaryButtonText : $fallback['primary_button_text'],
|
||||
'secondary_button_text' => $secondaryButtonText !== '' ? $secondaryButtonText : $fallback['secondary_button_text'],
|
||||
'image_path' => $imagePath !== '' ? $imagePath : ($fallback['image_path'] ?? null),
|
||||
'disk' => $imagePath !== '' ? $disk : ($fallback['disk'] ?? null),
|
||||
];
|
||||
})
|
||||
->filter(fn ($slide): bool => is_array($slide))
|
||||
|
||||
55
Modules/Site/App/Support/LocalMedia.php
Normal file
55
Modules/Site/App/Support/LocalMedia.php
Normal file
@ -0,0 +1,55 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Site\App\Support;
|
||||
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
final class LocalMedia
|
||||
{
|
||||
public const DISK = 'public';
|
||||
|
||||
public static function disk(): string
|
||||
{
|
||||
return self::DISK;
|
||||
}
|
||||
|
||||
public static function managesPath(mixed $path): bool
|
||||
{
|
||||
$value = is_string($path) ? trim($path) : '';
|
||||
|
||||
return $value !== ''
|
||||
&& ! self::isExternalUrl($value)
|
||||
&& ! self::isAssetPath($value);
|
||||
}
|
||||
|
||||
public static function url(mixed $path): ?string
|
||||
{
|
||||
$value = is_string($path) ? trim($path) : '';
|
||||
|
||||
if ($value === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (self::isExternalUrl($value)) {
|
||||
return $value;
|
||||
}
|
||||
|
||||
if (self::isAssetPath($value)) {
|
||||
return asset($value);
|
||||
}
|
||||
|
||||
return Storage::disk(self::DISK)->url($value);
|
||||
}
|
||||
|
||||
private static function isAssetPath(string $path): bool
|
||||
{
|
||||
return str_starts_with($path, 'images/');
|
||||
}
|
||||
|
||||
private static function isExternalUrl(string $path): bool
|
||||
{
|
||||
return str_starts_with($path, 'http://')
|
||||
|| str_starts_with($path, 'https://')
|
||||
|| str_starts_with($path, '//');
|
||||
}
|
||||
}
|
||||
@ -3,12 +3,10 @@
|
||||
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;
|
||||
use Modules\Location\Support\CountryCodeManager;
|
||||
use Modules\S3\Support\MediaStorage;
|
||||
use Modules\Site\App\Settings\GeneralSettings;
|
||||
use Modules\User\App\Models\User;
|
||||
use Throwable;
|
||||
@ -42,12 +40,10 @@ final class RequestAppData
|
||||
$fallbackAppleClientId = config('services.apple.client_id');
|
||||
$fallbackAppleClientSecret = config('services.apple.client_secret');
|
||||
$fallbackDefaultCountryCode = (string) config('app.default_country_code', '+90');
|
||||
$fallbackMediaDriver = MediaStorage::defaultDriver();
|
||||
|
||||
$generalSettings = [
|
||||
'site_name' => $fallbackName,
|
||||
'site_description' => $fallbackDescription,
|
||||
'media_disk' => $fallbackMediaDriver,
|
||||
'home_slides' => $fallbackHomeSlides,
|
||||
'site_logo_url' => null,
|
||||
'default_language' => $fallbackLocale,
|
||||
@ -71,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);
|
||||
@ -94,18 +82,13 @@ final class RequestAppData
|
||||
$appleClientId = trim((string) ($settings->apple_client_id ?: $fallbackAppleClientId));
|
||||
$appleClientSecret = trim((string) ($settings->apple_client_secret ?: $fallbackAppleClientSecret));
|
||||
$defaultCountryCode = CountryCodeManager::normalizeCountryCode($settings->default_country_code ?? $fallbackDefaultCountryCode);
|
||||
$mediaDriver = MediaStorage::normalizeDriver($settings->media_disk ?? null);
|
||||
|
||||
return [
|
||||
'site_name' => trim((string) ($settings->site_name ?: $fallbackName)),
|
||||
'site_description' => trim((string) ($settings->site_description ?: $fallbackDescription)),
|
||||
'media_disk' => $mediaDriver,
|
||||
'home_slides' => HomeSlideDefaults::normalize(
|
||||
$settings->home_slides ?? [],
|
||||
MediaStorage::diskFromDriver($mediaDriver),
|
||||
),
|
||||
'home_slides' => HomeSlideDefaults::normalize($settings->home_slides ?? []),
|
||||
'site_logo_url' => filled($settings->site_logo)
|
||||
? MediaStorage::url($settings->site_logo, $settings->site_logo_disk ?? null)
|
||||
? LocalMedia::url($settings->site_logo)
|
||||
: null,
|
||||
'default_language' => $defaultLanguage,
|
||||
'default_country_code' => $defaultCountryCode,
|
||||
@ -149,32 +132,26 @@ 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',
|
||||
'app.default_country_code' => $generalSettings['default_country_code'] ?? '+90',
|
||||
'app.default_country_iso2' => CountryCodeManager::iso2FromCountryCode($generalSettings['default_country_code'] ?? '+90') ?? 'TR',
|
||||
]);
|
||||
|
||||
MediaStorage::applyRuntimeConfig();
|
||||
}
|
||||
|
||||
private function resolveHeaderLocationCountries(): array
|
||||
{
|
||||
try {
|
||||
if (! Schema::hasTable('countries') || ! Schema::hasTable('cities')) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return Country::headerLocationOptions();
|
||||
} catch (Throwable) {
|
||||
return [];
|
||||
@ -184,10 +161,6 @@ final class RequestAppData
|
||||
private function resolveHeaderNavCategories(): array
|
||||
{
|
||||
try {
|
||||
if (! Schema::hasTable('categories')) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return Category::headerNavigationItems();
|
||||
} catch (Throwable) {
|
||||
return [];
|
||||
|
||||
34
Modules/Site/App/Support/ScopedMediaPathGenerator.php
Normal file
34
Modules/Site/App/Support/ScopedMediaPathGenerator.php
Normal file
@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Site\App\Support;
|
||||
|
||||
use Spatie\MediaLibrary\MediaCollections\Models\Media;
|
||||
use Spatie\MediaLibrary\Support\PathGenerator\PathGenerator;
|
||||
|
||||
class ScopedMediaPathGenerator implements PathGenerator
|
||||
{
|
||||
public function getPath(Media $media): string
|
||||
{
|
||||
return $this->basePath($media).'/';
|
||||
}
|
||||
|
||||
public function getPathForConversions(Media $media): string
|
||||
{
|
||||
return $this->basePath($media).'/conversions/';
|
||||
}
|
||||
|
||||
public function getPathForResponsiveImages(Media $media): string
|
||||
{
|
||||
return $this->basePath($media).'/responsive-images/';
|
||||
}
|
||||
|
||||
private function basePath(Media $media): string
|
||||
{
|
||||
$scope = trim((string) $media->getCustomProperty('path_scope', ''));
|
||||
$key = (string) $media->getKey();
|
||||
|
||||
return $scope !== ''
|
||||
? $scope.'/'.$key
|
||||
: $key;
|
||||
}
|
||||
}
|
||||
@ -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,24 +1,21 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Admin\Filament\Pages;
|
||||
namespace Modules\Site\Filament\Admin\Pages;
|
||||
|
||||
use BackedEnum;
|
||||
use Filament\Forms\Components\FileUpload;
|
||||
use Filament\Forms\Components\Hidden;
|
||||
use Filament\Forms\Components\Select;
|
||||
use Filament\Forms\Components\TagsInput;
|
||||
use Filament\Forms\Components\Textarea;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Forms\Components\Toggle;
|
||||
use Filament\Pages\SettingsPage;
|
||||
use Filament\Schemas\Components\Utilities\Get;
|
||||
use Filament\Schemas\Components\Utilities\Set;
|
||||
use Filament\Schemas\Schema;
|
||||
use Modules\Admin\Support\HomeSlideFormSchema;
|
||||
use Modules\Location\Support\CountryCodeManager;
|
||||
use Modules\S3\Support\MediaStorage;
|
||||
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;
|
||||
@ -27,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;
|
||||
|
||||
@ -44,15 +41,8 @@ class ManageGeneralSettings extends SettingsPage
|
||||
return [
|
||||
'site_name' => filled($data['site_name'] ?? null) ? $data['site_name'] : $defaults['site_name'],
|
||||
'site_description' => filled($data['site_description'] ?? null) ? $data['site_description'] : $defaults['site_description'],
|
||||
'media_disk' => MediaStorage::normalizeDriver($data['media_disk'] ?? $defaults['media_disk']),
|
||||
'home_slides' => $this->normalizeHomeSlides(
|
||||
$data['home_slides'] ?? $defaults['home_slides'],
|
||||
MediaStorage::storedDisk('public'),
|
||||
),
|
||||
'home_slides' => $this->normalizeHomeSlides($data['home_slides'] ?? $defaults['home_slides']),
|
||||
'site_logo' => $data['site_logo'] ?? null,
|
||||
'site_logo_disk' => filled($data['site_logo'] ?? null)
|
||||
? MediaStorage::storedDisk($data['site_logo_disk'] ?? 'public')
|
||||
: null,
|
||||
'sender_name' => filled($data['sender_name'] ?? null) ? $data['sender_name'] : $defaults['sender_name'],
|
||||
'sender_email' => filled($data['sender_email'] ?? null) ? $data['sender_email'] : $defaults['sender_email'],
|
||||
'default_language' => filled($data['default_language'] ?? null) ? $data['default_language'] : $defaults['default_language'],
|
||||
@ -77,14 +67,7 @@ class ManageGeneralSettings extends SettingsPage
|
||||
|
||||
protected function mutateFormDataBeforeSave(array $data): array
|
||||
{
|
||||
$mediaDriver = MediaStorage::normalizeDriver($data['media_disk'] ?? null);
|
||||
$mediaDisk = MediaStorage::diskFromDriver($mediaDriver);
|
||||
|
||||
$data['media_disk'] = $mediaDriver;
|
||||
$data['home_slides'] = $this->normalizeHomeSlides($data['home_slides'] ?? [], $mediaDisk);
|
||||
$data['site_logo_disk'] = MediaStorage::managesPath($data['site_logo'] ?? null)
|
||||
? MediaStorage::storedDisk($data['site_logo_disk'] ?? null, $mediaDriver)
|
||||
: null;
|
||||
$data['home_slides'] = $this->normalizeHomeSlides($data['home_slides'] ?? []);
|
||||
$data['currencies'] = $this->normalizeCurrencies($data['currencies'] ?? []);
|
||||
|
||||
return $data;
|
||||
@ -106,32 +89,16 @@ class ManageGeneralSettings extends SettingsPage
|
||||
->default($defaults['site_description'])
|
||||
->rows(3)
|
||||
->maxLength(500),
|
||||
Select::make('media_disk')
|
||||
->label('Media Storage')
|
||||
->options(MediaStorage::options())
|
||||
->default($defaults['media_disk'])
|
||||
->required()
|
||||
->native(false)
|
||||
->helperText('Storage driver used for listing images, videos, the site logo, and home slide visuals.'),
|
||||
HomeSlideFormSchema::make(
|
||||
$defaults['home_slides'],
|
||||
fn ($state): array => $this->normalizeHomeSlides($state, MediaStorage::activeDisk()),
|
||||
fn ($state): array => $this->normalizeHomeSlides($state),
|
||||
),
|
||||
Hidden::make('site_logo_disk'),
|
||||
FileUpload::make('site_logo')
|
||||
->label('Site Logo')
|
||||
->image()
|
||||
->disk(fn (Get $get): string => MediaStorage::storedDisk($get('site_logo_disk'), $get('media_disk')))
|
||||
->disk(LocalMedia::disk())
|
||||
->directory('settings')
|
||||
->visibility('public')
|
||||
->afterStateUpdated(function (Get $get, Set $set, mixed $state): void {
|
||||
$set(
|
||||
'site_logo_disk',
|
||||
MediaStorage::managesPath($state)
|
||||
? MediaStorage::diskFromDriver($get('media_disk'))
|
||||
: null,
|
||||
);
|
||||
}),
|
||||
->visibility('public'),
|
||||
TextInput::make('sender_name')
|
||||
->label('Sender Name')
|
||||
->default($defaults['sender_name'])
|
||||
@ -242,9 +209,7 @@ class ManageGeneralSettings extends SettingsPage
|
||||
return [
|
||||
'site_name' => $siteName,
|
||||
'site_description' => 'A fast and secure marketplace for buying and selling.',
|
||||
'media_disk' => MediaStorage::defaultDriver(),
|
||||
'home_slides' => $this->defaultHomeSlides(),
|
||||
'site_logo_disk' => null,
|
||||
'sender_name' => $siteName,
|
||||
'sender_email' => (string) config('mail.from.address', 'info@'.$siteHost),
|
||||
'default_language' => in_array(config('app.locale'), array_keys($this->localeOptions()), true) ? (string) config('app.locale') : 'en',
|
||||
@ -292,8 +257,8 @@ class ManageGeneralSettings extends SettingsPage
|
||||
return HomeSlideDefaults::defaults();
|
||||
}
|
||||
|
||||
private function normalizeHomeSlides(mixed $state, ?string $defaultDisk = null): array
|
||||
private function normalizeHomeSlides(mixed $state): array
|
||||
{
|
||||
return HomeSlideDefaults::normalize($state, $defaultDisk);
|
||||
return HomeSlideDefaults::normalize($state);
|
||||
}
|
||||
}
|
||||
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,15 +1,12 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Admin\Support;
|
||||
namespace Modules\Site\Support\Filament;
|
||||
|
||||
use Filament\Forms\Components\FileUpload;
|
||||
use Filament\Forms\Components\Hidden;
|
||||
use Filament\Forms\Components\Repeater;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Forms\Components\Textarea;
|
||||
use Filament\Schemas\Components\Utilities\Get;
|
||||
use Filament\Schemas\Components\Utilities\Set;
|
||||
use Modules\S3\Support\MediaStorage;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Modules\Site\App\Support\LocalMedia;
|
||||
|
||||
final class HomeSlideFormSchema
|
||||
{
|
||||
@ -19,24 +16,15 @@ final class HomeSlideFormSchema
|
||||
->label('Homepage Slides')
|
||||
->helperText('Use 1 to 5 slides. Upload a wide image for each slide to improve the hero area.')
|
||||
->schema([
|
||||
Hidden::make('disk'),
|
||||
FileUpload::make('image_path')
|
||||
->label('Slide Image')
|
||||
->image()
|
||||
->disk(fn (Get $get): string => MediaStorage::storedDisk($get('disk'), self::mediaDriver($get)))
|
||||
->disk(LocalMedia::disk())
|
||||
->directory('home-slides')
|
||||
->visibility('public')
|
||||
->imageEditor()
|
||||
->imagePreviewHeight('200')
|
||||
->helperText('Recommended: 1600x1000 or wider.')
|
||||
->afterStateUpdated(function (Get $get, Set $set, mixed $state): void {
|
||||
$set(
|
||||
'disk',
|
||||
MediaStorage::managesPath($state)
|
||||
? MediaStorage::diskFromDriver(self::mediaDriver($get))
|
||||
: null,
|
||||
);
|
||||
})
|
||||
->columnSpanFull(),
|
||||
TextInput::make('badge')
|
||||
->label('Badge')
|
||||
@ -72,13 +60,4 @@ final class HomeSlideFormSchema
|
||||
->itemLabel(fn (array $state): string => filled($state['title'] ?? null) ? (string) $state['title'] : 'New Slide')
|
||||
->dehydrateStateUsing(fn ($state) => $normalizeSlides($state));
|
||||
}
|
||||
|
||||
private static function mediaDriver(Get $get): string
|
||||
{
|
||||
$driver = $get('../../media_disk');
|
||||
|
||||
return is_string($driver) && trim($driver) !== ''
|
||||
? MediaStorage::normalizeDriver($driver)
|
||||
: MediaStorage::activeDriver();
|
||||
}
|
||||
}
|
||||
@ -44,7 +44,7 @@
|
||||
'subtitle' => $subtitle !== '' ? $subtitle : 'Buy and sell everything in your area',
|
||||
'primary_button_text' => $primaryButtonText !== '' ? $primaryButtonText : 'Browse Listings',
|
||||
'secondary_button_text' => $secondaryButtonText !== '' ? $secondaryButtonText : 'Post Listing',
|
||||
'image_url' => \Modules\S3\Support\MediaStorage::url($imagePath, $slide['disk'] ?? null),
|
||||
'image_url' => \Modules\Site\App\Support\LocalMedia::url($imagePath),
|
||||
];
|
||||
})
|
||||
->values();
|
||||
@ -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
|
||||
|
||||
@ -3,6 +3,11 @@
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use Modules\Site\App\Http\Controllers\HomeController;
|
||||
use Modules\Site\App\Http\Controllers\LanguageController;
|
||||
use Modules\Site\App\Http\Controllers\PublicMediaController;
|
||||
|
||||
Route::get('/storage/{path}', [PublicMediaController::class, 'show'])
|
||||
->where('path', '.*')
|
||||
->name('media.legacy');
|
||||
|
||||
Route::middleware('web')->group(function () {
|
||||
Route::get('/', [HomeController::class, 'index'])->name('home');
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -2,6 +2,7 @@
|
||||
|
||||
namespace Modules\User\App\Models;
|
||||
|
||||
use Database\Factories\UserFactory;
|
||||
use Filament\Models\Contracts\FilamentUser;
|
||||
use Filament\Models\Contracts\HasAvatar;
|
||||
use Filament\Panel;
|
||||
@ -9,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;
|
||||
@ -17,7 +19,7 @@ use Modules\Conversation\App\Models\Conversation;
|
||||
use Modules\Conversation\App\Models\ConversationMessage;
|
||||
use Modules\Favorite\App\Models\FavoriteSearch;
|
||||
use Modules\Listing\Models\Listing;
|
||||
use Modules\S3\Support\MediaStorage;
|
||||
use Modules\Site\App\Support\LocalMedia;
|
||||
use Modules\User\App\States\UserStatus;
|
||||
use Spatie\Activitylog\LogOptions;
|
||||
use Spatie\Activitylog\Traits\LogsActivity;
|
||||
@ -50,7 +52,7 @@ class User extends Authenticatable implements FilamentUser, HasAvatar
|
||||
|
||||
protected static function newFactory(): Factory
|
||||
{
|
||||
return \Database\Factories\UserFactory::new();
|
||||
return UserFactory::new();
|
||||
}
|
||||
|
||||
public function getActivitylogOptions(): LogOptions
|
||||
@ -128,21 +130,17 @@ class User extends Authenticatable implements FilamentUser, HasAvatar
|
||||
|
||||
$path = trim((string) $this->avatar_url);
|
||||
|
||||
if (! MediaStorage::managesPath($path)) {
|
||||
return MediaStorage::url($path);
|
||||
if (! LocalMedia::managesPath($path)) {
|
||||
return LocalMedia::url($path);
|
||||
}
|
||||
|
||||
$activeDisk = MediaStorage::activeDisk();
|
||||
$disk = LocalMedia::disk();
|
||||
|
||||
if (Storage::disk($activeDisk)->exists($path)) {
|
||||
return Storage::disk($activeDisk)->url($path);
|
||||
if (Storage::disk($disk)->exists($path)) {
|
||||
return Storage::disk($disk)->url($path);
|
||||
}
|
||||
|
||||
if ($activeDisk !== 'public' && Storage::disk('public')->exists($path)) {
|
||||
return Storage::disk('public')->url($path);
|
||||
}
|
||||
|
||||
return MediaStorage::url($path, $activeDisk);
|
||||
return LocalMedia::url($path);
|
||||
}
|
||||
|
||||
public function getDisplayName(): string
|
||||
@ -189,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());
|
||||
@ -226,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')
|
||||
@ -234,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;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user