Refactor home layout and seed data

This commit is contained in:
fatihalp 2026-03-08 20:18:56 +03:00
parent 46b70a91f7
commit 8c0365e710
16 changed files with 1072 additions and 403 deletions

View File

@ -13,7 +13,7 @@ class CategorySeeder extends Seeder
['name' => 'Vehicles', 'slug' => 'vehicles', 'icon' => 'img/category/car.png', 'children' => ['Cars', 'Motorcycles', 'Trucks', 'Boats']], ['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' => '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' => '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' => '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' => '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']], ['name' => 'Services', 'slug' => 'services', 'icon' => 'img/category/home_tools.png', 'children' => ['Cleaning', 'Repair', 'Education']],

View File

@ -165,4 +165,15 @@ class Conversation extends Model
return is_null($value) ? null : (int) $value; 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();
}
} }

View File

@ -19,41 +19,69 @@ class ConversationDemoSeeder extends Seeder
} }
$admin = User::query()->where('email', 'a@a.com')->first(); $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; return;
} }
$listings = Listing::query() $adminListings = Listing::query()
->where('user_id', $admin->getKey()) ->where('user_id', $admin->getKey())
->where('status', 'active') ->where('status', 'active')
->orderBy('id') ->orderBy('id')
->take(2) ->take(2)
->get(); ->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; return;
} }
$this->seedConversationThread( $this->seedConversationThread(
$listings->get(0),
$admin, $admin,
$partner, $member,
$adminListings->get(0),
[ [
['sender' => 'partner', 'body' => 'Hi, is this still available?', 'hours_ago' => 30, 'read_after_minutes' => 4], ['sender' => 'buyer', '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' => 'seller', 'body' => 'Yes, it is. 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' => 'Perfect. Can we meet tomorrow afternoon?', 'hours_ago' => 4, 'read_after_minutes' => null],
] ]
); );
$this->seedConversationThread( $this->seedConversationThread(
$listings->get(1),
$admin, $admin,
$partner, $member,
$adminListings->get(1),
[ [
['sender' => 'partner', 'body' => 'Can you confirm the final price?', 'hours_ago' => 20, 'read_after_minutes' => 8], ['sender' => 'buyer', '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' => '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( private function seedConversationThread(
User $seller,
User $buyer,
?Listing $listing, ?Listing $listing,
User $admin,
User $partner,
array $messages array $messages
): void { ): void {
if (! $listing) { if (! $listing) {
@ -76,10 +104,10 @@ class ConversationDemoSeeder extends Seeder
$conversation = Conversation::updateOrCreate( $conversation = Conversation::updateOrCreate(
[ [
'listing_id' => $listing->getKey(), 'listing_id' => $listing->getKey(),
'buyer_id' => $partner->getKey(), 'buyer_id' => $buyer->getKey(),
], ],
[ [
'seller_id' => $admin->getKey(), 'seller_id' => $seller->getKey(),
'last_message_at' => now(), 'last_message_at' => now(),
] ]
); );
@ -92,7 +120,7 @@ class ConversationDemoSeeder extends Seeder
foreach ($messages as $payload) { foreach ($messages as $payload) {
$createdAt = now()->subHours((int) $payload['hours_ago']); $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']; $readAfterMinutes = $payload['read_after_minutes'];
$readAt = is_numeric($readAfterMinutes) ? $createdAt->copy()->addMinutes((int) $readAfterMinutes) : null; $readAt = is_numeric($readAfterMinutes) ? $createdAt->copy()->addMinutes((int) $readAfterMinutes) : null;
@ -110,7 +138,7 @@ class ConversationDemoSeeder extends Seeder
} }
$conversation->forceFill([ $conversation->forceFill([
'seller_id' => $admin->getKey(), 'seller_id' => $seller->getKey(),
'last_message_at' => $lastMessageAt, 'last_message_at' => $lastMessageAt,
'updated_at' => $lastMessageAt, 'updated_at' => $lastMessageAt,
])->saveQuietly(); ])->saveQuietly();

View File

@ -9,10 +9,7 @@ class DemoContentSeeder extends Seeder
public function run(): void public function run(): void
{ {
$this->call([ $this->call([
\Modules\User\Database\Seeders\AuthUserSeeder::class, \Modules\User\Database\Seeders\UserWorkspaceSeeder::class,
\Modules\Listing\Database\Seeders\ListingPanelDemoSeeder::class,
\Modules\Favorite\Database\Seeders\FavoriteDemoSeeder::class,
\Modules\Conversation\Database\Seeders\ConversationDemoSeeder::class,
]); ]);
} }
} }

View File

@ -20,9 +20,9 @@ class FavoriteDemoSeeder extends Seeder
} }
$admin = User::query()->where('email', 'a@a.com')->first(); $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; return;
} }
@ -32,26 +32,33 @@ class FavoriteDemoSeeder extends Seeder
->orderBy('id') ->orderBy('id')
->get(); ->get();
if ($adminListings->isEmpty()) { $memberListings = Listing::query()
->where('user_id', $member->getKey())
->orderByDesc('is_featured')
->orderBy('id')
->get();
if ($adminListings->isEmpty() || $memberListings->isEmpty()) {
return; return;
} }
$activeAdminListings = $adminListings->where('status', 'active')->values(); $activeAdminListings = $adminListings->where('status', 'active')->values();
$activeMemberListings = $memberListings->where('status', 'active')->values();
$this->seedFavoriteListings( $this->seedFavoriteListings(
$partner, $member,
$activeAdminListings->take(6) $activeAdminListings->take(6)
); );
$this->seedFavoriteListings( $this->seedFavoriteListings(
$admin, $admin,
$adminListings->take(3)->values() $activeMemberListings->take(4)
); );
$this->seedFavoriteSeller($partner, $admin, now()->subDays(2)); $this->seedFavoriteSeller($member, $admin, now()->subDays(2));
$this->seedFavoriteSeller($admin, $partner, now()->subDays(1)); $this->seedFavoriteSeller($admin, $member, now()->subDays(1));
$this->seedFavoriteSearches($partner, $this->partnerSearchPayloads()); $this->seedFavoriteSearches($member, $this->memberSearchPayloads());
$this->seedFavoriteSearches($admin, $this->adminSearchPayloads()); $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'); $electronicsId = Category::query()->where('slug', 'electronics')->value('id');
$vehiclesId = Category::query()->where('name', 'Vehicles')->value('id'); $vehiclesId = Category::query()->where('slug', 'vehicles')->value('id');
$realEstateId = Category::query()->where('name', 'Real Estate')->value('id'); $realEstateId = Category::query()->where('slug', 'real-estate')->value('id');
$servicesId = Category::query()->where('slug', 'services')->value('id');
return [ return [
['search' => 'iphone', 'category_id' => $electronicsId], ['search' => 'iphone', 'category_id' => $electronicsId],
['search' => 'sedan', 'category_id' => $vehiclesId], ['search' => 'sedan', 'category_id' => $vehiclesId],
['search' => 'apartment', 'category_id' => $realEstateId], ['search' => 'apartment', 'category_id' => $realEstateId],
['search' => 'repair', 'category_id' => $servicesId],
]; ];
} }
private function adminSearchPayloads(): array private function adminSearchPayloads(): array
{ {
$fashionId = Category::query()->where('name', 'Fashion')->value('id'); $fashionId = Category::query()->where('slug', 'fashion')->value('id');
$homeGardenId = Category::query()->where('name', 'Home & Garden')->value('id'); $homeGardenId = Category::query()->where('slug', 'home-garden')->value('id');
$sportsId = Category::query()->where('slug', 'sports')->value('id');
return [ return [
['search' => 'vintage', 'category_id' => $fashionId], ['search' => 'vintage', 'category_id' => $fashionId],
['search' => 'garden', 'category_id' => $homeGardenId], ['search' => 'garden', 'category_id' => $homeGardenId],
['search' => 'fitness', 'category_id' => $sportsId],
]; ];
} }
} }

View File

@ -11,48 +11,100 @@ use Modules\User\App\Models\User;
class ListingPanelDemoSeeder extends Seeder class ListingPanelDemoSeeder extends Seeder
{ {
private const PANEL_LISTINGS = [ private const USER_PANEL_LISTINGS = [
[ 'a@a.com' => [
'slug' => 'admin-demo-sold-camera', [
'title' => 'Admin Demo Camera Bundle', 'slug' => 'admin-demo-pro-workstation',
'description' => 'Sample sold listing for the panel filters and activity cards.', 'title' => 'Admin Demo Pro Workstation',
'price' => 18450, 'description' => 'Active demo listing for inbox, favorites, and video testing.',
'status' => 'sold', 'price' => 28450,
'city' => 'Istanbul', 'status' => 'active',
'country' => 'Turkey', 'city' => 'Istanbul',
'image' => 'sample_image/macbook.jpg', 'country' => 'Turkey',
'expires_offset_days' => 12, 'image' => 'sample_image/macbook.jpg',
'is_featured' => false, '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,
],
], ],
[ 'b@b.com' => [
'slug' => 'admin-demo-expired-sofa', [
'title' => 'Admin Demo Sofa Set', 'slug' => 'member-demo-iphone',
'description' => 'Sample expired listing for the panel filters and republish flow.', 'title' => 'Member Demo iPhone Bundle',
'price' => 9800, 'description' => 'Active demo listing owned by the member account.',
'status' => 'expired', 'price' => 21990,
'city' => 'Ankara', 'status' => 'active',
'country' => 'Turkey', 'city' => 'Bursa',
'image' => 'sample_image/cup.jpg', 'country' => 'Turkey',
'expires_offset_days' => -5, 'image' => 'sample_image/phone.jpeg',
'is_featured' => false, 'expires_offset_days' => 24,
], 'is_featured' => true,
[ ],
'slug' => 'admin-demo-expired-bike', [
'title' => 'Admin Demo City Bike', 'slug' => 'member-demo-city-bike',
'description' => 'Extra expired sample listing so My Listings is not empty in filtered views.', 'title' => 'Member Demo City Bike',
'price' => 6200, 'description' => 'Second active listing so conversations and favorites are easy to test.',
'status' => 'expired', 'price' => 7600,
'city' => 'Izmir', 'status' => 'active',
'country' => 'Turkey', 'city' => 'Antalya',
'image' => 'sample_image/car2.jpeg', 'country' => 'Turkey',
'expires_offset_days' => -11, 'image' => 'sample_image/car2.jpeg',
'is_featured' => false, '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 public function run(): void
{ {
$admin = $this->resolveAdminUser(); $admin = $this->resolveUserByEmail('a@a.com');
if (! $admin) { if (! $admin) {
return; return;
@ -66,42 +118,48 @@ class ListingPanelDemoSeeder extends Seeder
return; return;
} }
foreach (self::PANEL_LISTINGS as $index => $payload) { foreach (self::USER_PANEL_LISTINGS as $email => $payloads) {
$category = $categories->get($index % $categories->count()); $owner = $this->resolveUserByEmail($email);
if (! $category instanceof Category) { if (! $owner) {
continue; continue;
} }
$listing = Listing::updateOrCreate( foreach ($payloads as $index => $payload) {
['slug' => $payload['slug']], $category = $categories->get($index % $categories->count());
[
'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']),
]
);
$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() return User::query()->where('email', $email)->first();
?? User::query()->whereHas('roles', fn ($query) => $query->where('name', 'admin'))->first()
?? User::query()->first();
} }
private function claimAllListingsForAdmin(User $admin): void private function claimAllListingsForAdmin(User $admin): void

View File

@ -14,16 +14,6 @@ use Modules\User\App\Models\User;
class ListingSeeder extends Seeder 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 = [ private const TITLE_PREFIXES = [
'Clean', 'Clean',
'Lightly used', 'Lightly used',
@ -132,7 +122,6 @@ class ListingSeeder extends Seeder
Collection $turkeyCities Collection $turkeyCities
): array { ): array {
$location = $this->resolveLocation($index, $countries, $turkeyCities); $location = $this->resolveLocation($index, $countries, $turkeyCities);
$image = self::SAMPLE_IMAGES[$index % count(self::SAMPLE_IMAGES)];
return [ return [
'title' => $this->buildTitle($category, $index), 'title' => $this->buildTitle($category, $index),
@ -140,7 +129,7 @@ class ListingSeeder extends Seeder
'price' => $this->priceForIndex($index), 'price' => $this->priceForIndex($index),
'city' => $location['city'], 'city' => $location['city'],
'country' => $location['country'], '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); $imageAbsolutePath = public_path($imageRelativePath);
if (! is_file($imageAbsolutePath)) { if (! is_file($imageAbsolutePath)) {

View File

@ -61,12 +61,11 @@
$reportUrl = 'mailto:'.$reportEmail.'?subject='.rawurlencode('Report listing '.$referenceCode); $reportUrl = 'mailto:'.$reportEmail.'?subject='.rawurlencode('Report listing '.$referenceCode);
$shareUrl = route('listings.show', $listing); $shareUrl = route('listings.show', $listing);
$specRows = collect([ $detailRows = collect([
['label' => 'Price', 'value' => $priceLabel],
['label' => 'Published', 'value' => $publishedAt],
['label' => 'Listing ID', 'value' => $referenceCode], ['label' => 'Listing ID', 'value' => $referenceCode],
['label' => 'Published', 'value' => $publishedAt],
['label' => 'Category', 'value' => $listing->category?->name ?? 'General'], ['label' => 'Category', 'value' => $listing->category?->name ?? 'General'],
['label' => 'Location', 'value' => $locationLabel !== '' ? str_replace(' / ', ' / ', $locationLabel) : 'Not specified'], ['label' => 'Location', 'value' => $locationLabel !== '' ? $locationLabel : 'Not specified'],
]) ])
->merge( ->merge(
collect($presentableCustomFields ?? [])->map(fn (array $field) => [ collect($presentableCustomFields ?? [])->map(fn (array $field) => [
@ -77,6 +76,9 @@
->filter(fn (array $item) => $item['label'] !== '' && $item['value'] !== '') ->filter(fn (array $item) => $item['label'] !== '' && $item['value'] !== '')
->unique(fn (array $item) => mb_strtolower($item['label'])) ->unique(fn (array $item) => mb_strtolower($item['label']))
->values(); ->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 @endphp
<div class="lt-wrap"> <div class="lt-wrap">
@ -90,314 +92,275 @@
<span>{{ $displayTitle }}</span> <span>{{ $displayTitle }}</span>
</nav> </nav>
<section class="lt-card lt-hero-card"> <section class="lt-card ld-header-card">
<div class="lt-hero-main"> <div class="ld-header-copy">
<div class="lt-hero-copy"> <p class="ld-header-ref">{{ $referenceCode }}</p>
<p class="lt-overline">{{ $listing->category?->name ?? 'Marketplace listing' }}</p> <h1 class="ld-header-title">{{ $displayTitle }}</h1>
<h1 class="lt-hero-title">{{ $displayTitle }}</h1> <div class="ld-header-meta">
<div class="lt-hero-meta"> <span>{{ $listing->category?->name ?? 'Marketplace listing' }}</span>
<span>{{ $referenceCode }}</span> <span>{{ $sellerName }}</span>
<span>{{ $sellerName }}</span> <span>{{ $postedAgo }}</span>
<span>{{ $postedAgo }}</span>
</div>
</div> </div>
</div>
<div class="lt-hero-side"> <div class="ld-header-actions">
<div class="lt-hero-price">{{ $priceLabel }}</div> <button
@if($locationLabel !== '') type="button"
<div class="lt-address-chip">{{ $locationLabel }}</div> class="ld-header-action"
@endif data-listing-share
data-share-url="{{ $shareUrl }}"
data-share-title="{{ $displayTitle }}"
>
Share
</button>
<div class="lt-hero-tools"> @auth
<button <form method="POST" action="{{ route('favorites.listings.toggle', $listing) }}" class="ld-inline-form">
type="button" @csrf
class="lt-link-action" <button type="submit" class="ld-header-action {{ $isListingFavorited ? 'is-active' : '' }}">
data-listing-share {{ $isListingFavorited ? 'Saved' : 'Save listing' }}
data-share-url="{{ $shareUrl }}"
data-share-title="{{ $displayTitle }}"
>
Share
</button> </button>
</form>
@else
<a href="{{ route('login') }}" class="ld-header-action">Save listing</a>
@endauth
@auth <button type="button" class="ld-header-action" onclick="window.print()">
<form method="POST" action="{{ route('favorites.listings.toggle', $listing) }}" class="lt-inline-form"> Print
@csrf </button>
<button type="submit" class="lt-link-action">
{{ $isListingFavorited ? 'Saved' : 'Save listing' }}
</button>
</form>
@else
<a href="{{ route('login') }}" class="lt-link-action">Save listing</a>
@endauth
</div>
</div>
</div> </div>
</section> </section>
<div class="lt-grid"> <div class="ld-stage">
<div class="lt-main-column"> <section class="lt-card ld-gallery-card" data-gallery>
<div class="lt-media-spec-grid"> <div class="lt-gallery-main">
<section class="lt-card lt-media-card" data-gallery> <div class="lt-gallery-top">
<div class="lt-gallery-main"> <div class="ld-gallery-chip">Photo gallery</div>
<div class="lt-gallery-top">
<div class="lt-gallery-spacer"></div>
<div class="lt-gallery-utility"> <div class="lt-gallery-utility">
<button
type="button"
class="lt-icon-btn"
data-listing-share
data-share-url="{{ $shareUrl }}"
data-share-title="{{ $displayTitle }}"
aria-label="Share listing"
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9">
<path d="M15 8a3 3 0 1 0-2.83-4H12a3 3 0 0 0 .17 1L8.91 6.94a3 3 0 0 0-1.91-.69 3 3 0 1 0 1.91 5.31l3.27 1.94A3 3 0 0 0 12 15a3 3 0 1 0 2.82 4H15a3 3 0 0 0-.17-1l-3.26-1.94a3 3 0 0 0 0-3.12L14.83 10A3 3 0 0 0 15 10h0a3 3 0 0 0 0-2Z"/>
</svg>
</button>
@auth
<form method="POST" action="{{ route('favorites.listings.toggle', $listing) }}">
@csrf
<button <button
type="button" type="submit"
class="lt-icon-btn" class="lt-icon-btn {{ $isListingFavorited ? 'is-active' : '' }}"
data-listing-share aria-label="{{ $isListingFavorited ? 'Remove from saved listings' : 'Save listing' }}"
data-share-url="{{ $shareUrl }}"
data-share-title="{{ $displayTitle }}"
aria-label="Share listing"
> >
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9"> <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9">
<path d="M15 8a3 3 0 1 0-2.83-4H12a3 3 0 0 0 .17 1L8.91 6.94a3 3 0 0 0-1.91-.69 3 3 0 1 0 1.91 5.31l3.27 1.94A3 3 0 0 0 12 15a3 3 0 1 0 2.82 4H15a3 3 0 0 0-.17-1l-3.26-1.94a3 3 0 0 0 0-3.12L14.83 10A3 3 0 0 0 15 10h0a3 3 0 0 0 0-2Z"/> <path d="M12 21l-1.45-1.32C5.4 15.03 2 12.01 2 8.31 2 5.3 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.08A6.04 6.04 0 0116.5 3C19.58 3 22 5.3 22 8.31c0 3.7-3.4 6.72-8.55 11.39L12 21z"/>
</svg> </svg>
</button> </button>
</form>
@auth
<form method="POST" action="{{ route('favorites.listings.toggle', $listing) }}">
@csrf
<button
type="submit"
class="lt-icon-btn {{ $isListingFavorited ? 'is-active' : '' }}"
aria-label="{{ $isListingFavorited ? 'Remove from saved listings' : 'Save listing' }}"
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9">
<path d="M12 21l-1.45-1.32C5.4 15.03 2 12.01 2 8.31 2 5.3 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.08A6.04 6.04 0 0116.5 3C19.58 3 22 5.3 22 8.31c0 3.7-3.4 6.72-8.55 11.39L12 21z"/>
</svg>
</button>
</form>
@else
<a href="{{ route('login') }}" class="lt-icon-btn" aria-label="Sign in to save this listing">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9">
<path d="M12 21l-1.45-1.32C5.4 15.03 2 12.01 2 8.31 2 5.3 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.08A6.04 6.04 0 0116.5 3C19.58 3 22 5.3 22 8.31c0 3.7-3.4 6.72-8.55 11.39L12 21z"/>
</svg>
</a>
@endauth
</div>
</div>
@if($initialGalleryImage)
<img src="{{ $initialGalleryImage }}" alt="{{ $displayTitle }}" data-gallery-main>
@else @else
<div class="lt-gallery-main-empty">No photos uploaded yet.</div> <a href="{{ route('login') }}" class="lt-icon-btn" aria-label="Sign in to save this listing">
@endif <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9">
<path d="M12 21l-1.45-1.32C5.4 15.03 2 12.01 2 8.31 2 5.3 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.08A6.04 6.04 0 0116.5 3C19.58 3 22 5.3 22 8.31c0 3.7-3.4 6.72-8.55 11.39L12 21z"/>
@if($galleryCount > 1)
<button type="button" class="lt-gallery-nav" data-gallery-prev aria-label="Previous photo">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="m15 18-6-6 6-6"/>
</svg> </svg>
</button> </a>
<button type="button" class="lt-gallery-nav" data-gallery-next aria-label="Next photo"> @endauth
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="m9 18 6-6-6-6"/>
</svg>
</button>
@endif
@if($galleryCount > 0)
<div class="lt-gallery-count">
<span data-gallery-current>1</span> / <span>{{ $galleryCount }}</span>
</div>
@endif
</div> </div>
</div>
@if($galleryImages !== []) @if($initialGalleryImage)
<div class="lt-thumbs" data-gallery-thumbs> <img src="{{ $initialGalleryImage }}" alt="{{ $displayTitle }}" data-gallery-main>
@foreach($galleryImages as $index => $image) @else
<button <div class="lt-gallery-main-empty">No photos uploaded yet.</div>
type="button" @endif
class="lt-thumb {{ $index === 0 ? 'is-active' : '' }}"
data-gallery-thumb
data-gallery-index="{{ $index }}"
data-gallery-src="{{ $image }}"
aria-label="Open photo {{ $index + 1 }}"
>
<img src="{{ $image }}" alt="{{ $displayTitle }} {{ $index + 1 }}">
</button>
@endforeach
</div>
@endif
</section>
<div class="lt-info-column"> @if($galleryCount > 1)
<section class="lt-card lt-spec-card lt-desktop-only"> <button type="button" class="lt-gallery-nav" data-gallery-prev aria-label="Previous photo">
<div class="lt-card-head"> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<div> <path d="m15 18-6-6 6-6"/>
<h2 class="lt-section-title">Listing details</h2> </svg>
<p class="lt-section-copy">Everything important, laid out cleanly.</p> </button>
</div> <button type="button" class="lt-gallery-nav" data-gallery-next aria-label="Next photo">
</div> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="m9 18 6-6-6-6"/>
</svg>
</button>
@endif
@if($locationLabel !== '') @if($galleryCount > 0)
<div class="lt-address-chip lt-address-chip-soft">{{ $locationLabel }}</div> <div class="lt-gallery-count">
@endif <span data-gallery-current>1</span> / <span>{{ $galleryCount }}</span>
</div>
@endif
</div>
<div class="lt-spec-table"> @if($galleryImages !== [])
@foreach($specRows as $row) <div class="lt-thumbs" data-gallery-thumbs>
<div class="lt-spec-row"> @foreach($galleryImages as $index => $image)
<span>{{ $row['label'] }}</span> <button
<strong>{{ $row['value'] }}</strong> type="button"
</div> class="lt-thumb {{ $index === 0 ? 'is-active' : '' }}"
@endforeach data-gallery-thumb
</div> data-gallery-index="{{ $index }}"
data-gallery-src="{{ $image }}"
aria-label="Open photo {{ $index + 1 }}"
>
<img src="{{ $image }}" alt="{{ $displayTitle }} {{ $index + 1 }}">
</button>
@endforeach
</div>
@endif
</section>
<a href="{{ $reportUrl }}" class="lt-inline-link">Report this listing</a> <section class="lt-card ld-summary-card">
</section> <div class="ld-summary-head">
<div class="ld-summary-price">{{ $priceLabel }}</div>
<div class="ld-summary-date">{{ $publishedAt }}</div>
</div>
<section class="lt-card lt-description-card lt-desktop-only"> <div class="ld-summary-location">{{ $locationText }}</div>
<div class="lt-card-head">
<div>
<h2 class="lt-section-title">Description</h2>
<p class="lt-section-copy">Seller notes, condition, and extra context.</p>
</div>
</div>
<div class="lt-description"> <div class="lt-spec-table">
{!! nl2br(e($displayDescription)) !!} @foreach($summaryRows as $row)
</div> <div class="lt-spec-row">
</section> <span>{{ $row['label'] }}</span>
<strong>{{ $row['value'] }}</strong>
</div>
@endforeach
</div>
<a href="{{ $reportUrl }}" class="lt-inline-link">Report this listing</a>
</section>
<aside class="lt-card ld-seller-card">
<div class="lt-seller-head">
<div class="lt-avatar">{{ $sellerInitial !== '' ? $sellerInitial : 'S' }}</div>
<div>
<p class="lt-seller-kicker">Seller</p>
<p class="lt-seller-name">{{ $sellerName }}</p>
<div class="lt-seller-meta">{{ $sellerMemberText }}</div>
</div> </div>
</div> </div>
<section class="lt-card lt-mobile-seller-card lt-mobile-only"> @if($listing->user)
<div class="lt-mobile-seller-row"> <div class="ld-seller-links">
<div class="lt-avatar">{{ $sellerInitial !== '' ? $sellerInitial : 'S' }}</div> <a href="{{ $sellerListingsUrl }}" class="ld-seller-link">All listings</a>
<div>
<p class="lt-mobile-seller-name">{{ $sellerName }}</p>
<p class="lt-mobile-seller-meta">{{ $sellerMemberText }}</p>
</div>
</div>
</section>
<section class="lt-card lt-mobile-tabs lt-mobile-only" data-detail-tabs>
<div class="lt-tab-list" role="tablist" aria-label="Listing sections">
<button type="button" class="lt-tab-button is-active" data-detail-tab-button data-tab="details" role="tab" aria-selected="true">
Listing details
</button>
<button type="button" class="lt-tab-button" data-detail-tab-button data-tab="description" role="tab" aria-selected="false">
Description
</button>
</div>
<div class="lt-tab-panel is-active" data-detail-tab-panel data-panel="details" role="tabpanel">
@if($locationLabel !== '')
<div class="lt-address-chip lt-address-chip-soft">{{ $locationLabel }}</div>
@endif
<div class="lt-spec-table">
@foreach($specRows as $row)
<div class="lt-spec-row">
<span>{{ $row['label'] }}</span>
<strong>{{ $row['value'] }}</strong>
</div>
@endforeach
</div>
</div>
<div class="lt-tab-panel" data-detail-tab-panel data-panel="description" role="tabpanel">
<div class="lt-description">
{!! nl2br(e($displayDescription)) !!}
</div>
</div>
</section>
@if(($listingVideos ?? collect())->isNotEmpty())
<section class="lt-card lt-video-section">
<div class="lt-card-head">
<div>
<h2 class="lt-section-title">Videos</h2>
<p class="lt-section-copy">Additional media attached to the listing.</p>
</div>
</div>
<div class="lt-video-grid">
@foreach($listingVideos as $video)
<div class="lt-video-card">
<video class="lt-video-player" controls preload="metadata" src="{{ $video->playableUrl() }}"></video>
<p class="lt-video-title">{{ $video->titleLabel() }}</p>
</div>
@endforeach
</div>
</section>
@endif
</div>
<aside class="lt-side-rail">
<section class="lt-card lt-side-card">
<div class="lt-seller-head">
<div class="lt-avatar">{{ $sellerInitial !== '' ? $sellerInitial : 'S' }}</div>
<div>
<p class="lt-seller-kicker">Seller</p>
<p class="lt-seller-name">{{ $sellerName }}</p>
<div class="lt-seller-meta">{{ $sellerMemberText }}</div>
</div>
</div>
@if(filled($listing->contact_phone) || filled($listing->contact_email))
<div class="lt-contact-panel">
@if(filled($listing->contact_phone))
<a href="tel:{{ preg_replace('/\s+/', '', (string) $listing->contact_phone) }}" class="lt-contact-primary">
{{ $listing->contact_phone }}
</a>
@endif
@if(filled($listing->contact_email))
<a href="mailto:{{ $listing->contact_email }}" class="lt-contact-secondary">
{{ $listing->contact_email }}
</a>
@endif
</div>
@endif
<div class="lt-actions">
<div class="lt-row-2">
@if(! $listing->user)
<button type="button" class="lt-btn" disabled>Unavailable</button>
@elseif($canStartConversation)
<button type="button" class="lt-btn" data-inline-chat-open>Message</button>
@elseif($isOwnListing)
<button type="button" class="lt-btn" disabled>Your listing</button>
@else
<a href="{{ $loginRedirectRoute }}" class="lt-btn">Message</a>
@endif
@if($primaryContactHref)
<a href="{{ $primaryContactHref }}" class="lt-btn lt-btn-outline">{{ $primaryContactLabel }}</a>
@else
<button type="button" class="lt-btn lt-btn-outline" disabled>No contact</button>
@endif
</div>
@if($listing->user && ! $isOwnListing) @if($listing->user && ! $isOwnListing)
@auth @auth
<form method="POST" action="{{ route('favorites.sellers.toggle', $listing->user) }}" class="lt-action-form"> <form method="POST" action="{{ route('favorites.sellers.toggle', $listing->user) }}" class="ld-inline-form">
@csrf @csrf
<input type="hidden" name="redirect_to" value="{{ request()->fullUrl() }}"> <input type="hidden" name="redirect_to" value="{{ request()->fullUrl() }}">
<button type="submit" class="lt-btn lt-btn-outline"> <button type="submit" class="ld-seller-link">
{{ $isSellerFavorited ? 'Saved seller' : 'Save seller' }} {{ $isSellerFavorited ? 'Saved seller' : 'Save seller' }}
</button> </button>
</form> </form>
@else @else
<a href="{{ $loginRedirectRoute }}" class="lt-btn lt-btn-outline">Save seller</a> <a href="{{ $loginRedirectRoute }}" class="ld-seller-link">Save seller</a>
@endauth @endauth
@elseif($isOwnListing)
<button type="button" class="lt-btn lt-btn-outline" disabled>Your account</button>
@endif @endif
</div> </div>
</section> @endif
<section class="lt-card lt-safety-card"> @if(filled($listing->contact_phone) || filled($listing->contact_email))
<h3 class="lt-safety-title">Safety tips</h3> <div class="lt-contact-panel">
<p class="lt-safety-copy">Inspect the item in person, avoid sending money in advance, and confirm the seller identity before closing the deal.</p> @if(filled($listing->contact_phone))
<a href="{{ $reportUrl }}" class="lt-inline-link">Report this listing</a> <a href="tel:{{ preg_replace('/\s+/', '', (string) $listing->contact_phone) }}" class="lt-contact-primary">
</section> {{ $listing->contact_phone }}
</a>
@endif
@if(filled($listing->contact_email))
<a href="mailto:{{ $listing->contact_email }}" class="lt-contact-secondary">
{{ $listing->contact_email }}
</a>
@endif
</div>
@else
<div class="ld-empty-contact">No contact details provided.</div>
@endif
<div class="lt-actions">
<div class="lt-row-2">
@if(! $listing->user)
<button type="button" class="lt-btn" disabled>Unavailable</button>
@elseif($canStartConversation)
<button type="button" class="lt-btn" data-inline-chat-open>Message</button>
@elseif($isOwnListing)
<button type="button" class="lt-btn" disabled>Your listing</button>
@else
<a href="{{ $loginRedirectRoute }}" class="lt-btn">Message</a>
@endif
@if($primaryContactHref)
<a href="{{ $primaryContactHref }}" class="lt-btn lt-btn-outline">{{ $primaryContactLabel }}</a>
@else
<button type="button" class="lt-btn lt-btn-outline" disabled>No contact</button>
@endif
</div>
@if($listing->user && ! $isOwnListing)
<a href="{{ $sellerListingsUrl }}" class="lt-btn lt-btn-outline">View listings</a>
@elseif($isOwnListing)
<button type="button" class="lt-btn lt-btn-outline" disabled>Your account</button>
@endif
</div>
</aside> </aside>
</div> </div>
<section class="lt-card ld-tab-card" data-detail-tabs>
<div class="lt-tab-list" role="tablist" aria-label="Listing sections">
<button type="button" class="lt-tab-button is-active" data-detail-tab-button data-tab="details" role="tab" aria-selected="true">
Listing details
</button>
<button type="button" class="lt-tab-button" data-detail-tab-button data-tab="description" role="tab" aria-selected="false">
Description
</button>
</div>
<div class="lt-tab-panel is-active" data-detail-tab-panel data-panel="details" role="tabpanel">
<div class="lt-spec-table">
@foreach($detailRows as $row)
<div class="lt-spec-row">
<span>{{ $row['label'] }}</span>
<strong>{{ $row['value'] }}</strong>
</div>
@endforeach
</div>
</div>
<div class="lt-tab-panel" data-detail-tab-panel data-panel="description" role="tabpanel">
<div class="lt-description">
{!! nl2br(e($displayDescription)) !!}
</div>
</div>
</section>
@if(($listingVideos ?? collect())->isNotEmpty())
<section class="lt-card lt-video-section">
<div class="lt-card-head">
<div>
<h2 class="lt-section-title">Videos</h2>
<p class="lt-section-copy">Additional media attached to the listing.</p>
</div>
</div>
<div class="lt-video-grid">
@foreach($listingVideos as $video)
<div class="lt-video-card">
<video class="lt-video-player" controls preload="metadata" src="{{ $video->playableUrl() }}"></video>
<p class="lt-video-title">{{ $video->titleLabel() }}</p>
</div>
@endforeach
</div>
</section>
@endif
<div class="lt-mobile-actions"> <div class="lt-mobile-actions">
<div class="lt-mobile-actions-shell"> <div class="lt-mobile-actions-shell">
<div class="lt-mobile-actions-row"> <div class="lt-mobile-actions-row">

View File

@ -22,6 +22,7 @@ use Spatie\Activitylog\LogOptions;
use Spatie\Activitylog\Traits\LogsActivity; use Spatie\Activitylog\Traits\LogsActivity;
use Spatie\ModelStates\HasStates; use Spatie\ModelStates\HasStates;
use Spatie\Permission\Traits\HasRoles; use Spatie\Permission\Traits\HasRoles;
use Throwable;
class User extends Authenticatable implements FilamentUser, HasAvatar class User extends Authenticatable implements FilamentUser, HasAvatar
{ {
@ -186,4 +187,36 @@ class User extends Authenticatable implements FilamentUser, HasAvatar
return true; 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(),
];
}
} }

View File

@ -0,0 +1,18 @@
<?php
namespace Modules\User\Database\Seeders;
use Illuminate\Database\Seeder;
class UserWorkspaceSeeder extends Seeder
{
public function run(): void
{
$this->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,
]);
}
}

View File

@ -0,0 +1,102 @@
<?php
namespace Modules\Video\Database\Seeders;
use Illuminate\Database\Seeder;
use Illuminate\Support\Facades\Schema;
use Modules\Listing\Models\Listing;
use Modules\User\App\Models\User;
use Modules\Video\Enums\VideoStatus;
use Modules\Video\Models\Video;
class VideoDemoSeeder extends Seeder
{
private const VIDEO_BLUEPRINTS = [
'a@a.com' => [
[
'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();
}
}
}
}

View File

@ -48,7 +48,7 @@ final class HomeSlideDefaults
$normalized = collect($source) $normalized = collect($source)
->filter(fn ($slide): bool => is_array($slide)) ->filter(fn ($slide): bool => is_array($slide))
->values() ->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)]; $fallback = $defaults[$index] ?? $defaults[array_key_last($defaults)];
$badge = trim((string) ($slide['badge'] ?? '')); $badge = trim((string) ($slide['badge'] ?? ''));
$title = trim((string) ($slide['title'] ?? '')); $title = trim((string) ($slide['title'] ?? ''));

View File

@ -9,6 +9,7 @@ use Illuminate\Support\Facades\View;
use Modules\Category\Models\Category; use Modules\Category\Models\Category;
use Modules\Location\Models\Country; use Modules\Location\Models\Country;
use Modules\S3\Support\MediaStorage; use Modules\S3\Support\MediaStorage;
use Modules\User\App\Models\User;
use Throwable; use Throwable;
final class RequestAppData final class RequestAppData
@ -22,6 +23,7 @@ final class RequestAppData
View::share('generalSettings', $generalSettings); View::share('generalSettings', $generalSettings);
View::share('headerLocationCountries', $this->resolveHeaderLocationCountries()); View::share('headerLocationCountries', $this->resolveHeaderLocationCountries());
View::share('headerNavCategories', $this->resolveHeaderNavCategories()); View::share('headerNavCategories', $this->resolveHeaderNavCategories());
View::share('headerAccountMeta', $this->resolveHeaderAccountMeta());
} }
private function resolveGeneralSettings(): array 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 private function normalizeCurrencies(array $currencies): array
{ {
$normalized = collect($currencies) $normalized = collect($currencies)

View File

@ -15,12 +15,7 @@ class DatabaseSeeder extends Seeder
\Modules\Category\Database\Seeders\CategorySeeder::class, \Modules\Category\Database\Seeders\CategorySeeder::class,
\Modules\Listing\Database\Seeders\ListingCustomFieldSeeder::class, \Modules\Listing\Database\Seeders\ListingCustomFieldSeeder::class,
\Modules\Listing\Database\Seeders\ListingSeeder::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,
]);
}
} }
} }

View File

@ -192,6 +192,11 @@ h6 {
min-width: 0; min-width: 0;
} }
.oc-location[open],
.oc-account-menu[open] {
z-index: 90;
}
.oc-location-trigger { .oc-location-trigger {
justify-content: space-between; justify-content: space-between;
width: 100%; width: 100%;
@ -435,7 +440,14 @@ h6 {
align-items: center; align-items: center;
gap: 8px; gap: 8px;
overflow-x: auto; overflow-x: auto;
overflow-y: hidden;
padding-bottom: 2px; padding-bottom: 2px;
scrollbar-width: none;
-ms-overflow-style: none;
}
.oc-category-track::-webkit-scrollbar {
display: none;
} }
.oc-category-pill { .oc-category-pill {
@ -534,6 +546,116 @@ h6 {
height: 1.125rem; 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-compact-menu-trigger,
.header-utility.oc-mobile-menu-close { .header-utility.oc-mobile-menu-close {
display: inline-flex; display: inline-flex;
@ -547,6 +669,7 @@ h6 {
.location-panel { .location-panel {
width: min(calc(100vw - 32px), 360px); width: min(calc(100vw - 32px), 360px);
z-index: 120;
} }
.location-panel select { .location-panel select {
@ -682,6 +805,10 @@ h6 {
box-shadow: none; box-shadow: none;
} }
.oc-account-trigger {
min-height: 3rem;
}
.oc-text-link { .oc-text-link {
min-height: 3rem; min-height: 3rem;
display: inline-flex; display: inline-flex;
@ -707,6 +834,15 @@ h6 {
.oc-category-row { .oc-category-row {
display: block; 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; 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) { @media (min-width: 721px) {
.lt-wrap { .lt-wrap {
padding: 22px 16px 56px; padding: 22px 16px 56px;
@ -1703,6 +2064,34 @@ summary::-webkit-details-marker {
line-height: 1.15; 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 { .lt-hero-side {
justify-items: flex-end; justify-items: flex-end;
} }
@ -1786,6 +2175,19 @@ summary::-webkit-details-marker {
} }
@media (min-width: 900px) { @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 { .lt-media-spec-grid {
grid-template-columns: minmax(0, 1fr) minmax(320px, 360px); grid-template-columns: minmax(0, 1fr) minmax(320px, 360px);
align-items: start; align-items: start;
@ -1801,6 +2203,11 @@ summary::-webkit-details-marker {
} }
@media (min-width: 1180px) { @media (min-width: 1180px) {
.ld-stage {
grid-template-columns: minmax(0, 1fr) 420px 300px;
grid-template-areas: "gallery summary seller";
}
.lt-grid { .lt-grid {
grid-template-columns: minmax(0, 1fr) 320px; grid-template-columns: minmax(0, 1fr) 320px;
align-items: start; align-items: start;

View File

@ -14,6 +14,8 @@
$panelListingsRoute = auth()->check() ? route('panel.listings.index') : $loginRoute; $panelListingsRoute = auth()->check() ? route('panel.listings.index') : $loginRoute;
$inboxRoute = auth()->check() ? route('panel.inbox.index') : $loginRoute; $inboxRoute = auth()->check() ? route('panel.inbox.index') : $loginRoute;
$favoritesRoute = auth()->check() ? route('favorites.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'); $demoEnabled = (bool) config('demo.enabled');
$hasDemoSession = (bool) session('is_demo_session') || filled(session('demo_uuid')); $hasDemoSession = (bool) session('is_demo_session') || filled(session('demo_uuid'));
$demoLandingMode = $demoEnabled && request()->routeIs('home') && !auth()->check() && !$hasDemoSession; $demoLandingMode = $demoEnabled && request()->routeIs('home') && !auth()->check() && !$hasDemoSession;
@ -62,6 +64,11 @@
? route('locations.cities', ['country' => '__COUNTRY__'], false) ? route('locations.cities', ['country' => '__COUNTRY__'], false)
: ''; : '';
$simplePage = trim((string) $__env->yieldContent('simple_page')) === '1'; $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 @endphp
<!DOCTYPE html> <!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}" dir="{{ in_array(app()->getLocale(), ['ar']) ? 'rtl' : 'ltr' }}"> <html lang="{{ str_replace('_', '-', app()->getLocale()) }}" dir="{{ in_array(app()->getLocale(), ['ar']) ? 'rtl' : 'ltr' }}">
@ -191,29 +198,53 @@
</details> </details>
@auth @auth
<a href="{{ $favoritesRoute }}" class="header-utility oc-desktop-utility" aria-label="Favorites"> <details class="oc-account-menu oc-desktop-utility">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <summary class="oc-account-trigger list-none cursor-pointer">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.8" d="M12 21l-1.45-1.32C5.4 15.03 2 12.01 2 8.31 2 5.3 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.08A6.04 6.04 0 0116.5 3C19.58 3 22 5.3 22 8.31c0 3.7-3.4 6.72-8.55 11.39L12 21z"/> <span class="oc-account-name">{{ $headerAccount['name'] ?? auth()->user()->name }}</span>
</svg> <svg class="oc-account-chevron" fill="none" stroke="currentColor" viewBox="0 0 24 24">
</a> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.8" d="M6 9l6 6 6-6"/>
<a href="{{ $inboxRoute }}" class="header-utility oc-desktop-utility" aria-label="Inbox"> </svg>
</summary>
<div class="oc-account-panel">
<a href="{{ $panelListingsRoute }}" class="oc-account-link">My Listings</a>
<a href="{{ $profileRoute }}" class="oc-account-link">My Profile</a>
<a href="{{ $favoritesRoute }}" class="oc-account-link">Favorites</a>
<a href="{{ $inboxRoute }}" class="oc-account-link">Inbox</a>
<form method="POST" action="{{ $logoutRoute }}">
@csrf
<button type="submit" class="oc-account-link oc-account-link-button">Logout</button>
</form>
</div>
</details>
<a href="{{ $inboxRoute }}" class="header-utility oc-desktop-utility oc-header-icon" aria-label="Inbox">
@if($headerMessageCount > 0)
<span class="oc-header-badge">{{ $headerBadgeLabel($headerMessageCount) }}</span>
@endif
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.8" d="M4 6h16a1 1 0 011 1v10a1 1 0 01-1 1H4a1 1 0 01-1-1V7a1 1 0 011-1z"/> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.8" d="M4 6h16a1 1 0 011 1v10a1 1 0 01-1 1H4a1 1 0 01-1-1V7a1 1 0 011-1z"/>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.8" d="M3 8l9 6 9-6"/> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.8" d="M3 8l9 6 9-6"/>
</svg> </svg>
</a> </a>
<a href="{{ $panelListingsRoute }}" class="header-utility oc-desktop-utility" aria-label="Dashboard"> <a href="{{ $notificationsRoute }}" class="header-utility oc-desktop-utility oc-header-icon" aria-label="Notifications">
@if($headerNotificationCount > 0)
<span class="oc-header-badge">{{ $headerBadgeLabel($headerNotificationCount) }}</span>
@endif
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.8" d="M3 12l9-9 9 9M5 10v10h14V10"/> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.8" d="M15 17h5l-1.4-1.4a2 2 0 0 1-.6-1.4V11a6 6 0 1 0-12 0v3.2a2 2 0 0 1-.6 1.4L4 17h5"/>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.8" d="M10 17a2 2 0 0 0 4 0"/>
</svg>
</a>
<a href="{{ $favoritesRoute }}" class="header-utility oc-desktop-utility oc-header-icon" aria-label="Favorites">
@if($headerFavoritesCount > 0)
<span class="oc-header-badge is-neutral">{{ $headerBadgeLabel($headerFavoritesCount) }}</span>
@endif
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.8" d="m12 3 2.8 5.67 6.2.9-4.5 4.39 1.06 6.2L12 17.21 6.44 20.16 7.5 13.96 3 9.57l6.2-.9L12 3z"/>
</svg> </svg>
</a> </a>
<a href="{{ $panelCreateRoute }}" class="btn-primary oc-cta"> <a href="{{ $panelCreateRoute }}" class="btn-primary oc-cta">
Sell Sell
</a> </a>
<form method="POST" action="{{ $logoutRoute }}" class="oc-logout">
@csrf
<button type="submit" class="oc-text-link">{{ __('messages.logout') }}</button>
</form>
@else @else
<a href="{{ $loginRoute }}" class="oc-text-link oc-auth-link"> <a href="{{ $loginRoute }}" class="oc-text-link oc-auth-link">
{{ __('messages.login') }} {{ __('messages.login') }}