Refactor Filament listing modules
@ -187,7 +187,7 @@ class Conversation extends Model
|
|||||||
{
|
{
|
||||||
$this->loadMissing('listing');
|
$this->loadMissing('listing');
|
||||||
|
|
||||||
$url = $this->listing?->getFirstMediaUrl('listing-images');
|
$url = $this->listing?->primaryImageUrl('thumb', 'desktop');
|
||||||
|
|
||||||
return is_string($url) && trim($url) !== '' ? $url : null;
|
return is_string($url) && trim($url) !== '' ? $url : null;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -19,14 +19,18 @@
|
|||||||
$conversationListing = $conversation->listing;
|
$conversationListing = $conversation->listing;
|
||||||
$partner = (int) $conversation->buyer_id === (int) auth()->id() ? $conversation->seller : $conversation->buyer;
|
$partner = (int) $conversation->buyer_id === (int) auth()->id() ? $conversation->seller : $conversation->buyer;
|
||||||
$isSelected = $selectedConversation && (int) $selectedConversation->id === (int) $conversation->id;
|
$isSelected = $selectedConversation && (int) $selectedConversation->id === (int) $conversation->id;
|
||||||
$conversationImage = $conversationListing?->getFirstMediaUrl('listing-images');
|
$conversationImage = $conversationListing?->primaryImageData('thumb');
|
||||||
$lastMessage = trim((string) ($conversation->lastMessage?->body ?? ''));
|
$lastMessage = trim((string) ($conversation->lastMessage?->body ?? ''));
|
||||||
@endphp
|
@endphp
|
||||||
<a href="{{ route('panel.inbox.index', ['message_filter' => $messageFilter, 'conversation' => $conversation->id]) }}" class="block px-6 py-4 transition {{ $isSelected ? 'bg-rose-50' : 'hover:bg-slate-50' }}">
|
<a href="{{ route('panel.inbox.index', ['message_filter' => $messageFilter, 'conversation' => $conversation->id]) }}" class="block px-6 py-4 transition {{ $isSelected ? 'bg-rose-50' : 'hover:bg-slate-50' }}">
|
||||||
<div class="flex gap-3">
|
<div class="flex gap-3">
|
||||||
<div class="w-14 h-14 rounded-xl bg-slate-100 border border-slate-200 overflow-hidden shrink-0">
|
<div class="w-14 h-14 rounded-xl bg-slate-100 border border-slate-200 overflow-hidden shrink-0">
|
||||||
@if($conversationImage)
|
@if($conversationImage)
|
||||||
<img src="{{ $conversationImage }}" alt="{{ $conversationListing?->title }}" class="w-full h-full object-cover">
|
@include('listing::partials.responsive-image', [
|
||||||
|
'image' => $conversationImage,
|
||||||
|
'alt' => $conversationListing?->title,
|
||||||
|
'class' => 'w-full h-full object-cover',
|
||||||
|
])
|
||||||
@else
|
@else
|
||||||
<div class="w-full h-full grid place-items-center text-slate-400 text-xs">Listing</div>
|
<div class="w-full h-full grid place-items-center text-slate-400 text-xs">Listing</div>
|
||||||
@endif
|
@endif
|
||||||
|
|||||||
@ -64,7 +64,7 @@
|
|||||||
<tbody>
|
<tbody>
|
||||||
@forelse($favoriteListings as $listing)
|
@forelse($favoriteListings as $listing)
|
||||||
@php
|
@php
|
||||||
$listingImage = $listing->getFirstMediaUrl('listing-images');
|
$listingImage = $listing->primaryImageData('card');
|
||||||
$priceLabel = $listing->price ? number_format((float) $listing->price, 0).' '.$listing->currency : 'Free';
|
$priceLabel = $listing->price ? number_format((float) $listing->price, 0).' '.$listing->currency : 'Free';
|
||||||
$meta = collect([
|
$meta = collect([
|
||||||
$listing->category?->name,
|
$listing->category?->name,
|
||||||
@ -80,7 +80,11 @@
|
|||||||
<div class="flex gap-3">
|
<div class="flex gap-3">
|
||||||
<a href="{{ route('listings.show', $listing) }}" class="w-36 h-24 shrink-0 bg-slate-100 border border-slate-200 overflow-hidden">
|
<a href="{{ route('listings.show', $listing) }}" class="w-36 h-24 shrink-0 bg-slate-100 border border-slate-200 overflow-hidden">
|
||||||
@if($listingImage)
|
@if($listingImage)
|
||||||
<img src="{{ $listingImage }}" alt="{{ $listing->title }}" class="w-full h-full object-cover">
|
@include('listing::partials.responsive-image', [
|
||||||
|
'image' => $listingImage,
|
||||||
|
'alt' => $listing->title,
|
||||||
|
'class' => 'w-full h-full object-cover',
|
||||||
|
])
|
||||||
@else
|
@else
|
||||||
<div class="w-full h-full grid place-items-center text-slate-400">No image</div>
|
<div class="w-full h-full grid place-items-center text-slate-400">No image</div>
|
||||||
@endif
|
@endif
|
||||||
|
|||||||
@ -8,7 +8,7 @@ use Illuminate\Support\Facades\Schema;
|
|||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
use Modules\Category\Models\Category;
|
use Modules\Category\Models\Category;
|
||||||
use Modules\Listing\Models\Listing;
|
use Modules\Listing\Models\Listing;
|
||||||
use Modules\Listing\Support\DemoListingImageFactory;
|
use Modules\Listing\Support\SampleListingImageCatalog;
|
||||||
use Modules\Location\Models\City;
|
use Modules\Location\Models\City;
|
||||||
use Modules\Location\Models\Country;
|
use Modules\Location\Models\Country;
|
||||||
use Modules\User\App\Models\User;
|
use Modules\User\App\Models\User;
|
||||||
@ -143,7 +143,6 @@ class ListingSeeder extends Seeder
|
|||||||
$location = $this->resolveLocation($index, $countries, $turkeyCities);
|
$location = $this->resolveLocation($index, $countries, $turkeyCities);
|
||||||
$title = $this->buildTitle($category, $index, $user);
|
$title = $this->buildTitle($category, $index, $user);
|
||||||
$slug = 'demo-'.Str::slug($user->email).'-'.$category->slug;
|
$slug = 'demo-'.Str::slug($user->email).'-'.$category->slug;
|
||||||
$familyName = trim((string) ($category->parent?->name ?? $category->name));
|
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'slug' => $slug,
|
'slug' => $slug,
|
||||||
@ -156,13 +155,7 @@ class ListingSeeder extends Seeder
|
|||||||
'is_featured' => $index % 7 === 0,
|
'is_featured' => $index % 7 === 0,
|
||||||
'expires_at' => now()->addDays(21 + ($index % 9)),
|
'expires_at' => now()->addDays(21 + ($index % 9)),
|
||||||
'created_at' => now()->subHours(6 + $index),
|
'created_at' => now()->subHours(6 + $index),
|
||||||
'image_path' => DemoListingImageFactory::ensure(
|
'image_path' => SampleListingImageCatalog::pathFor($category, $index),
|
||||||
$slug,
|
|
||||||
$title,
|
|
||||||
$familyName,
|
|
||||||
$user->name,
|
|
||||||
$index
|
|
||||||
),
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -216,7 +209,7 @@ class ListingSeeder extends Seeder
|
|||||||
$location = trim(collect([$city, $country])->filter()->join(', '));
|
$location = trim(collect([$city, $country])->filter()->join(', '));
|
||||||
|
|
||||||
return sprintf(
|
return sprintf(
|
||||||
'%s listed by %s. Clean demo condition, unique seeded media, and ready for browsing, favorites, inbox, and panel testing. Pickup area: %s.',
|
'%s listed by %s. Clean demo condition, sample product photo assigned from the provided catalog, and ready for browsing, favorites, inbox, and panel testing. Pickup area: %s.',
|
||||||
$categoryName !== '' ? $categoryName : 'Item',
|
$categoryName !== '' ? $categoryName : 'Item',
|
||||||
trim((string) $user->name) !== '' ? trim((string) $user->name) : 'a marketplace user',
|
trim((string) $user->name) !== '' ? trim((string) $user->name) : 'a marketplace user',
|
||||||
$location !== '' ? $location : 'Turkey'
|
$location !== '' ? $location : 'Turkey'
|
||||||
@ -272,8 +265,15 @@ class ListingSeeder extends Seeder
|
|||||||
return $listing;
|
return $listing;
|
||||||
}
|
}
|
||||||
|
|
||||||
private function syncListingImage(Listing $listing, string $imageAbsolutePath): void
|
private function syncListingImage(Listing $listing, ?string $imageAbsolutePath): void
|
||||||
{
|
{
|
||||||
$listing->replacePublicImage($imageAbsolutePath, $listing->slug.'.svg');
|
if (! is_string($imageAbsolutePath) || ! is_file($imageAbsolutePath)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$listing->replacePublicImage(
|
||||||
|
$imageAbsolutePath,
|
||||||
|
SampleListingImageCatalog::fileNameFor($imageAbsolutePath, $listing->slug)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -178,7 +178,7 @@ class ListingController extends Controller
|
|||||||
$listing->category_id ? (int) $listing->category_id : null,
|
$listing->category_id ? (int) $listing->category_id : null,
|
||||||
$listing->custom_fields ?? [],
|
$listing->custom_fields ?? [],
|
||||||
);
|
);
|
||||||
$gallery = $listing->themeGallery();
|
$gallery = $listing->galleryImageData();
|
||||||
$listingVideos = $listing->getRelation('videos');
|
$listingVideos = $listing->getRelation('videos');
|
||||||
$relatedListings = $listing->relatedSuggestions(12);
|
$relatedListings = $listing->relatedSuggestions(12);
|
||||||
$themePillCategories = Category::themePills(10);
|
$themePillCategories = Category::themePills(10);
|
||||||
|
|||||||
@ -10,13 +10,16 @@ use Illuminate\Support\Carbon;
|
|||||||
use Illuminate\Support\Collection;
|
use Illuminate\Support\Collection;
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
use Modules\Category\Models\Category;
|
use Modules\Category\Models\Category;
|
||||||
|
use Modules\Listing\Support\ListingImageViewData;
|
||||||
use Modules\Listing\States\ListingStatus;
|
use Modules\Listing\States\ListingStatus;
|
||||||
use Modules\Listing\Support\ListingPanelHelper;
|
use Modules\Listing\Support\ListingPanelHelper;
|
||||||
use Modules\Video\Models\Video;
|
use Modules\Video\Models\Video;
|
||||||
|
use Spatie\Image\Enums\Fit;
|
||||||
use Spatie\Activitylog\LogOptions;
|
use Spatie\Activitylog\LogOptions;
|
||||||
use Spatie\Activitylog\Traits\LogsActivity;
|
use Spatie\Activitylog\Traits\LogsActivity;
|
||||||
use Spatie\MediaLibrary\HasMedia;
|
use Spatie\MediaLibrary\HasMedia;
|
||||||
use Spatie\MediaLibrary\InteractsWithMedia;
|
use Spatie\MediaLibrary\InteractsWithMedia;
|
||||||
|
use Spatie\MediaLibrary\MediaCollections\Models\Media;
|
||||||
use Spatie\ModelStates\HasStates;
|
use Spatie\ModelStates\HasStates;
|
||||||
use Throwable;
|
use Throwable;
|
||||||
|
|
||||||
@ -183,22 +186,56 @@ class Listing extends Model implements HasMedia
|
|||||||
|
|
||||||
public function themeGallery(): array
|
public function themeGallery(): array
|
||||||
{
|
{
|
||||||
$mediaUrls = $this->getMedia('listing-images')
|
return collect($this->galleryImageData())
|
||||||
->map(fn ($media): string => $media->getUrl())
|
->map(fn (array $image): ?string => ListingImageViewData::pickUrl($image['gallery'] ?? null))
|
||||||
->filter(fn (string $url): bool => $url !== '')
|
->filter(fn (?string $url): bool => is_string($url) && $url !== '')
|
||||||
->values()
|
->values()
|
||||||
->all();
|
->all();
|
||||||
|
}
|
||||||
|
|
||||||
if ($mediaUrls !== []) {
|
public function galleryImageData(): array
|
||||||
return $mediaUrls;
|
{
|
||||||
|
$mediaItems = $this->getMedia('listing-images');
|
||||||
|
|
||||||
|
if ($mediaItems->isNotEmpty()) {
|
||||||
|
return $mediaItems
|
||||||
|
->map(fn (Media $media): array => [
|
||||||
|
'gallery' => ListingImageViewData::fromMedia($media, 'gallery'),
|
||||||
|
'thumb' => ListingImageViewData::fromMedia($media, 'thumb'),
|
||||||
|
])
|
||||||
|
->values()
|
||||||
|
->all();
|
||||||
}
|
}
|
||||||
|
|
||||||
return collect($this->images ?? [])
|
return collect($this->images ?? [])
|
||||||
->filter(fn ($value): bool => is_string($value) && trim($value) !== '')
|
->filter(fn ($value): bool => is_string($value) && trim($value) !== '')
|
||||||
|
->map(fn (string $url): array => [
|
||||||
|
'gallery' => ListingImageViewData::fromUrl($url),
|
||||||
|
'thumb' => ListingImageViewData::fromUrl($url),
|
||||||
|
])
|
||||||
->values()
|
->values()
|
||||||
->all();
|
->all();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function primaryImageData(string $context = 'card'): ?array
|
||||||
|
{
|
||||||
|
$media = $this->getFirstMedia('listing-images');
|
||||||
|
|
||||||
|
if ($media instanceof Media) {
|
||||||
|
return ListingImageViewData::fromMedia($media, $context);
|
||||||
|
}
|
||||||
|
|
||||||
|
$fallback = collect($this->images ?? [])
|
||||||
|
->first(fn ($value): bool => is_string($value) && trim($value) !== '');
|
||||||
|
|
||||||
|
return ListingImageViewData::fromUrl(is_string($fallback) ? $fallback : null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function primaryImageUrl(string $context = 'card', string $viewport = 'desktop'): ?string
|
||||||
|
{
|
||||||
|
return ListingImageViewData::pickUrl($this->primaryImageData($context), $viewport);
|
||||||
|
}
|
||||||
|
|
||||||
public function relatedSuggestions(int $limit = 8): Collection
|
public function relatedSuggestions(int $limit = 8): Collection
|
||||||
{
|
{
|
||||||
$baseQuery = static::query()
|
$baseQuery = static::query()
|
||||||
@ -254,9 +291,7 @@ class Listing extends Model implements HasMedia
|
|||||||
|
|
||||||
public function panelPrimaryImageUrl(): ?string
|
public function panelPrimaryImageUrl(): ?string
|
||||||
{
|
{
|
||||||
$url = trim((string) $this->getFirstMediaUrl('listing-images'));
|
return $this->primaryImageUrl('card', 'desktop');
|
||||||
|
|
||||||
return $url !== '' ? $url : null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function panelPriceLabel(): string
|
public function panelPriceLabel(): string
|
||||||
@ -444,7 +479,73 @@ class Listing extends Model implements HasMedia
|
|||||||
|
|
||||||
public function registerMediaCollections(): void
|
public function registerMediaCollections(): void
|
||||||
{
|
{
|
||||||
$this->addMediaCollection('listing-images');
|
$this->addMediaCollection('listing-images')->useDisk('public');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function registerMediaConversions(?Media $media = null): void
|
||||||
|
{
|
||||||
|
if ($this->shouldSkipConversionsForSeeder()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this
|
||||||
|
->addMediaConversion('gallery-mobile')
|
||||||
|
->fit(Fit::Max, 960, 960)
|
||||||
|
->format('webp')
|
||||||
|
->quality(78)
|
||||||
|
->performOnCollections('listing-images')
|
||||||
|
->nonQueued();
|
||||||
|
|
||||||
|
$this
|
||||||
|
->addMediaConversion('gallery-desktop')
|
||||||
|
->fit(Fit::Max, 1680, 1680)
|
||||||
|
->format('webp')
|
||||||
|
->quality(82)
|
||||||
|
->performOnCollections('listing-images')
|
||||||
|
->nonQueued();
|
||||||
|
|
||||||
|
$this
|
||||||
|
->addMediaConversion('card-mobile')
|
||||||
|
->fit(Fit::Crop, 720, 540)
|
||||||
|
->format('webp')
|
||||||
|
->quality(76)
|
||||||
|
->performOnCollections('listing-images')
|
||||||
|
->nonQueued();
|
||||||
|
|
||||||
|
$this
|
||||||
|
->addMediaConversion('card-desktop')
|
||||||
|
->fit(Fit::Crop, 1080, 810)
|
||||||
|
->format('webp')
|
||||||
|
->quality(80)
|
||||||
|
->performOnCollections('listing-images')
|
||||||
|
->nonQueued();
|
||||||
|
|
||||||
|
$this
|
||||||
|
->addMediaConversion('thumb-mobile')
|
||||||
|
->fit(Fit::Crop, 220, 220)
|
||||||
|
->format('webp')
|
||||||
|
->quality(74)
|
||||||
|
->performOnCollections('listing-images')
|
||||||
|
->nonQueued();
|
||||||
|
|
||||||
|
$this
|
||||||
|
->addMediaConversion('thumb-desktop')
|
||||||
|
->fit(Fit::Crop, 320, 320)
|
||||||
|
->format('webp')
|
||||||
|
->quality(78)
|
||||||
|
->performOnCollections('listing-images')
|
||||||
|
->nonQueued();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function shouldSkipConversionsForSeeder(): bool
|
||||||
|
{
|
||||||
|
if (! app()->runningInConsole()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$argv = implode(' ', (array) ($_SERVER['argv'] ?? []));
|
||||||
|
|
||||||
|
return str_contains($argv, 'db:seed') || str_contains($argv, '--seed');
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function location(): Attribute
|
protected function location(): Attribute
|
||||||
|
|||||||
@ -1,84 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace Modules\Listing\Support;
|
|
||||||
|
|
||||||
use Illuminate\Support\Str;
|
|
||||||
|
|
||||||
final class DemoListingImageFactory
|
|
||||||
{
|
|
||||||
private const PALETTES = [
|
|
||||||
['#0f172a', '#1d4ed8', '#dbeafe'],
|
|
||||||
['#172554', '#2563eb', '#dbeafe'],
|
|
||||||
['#0f3b2e', '#059669', '#d1fae5'],
|
|
||||||
['#3f2200', '#ea580c', '#ffedd5'],
|
|
||||||
['#3b0764', '#9333ea', '#f3e8ff'],
|
|
||||||
['#3f3f46', '#e11d48', '#ffe4e6'],
|
|
||||||
['#0b3b66', '#0891b2', '#cffafe'],
|
|
||||||
['#422006', '#ca8a04', '#fef3c7'],
|
|
||||||
];
|
|
||||||
|
|
||||||
public static function ensure(
|
|
||||||
string $slug,
|
|
||||||
string $title,
|
|
||||||
string $categoryName,
|
|
||||||
string $ownerName,
|
|
||||||
int $seed
|
|
||||||
): string {
|
|
||||||
$directory = public_path('generated/demo-listings');
|
|
||||||
|
|
||||||
if (! is_dir($directory)) {
|
|
||||||
mkdir($directory, 0755, true);
|
|
||||||
}
|
|
||||||
|
|
||||||
$filePath = $directory.'/'.Str::slug($slug).'.svg';
|
|
||||||
$palette = self::PALETTES[$seed % count(self::PALETTES)];
|
|
||||||
[$baseColor, $accentColor, $surfaceColor] = $palette;
|
|
||||||
|
|
||||||
$shortTitle = self::escape(Str::limit($title, 36, ''));
|
|
||||||
$shortCategory = self::escape(Str::limit($categoryName, 18, ''));
|
|
||||||
$shortOwner = self::escape(Str::limit($ownerName, 18, ''));
|
|
||||||
$code = self::escape(strtoupper(Str::substr(md5($slug), 0, 6)));
|
|
||||||
|
|
||||||
$svg = <<<SVG
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="1600" height="1200" viewBox="0 0 1600 1200" fill="none">
|
|
||||||
<defs>
|
|
||||||
<linearGradient id="bg" x1="120" y1="80" x2="1480" y2="1120" gradientUnits="userSpaceOnUse">
|
|
||||||
<stop stop-color="{$baseColor}"/>
|
|
||||||
<stop offset="1" stop-color="{$accentColor}"/>
|
|
||||||
</linearGradient>
|
|
||||||
<linearGradient id="glass" x1="320" y1="220" x2="1200" y2="920" gradientUnits="userSpaceOnUse">
|
|
||||||
<stop stop-color="white" stop-opacity="0.95"/>
|
|
||||||
<stop offset="1" stop-color="{$surfaceColor}" stop-opacity="0.86"/>
|
|
||||||
</linearGradient>
|
|
||||||
</defs>
|
|
||||||
<rect width="1600" height="1200" rx="64" fill="url(#bg)"/>
|
|
||||||
<circle cx="1320" cy="230" r="170" fill="white" fill-opacity="0.08"/>
|
|
||||||
<circle cx="240" cy="1010" r="220" fill="white" fill-opacity="0.06"/>
|
|
||||||
<rect x="170" y="146" width="1260" height="908" rx="58" fill="url(#glass)" stroke="white" stroke-opacity="0.22" stroke-width="6"/>
|
|
||||||
<rect x="260" y="248" width="420" height="700" rx="44" fill="{$baseColor}" fill-opacity="0.94"/>
|
|
||||||
<rect x="740" y="248" width="520" height="200" rx="34" fill="white" fill-opacity="0.72"/>
|
|
||||||
<rect x="740" y="490" width="520" height="210" rx="34" fill="white" fill-opacity="0.52"/>
|
|
||||||
<rect x="740" y="742" width="240" height="180" rx="30" fill="white" fill-opacity="0.58"/>
|
|
||||||
<rect x="1020" y="742" width="240" height="180" rx="30" fill="white" fill-opacity="0.32"/>
|
|
||||||
<rect x="340" y="338" width="260" height="260" rx="130" fill="white" fill-opacity="0.12"/>
|
|
||||||
<rect x="830" y="310" width="230" height="38" rx="19" fill="{$accentColor}" fill-opacity="0.16"/>
|
|
||||||
<rect x="830" y="548" width="340" height="26" rx="13" fill="{$baseColor}" fill-opacity="0.12"/>
|
|
||||||
<rect x="830" y="596" width="260" height="26" rx="13" fill="{$baseColor}" fill-opacity="0.08"/>
|
|
||||||
<text x="262" y="214" fill="white" fill-opacity="0.92" font-family="Arial, Helvetica, sans-serif" font-size="40" font-weight="700" letter-spacing="10">OPENCLASSIFY DEMO</text>
|
|
||||||
<text x="258" y="760" fill="white" font-family="Arial, Helvetica, sans-serif" font-size="86" font-weight="700">{$shortCategory}</text>
|
|
||||||
<text x="258" y="840" fill="white" fill-opacity="0.78" font-family="Arial, Helvetica, sans-serif" font-size="44" font-weight="500">{$shortOwner}</text>
|
|
||||||
<text x="818" y="390" fill="{$baseColor}" font-family="Arial, Helvetica, sans-serif" font-size="72" font-weight="700">{$shortTitle}</text>
|
|
||||||
<text x="818" y="474" fill="{$accentColor}" font-family="Arial, Helvetica, sans-serif" font-size="34" font-weight="700" letter-spacing="8">{$code}</text>
|
|
||||||
</svg>
|
|
||||||
SVG;
|
|
||||||
|
|
||||||
file_put_contents($filePath, $svg);
|
|
||||||
|
|
||||||
return $filePath;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static function escape(string $value): string
|
|
||||||
{
|
|
||||||
return htmlspecialchars($value, ENT_QUOTES | ENT_XML1, 'UTF-8');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
63
Modules/Listing/Support/ListingImageViewData.php
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Modules\Listing\Support;
|
||||||
|
|
||||||
|
use Spatie\MediaLibrary\MediaCollections\Models\Media;
|
||||||
|
|
||||||
|
final class ListingImageViewData
|
||||||
|
{
|
||||||
|
private const CONTEXTS = [
|
||||||
|
'card' => ['mobile' => 'card-mobile', 'desktop' => 'card-desktop'],
|
||||||
|
'gallery' => ['mobile' => 'gallery-mobile', 'desktop' => 'gallery-desktop'],
|
||||||
|
'thumb' => ['mobile' => 'thumb-mobile', 'desktop' => 'thumb-desktop'],
|
||||||
|
];
|
||||||
|
|
||||||
|
public static function fromMedia(Media $media, string $context = 'card'): array
|
||||||
|
{
|
||||||
|
$conversion = self::CONTEXTS[$context] ?? self::CONTEXTS['card'];
|
||||||
|
|
||||||
|
return [
|
||||||
|
'mobile' => $media->getAvailableUrl([$conversion['mobile'], $conversion['desktop']]),
|
||||||
|
'desktop' => $media->getAvailableUrl([$conversion['desktop'], $conversion['mobile']]),
|
||||||
|
'fallback' => $media->getUrl(),
|
||||||
|
'alt' => trim((string) $media->name),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function fromUrl(?string $url): ?array
|
||||||
|
{
|
||||||
|
$value = is_string($url) ? trim($url) : '';
|
||||||
|
|
||||||
|
if ($value === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'mobile' => $value,
|
||||||
|
'desktop' => $value,
|
||||||
|
'fallback' => $value,
|
||||||
|
'alt' => '',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function pickUrl(?array $image, string $viewport = 'desktop'): ?string
|
||||||
|
{
|
||||||
|
if (! is_array($image)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$preferred = $viewport === 'mobile'
|
||||||
|
? ($image['mobile'] ?? null)
|
||||||
|
: ($image['desktop'] ?? null);
|
||||||
|
|
||||||
|
if (is_string($preferred) && trim($preferred) !== '') {
|
||||||
|
return trim($preferred);
|
||||||
|
}
|
||||||
|
|
||||||
|
$fallback = $image['fallback'] ?? null;
|
||||||
|
|
||||||
|
return is_string($fallback) && trim($fallback) !== ''
|
||||||
|
? trim($fallback)
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
}
|
||||||
264
Modules/Listing/Support/SampleListingImageCatalog.php
Normal file
@ -0,0 +1,264 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Modules\Listing\Support;
|
||||||
|
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
|
use Modules\Category\Models\Category;
|
||||||
|
|
||||||
|
final class SampleListingImageCatalog
|
||||||
|
{
|
||||||
|
private const DIRECTORY = 'sample_image';
|
||||||
|
private const MAX_PIXELS = 12000000;
|
||||||
|
private const MAX_EDGE = 4200;
|
||||||
|
|
||||||
|
private const CATEGORY_IMAGES = [
|
||||||
|
'electronics-phones' => [
|
||||||
|
'phone.jpeg',
|
||||||
|
],
|
||||||
|
'electronics-computers' => [
|
||||||
|
'laptop.jpg',
|
||||||
|
'macbook.jpg',
|
||||||
|
'tech product macbook digital image render macbook pro.jpg',
|
||||||
|
],
|
||||||
|
'electronics-tablets' => [
|
||||||
|
'tech product macbook digital image render macbook pro.jpg',
|
||||||
|
'phone.jpeg',
|
||||||
|
],
|
||||||
|
'electronics-tvs' => [
|
||||||
|
'headphones.jpg',
|
||||||
|
'macbook.jpg',
|
||||||
|
],
|
||||||
|
'vehicles-cars' => [
|
||||||
|
'car.jpeg',
|
||||||
|
'car2.jpeg',
|
||||||
|
'sportscars car sports car vehicle.jpg',
|
||||||
|
],
|
||||||
|
'vehicles-motorcycles' => [
|
||||||
|
'sport motorbike product photography products moto sports bike enduro motorsports clothing.jpg',
|
||||||
|
],
|
||||||
|
'vehicles-trucks' => [
|
||||||
|
'car2.jpeg',
|
||||||
|
'car.jpeg',
|
||||||
|
],
|
||||||
|
'vehicles-boats' => [
|
||||||
|
'sportscars car sports car vehicle.jpg',
|
||||||
|
'car.jpeg',
|
||||||
|
],
|
||||||
|
'real-estate-for-sale' => [
|
||||||
|
'roof large house fence gate.jpg',
|
||||||
|
'house interior design home interior bedroom.jpg',
|
||||||
|
],
|
||||||
|
'real-estate-for-rent' => [
|
||||||
|
'house interior design home interior bedroom.jpg',
|
||||||
|
'roof large house fence gate.jpg',
|
||||||
|
],
|
||||||
|
'real-estate-commercial' => [
|
||||||
|
'office building black laptop programming grey interior desk men .jpg',
|
||||||
|
'roof large house fence gate.jpg',
|
||||||
|
],
|
||||||
|
'fashion-men' => [
|
||||||
|
'grey product photography hat sustainable fashion beanie ethical fashion ambleside .jpg',
|
||||||
|
'sunglasses.jpg',
|
||||||
|
],
|
||||||
|
'fashion-women' => [
|
||||||
|
'fashion natural wedding product shoes.jpg',
|
||||||
|
'sunglasses.jpg',
|
||||||
|
],
|
||||||
|
'fashion-kids' => [
|
||||||
|
'nike-sport-wear.png',
|
||||||
|
'fashion natural wedding product shoes.jpg',
|
||||||
|
],
|
||||||
|
'fashion-shoes' => [
|
||||||
|
'fashion natural wedding product shoes.jpg',
|
||||||
|
'nike-sport-wear.png',
|
||||||
|
],
|
||||||
|
'home-garden-furniture' => [
|
||||||
|
'house interior design home interior bedroom.jpg',
|
||||||
|
'cup.jpg',
|
||||||
|
],
|
||||||
|
'home-garden-garden' => [
|
||||||
|
'roof large house fence gate.jpg',
|
||||||
|
'house interior design home interior bedroom.jpg',
|
||||||
|
],
|
||||||
|
'home-garden-appliances' => [
|
||||||
|
'cup.jpg',
|
||||||
|
'house interior design home interior bedroom.jpg',
|
||||||
|
],
|
||||||
|
'sports-outdoor' => [
|
||||||
|
'sport motorbike product photography products moto sports bike enduro motorsports clothing.jpg',
|
||||||
|
'nike-sport-wear.png',
|
||||||
|
],
|
||||||
|
'sports-fitness' => [
|
||||||
|
'smart-watch.jpg',
|
||||||
|
' watch_band.jpg',
|
||||||
|
'nike-sport-wear.png',
|
||||||
|
],
|
||||||
|
'sports-team-sports' => [
|
||||||
|
'nike-sport-wear.png',
|
||||||
|
'smart-watch.jpg',
|
||||||
|
],
|
||||||
|
'jobs-full-time' => [
|
||||||
|
'business white career hiring recruitment academic jobs.jpg',
|
||||||
|
'office business people laptop work team classroom grey teamwork table.jpg',
|
||||||
|
],
|
||||||
|
'jobs-part-time' => [
|
||||||
|
'jobs.jpg',
|
||||||
|
'business white career hiring recruitment academic jobs.jpg',
|
||||||
|
],
|
||||||
|
'jobs-freelance' => [
|
||||||
|
'office business technology meeting coding grey engineering engineer software engineer professional woman whiteboard tutor.jpg',
|
||||||
|
'office building black laptop programming grey interior desk men .jpg',
|
||||||
|
],
|
||||||
|
'services-cleaning' => [
|
||||||
|
'office business work team white customer service studio office building.jpg',
|
||||||
|
'cup.jpg',
|
||||||
|
],
|
||||||
|
'services-repair' => [
|
||||||
|
'office building black laptop programming grey interior desk men .jpg',
|
||||||
|
'office business technology meeting coding grey engineering engineer software engineer professional woman whiteboard tutor.jpg',
|
||||||
|
],
|
||||||
|
'services-education' => [
|
||||||
|
'office business people laptop work team classroom grey teamwork table.jpg',
|
||||||
|
'business white career hiring recruitment academic jobs.jpg',
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
private const FAMILY_IMAGES = [
|
||||||
|
'electronics' => [
|
||||||
|
'phone.jpeg',
|
||||||
|
'laptop.jpg',
|
||||||
|
'macbook.jpg',
|
||||||
|
'tech product macbook digital image render macbook pro.jpg',
|
||||||
|
'headphones.jpg',
|
||||||
|
'smart-watch.jpg',
|
||||||
|
' watch_band.jpg',
|
||||||
|
],
|
||||||
|
'vehicles' => [
|
||||||
|
'car.jpeg',
|
||||||
|
'car2.jpeg',
|
||||||
|
'sportscars car sports car vehicle.jpg',
|
||||||
|
'sport motorbike product photography products moto sports bike enduro motorsports clothing.jpg',
|
||||||
|
],
|
||||||
|
'real-estate' => [
|
||||||
|
'roof large house fence gate.jpg',
|
||||||
|
'house interior design home interior bedroom.jpg',
|
||||||
|
'office building black laptop programming grey interior desk men .jpg',
|
||||||
|
],
|
||||||
|
'fashion' => [
|
||||||
|
'fashion natural wedding product shoes.jpg',
|
||||||
|
'grey product photography hat sustainable fashion beanie ethical fashion ambleside .jpg',
|
||||||
|
'nike-sport-wear.png',
|
||||||
|
'sunglasses.jpg',
|
||||||
|
],
|
||||||
|
'home-garden' => [
|
||||||
|
'house interior design home interior bedroom.jpg',
|
||||||
|
'roof large house fence gate.jpg',
|
||||||
|
'cup.jpg',
|
||||||
|
],
|
||||||
|
'sports' => [
|
||||||
|
'sport motorbike product photography products moto sports bike enduro motorsports clothing.jpg',
|
||||||
|
'nike-sport-wear.png',
|
||||||
|
'smart-watch.jpg',
|
||||||
|
' watch_band.jpg',
|
||||||
|
],
|
||||||
|
'jobs' => [
|
||||||
|
'jobs.jpg',
|
||||||
|
'business white career hiring recruitment academic jobs.jpg',
|
||||||
|
'office business people laptop work team classroom grey teamwork table.jpg',
|
||||||
|
'office business technology meeting coding grey engineering engineer software engineer professional woman whiteboard tutor.jpg',
|
||||||
|
'vintage red text retro machine sign blur bokeh flag hiring.jpg',
|
||||||
|
],
|
||||||
|
'services' => [
|
||||||
|
'office business work team white customer service studio office building.jpg',
|
||||||
|
'office building black laptop programming grey interior desk men .jpg',
|
||||||
|
'office business people laptop work team classroom grey teamwork table.jpg',
|
||||||
|
'cup.jpg',
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
public static function pathFor(Category $category, int $seed): ?string
|
||||||
|
{
|
||||||
|
$categorySlug = trim((string) $category->slug);
|
||||||
|
$familySlug = trim((string) ($category->parent?->slug ?? $category->slug));
|
||||||
|
|
||||||
|
$paths = self::resolvePathsForSlug($categorySlug);
|
||||||
|
|
||||||
|
if ($paths->isEmpty()) {
|
||||||
|
$paths = self::resolvePathsForSlug($familySlug);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($paths->isEmpty()) {
|
||||||
|
$paths = self::allPaths();
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($paths->isEmpty()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $paths->values()->get($seed % $paths->count());
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function fileNameFor(string $absolutePath, string $slug): string
|
||||||
|
{
|
||||||
|
$extension = strtolower((string) pathinfo($absolutePath, PATHINFO_EXTENSION));
|
||||||
|
|
||||||
|
return $slug.($extension !== '' ? '.'.$extension : '');
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function resolvePathsForSlug(string $slug): Collection
|
||||||
|
{
|
||||||
|
$fileNames = self::CATEGORY_IMAGES[$slug] ?? self::FAMILY_IMAGES[$slug] ?? [];
|
||||||
|
|
||||||
|
return collect($fileNames)
|
||||||
|
->map(fn (string $fileName): string => public_path(self::DIRECTORY.'/'.$fileName))
|
||||||
|
->filter(fn (string $path): bool => self::isAllowed($path))
|
||||||
|
->values();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function allPaths(): Collection
|
||||||
|
{
|
||||||
|
$paths = glob(public_path(self::DIRECTORY.'/*')) ?: [];
|
||||||
|
|
||||||
|
return collect($paths)
|
||||||
|
->filter(function (string $path): bool {
|
||||||
|
if (! self::isAllowed($path)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$extension = strtolower((string) pathinfo($path, PATHINFO_EXTENSION));
|
||||||
|
|
||||||
|
return in_array($extension, ['jpg', 'jpeg', 'png', 'webp'], true);
|
||||||
|
})
|
||||||
|
->values();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function isAllowed(string $path): bool
|
||||||
|
{
|
||||||
|
if (! is_file($path)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filesize($path) > (int) config('media-library.max_file_size', 10 * 1024 * 1024)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$dimensions = @getimagesize($path);
|
||||||
|
|
||||||
|
if (! is_array($dimensions)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$width = (int) ($dimensions[0] ?? 0);
|
||||||
|
$height = (int) ($dimensions[1] ?? 0);
|
||||||
|
|
||||||
|
if ($width < 1 || $height < 1) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (max($width, $height) > self::MAX_EDGE) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return ($width * $height) <= self::MAX_PIXELS;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -231,7 +231,7 @@
|
|||||||
<div class="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4 gap-3.5">
|
<div class="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4 gap-3.5">
|
||||||
@foreach($listings as $listing)
|
@foreach($listings as $listing)
|
||||||
@php
|
@php
|
||||||
$listingImage = $listing->getFirstMediaUrl('listing-images');
|
$listingImage = $listing->primaryImageData('card');
|
||||||
$isFavorited = in_array($listing->id, $favoriteListingIds ?? [], true);
|
$isFavorited = in_array($listing->id, $favoriteListingIds ?? [], true);
|
||||||
$priceValue = ! is_null($listing->price) ? (float) $listing->price : null;
|
$priceValue = ! is_null($listing->price) ? (float) $listing->price : null;
|
||||||
$locationParts = array_filter([
|
$locationParts = array_filter([
|
||||||
@ -244,7 +244,11 @@
|
|||||||
<div class="relative h-52 bg-slate-200">
|
<div class="relative h-52 bg-slate-200">
|
||||||
@if($listingImage)
|
@if($listingImage)
|
||||||
<a href="{{ route('listings.show', $listing) }}" class="block w-full h-full">
|
<a href="{{ route('listings.show', $listing) }}" class="block w-full h-full">
|
||||||
<img src="{{ $listingImage }}" alt="{{ $listing->title }}" class="w-full h-full object-cover">
|
@include('listing::partials.responsive-image', [
|
||||||
|
'image' => $listingImage,
|
||||||
|
'alt' => $listing->title,
|
||||||
|
'class' => 'w-full h-full object-cover',
|
||||||
|
])
|
||||||
</a>
|
</a>
|
||||||
@else
|
@else
|
||||||
<a href="{{ route('listings.show', $listing) }}" class="w-full h-full grid place-items-center text-slate-400">
|
<a href="{{ route('listings.show', $listing) }}" class="w-full h-full grid place-items-center text-slate-400">
|
||||||
|
|||||||
@ -0,0 +1,31 @@
|
|||||||
|
@php
|
||||||
|
$image = is_array($image ?? null) ? $image : null;
|
||||||
|
$fallback = is_string($image['fallback'] ?? null) ? trim((string) $image['fallback']) : '';
|
||||||
|
$mobile = is_string($image['mobile'] ?? null) ? trim((string) $image['mobile']) : '';
|
||||||
|
$desktop = is_string($image['desktop'] ?? null) ? trim((string) $image['desktop']) : '';
|
||||||
|
$altText = trim((string) ($alt ?? ($image['alt'] ?? '')));
|
||||||
|
$imageClass = trim((string) ($class ?? ''));
|
||||||
|
$loadingMode = trim((string) ($loading ?? 'lazy'));
|
||||||
|
$fetchPriority = trim((string) ($fetchpriority ?? ''));
|
||||||
|
$sizesValue = trim((string) ($sizes ?? ''));
|
||||||
|
@endphp
|
||||||
|
|
||||||
|
@if($fallback !== '' || $mobile !== '' || $desktop !== '')
|
||||||
|
<picture>
|
||||||
|
@if($mobile !== '')
|
||||||
|
<source media="(max-width: 767px)" srcset="{{ $mobile }}">
|
||||||
|
@endif
|
||||||
|
@if($desktop !== '')
|
||||||
|
<source media="(min-width: 768px)" srcset="{{ $desktop }}">
|
||||||
|
@endif
|
||||||
|
<img
|
||||||
|
src="{{ $fallback !== '' ? $fallback : ($desktop !== '' ? $desktop : $mobile) }}"
|
||||||
|
alt="{{ $altText }}"
|
||||||
|
class="{{ $imageClass }}"
|
||||||
|
loading="{{ $loadingMode }}"
|
||||||
|
decoding="async"
|
||||||
|
@if($fetchPriority !== '') fetchpriority="{{ $fetchPriority }}" @endif
|
||||||
|
@if($sizesValue !== '') sizes="{{ $sizesValue }}" @endif
|
||||||
|
>
|
||||||
|
</picture>
|
||||||
|
@endif
|
||||||
@ -22,10 +22,10 @@
|
|||||||
$publishedAt = $listing->created_at?->format('M j, Y') ?? 'Recently';
|
$publishedAt = $listing->created_at?->format('M j, Y') ?? 'Recently';
|
||||||
$postedAgo = $listing->created_at?->diffForHumans() ?? 'Listed recently';
|
$postedAgo = $listing->created_at?->diffForHumans() ?? 'Listed recently';
|
||||||
$galleryImages = collect($gallery ?? [])
|
$galleryImages = collect($gallery ?? [])
|
||||||
->filter(fn ($value) => is_string($value) && trim($value) !== '')
|
->filter(fn ($value) => is_array($value) && is_array($value['gallery'] ?? null))
|
||||||
->values()
|
->values()
|
||||||
->all();
|
->all();
|
||||||
$initialGalleryImage = $galleryImages[0] ?? null;
|
$initialGalleryImage = $galleryImages[0]['gallery'] ?? null;
|
||||||
$galleryCount = count($galleryImages);
|
$galleryCount = count($galleryImages);
|
||||||
|
|
||||||
$description = trim((string) ($listing->description ?? ''));
|
$description = trim((string) ($listing->description ?? ''));
|
||||||
@ -143,7 +143,23 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
@if($initialGalleryImage)
|
@if($initialGalleryImage)
|
||||||
<img src="{{ $initialGalleryImage }}" alt="{{ $displayTitle }}" data-gallery-main>
|
<picture data-gallery-picture>
|
||||||
|
<source
|
||||||
|
data-gallery-source-mobile
|
||||||
|
media="(max-width: 767px)"
|
||||||
|
srcset="{{ $initialGalleryImage['mobile'] ?? ($initialGalleryImage['fallback'] ?? '') }}"
|
||||||
|
>
|
||||||
|
<source
|
||||||
|
data-gallery-source-desktop
|
||||||
|
media="(min-width: 768px)"
|
||||||
|
srcset="{{ $initialGalleryImage['desktop'] ?? ($initialGalleryImage['fallback'] ?? '') }}"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src="{{ $initialGalleryImage['fallback'] ?? ($initialGalleryImage['desktop'] ?? ($initialGalleryImage['mobile'] ?? '')) }}"
|
||||||
|
alt="{{ $displayTitle }}"
|
||||||
|
data-gallery-main
|
||||||
|
>
|
||||||
|
</picture>
|
||||||
@else
|
@else
|
||||||
<div class="lt-gallery-main-empty">No photos uploaded yet.</div>
|
<div class="lt-gallery-main-empty">No photos uploaded yet.</div>
|
||||||
@endif
|
@endif
|
||||||
@ -171,15 +187,25 @@
|
|||||||
@if($galleryImages !== [])
|
@if($galleryImages !== [])
|
||||||
<div class="lt-thumbs" data-gallery-thumbs>
|
<div class="lt-thumbs" data-gallery-thumbs>
|
||||||
@foreach($galleryImages as $index => $image)
|
@foreach($galleryImages as $index => $image)
|
||||||
|
@php
|
||||||
|
$galleryImage = $image['gallery'] ?? null;
|
||||||
|
$thumbImage = $image['thumb'] ?? $galleryImage;
|
||||||
|
@endphp
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="lt-thumb {{ $index === 0 ? 'is-active' : '' }}"
|
class="lt-thumb {{ $index === 0 ? 'is-active' : '' }}"
|
||||||
data-gallery-thumb
|
data-gallery-thumb
|
||||||
data-gallery-index="{{ $index }}"
|
data-gallery-index="{{ $index }}"
|
||||||
data-gallery-src="{{ $image }}"
|
data-gallery-mobile-src="{{ $galleryImage['mobile'] ?? ($galleryImage['fallback'] ?? '') }}"
|
||||||
|
data-gallery-desktop-src="{{ $galleryImage['desktop'] ?? ($galleryImage['fallback'] ?? '') }}"
|
||||||
|
data-gallery-fallback-src="{{ $galleryImage['fallback'] ?? '' }}"
|
||||||
aria-label="Open photo {{ $index + 1 }}"
|
aria-label="Open photo {{ $index + 1 }}"
|
||||||
>
|
>
|
||||||
<img src="{{ $image }}" alt="{{ $displayTitle }} {{ $index + 1 }}">
|
@include('listing::partials.responsive-image', [
|
||||||
|
'image' => $thumbImage,
|
||||||
|
'alt' => $displayTitle.' '.($index + 1),
|
||||||
|
'class' => 'w-full h-full object-cover',
|
||||||
|
])
|
||||||
</button>
|
</button>
|
||||||
@endforeach
|
@endforeach
|
||||||
</div>
|
</div>
|
||||||
@ -439,11 +465,7 @@
|
|||||||
<div class="lt-scroll-track" data-theme-scroll-track>
|
<div class="lt-scroll-track" data-theme-scroll-track>
|
||||||
@foreach(($relatedListings ?? collect()) as $related)
|
@foreach(($relatedListings ?? collect()) as $related)
|
||||||
@php
|
@php
|
||||||
$relatedImage = $related->getFirstMediaUrl('listing-images');
|
$relatedImage = $related->primaryImageData('card');
|
||||||
if (! $relatedImage && is_array($related->images ?? null)) {
|
|
||||||
$relatedImage = collect($related->images)->first();
|
|
||||||
}
|
|
||||||
|
|
||||||
$relatedPrice = 'Price on request';
|
$relatedPrice = 'Price on request';
|
||||||
if (! is_null($related->price)) {
|
if (! is_null($related->price)) {
|
||||||
$relatedPriceValue = (float) $related->price;
|
$relatedPriceValue = (float) $related->price;
|
||||||
@ -458,7 +480,11 @@
|
|||||||
<a href="{{ route('listings.show', $related) }}" class="lt-rel-card">
|
<a href="{{ route('listings.show', $related) }}" class="lt-rel-card">
|
||||||
<div class="lt-rel-photo">
|
<div class="lt-rel-photo">
|
||||||
@if($relatedImage)
|
@if($relatedImage)
|
||||||
<img src="{{ $relatedImage }}" alt="{{ $related->title }}">
|
@include('listing::partials.responsive-image', [
|
||||||
|
'image' => $relatedImage,
|
||||||
|
'alt' => $related->title,
|
||||||
|
'class' => 'w-full h-full object-cover',
|
||||||
|
])
|
||||||
@endif
|
@endif
|
||||||
</div>
|
</div>
|
||||||
<div class="lt-rel-body">
|
<div class="lt-rel-body">
|
||||||
@ -496,6 +522,8 @@
|
|||||||
(() => {
|
(() => {
|
||||||
document.querySelectorAll('[data-gallery]').forEach((galleryRoot) => {
|
document.querySelectorAll('[data-gallery]').forEach((galleryRoot) => {
|
||||||
const mainImage = galleryRoot.querySelector('[data-gallery-main]');
|
const mainImage = galleryRoot.querySelector('[data-gallery-main]');
|
||||||
|
const mainMobileSource = galleryRoot.querySelector('[data-gallery-source-mobile]');
|
||||||
|
const mainDesktopSource = galleryRoot.querySelector('[data-gallery-source-desktop]');
|
||||||
const thumbButtons = Array.from(galleryRoot.querySelectorAll('[data-gallery-thumb]'));
|
const thumbButtons = Array.from(galleryRoot.querySelectorAll('[data-gallery-thumb]'));
|
||||||
const prevButton = galleryRoot.querySelector('[data-gallery-prev]');
|
const prevButton = galleryRoot.querySelector('[data-gallery-prev]');
|
||||||
const nextButton = galleryRoot.querySelector('[data-gallery-next]');
|
const nextButton = galleryRoot.querySelector('[data-gallery-next]');
|
||||||
@ -517,9 +545,20 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
activeIndex = index;
|
activeIndex = index;
|
||||||
const src = thumbButtons[index].dataset.gallerySrc;
|
const mobileSrc = thumbButtons[index].dataset.galleryMobileSrc || '';
|
||||||
if (src) {
|
const desktopSrc = thumbButtons[index].dataset.galleryDesktopSrc || '';
|
||||||
mainImage.src = src;
|
const fallbackSrc = thumbButtons[index].dataset.galleryFallbackSrc || desktopSrc || mobileSrc;
|
||||||
|
|
||||||
|
if (mainMobileSource && mobileSrc) {
|
||||||
|
mainMobileSource.srcset = mobileSrc;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mainDesktopSource && desktopSrc) {
|
||||||
|
mainDesktopSource.srcset = desktopSrc;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fallbackSrc) {
|
||||||
|
mainImage.src = fallbackSrc;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (currentCounter) {
|
if (currentCounter) {
|
||||||
|
|||||||
@ -3,7 +3,7 @@
|
|||||||
@php
|
@php
|
||||||
$menuCategories = $categories->take(8);
|
$menuCategories = $categories->take(8);
|
||||||
$heroListing = $featuredListings->first() ?? $recentListings->first();
|
$heroListing = $featuredListings->first() ?? $recentListings->first();
|
||||||
$heroImage = $heroListing?->getFirstMediaUrl('listing-images');
|
$heroImage = $heroListing?->primaryImageData('gallery');
|
||||||
$listingCards = $recentListings->take(6);
|
$listingCards = $recentListings->take(6);
|
||||||
$demoEnabled = (bool) config('demo.enabled');
|
$demoEnabled = (bool) config('demo.enabled');
|
||||||
$prepareDemoRoute = $demoEnabled ? route('demo.prepare') : null;
|
$prepareDemoRoute = $demoEnabled ? route('demo.prepare') : null;
|
||||||
@ -180,7 +180,13 @@
|
|||||||
@if($slide['image_url'])
|
@if($slide['image_url'])
|
||||||
<img src="{{ $slide['image_url'] }}" alt="{{ $slide['title'] }}" class="w-full h-full object-cover rounded-2xl">
|
<img src="{{ $slide['image_url'] }}" alt="{{ $slide['title'] }}" class="w-full h-full object-cover rounded-2xl">
|
||||||
@elseif($heroImage)
|
@elseif($heroImage)
|
||||||
<img src="{{ $heroImage }}" alt="{{ $heroListing?->title }}" class="w-full h-full object-cover rounded-2xl">
|
@include('listing::partials.responsive-image', [
|
||||||
|
'image' => $heroImage,
|
||||||
|
'alt' => $heroListing?->title,
|
||||||
|
'class' => 'w-full h-full object-cover rounded-2xl',
|
||||||
|
'loading' => 'eager',
|
||||||
|
'fetchpriority' => 'high',
|
||||||
|
])
|
||||||
@else
|
@else
|
||||||
<div class="w-full h-full rounded-2xl bg-white/90 text-slate-800 flex flex-col justify-center items-center gap-3">
|
<div class="w-full h-full rounded-2xl bg-white/90 text-slate-800 flex flex-col justify-center items-center gap-3">
|
||||||
<span class="text-6xl">◌</span>
|
<span class="text-6xl">◌</span>
|
||||||
@ -274,7 +280,7 @@
|
|||||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-3 md:gap-4">
|
<div class="grid grid-cols-1 lg:grid-cols-2 gap-3 md:gap-4">
|
||||||
@forelse($listingCards as $listing)
|
@forelse($listingCards as $listing)
|
||||||
@php
|
@php
|
||||||
$listingImage = $listing->getFirstMediaUrl('listing-images');
|
$listingImage = $listing->primaryImageData('card');
|
||||||
$priceLabel = $listing->price ? number_format((float) $listing->price, 0).' '.$listing->currency : __('messages.free');
|
$priceLabel = $listing->price ? number_format((float) $listing->price, 0).' '.$listing->currency : __('messages.free');
|
||||||
$locationLabel = trim(collect([$listing->city, $listing->country])->filter()->join(', '));
|
$locationLabel = trim(collect([$listing->city, $listing->country])->filter()->join(', '));
|
||||||
$isFavorited = in_array($listing->id, $favoriteListingIds ?? [], true);
|
$isFavorited = in_array($listing->id, $favoriteListingIds ?? [], true);
|
||||||
@ -283,7 +289,11 @@
|
|||||||
<div class="relative h-44 sm:h-64 md:h-[290px] bg-slate-100">
|
<div class="relative h-44 sm:h-64 md:h-[290px] bg-slate-100">
|
||||||
<a href="{{ route('listings.show', $listing) }}" class="block h-full w-full" aria-label="{{ $listing->title }}">
|
<a href="{{ route('listings.show', $listing) }}" class="block h-full w-full" aria-label="{{ $listing->title }}">
|
||||||
@if($listingImage)
|
@if($listingImage)
|
||||||
<img src="{{ $listingImage }}" alt="{{ $listing->title }}" class="w-full h-full object-cover">
|
@include('listing::partials.responsive-image', [
|
||||||
|
'image' => $listingImage,
|
||||||
|
'alt' => $listing->title,
|
||||||
|
'class' => 'w-full h-full object-cover',
|
||||||
|
])
|
||||||
@else
|
@else
|
||||||
<div class="w-full h-full grid place-items-center text-slate-400">
|
<div class="w-full h-full grid place-items-center text-slate-400">
|
||||||
<svg class="w-14 h-14" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-14 h-14" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
|||||||
@ -131,7 +131,7 @@
|
|||||||
@forelse ($listings as $listing)
|
@forelse ($listings as $listing)
|
||||||
@php
|
@php
|
||||||
$statusMeta = $listing->panelStatusMeta();
|
$statusMeta = $listing->panelStatusMeta();
|
||||||
$listingImage = $listing->panelPrimaryImageUrl();
|
$listingImage = $listing->primaryImageData('card');
|
||||||
$priceLabel = $listing->panelPriceLabel();
|
$priceLabel = $listing->panelPriceLabel();
|
||||||
$favoriteCount = (int) ($listing->favorited_by_users_count ?? 0);
|
$favoriteCount = (int) ($listing->favorited_by_users_count ?? 0);
|
||||||
$viewCount = (int) ($listing->view_count ?? 0);
|
$viewCount = (int) ($listing->view_count ?? 0);
|
||||||
@ -147,7 +147,11 @@
|
|||||||
<article class="listings-dashboard-card">
|
<article class="listings-dashboard-card">
|
||||||
<a href="{{ route('listings.show', $listing) }}" class="listings-dashboard-media" aria-label="{{ $listing->title }}">
|
<a href="{{ route('listings.show', $listing) }}" class="listings-dashboard-media" aria-label="{{ $listing->title }}">
|
||||||
@if ($listingImage)
|
@if ($listingImage)
|
||||||
<img src="{{ $listingImage }}" alt="{{ $listing->title }}" class="h-full w-full object-cover">
|
@include('listing::partials.responsive-image', [
|
||||||
|
'image' => $listingImage,
|
||||||
|
'alt' => $listing->title,
|
||||||
|
'class' => 'h-full w-full object-cover',
|
||||||
|
])
|
||||||
@else
|
@else
|
||||||
<div class="listings-dashboard-placeholder">
|
<div class="listings-dashboard-placeholder">
|
||||||
<span>No image</span>
|
<span>No image</span>
|
||||||
|
|||||||
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 208 KiB |
|
After Width: | Height: | Size: 48 KiB |
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 49 KiB |
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 6.2 KiB |
|
After Width: | Height: | Size: 89 KiB |
|
After Width: | Height: | Size: 3.4 MiB |
|
After Width: | Height: | Size: 3.4 MiB |