diff --git a/Modules/Category/Database/Seeders/CategorySeeder.php b/Modules/Category/Database/Seeders/CategorySeeder.php index f81af9e90..f929c1b91 100644 --- a/Modules/Category/Database/Seeders/CategorySeeder.php +++ b/Modules/Category/Database/Seeders/CategorySeeder.php @@ -13,7 +13,7 @@ class CategorySeeder extends Seeder ['name' => 'Vehicles', 'slug' => 'vehicles', 'icon' => 'img/category/car.png', 'children' => ['Cars', 'Motorcycles', 'Trucks', 'Boats']], ['name' => 'Real Estate', 'slug' => 'real-estate', 'icon' => 'img/category/home_garden.png', 'children' => ['For Sale', 'For Rent', 'Commercial']], ['name' => 'Fashion', 'slug' => 'fashion', 'icon' => 'img/category/phone.png', 'children' => ['Men', 'Women', 'Kids', 'Shoes']], - ['name' => 'Home & Garden', 'slug' => 'home-garden', 'icon' => 'img/category/home_tools.png', 'children' => ['Furniture', 'Garden', 'Appliances']], + ['name' => 'Home', 'slug' => 'home-garden', 'icon' => 'img/category/home_tools.png', 'children' => ['Furniture', 'Garden', 'Appliances']], ['name' => 'Sports', 'slug' => 'sports', 'icon' => 'img/category/sports.png', 'children' => ['Outdoor', 'Fitness', 'Team Sports']], ['name' => 'Jobs', 'slug' => 'jobs', 'icon' => 'img/category/education.png', 'children' => ['Full Time', 'Part Time', 'Freelance']], ['name' => 'Services', 'slug' => 'services', 'icon' => 'img/category/home_tools.png', 'children' => ['Cleaning', 'Repair', 'Education']], diff --git a/Modules/Conversation/App/Models/Conversation.php b/Modules/Conversation/App/Models/Conversation.php index 987baec96..8b355a8ca 100644 --- a/Modules/Conversation/App/Models/Conversation.php +++ b/Modules/Conversation/App/Models/Conversation.php @@ -165,4 +165,15 @@ class Conversation extends Model return is_null($value) ? null : (int) $value; } + + public static function unreadCountForUser(int $userId): int + { + return (int) ConversationMessage::query() + ->whereHas('conversation', function (Builder $query) use ($userId): void { + $query->forUser($userId); + }) + ->where('sender_id', '!=', $userId) + ->whereNull('read_at') + ->count(); + } } diff --git a/Modules/Conversation/database/seeders/ConversationDemoSeeder.php b/Modules/Conversation/database/seeders/ConversationDemoSeeder.php index e9e6f7cbb..a8b2cd53f 100644 --- a/Modules/Conversation/database/seeders/ConversationDemoSeeder.php +++ b/Modules/Conversation/database/seeders/ConversationDemoSeeder.php @@ -19,41 +19,69 @@ class ConversationDemoSeeder extends Seeder } $admin = User::query()->where('email', 'a@a.com')->first(); - $partner = User::query()->where('email', 'b@b.com')->first(); + $member = User::query()->where('email', 'b@b.com')->first(); - if (! $admin || ! $partner) { + if (! $admin || ! $member) { return; } - $listings = Listing::query() + $adminListings = Listing::query() ->where('user_id', $admin->getKey()) ->where('status', 'active') ->orderBy('id') ->take(2) ->get(); - if ($listings->count() < 2) { + $memberListings = Listing::query() + ->where('user_id', $member->getKey()) + ->where('status', 'active') + ->orderBy('id') + ->take(2) + ->get(); + + if ($adminListings->count() < 2 || $memberListings->count() < 2) { return; } $this->seedConversationThread( - $listings->get(0), $admin, - $partner, + $member, + $adminListings->get(0), [ - ['sender' => 'partner', 'body' => 'Hi, is this still available?', 'hours_ago' => 30, 'read_after_minutes' => 4], - ['sender' => 'admin', 'body' => 'Yes, it is available. I can share more photos.', 'hours_ago' => 29, 'read_after_minutes' => 7], - ['sender' => 'partner', 'body' => 'Perfect. Can we meet tomorrow afternoon?', 'hours_ago' => 4, 'read_after_minutes' => null], + ['sender' => 'buyer', 'body' => 'Hi, is this still available?', 'hours_ago' => 30, 'read_after_minutes' => 4], + ['sender' => 'seller', 'body' => 'Yes, it is. I can share more photos.', 'hours_ago' => 29, 'read_after_minutes' => 7], + ['sender' => 'buyer', 'body' => 'Perfect. Can we meet tomorrow afternoon?', 'hours_ago' => 4, 'read_after_minutes' => null], ] ); $this->seedConversationThread( - $listings->get(1), $admin, - $partner, + $member, + $adminListings->get(1), [ - ['sender' => 'partner', 'body' => 'Can you confirm the final price?', 'hours_ago' => 20, 'read_after_minutes' => 8], - ['sender' => 'admin', 'body' => 'I can do a small discount if you pick it up today.', 'hours_ago' => 18, 'read_after_minutes' => null], + ['sender' => 'buyer', 'body' => 'Can you confirm the final price?', 'hours_ago' => 20, 'read_after_minutes' => 8], + ['sender' => 'seller', 'body' => 'I can do a small discount if you pick it up today.', 'hours_ago' => 18, 'read_after_minutes' => null], + ] + ); + + $this->seedConversationThread( + $member, + $admin, + $memberListings->get(0), + [ + ['sender' => 'buyer', 'body' => 'Hello, does this listing include the original accessories?', 'hours_ago' => 14, 'read_after_minutes' => 6], + ['sender' => 'seller', 'body' => 'Yes, box and accessories are included.', 'hours_ago' => 13, 'read_after_minutes' => 9], + ['sender' => 'buyer', 'body' => 'Great. I can pick it up tonight.', 'hours_ago' => 2, 'read_after_minutes' => null], + ] + ); + + $this->seedConversationThread( + $member, + $admin, + $memberListings->get(1), + [ + ['sender' => 'buyer', 'body' => 'Would you accept a bank transfer?', 'hours_ago' => 11, 'read_after_minutes' => 5], + ['sender' => 'seller', 'body' => 'Yes, that works for me.', 'hours_ago' => 10, 'read_after_minutes' => null], ] ); } @@ -64,9 +92,9 @@ class ConversationDemoSeeder extends Seeder } private function seedConversationThread( + User $seller, + User $buyer, ?Listing $listing, - User $admin, - User $partner, array $messages ): void { if (! $listing) { @@ -76,10 +104,10 @@ class ConversationDemoSeeder extends Seeder $conversation = Conversation::updateOrCreate( [ 'listing_id' => $listing->getKey(), - 'buyer_id' => $partner->getKey(), + 'buyer_id' => $buyer->getKey(), ], [ - 'seller_id' => $admin->getKey(), + 'seller_id' => $seller->getKey(), 'last_message_at' => now(), ] ); @@ -92,7 +120,7 @@ class ConversationDemoSeeder extends Seeder foreach ($messages as $payload) { $createdAt = now()->subHours((int) $payload['hours_ago']); - $sender = ($payload['sender'] ?? 'partner') === 'admin' ? $admin : $partner; + $sender = ($payload['sender'] ?? 'buyer') === 'seller' ? $seller : $buyer; $readAfterMinutes = $payload['read_after_minutes']; $readAt = is_numeric($readAfterMinutes) ? $createdAt->copy()->addMinutes((int) $readAfterMinutes) : null; @@ -110,7 +138,7 @@ class ConversationDemoSeeder extends Seeder } $conversation->forceFill([ - 'seller_id' => $admin->getKey(), + 'seller_id' => $seller->getKey(), 'last_message_at' => $lastMessageAt, 'updated_at' => $lastMessageAt, ])->saveQuietly(); diff --git a/Modules/Demo/database/Seeders/DemoContentSeeder.php b/Modules/Demo/database/Seeders/DemoContentSeeder.php index cb991dd69..b1d6adf32 100644 --- a/Modules/Demo/database/Seeders/DemoContentSeeder.php +++ b/Modules/Demo/database/Seeders/DemoContentSeeder.php @@ -9,10 +9,7 @@ class DemoContentSeeder extends Seeder public function run(): void { $this->call([ - \Modules\User\Database\Seeders\AuthUserSeeder::class, - \Modules\Listing\Database\Seeders\ListingPanelDemoSeeder::class, - \Modules\Favorite\Database\Seeders\FavoriteDemoSeeder::class, - \Modules\Conversation\Database\Seeders\ConversationDemoSeeder::class, + \Modules\User\Database\Seeders\UserWorkspaceSeeder::class, ]); } } diff --git a/Modules/Favorite/database/seeders/FavoriteDemoSeeder.php b/Modules/Favorite/database/seeders/FavoriteDemoSeeder.php index 0a73e3acc..b5f421e00 100644 --- a/Modules/Favorite/database/seeders/FavoriteDemoSeeder.php +++ b/Modules/Favorite/database/seeders/FavoriteDemoSeeder.php @@ -20,9 +20,9 @@ class FavoriteDemoSeeder extends Seeder } $admin = User::query()->where('email', 'a@a.com')->first(); - $partner = User::query()->where('email', 'b@b.com')->first(); + $member = User::query()->where('email', 'b@b.com')->first(); - if (! $admin || ! $partner) { + if (! $admin || ! $member) { return; } @@ -32,26 +32,33 @@ class FavoriteDemoSeeder extends Seeder ->orderBy('id') ->get(); - if ($adminListings->isEmpty()) { + $memberListings = Listing::query() + ->where('user_id', $member->getKey()) + ->orderByDesc('is_featured') + ->orderBy('id') + ->get(); + + if ($adminListings->isEmpty() || $memberListings->isEmpty()) { return; } $activeAdminListings = $adminListings->where('status', 'active')->values(); + $activeMemberListings = $memberListings->where('status', 'active')->values(); $this->seedFavoriteListings( - $partner, + $member, $activeAdminListings->take(6) ); $this->seedFavoriteListings( $admin, - $adminListings->take(3)->values() + $activeMemberListings->take(4) ); - $this->seedFavoriteSeller($partner, $admin, now()->subDays(2)); - $this->seedFavoriteSeller($admin, $partner, now()->subDays(1)); + $this->seedFavoriteSeller($member, $admin, now()->subDays(2)); + $this->seedFavoriteSeller($admin, $member, now()->subDays(1)); - $this->seedFavoriteSearches($partner, $this->partnerSearchPayloads()); + $this->seedFavoriteSearches($member, $this->memberSearchPayloads()); $this->seedFavoriteSearches($admin, $this->adminSearchPayloads()); } @@ -147,27 +154,31 @@ class FavoriteDemoSeeder extends Seeder } } - private function partnerSearchPayloads(): array + private function memberSearchPayloads(): array { - $electronicsId = Category::query()->where('name', 'Electronics')->value('id'); - $vehiclesId = Category::query()->where('name', 'Vehicles')->value('id'); - $realEstateId = Category::query()->where('name', 'Real Estate')->value('id'); + $electronicsId = Category::query()->where('slug', 'electronics')->value('id'); + $vehiclesId = Category::query()->where('slug', 'vehicles')->value('id'); + $realEstateId = Category::query()->where('slug', 'real-estate')->value('id'); + $servicesId = Category::query()->where('slug', 'services')->value('id'); return [ ['search' => 'iphone', 'category_id' => $electronicsId], ['search' => 'sedan', 'category_id' => $vehiclesId], ['search' => 'apartment', 'category_id' => $realEstateId], + ['search' => 'repair', 'category_id' => $servicesId], ]; } private function adminSearchPayloads(): array { - $fashionId = Category::query()->where('name', 'Fashion')->value('id'); - $homeGardenId = Category::query()->where('name', 'Home & Garden')->value('id'); + $fashionId = Category::query()->where('slug', 'fashion')->value('id'); + $homeGardenId = Category::query()->where('slug', 'home-garden')->value('id'); + $sportsId = Category::query()->where('slug', 'sports')->value('id'); return [ ['search' => 'vintage', 'category_id' => $fashionId], ['search' => 'garden', 'category_id' => $homeGardenId], + ['search' => 'fitness', 'category_id' => $sportsId], ]; } } diff --git a/Modules/Listing/Database/Seeders/ListingPanelDemoSeeder.php b/Modules/Listing/Database/Seeders/ListingPanelDemoSeeder.php index 46cfaa747..712da266b 100644 --- a/Modules/Listing/Database/Seeders/ListingPanelDemoSeeder.php +++ b/Modules/Listing/Database/Seeders/ListingPanelDemoSeeder.php @@ -11,48 +11,100 @@ use Modules\User\App\Models\User; class ListingPanelDemoSeeder extends Seeder { - private const PANEL_LISTINGS = [ - [ - 'slug' => 'admin-demo-sold-camera', - 'title' => 'Admin Demo Camera Bundle', - 'description' => 'Sample sold listing for the panel filters and activity cards.', - 'price' => 18450, - 'status' => 'sold', - 'city' => 'Istanbul', - 'country' => 'Turkey', - 'image' => 'sample_image/macbook.jpg', - 'expires_offset_days' => 12, - 'is_featured' => false, + private const USER_PANEL_LISTINGS = [ + 'a@a.com' => [ + [ + 'slug' => 'admin-demo-pro-workstation', + 'title' => 'Admin Demo Pro Workstation', + 'description' => 'Active demo listing for inbox, favorites, and video testing.', + 'price' => 28450, + 'status' => 'active', + 'city' => 'Istanbul', + 'country' => 'Turkey', + 'image' => 'sample_image/macbook.jpg', + 'expires_offset_days' => 21, + 'is_featured' => true, + ], + [ + 'slug' => 'admin-demo-sold-camera', + 'title' => 'Admin Demo Camera Bundle', + 'description' => 'Sold demo listing for panel status testing.', + 'price' => 18450, + 'status' => 'sold', + 'city' => 'Ankara', + 'country' => 'Turkey', + 'image' => 'sample_image/headphones.jpg', + 'expires_offset_days' => 12, + 'is_featured' => false, + ], + [ + 'slug' => 'admin-demo-expired-sofa', + 'title' => 'Admin Demo Sofa Set', + 'description' => 'Expired demo listing for republish testing.', + 'price' => 9800, + 'status' => 'expired', + 'city' => 'Izmir', + 'country' => 'Turkey', + 'image' => 'sample_image/cup.jpg', + 'expires_offset_days' => -5, + 'is_featured' => false, + ], ], - [ - 'slug' => 'admin-demo-expired-sofa', - 'title' => 'Admin Demo Sofa Set', - 'description' => 'Sample expired listing for the panel filters and republish flow.', - 'price' => 9800, - 'status' => 'expired', - 'city' => 'Ankara', - 'country' => 'Turkey', - 'image' => 'sample_image/cup.jpg', - 'expires_offset_days' => -5, - 'is_featured' => false, - ], - [ - 'slug' => 'admin-demo-expired-bike', - 'title' => 'Admin Demo City Bike', - 'description' => 'Extra expired sample listing so My Listings is not empty in filtered views.', - 'price' => 6200, - 'status' => 'expired', - 'city' => 'Izmir', - 'country' => 'Turkey', - 'image' => 'sample_image/car2.jpeg', - 'expires_offset_days' => -11, - 'is_featured' => false, + 'b@b.com' => [ + [ + 'slug' => 'member-demo-iphone', + 'title' => 'Member Demo iPhone Bundle', + 'description' => 'Active demo listing owned by the member account.', + 'price' => 21990, + 'status' => 'active', + 'city' => 'Bursa', + 'country' => 'Turkey', + 'image' => 'sample_image/phone.jpeg', + 'expires_offset_days' => 24, + 'is_featured' => true, + ], + [ + 'slug' => 'member-demo-city-bike', + 'title' => 'Member Demo City Bike', + 'description' => 'Second active listing so conversations and favorites are easy to test.', + 'price' => 7600, + 'status' => 'active', + 'city' => 'Antalya', + 'country' => 'Turkey', + 'image' => 'sample_image/car2.jpeg', + 'expires_offset_days' => 17, + 'is_featured' => false, + ], + [ + 'slug' => 'member-demo-vintage-chair', + 'title' => 'Member Demo Vintage Chair', + 'description' => 'Sold listing for panel filters on the member account.', + 'price' => 4350, + 'status' => 'sold', + 'city' => 'Istanbul', + 'country' => 'Turkey', + 'image' => 'sample_image/car.jpeg', + 'expires_offset_days' => 8, + 'is_featured' => false, + ], + [ + 'slug' => 'member-demo-garden-tools', + 'title' => 'Member Demo Garden Tools Set', + 'description' => 'Expired listing for republish flow on the member account.', + 'price' => 3150, + 'status' => 'expired', + 'city' => 'Ankara', + 'country' => 'Turkey', + 'image' => 'sample_image/laptop.jpg', + 'expires_offset_days' => -7, + 'is_featured' => false, + ], ], ]; public function run(): void { - $admin = $this->resolveAdminUser(); + $admin = $this->resolveUserByEmail('a@a.com'); if (! $admin) { return; @@ -66,42 +118,48 @@ class ListingPanelDemoSeeder extends Seeder return; } - foreach (self::PANEL_LISTINGS as $index => $payload) { - $category = $categories->get($index % $categories->count()); + foreach (self::USER_PANEL_LISTINGS as $email => $payloads) { + $owner = $this->resolveUserByEmail($email); - if (! $category instanceof Category) { + if (! $owner) { continue; } - $listing = Listing::updateOrCreate( - ['slug' => $payload['slug']], - [ - 'slug' => $payload['slug'], - 'title' => $payload['title'], - 'description' => $payload['description'], - 'price' => $payload['price'], - 'currency' => 'TRY', - 'city' => $payload['city'], - 'country' => $payload['country'], - 'category_id' => $category->getKey(), - 'user_id' => $admin->getKey(), - 'status' => $payload['status'], - 'contact_email' => $admin->email, - 'contact_phone' => '+905551112233', - 'is_featured' => $payload['is_featured'], - 'expires_at' => now()->addDays((int) $payload['expires_offset_days']), - ] - ); + foreach ($payloads as $index => $payload) { + $category = $categories->get($index % $categories->count()); - $this->syncListingImage($listing, (string) $payload['image']); + if (! $category instanceof Category) { + continue; + } + + $listing = Listing::updateOrCreate( + ['slug' => $payload['slug']], + [ + 'slug' => $payload['slug'], + 'title' => $payload['title'], + 'description' => $payload['description'], + 'price' => $payload['price'], + 'currency' => 'TRY', + 'city' => $payload['city'], + 'country' => $payload['country'], + 'category_id' => $category->getKey(), + 'user_id' => $owner->getKey(), + 'status' => $payload['status'], + 'contact_email' => $owner->email, + 'contact_phone' => $email === 'a@a.com' ? '+905551112233' : '+905551112244', + 'is_featured' => $payload['is_featured'], + 'expires_at' => now()->addDays((int) $payload['expires_offset_days']), + ] + ); + + $this->syncListingImage($listing, (string) $payload['image']); + } } } - private function resolveAdminUser(): ?User + private function resolveUserByEmail(string $email): ?User { - return User::query()->where('email', 'a@a.com')->first() - ?? User::query()->whereHas('roles', fn ($query) => $query->where('name', 'admin'))->first() - ?? User::query()->first(); + return User::query()->where('email', $email)->first(); } private function claimAllListingsForAdmin(User $admin): void diff --git a/Modules/Listing/Database/Seeders/ListingSeeder.php b/Modules/Listing/Database/Seeders/ListingSeeder.php index ea7d84f50..85b945c88 100644 --- a/Modules/Listing/Database/Seeders/ListingSeeder.php +++ b/Modules/Listing/Database/Seeders/ListingSeeder.php @@ -14,16 +14,6 @@ use Modules\User\App\Models\User; class ListingSeeder extends Seeder { - private const SAMPLE_IMAGES = [ - 'sample_image/phone.jpeg', - 'sample_image/macbook.jpg', - 'sample_image/car.jpeg', - 'sample_image/headphones.jpg', - 'sample_image/laptop.jpg', - 'sample_image/cup.jpg', - 'sample_image/car2.jpeg', - ]; - private const TITLE_PREFIXES = [ 'Clean', 'Lightly used', @@ -132,7 +122,6 @@ class ListingSeeder extends Seeder Collection $turkeyCities ): array { $location = $this->resolveLocation($index, $countries, $turkeyCities); - $image = self::SAMPLE_IMAGES[$index % count(self::SAMPLE_IMAGES)]; return [ 'title' => $this->buildTitle($category, $index), @@ -140,7 +129,7 @@ class ListingSeeder extends Seeder 'price' => $this->priceForIndex($index), 'city' => $location['city'], 'country' => $location['country'], - 'image' => $image, + 'image' => null, ]; } @@ -238,8 +227,14 @@ class ListingSeeder extends Seeder ); } - private function syncListingImage(Listing $listing, string $imageRelativePath): void + private function syncListingImage(Listing $listing, ?string $imageRelativePath): void { + if (blank($imageRelativePath)) { + $listing->clearMediaCollection('listing-images'); + + return; + } + $imageAbsolutePath = public_path($imageRelativePath); if (! is_file($imageAbsolutePath)) { diff --git a/Modules/Listing/resources/views/themes/otoplus/show.blade.php b/Modules/Listing/resources/views/themes/otoplus/show.blade.php index e2930b6f2..01c63e4fe 100644 --- a/Modules/Listing/resources/views/themes/otoplus/show.blade.php +++ b/Modules/Listing/resources/views/themes/otoplus/show.blade.php @@ -61,12 +61,11 @@ $reportUrl = 'mailto:'.$reportEmail.'?subject='.rawurlencode('Report listing '.$referenceCode); $shareUrl = route('listings.show', $listing); - $specRows = collect([ - ['label' => 'Price', 'value' => $priceLabel], - ['label' => 'Published', 'value' => $publishedAt], + $detailRows = collect([ ['label' => 'Listing ID', 'value' => $referenceCode], + ['label' => 'Published', 'value' => $publishedAt], ['label' => 'Category', 'value' => $listing->category?->name ?? 'General'], - ['label' => 'Location', 'value' => $locationLabel !== '' ? str_replace(' / ', ' / ', $locationLabel) : 'Not specified'], + ['label' => 'Location', 'value' => $locationLabel !== '' ? $locationLabel : 'Not specified'], ]) ->merge( collect($presentableCustomFields ?? [])->map(fn (array $field) => [ @@ -77,6 +76,9 @@ ->filter(fn (array $item) => $item['label'] !== '' && $item['value'] !== '') ->unique(fn (array $item) => mb_strtolower($item['label'])) ->values(); + $summaryRows = $detailRows->take(8); + $sellerListingsUrl = $listing->user ? route('listings.index', ['user' => $listing->user->getKey()]) : route('listings.index'); + $locationText = $locationLabel !== '' ? $locationLabel : 'Location not specified'; @endphp
@@ -90,314 +92,275 @@ {{ $displayTitle }} -
-
-
-

{{ $listing->category?->name ?? 'Marketplace listing' }}

-

{{ $displayTitle }}

-
- {{ $referenceCode }} - {{ $sellerName }} - {{ $postedAgo }} -
+
+
+

{{ $referenceCode }}

+

{{ $displayTitle }}

+
+ {{ $listing->category?->name ?? 'Marketplace listing' }} + {{ $sellerName }} + {{ $postedAgo }}
+
-
-
{{ $priceLabel }}
- @if($locationLabel !== '') -
{{ $locationLabel }}
- @endif +
+ -
- + + @else + Save listing + @endauth - @auth -
- @csrf - -
- @else - Save listing - @endauth -
-
+
-
-
-
-
-
- Report this listing -
+
+
+
{{ $priceLabel }}
+
{{ $publishedAt }}
+
-
-
-
-

Description

-

Seller notes, condition, and extra context.

-
-
+
{{ $locationText }}
-
- {!! nl2br(e($displayDescription)) !!} -
-
+
+ @foreach($summaryRows as $row) +
+ {{ $row['label'] }} + {{ $row['value'] }} +
+ @endforeach +
+ + Report this listing +
+ +
- - +
+
+ + +
+ +
+
+ @foreach($detailRows as $row) +
+ {{ $row['label'] }} + {{ $row['value'] }} +
+ @endforeach +
+
+ +
+
+ {!! nl2br(e($displayDescription)) !!} +
+
+
+ + @if(($listingVideos ?? collect())->isNotEmpty()) +
+
+
+

Videos

+

Additional media attached to the listing.

+
+
+ +
+ @foreach($listingVideos as $video) +
+ +

{{ $video->titleLabel() }}

+
+ @endforeach +
+
+ @endif +
diff --git a/Modules/User/App/Models/User.php b/Modules/User/App/Models/User.php index 46be020d7..1e235d235 100644 --- a/Modules/User/App/Models/User.php +++ b/Modules/User/App/Models/User.php @@ -22,6 +22,7 @@ use Spatie\Activitylog\LogOptions; use Spatie\Activitylog\Traits\LogsActivity; use Spatie\ModelStates\HasStates; use Spatie\Permission\Traits\HasRoles; +use Throwable; class User extends Authenticatable implements FilamentUser, HasAvatar { @@ -186,4 +187,36 @@ class User extends Authenticatable implements FilamentUser, HasAvatar return true; } + + public function unreadInboxCount(): int + { + return Conversation::unreadCountForUser((int) $this->getKey()); + } + + public function unreadNotificationCount(): int + { + try { + return (int) $this->unreadNotifications()->count(); + } catch (Throwable) { + return 0; + } + } + + public function savedListingsCount(): int + { + try { + return (int) $this->favoriteListings()->count(); + } catch (Throwable) { + return 0; + } + } + + public function headerBadgeCounts(): array + { + return [ + 'messages' => $this->unreadInboxCount(), + 'notifications' => $this->unreadNotificationCount(), + 'favorites' => $this->savedListingsCount(), + ]; + } } diff --git a/Modules/User/database/seeders/UserWorkspaceSeeder.php b/Modules/User/database/seeders/UserWorkspaceSeeder.php new file mode 100644 index 000000000..ad0932318 --- /dev/null +++ b/Modules/User/database/seeders/UserWorkspaceSeeder.php @@ -0,0 +1,18 @@ +call([ + \Modules\Listing\Database\Seeders\ListingPanelDemoSeeder::class, + \Modules\Favorite\Database\Seeders\FavoriteDemoSeeder::class, + \Modules\Conversation\Database\Seeders\ConversationDemoSeeder::class, + \Modules\Video\Database\Seeders\VideoDemoSeeder::class, + ]); + } +} diff --git a/Modules/Video/database/seeders/VideoDemoSeeder.php b/Modules/Video/database/seeders/VideoDemoSeeder.php new file mode 100644 index 000000000..b8b43d6e5 --- /dev/null +++ b/Modules/Video/database/seeders/VideoDemoSeeder.php @@ -0,0 +1,102 @@ + [ + [ + 'title' => 'Workspace walkaround', + 'description' => 'Pending demo video for upload and processing states.', + 'status' => VideoStatus::Pending, + 'is_active' => true, + 'processing_error' => null, + ], + [ + 'title' => 'Packaging close-up', + 'description' => 'Failed demo video to test retry and edit flows.', + 'status' => VideoStatus::Failed, + 'is_active' => false, + 'processing_error' => 'Demo processing was skipped intentionally.', + ], + ], + 'b@b.com' => [ + [ + 'title' => 'Short product overview', + 'description' => 'Pending demo video for the member workspace.', + 'status' => VideoStatus::Pending, + 'is_active' => true, + 'processing_error' => null, + ], + [ + 'title' => 'Condition details clip', + 'description' => 'Failed demo video to show a second panel state.', + 'status' => VideoStatus::Failed, + 'is_active' => false, + 'processing_error' => 'Demo processing was skipped intentionally.', + ], + ], + ]; + + public function run(): void + { + if (! Schema::hasTable('videos') || ! Schema::hasTable('listings')) { + return; + } + + foreach (self::VIDEO_BLUEPRINTS as $email => $blueprints) { + $user = User::query()->where('email', $email)->first(); + + if (! $user) { + continue; + } + + $listings = Listing::query() + ->where('user_id', $user->getKey()) + ->where('status', 'active') + ->orderBy('id') + ->take(count($blueprints)) + ->get(); + + foreach ($blueprints as $index => $blueprint) { + $listing = $listings->get($index); + + if (! $listing) { + continue; + } + + $video = Video::query()->firstOrNew([ + 'listing_id' => $listing->getKey(), + 'user_id' => $user->getKey(), + 'title' => $blueprint['title'], + ]); + + $video->forceFill([ + 'listing_id' => $listing->getKey(), + 'user_id' => $user->getKey(), + 'title' => $blueprint['title'], + 'description' => $blueprint['description'], + 'status' => $blueprint['status'], + 'disk' => 'public', + 'path' => null, + 'upload_disk' => 'public', + 'upload_path' => null, + 'mime_type' => 'video/mp4', + 'size' => null, + 'sort_order' => $index + 1, + 'is_active' => $blueprint['is_active'], + 'processing_error' => $blueprint['processing_error'], + 'processed_at' => null, + ])->saveQuietly(); + } + } + } +} diff --git a/app/Support/HomeSlideDefaults.php b/app/Support/HomeSlideDefaults.php index ceb21556a..7b00d81c1 100644 --- a/app/Support/HomeSlideDefaults.php +++ b/app/Support/HomeSlideDefaults.php @@ -48,7 +48,7 @@ final class HomeSlideDefaults $normalized = collect($source) ->filter(fn ($slide): bool => is_array($slide)) ->values() - ->map(function (array $slide, int $index) use ($defaults): ?array { + ->map(function (array $slide, int $index) use ($defaults, $defaultDisk): ?array { $fallback = $defaults[$index] ?? $defaults[array_key_last($defaults)]; $badge = trim((string) ($slide['badge'] ?? '')); $title = trim((string) ($slide['title'] ?? '')); diff --git a/app/Support/RequestAppData.php b/app/Support/RequestAppData.php index 371aedf51..b99f43823 100644 --- a/app/Support/RequestAppData.php +++ b/app/Support/RequestAppData.php @@ -9,6 +9,7 @@ use Illuminate\Support\Facades\View; use Modules\Category\Models\Category; use Modules\Location\Models\Country; use Modules\S3\Support\MediaStorage; +use Modules\User\App\Models\User; use Throwable; final class RequestAppData @@ -22,6 +23,7 @@ final class RequestAppData View::share('generalSettings', $generalSettings); View::share('headerLocationCountries', $this->resolveHeaderLocationCountries()); View::share('headerNavCategories', $this->resolveHeaderNavCategories()); + View::share('headerAccountMeta', $this->resolveHeaderAccountMeta()); } private function resolveGeneralSettings(): array @@ -214,6 +216,24 @@ final class RequestAppData } } + private function resolveHeaderAccountMeta(): ?array + { + $user = auth()->user(); + + if (! $user instanceof User) { + return null; + } + + $badgeCounts = $user->headerBadgeCounts(); + + return [ + 'name' => $user->getDisplayName(), + 'messages' => max(0, (int) ($badgeCounts['messages'] ?? 0)), + 'notifications' => max(0, (int) ($badgeCounts['notifications'] ?? 0)), + 'favorites' => max(0, (int) ($badgeCounts['favorites'] ?? 0)), + ]; + } + private function normalizeCurrencies(array $currencies): array { $normalized = collect($currencies) diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index f1e8e5eee..3a7f6f8e5 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -15,12 +15,7 @@ class DatabaseSeeder extends Seeder \Modules\Category\Database\Seeders\CategorySeeder::class, \Modules\Listing\Database\Seeders\ListingCustomFieldSeeder::class, \Modules\Listing\Database\Seeders\ListingSeeder::class, + \Modules\User\Database\Seeders\UserWorkspaceSeeder::class, ]); - - if ((bool) config('demo.enabled') || (bool) config('demo.provisioning')) { - $this->call([ - \Modules\Demo\Database\Seeders\DemoContentSeeder::class, - ]); - } } } diff --git a/resources/css/app.css b/resources/css/app.css index d7ccdf609..5c05a62ee 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -192,6 +192,11 @@ h6 { min-width: 0; } +.oc-location[open], +.oc-account-menu[open] { + z-index: 90; +} + .oc-location-trigger { justify-content: space-between; width: 100%; @@ -435,7 +440,14 @@ h6 { align-items: center; gap: 8px; overflow-x: auto; + overflow-y: hidden; padding-bottom: 2px; + scrollbar-width: none; + -ms-overflow-style: none; +} + +.oc-category-track::-webkit-scrollbar { + display: none; } .oc-category-pill { @@ -534,6 +546,116 @@ h6 { height: 1.125rem; } +.oc-header-icon { + position: relative; +} + +.oc-header-badge { + position: absolute; + top: -0.35rem; + right: -0.2rem; + min-width: 1.2rem; + height: 1.2rem; + padding: 0 0.28rem; + border-radius: 0.45rem; + background: #e66767; + color: #ffffff; + font-size: 0.72rem; + font-weight: 800; + line-height: 1; + display: inline-flex; + align-items: center; + justify-content: center; + box-shadow: 0 6px 14px rgba(230, 103, 103, 0.28); +} + +.oc-header-badge.is-neutral { + background: #5f6f89; + box-shadow: 0 6px 14px rgba(95, 111, 137, 0.24); +} + +.oc-account-menu { + position: relative; +} + +.oc-account-trigger { + min-height: 2.9rem; + padding: 0 0.9rem 0 1rem; + border: 1px solid rgba(29, 29, 31, 0.08); + border-radius: 999px; + background: rgba(61, 69, 97, 0.92); + color: #f8fafc; + display: inline-flex; + align-items: center; + gap: 0.6rem; + box-shadow: 0 10px 24px rgba(15, 23, 42, 0.16); + transition: background 0.2s ease, transform 0.2s ease, box-shadow 0.2s ease; +} + +.oc-account-trigger:hover { + transform: translateY(-1px); + background: rgba(55, 63, 90, 0.98); + box-shadow: 0 14px 28px rgba(15, 23, 42, 0.18); +} + +.oc-account-name { + max-width: 9rem; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 0.95rem; + font-weight: 700; +} + +.oc-account-chevron { + width: 1rem; + height: 1rem; + flex-shrink: 0; + opacity: 0.84; +} + +.oc-account-panel { + position: absolute; + top: calc(100% + 12px); + left: 0; + z-index: 20; + min-width: 13rem; + padding: 0.6rem; + border: 1px solid rgba(29, 29, 31, 0.08); + border-radius: 1rem; + background: rgba(255, 255, 255, 0.98); + box-shadow: 0 24px 48px rgba(15, 23, 42, 0.14); + backdrop-filter: blur(18px); + display: grid; + gap: 0.25rem; +} + +.oc-account-link { + min-height: 2.7rem; + padding: 0 0.9rem; + border-radius: 0.8rem; + color: #344054; + font-size: 0.9rem; + font-weight: 600; + text-decoration: none; + display: inline-flex; + align-items: center; +} + +.oc-account-link:hover, +.oc-account-link-button:hover { + background: #f3f6fa; + color: var(--oc-text); +} + +.oc-account-link-button { + width: 100%; + border: 0; + background: transparent; + text-align: left; + cursor: pointer; +} + .header-utility.oc-compact-menu-trigger, .header-utility.oc-mobile-menu-close { display: inline-flex; @@ -547,6 +669,7 @@ h6 { .location-panel { width: min(calc(100vw - 32px), 360px); + z-index: 120; } .location-panel select { @@ -682,6 +805,10 @@ h6 { box-shadow: none; } + .oc-account-trigger { + min-height: 3rem; + } + .oc-text-link { min-height: 3rem; display: inline-flex; @@ -707,6 +834,15 @@ h6 { .oc-category-row { display: block; + overflow: hidden; + } + + .oc-category-track { + flex-wrap: wrap; + row-gap: 10px; + overflow-x: hidden; + overflow-y: visible; + padding-bottom: 0; } } @@ -1674,6 +1810,231 @@ summary::-webkit-details-marker { font-weight: 600; } +.ld-header-card { + display: grid; + gap: 18px; + padding: 18px 16px; + margin-bottom: 16px; +} + +.ld-header-ref { + margin: 0 0 8px; + color: #98a2b3; + font-size: 0.8rem; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.ld-header-title { + margin: 0; + color: var(--oc-text); + font-size: 1.75rem; + line-height: 1.12; + font-weight: 700; + letter-spacing: -0.05em; +} + +.ld-header-meta { + display: flex; + flex-wrap: wrap; + gap: 8px 14px; + margin-top: 12px; + color: #667085; + font-size: 0.9rem; +} + +.ld-header-actions { + display: flex; + flex-wrap: wrap; + gap: 10px; + align-items: center; +} + +.ld-inline-form { + display: inline-flex; +} + +.ld-header-action { + min-height: 42px; + padding: 0 16px; + border: 1px solid rgba(29, 29, 31, 0.08); + border-radius: 999px; + background: rgba(255, 255, 255, 0.88); + color: #344054; + font-size: 0.9rem; + font-weight: 600; + text-decoration: none; + display: inline-flex; + align-items: center; + justify-content: center; + cursor: pointer; + transition: transform 0.2s ease, box-shadow 0.2s ease, background 0.2s ease, color 0.2s ease, border-color 0.2s ease; +} + +.ld-header-action:hover, +.ld-seller-link:hover { + transform: translateY(-1px); + box-shadow: 0 12px 24px rgba(15, 23, 42, 0.08); + background: #ffffff; + color: var(--oc-text); +} + +.ld-header-action.is-active { + border-color: rgba(0, 113, 227, 0.18); + background: rgba(0, 113, 227, 0.08); + color: var(--oc-primary); +} + +.ld-stage { + display: grid; + gap: 16px; + grid-template-areas: + "gallery" + "summary" + "seller"; +} + +.ld-gallery-card { + grid-area: gallery; + padding: 14px; + overflow: hidden; +} + +.ld-gallery-card .lt-gallery-main { + min-height: 360px; + border-radius: 20px; + background: linear-gradient(180deg, #edf2f7 0%, #dbe3ee 100%); +} + +.ld-gallery-card .lt-gallery-main img { + min-height: 360px; + padding: 14px; +} + +.ld-gallery-card .lt-gallery-top { + justify-content: space-between; + align-items: center; +} + +.ld-gallery-chip { + display: inline-flex; + align-items: center; + min-height: 34px; + padding: 0 12px; + border: 1px solid rgba(255, 255, 255, 0.4); + border-radius: 999px; + background: rgba(255, 255, 255, 0.78); + color: #0f172a; + font-size: 0.76rem; + font-weight: 700; + letter-spacing: 0.02em; + backdrop-filter: blur(14px); +} + +.ld-summary-card, +.ld-seller-card, +.ld-tab-card { + padding: 18px 16px; +} + +.ld-summary-card { + grid-area: summary; + display: grid; + gap: 18px; +} + +.ld-summary-head { + display: flex; + flex-wrap: wrap; + justify-content: space-between; + align-items: flex-end; + gap: 10px 18px; +} + +.ld-summary-price { + color: var(--oc-text); + font-size: clamp(2rem, 4vw, 2.8rem); + line-height: 1; + font-weight: 700; + letter-spacing: -0.06em; +} + +.ld-summary-date { + color: #667085; + font-size: 0.92rem; + font-weight: 600; +} + +.ld-summary-location { + color: #0b4a8a; + font-size: 1rem; + font-weight: 600; + line-height: 1.5; +} + +.ld-summary-card .lt-spec-table, +.ld-tab-card .lt-spec-table { + border-top: 0; +} + +.ld-summary-card .lt-spec-row:first-child, +.ld-tab-card .lt-spec-row:first-child { + padding-top: 0; +} + +.ld-seller-card { + grid-area: seller; + display: grid; + gap: 16px; + align-self: start; +} + +.ld-seller-links { + display: flex; + flex-wrap: wrap; + gap: 10px 14px; +} + +.ld-seller-link { + padding: 0; + border: 0; + background: transparent; + color: var(--oc-primary); + font-size: 0.92rem; + font-weight: 600; + text-decoration: none; + display: inline-flex; + align-items: center; + cursor: pointer; + transition: transform 0.2s ease, box-shadow 0.2s ease, color 0.2s ease; +} + +.ld-seller-card .lt-contact-panel { + margin-bottom: 0; +} + +.ld-empty-contact { + padding: 14px; + border: 1px solid rgba(29, 29, 31, 0.08); + border-radius: 18px; + background: linear-gradient(180deg, #ffffff 0%, #f7f9fc 100%); + color: #667085; + font-size: 0.9rem; +} + +.ld-tab-card { + margin-top: 18px; +} + +.ld-tab-card .lt-tab-list { + margin-bottom: 18px; +} + +.ld-tab-card .lt-description { + max-width: 78ch; +} + @media (min-width: 721px) { .lt-wrap { padding: 22px 16px 56px; @@ -1703,6 +2064,34 @@ summary::-webkit-details-marker { line-height: 1.15; } + .ld-header-card { + padding: 22px 24px; + } + + .ld-header-title { + font-size: 2.4rem; + } + + .ld-gallery-card { + padding: 18px; + } + + .ld-gallery-card .lt-gallery-main { + min-height: 520px; + border-radius: 24px; + } + + .ld-gallery-card .lt-gallery-main img { + min-height: 520px; + padding: 28px; + } + + .ld-summary-card, + .ld-seller-card, + .ld-tab-card { + padding: 22px; + } + .lt-hero-side { justify-items: flex-end; } @@ -1786,6 +2175,19 @@ summary::-webkit-details-marker { } @media (min-width: 900px) { + .ld-stage { + grid-template-columns: minmax(0, 1fr) 360px; + grid-template-areas: + "gallery summary" + "gallery seller"; + align-items: start; + } + + .ld-seller-card { + position: sticky; + top: 92px; + } + .lt-media-spec-grid { grid-template-columns: minmax(0, 1fr) minmax(320px, 360px); align-items: start; @@ -1801,6 +2203,11 @@ summary::-webkit-details-marker { } @media (min-width: 1180px) { + .ld-stage { + grid-template-columns: minmax(0, 1fr) 420px 300px; + grid-template-areas: "gallery summary seller"; + } + .lt-grid { grid-template-columns: minmax(0, 1fr) 320px; align-items: start; diff --git a/resources/views/layouts/app.blade.php b/resources/views/layouts/app.blade.php index 0a0f7fb4c..5fa03a29f 100644 --- a/resources/views/layouts/app.blade.php +++ b/resources/views/layouts/app.blade.php @@ -14,6 +14,8 @@ $panelListingsRoute = auth()->check() ? route('panel.listings.index') : $loginRoute; $inboxRoute = auth()->check() ? route('panel.inbox.index') : $loginRoute; $favoritesRoute = auth()->check() ? route('favorites.index') : $loginRoute; + $profileRoute = auth()->check() ? route('panel.profile.edit') : $loginRoute; + $notificationsRoute = auth()->check() ? route('panel.listings.index') : $loginRoute; $demoEnabled = (bool) config('demo.enabled'); $hasDemoSession = (bool) session('is_demo_session') || filled(session('demo_uuid')); $demoLandingMode = $demoEnabled && request()->routeIs('home') && !auth()->check() && !$hasDemoSession; @@ -62,6 +64,11 @@ ? route('locations.cities', ['country' => '__COUNTRY__'], false) : ''; $simplePage = trim((string) $__env->yieldContent('simple_page')) === '1'; + $headerAccount = is_array($headerAccountMeta ?? null) ? $headerAccountMeta : null; + $headerMessageCount = max(0, (int) ($headerAccount['messages'] ?? 0)); + $headerNotificationCount = max(0, (int) ($headerAccount['notifications'] ?? 0)); + $headerFavoritesCount = max(0, (int) ($headerAccount['favorites'] ?? 0)); + $headerBadgeLabel = static fn (int $count): string => $count > 99 ? '99+' : (string) $count; @endphp @@ -191,29 +198,53 @@ @auth - - - - - - + + + @if($headerMessageCount > 0) + {{ $headerBadgeLabel($headerMessageCount) }} + @endif - + + @if($headerNotificationCount > 0) + {{ $headerBadgeLabel($headerNotificationCount) }} + @endif - + + + + + + @if($headerFavoritesCount > 0) + {{ $headerBadgeLabel($headerFavoritesCount) }} + @endif + + Sell -
- @csrf - -
@else {{ __('messages.login') }}