Compare commits
3 Commits
89003f45e1
...
46b70a91f7
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
46b70a91f7 | ||
|
|
6fde32cc8b | ||
|
|
222928d1d9 |
1
.gitignore
vendored
@ -28,3 +28,4 @@ Thumbs.db
|
||||
composer.lock
|
||||
.codex/config.toml
|
||||
/public/vendor/
|
||||
package-lock.json
|
||||
|
||||
@ -9,25 +9,25 @@ class CategorySeeder extends Seeder
|
||||
public function run(): void
|
||||
{
|
||||
$categories = [
|
||||
['name' => 'Electronics', 'slug' => 'electronics', 'icon' => 'laptop', 'children' => ['Phones', 'Computers', 'Tablets', 'TVs']],
|
||||
['name' => 'Vehicles', 'slug' => 'vehicles', 'icon' => 'car', 'children' => ['Cars', 'Motorcycles', 'Trucks', 'Boats']],
|
||||
['name' => 'Real Estate', 'slug' => 'real-estate', 'icon' => 'home', 'children' => ['For Sale', 'For Rent', 'Commercial']],
|
||||
['name' => 'Fashion', 'slug' => 'fashion', 'icon' => 'shirt', 'children' => ['Men', 'Women', 'Kids', 'Shoes']],
|
||||
['name' => 'Home & Garden', 'slug' => 'home-garden', 'icon' => 'sofa', 'children' => ['Furniture', 'Garden', 'Appliances']],
|
||||
['name' => 'Sports', 'slug' => 'sports', 'icon' => 'football', 'children' => ['Outdoor', 'Fitness', 'Team Sports']],
|
||||
['name' => 'Jobs', 'slug' => 'jobs', 'icon' => 'briefcase', 'children' => ['Full Time', 'Part Time', 'Freelance']],
|
||||
['name' => 'Services', 'slug' => 'services', 'icon' => 'wrench', 'children' => ['Cleaning', 'Repair', 'Education']],
|
||||
['name' => 'Electronics', 'slug' => 'electronics', 'icon' => 'img/category/electronics.png', 'children' => ['Phones', 'Computers', 'Tablets', 'TVs']],
|
||||
['name' => 'Vehicles', 'slug' => 'vehicles', 'icon' => 'img/category/car.png', 'children' => ['Cars', 'Motorcycles', 'Trucks', 'Boats']],
|
||||
['name' => 'Real Estate', 'slug' => 'real-estate', 'icon' => 'img/category/home_garden.png', 'children' => ['For Sale', 'For Rent', 'Commercial']],
|
||||
['name' => 'Fashion', 'slug' => 'fashion', 'icon' => 'img/category/phone.png', 'children' => ['Men', 'Women', 'Kids', 'Shoes']],
|
||||
['name' => 'Home & Garden', 'slug' => 'home-garden', 'icon' => 'img/category/home_tools.png', 'children' => ['Furniture', 'Garden', 'Appliances']],
|
||||
['name' => 'Sports', 'slug' => 'sports', 'icon' => 'img/category/sports.png', 'children' => ['Outdoor', 'Fitness', 'Team Sports']],
|
||||
['name' => 'Jobs', 'slug' => 'jobs', 'icon' => 'img/category/education.png', 'children' => ['Full Time', 'Part Time', 'Freelance']],
|
||||
['name' => 'Services', 'slug' => 'services', 'icon' => 'img/category/home_tools.png', 'children' => ['Cleaning', 'Repair', 'Education']],
|
||||
];
|
||||
|
||||
foreach ($categories as $index => $data) {
|
||||
$parent = Category::firstOrCreate(
|
||||
$parent = Category::updateOrCreate(
|
||||
['slug' => $data['slug']],
|
||||
['name' => $data['name'], 'slug' => $data['slug'], 'icon' => $data['icon'], 'level' => 0, 'sort_order' => $index, 'is_active' => true]
|
||||
);
|
||||
|
||||
foreach ($data['children'] as $i => $childName) {
|
||||
$childSlug = $data['slug'] . '-' . \Illuminate\Support\Str::slug($childName);
|
||||
Category::firstOrCreate(
|
||||
Category::updateOrCreate(
|
||||
['slug' => $childSlug],
|
||||
['name' => $childName, 'slug' => $childSlug, 'parent_id' => $parent->id, 'level' => 1, 'sort_order' => $i, 'is_active' => true]
|
||||
);
|
||||
|
||||
@ -14,6 +14,23 @@ class Category extends Model
|
||||
{
|
||||
use LogsActivity;
|
||||
|
||||
private const ICON_PATHS = [
|
||||
'car' => 'img/category/car.png',
|
||||
'education' => 'img/category/education.png',
|
||||
'electronics' => 'img/category/electronics.png',
|
||||
'football' => 'img/category/sports.png',
|
||||
'home' => 'img/category/home_garden.png',
|
||||
'home-garden' => 'img/category/home_garden.png',
|
||||
'home_garden' => 'img/category/home_garden.png',
|
||||
'home-tools' => 'img/category/home_tools.png',
|
||||
'home_tools' => 'img/category/home_tools.png',
|
||||
'laptop' => 'img/category/laptop.png',
|
||||
'mobile' => 'img/category/phone.png',
|
||||
'pet' => 'img/category/pet.png',
|
||||
'phone' => 'img/category/phone.png',
|
||||
'sports' => 'img/category/sports.png',
|
||||
];
|
||||
|
||||
protected $fillable = ['name', 'slug', 'description', 'icon', 'parent_id', 'level', 'sort_order', 'is_active'];
|
||||
protected $casts = ['is_active' => 'boolean'];
|
||||
|
||||
@ -219,6 +236,32 @@ class Category extends Model
|
||||
return $this->hasMany(\Modules\Listing\Models\Listing::class)->where('status', 'active');
|
||||
}
|
||||
|
||||
public function resolvedIconPath(): ?string
|
||||
{
|
||||
$icon = trim((string) $this->icon);
|
||||
|
||||
if ($icon === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (isset(self::ICON_PATHS[$icon])) {
|
||||
return self::ICON_PATHS[$icon];
|
||||
}
|
||||
|
||||
if (preg_match('/\.(png|jpg|jpeg|webp|svg)$/i', $icon) === 1) {
|
||||
return ltrim($icon, '/');
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public function iconUrl(): ?string
|
||||
{
|
||||
$path = $this->resolvedIconPath();
|
||||
|
||||
return $path ? asset($path) : null;
|
||||
}
|
||||
|
||||
private static function buildListingDirectoryTree(Collection $categories, Collection $activeListingCounts, ?int $parentId = null): Collection
|
||||
{
|
||||
return $categories
|
||||
|
||||
@ -1,37 +0,0 @@
|
||||
<?php
|
||||
namespace Modules\Category\Database\Seeders;
|
||||
|
||||
use Illuminate\Database\Seeder;
|
||||
use Modules\Category\Models\Category;
|
||||
|
||||
class CategorySeeder extends Seeder
|
||||
{
|
||||
public function run(): void
|
||||
{
|
||||
$categories = [
|
||||
['name' => 'Electronics', 'slug' => 'electronics', 'icon' => 'laptop', 'children' => ['Phones', 'Computers', 'Tablets', 'TVs']],
|
||||
['name' => 'Vehicles', 'slug' => 'vehicles', 'icon' => 'car', 'children' => ['Cars', 'Motorcycles', 'Trucks', 'Boats']],
|
||||
['name' => 'Real Estate', 'slug' => 'real-estate', 'icon' => 'home', 'children' => ['For Sale', 'For Rent', 'Commercial']],
|
||||
['name' => 'Fashion', 'slug' => 'fashion', 'icon' => 'shirt', 'children' => ['Men', 'Women', 'Kids', 'Shoes']],
|
||||
['name' => 'Home & Garden', 'slug' => 'home-garden', 'icon' => 'sofa', 'children' => ['Furniture', 'Garden', 'Appliances']],
|
||||
['name' => 'Sports', 'slug' => 'sports', 'icon' => 'football', 'children' => ['Outdoor', 'Fitness', 'Team Sports']],
|
||||
['name' => 'Jobs', 'slug' => 'jobs', 'icon' => 'briefcase', 'children' => ['Full Time', 'Part Time', 'Freelance']],
|
||||
['name' => 'Services', 'slug' => 'services', 'icon' => 'wrench', 'children' => ['Cleaning', 'Repair', 'Education']],
|
||||
];
|
||||
|
||||
foreach ($categories as $index => $data) {
|
||||
$parent = Category::firstOrCreate(
|
||||
['slug' => $data['slug']],
|
||||
['name' => $data['name'], 'slug' => $data['slug'], 'icon' => $data['icon'], 'level' => 0, 'sort_order' => $index, 'is_active' => true]
|
||||
);
|
||||
|
||||
foreach ($data['children'] as $i => $childName) {
|
||||
$childSlug = $data['slug'] . '-' . \Illuminate\Support\Str::slug($childName);
|
||||
Category::firstOrCreate(
|
||||
['slug' => $childSlug],
|
||||
['name' => $childName, 'slug' => $childSlug, 'parent_id' => $parent->id, 'level' => 1, 'sort_order' => $i, 'is_active' => true]
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -21,17 +21,7 @@
|
||||
->filter()
|
||||
->implode(' · ');
|
||||
$extraChildCount = max($category->children->count() - 3, 0);
|
||||
$icon = match (trim((string) ($category->icon ?? ''))) {
|
||||
'laptop' => 'heroicon-o-computer-desktop',
|
||||
'car' => 'heroicon-o-truck',
|
||||
'home' => 'heroicon-o-home',
|
||||
'shirt' => 'heroicon-o-shopping-bag',
|
||||
'sofa' => 'heroicon-o-home-modern',
|
||||
'football' => 'heroicon-o-trophy',
|
||||
'briefcase' => 'heroicon-o-briefcase',
|
||||
'wrench' => 'heroicon-o-wrench-screwdriver',
|
||||
default => null,
|
||||
};
|
||||
$iconUrl = $category->iconUrl();
|
||||
$iconLabel = strtoupper(\Illuminate\Support\Str::substr($category->name, 0, 1));
|
||||
@endphp
|
||||
<a
|
||||
@ -39,11 +29,11 @@
|
||||
class="group flex h-full flex-col rounded-[28px] border border-slate-200 bg-white p-6 shadow-sm transition duration-200 hover:-translate-y-0.5 hover:border-blue-200 hover:shadow-lg"
|
||||
>
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<span class="flex h-14 w-14 shrink-0 items-center justify-center rounded-2xl border border-slate-200 bg-slate-50 text-slate-900 shadow-sm">
|
||||
@if($icon)
|
||||
<x-dynamic-component :component="$icon" class="h-7 w-7" />
|
||||
<span class="flex h-16 w-16 shrink-0 items-center justify-center rounded-2xl border border-slate-200 bg-slate-50 text-slate-900 shadow-sm">
|
||||
@if($iconUrl)
|
||||
<img src="{{ $iconUrl }}" alt="{{ $category->name }}" class="h-11 w-11 object-contain">
|
||||
@else
|
||||
<span class="text-2xl font-semibold">{{ $iconLabel }}</span>
|
||||
<span class="text-3xl font-semibold">{{ $iconLabel }}</span>
|
||||
@endif
|
||||
</span>
|
||||
<span class="inline-flex items-center rounded-full border border-slate-200 bg-slate-50 px-3 py-1 text-xs font-semibold text-slate-600">
|
||||
|
||||
@ -94,7 +94,7 @@ class Video extends Model
|
||||
|
||||
public static function createFromTemporaryUpload(Listing $listing, TemporaryUploadedFile $file, array $attributes = []): self
|
||||
{
|
||||
$disk = (string) config('video.disk', MediaStorage::activeDisk());
|
||||
$disk = (string) ($attributes['disk'] ?? config('video.disk', MediaStorage::activeDisk()));
|
||||
$path = $file->storeAs(
|
||||
trim((string) config('video.upload_directory', 'videos/uploads').'/'.$listing->getKey(), '/'),
|
||||
Str::ulid().'.'.($file->getClientOriginalExtension() ?: $file->guessExtension() ?: 'mp4'),
|
||||
@ -117,7 +117,7 @@ class Video extends Model
|
||||
|
||||
public static function createFromUploadedFile(Listing $listing, UploadedFile $file, array $attributes = []): self
|
||||
{
|
||||
$disk = (string) config('video.disk', MediaStorage::activeDisk());
|
||||
$disk = (string) ($attributes['disk'] ?? config('video.disk', MediaStorage::activeDisk()));
|
||||
$path = $file->storeAs(
|
||||
trim((string) config('video.upload_directory', 'videos/uploads').'/'.$listing->getKey(), '/'),
|
||||
Str::ulid().'.'.($file->getClientOriginalExtension() ?: $file->extension() ?: 'mp4'),
|
||||
|
||||
@ -7,6 +7,7 @@ use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Livewire\Component;
|
||||
use Livewire\Features\SupportFileUploads\TemporaryUploadedFile;
|
||||
use Livewire\Features\SupportFileUploads\WithFileUploads;
|
||||
@ -17,6 +18,7 @@ use Modules\Listing\Support\ListingCustomFieldSchemaBuilder;
|
||||
use Modules\Listing\Support\ListingPanelHelper;
|
||||
use Modules\Location\Models\City;
|
||||
use Modules\Location\Models\Country;
|
||||
use Modules\S3\Support\MediaStorage;
|
||||
use Modules\User\App\Models\Profile;
|
||||
use Modules\Video\Models\Video;
|
||||
use Throwable;
|
||||
@ -26,6 +28,8 @@ class PanelQuickListingForm extends Component
|
||||
use WithFileUploads;
|
||||
|
||||
private const TOTAL_STEPS = 5;
|
||||
private const DRAFT_SESSION_KEY = 'panel_quick_listing_draft';
|
||||
private const OTHER_CITY_ID = -1;
|
||||
|
||||
public array $photos = [];
|
||||
public array $videos = [];
|
||||
@ -53,12 +57,15 @@ class PanelQuickListingForm extends Component
|
||||
public ?int $selectedCountryId = null;
|
||||
public ?int $selectedCityId = null;
|
||||
public bool $isPublishing = false;
|
||||
public bool $shouldPersistDraft = true;
|
||||
public ?string $publishError = null;
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$this->loadCategories();
|
||||
$this->loadLocations();
|
||||
$this->hydrateLocationDefaultsFromProfile();
|
||||
$this->restoreDraft();
|
||||
}
|
||||
|
||||
public function render()
|
||||
@ -66,6 +73,15 @@ class PanelQuickListingForm extends Component
|
||||
return view('panel.quick-create');
|
||||
}
|
||||
|
||||
public function dehydrate(): void
|
||||
{
|
||||
if (! $this->shouldPersistDraft) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->persistDraft();
|
||||
}
|
||||
|
||||
public function updatedPhotos(): void
|
||||
{
|
||||
$this->validatePhotos();
|
||||
@ -103,11 +119,13 @@ class PanelQuickListingForm extends Component
|
||||
|
||||
public function goToStep(int $step): void
|
||||
{
|
||||
$this->publishError = null;
|
||||
$this->currentStep = max(1, min(self::TOTAL_STEPS, $step));
|
||||
}
|
||||
|
||||
public function goToCategoryStep(): void
|
||||
{
|
||||
$this->publishError = null;
|
||||
$this->validatePhotos();
|
||||
$this->validateVideos();
|
||||
$this->currentStep = 2;
|
||||
@ -119,12 +137,14 @@ class PanelQuickListingForm extends Component
|
||||
|
||||
public function goToDetailsStep(): void
|
||||
{
|
||||
$this->publishError = null;
|
||||
$this->validateCategoryStep();
|
||||
$this->currentStep = 3;
|
||||
}
|
||||
|
||||
public function goToFeaturesStep(): void
|
||||
{
|
||||
$this->publishError = null;
|
||||
$this->validateCategoryStep();
|
||||
$this->validateDetailsStep();
|
||||
$this->currentStep = 4;
|
||||
@ -132,6 +152,7 @@ class PanelQuickListingForm extends Component
|
||||
|
||||
public function goToPreviewStep(): void
|
||||
{
|
||||
$this->publishError = null;
|
||||
$this->validateCategoryStep();
|
||||
$this->validateDetailsStep();
|
||||
$this->validateCustomFieldsStep();
|
||||
@ -185,6 +206,7 @@ class PanelQuickListingForm extends Component
|
||||
return;
|
||||
}
|
||||
|
||||
$this->publishError = null;
|
||||
$this->selectedCategoryId = $categoryId;
|
||||
$this->loadListingCustomFields();
|
||||
}
|
||||
@ -196,18 +218,26 @@ class PanelQuickListingForm extends Component
|
||||
}
|
||||
|
||||
$this->isPublishing = true;
|
||||
$this->publishError = null;
|
||||
$this->resetErrorBag();
|
||||
|
||||
try {
|
||||
$this->validatePhotos();
|
||||
$this->validateVideos();
|
||||
$this->validateCategoryStep();
|
||||
$this->validateDetailsStep();
|
||||
$this->validateCustomFieldsStep();
|
||||
|
||||
try {
|
||||
$listing = $this->createListing();
|
||||
} catch (ValidationException $exception) {
|
||||
$this->isPublishing = false;
|
||||
$this->handlePublishValidationFailure($exception);
|
||||
|
||||
return;
|
||||
} catch (Throwable $exception) {
|
||||
report($exception);
|
||||
$this->isPublishing = false;
|
||||
$this->publishError = 'The listing could not be created. Please try again.';
|
||||
session()->flash('error', 'The listing could not be created. Please try again.');
|
||||
|
||||
return;
|
||||
@ -215,9 +245,10 @@ class PanelQuickListingForm extends Component
|
||||
|
||||
$this->isPublishing = false;
|
||||
session()->flash('success', 'Your listing has been created successfully.');
|
||||
$this->clearDraft();
|
||||
|
||||
if (Route::has('panel.listings.edit')) {
|
||||
$this->redirect(route('panel.listings.edit', $listing), navigate: true);
|
||||
$this->redirectRoute('panel.listings.edit', ['listing' => $listing->getKey()]);
|
||||
|
||||
return;
|
||||
}
|
||||
@ -342,10 +373,20 @@ class PanelQuickListingForm extends Component
|
||||
return [];
|
||||
}
|
||||
|
||||
return collect($this->cities)
|
||||
$cities = collect($this->cities)
|
||||
->where('country_id', $this->selectedCountryId)
|
||||
->values()
|
||||
->all();
|
||||
|
||||
if ($cities !== []) {
|
||||
return $cities;
|
||||
}
|
||||
|
||||
return [[
|
||||
'id' => self::OTHER_CITY_ID,
|
||||
'name' => 'Other',
|
||||
'country_id' => $this->selectedCountryId,
|
||||
]];
|
||||
}
|
||||
|
||||
public function getSelectedCountryNameProperty(): ?string
|
||||
@ -365,6 +406,10 @@ class PanelQuickListingForm extends Component
|
||||
return null;
|
||||
}
|
||||
|
||||
if ((int) $this->selectedCityId === self::OTHER_CITY_ID) {
|
||||
return 'Other';
|
||||
}
|
||||
|
||||
$city = collect($this->cities)->firstWhere('id', $this->selectedCityId);
|
||||
|
||||
return $city['name'] ?? null;
|
||||
@ -570,6 +615,7 @@ class PanelQuickListingForm extends Component
|
||||
];
|
||||
|
||||
$listing = Listing::createFromFrontend($payload, $user->getKey());
|
||||
$mediaDisk = $this->frontendMediaDisk();
|
||||
|
||||
foreach ($this->photos as $photo) {
|
||||
if (! $photo instanceof TemporaryUploadedFile) {
|
||||
@ -579,7 +625,7 @@ class PanelQuickListingForm extends Component
|
||||
$listing
|
||||
->addMedia($photo->getRealPath())
|
||||
->usingFileName($photo->getClientOriginalName())
|
||||
->toMediaCollection('listing-images');
|
||||
->toMediaCollection('listing-images', $mediaDisk);
|
||||
}
|
||||
|
||||
foreach ($this->videos as $index => $video) {
|
||||
@ -588,6 +634,7 @@ class PanelQuickListingForm extends Component
|
||||
}
|
||||
|
||||
Video::createFromTemporaryUpload($listing, $video, [
|
||||
'disk' => $mediaDisk,
|
||||
'sort_order' => $index + 1,
|
||||
'title' => pathinfo($video->getClientOriginalName(), PATHINFO_FILENAME),
|
||||
]);
|
||||
@ -746,6 +793,117 @@ class PanelQuickListingForm extends Component
|
||||
return collect($this->categories)->contains(fn (array $category): bool => $category['id'] === $categoryId);
|
||||
}
|
||||
|
||||
private function frontendMediaDisk(): string
|
||||
{
|
||||
return (string) config('media_storage.local_disk', MediaStorage::diskFromDriver(MediaStorage::DRIVER_LOCAL));
|
||||
}
|
||||
|
||||
private function handlePublishValidationFailure(ValidationException $exception): void
|
||||
{
|
||||
$errors = $exception->errors();
|
||||
|
||||
foreach ($errors as $key => $messages) {
|
||||
foreach ($messages as $message) {
|
||||
$this->addError($key, $message);
|
||||
}
|
||||
}
|
||||
|
||||
$this->currentStep = $this->stepForValidationErrors(array_keys($errors));
|
||||
$this->publishError = collect($errors)->flatten()->filter()->first() ?: 'Please fix the highlighted fields before publishing.';
|
||||
}
|
||||
|
||||
private function stepForValidationErrors(array $keys): int
|
||||
{
|
||||
$normalizedKeys = collect($keys)->map(fn ($key) => (string) $key)->values();
|
||||
|
||||
if ($normalizedKeys->contains(fn ($key) => str_starts_with($key, 'photos') || str_starts_with($key, 'videos'))) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
if ($normalizedKeys->contains('selectedCategoryId')) {
|
||||
return 2;
|
||||
}
|
||||
|
||||
if ($normalizedKeys->contains(fn ($key) => in_array($key, [
|
||||
'listingTitle',
|
||||
'price',
|
||||
'description',
|
||||
'selectedCountryId',
|
||||
'selectedCityId',
|
||||
], true))) {
|
||||
return 3;
|
||||
}
|
||||
|
||||
if ($normalizedKeys->contains(fn ($key) => str_starts_with($key, 'customFieldValues.'))) {
|
||||
return 4;
|
||||
}
|
||||
|
||||
return 5;
|
||||
}
|
||||
|
||||
private function restoreDraft(): void
|
||||
{
|
||||
$draft = session()->get($this->draftSessionKey(), []);
|
||||
|
||||
if (! is_array($draft) || $draft === []) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->currentStep = max(1, min(self::TOTAL_STEPS, (int) ($draft['currentStep'] ?? 1)));
|
||||
$this->categorySearch = (string) ($draft['categorySearch'] ?? '');
|
||||
$this->selectedCategoryId = isset($draft['selectedCategoryId']) ? (int) $draft['selectedCategoryId'] : null;
|
||||
$this->activeParentCategoryId = isset($draft['activeParentCategoryId']) ? (int) $draft['activeParentCategoryId'] : null;
|
||||
$this->detectedCategoryId = isset($draft['detectedCategoryId']) ? (int) $draft['detectedCategoryId'] : null;
|
||||
$this->detectedConfidence = isset($draft['detectedConfidence']) ? (float) $draft['detectedConfidence'] : null;
|
||||
$this->detectedReason = isset($draft['detectedReason']) ? (string) $draft['detectedReason'] : null;
|
||||
$this->detectedError = isset($draft['detectedError']) ? (string) $draft['detectedError'] : null;
|
||||
$this->detectedAlternatives = collect($draft['detectedAlternatives'] ?? [])->filter(fn ($id) => is_numeric($id))->map(fn ($id) => (int) $id)->values()->all();
|
||||
$this->listingTitle = (string) ($draft['listingTitle'] ?? '');
|
||||
$this->price = (string) ($draft['price'] ?? '');
|
||||
$this->description = (string) ($draft['description'] ?? '');
|
||||
$this->selectedCountryId = isset($draft['selectedCountryId']) ? (int) $draft['selectedCountryId'] : $this->selectedCountryId;
|
||||
$this->selectedCityId = isset($draft['selectedCityId']) ? (int) $draft['selectedCityId'] : null;
|
||||
$this->customFieldValues = is_array($draft['customFieldValues'] ?? null) ? $draft['customFieldValues'] : [];
|
||||
|
||||
if ($this->selectedCategoryId) {
|
||||
$this->loadListingCustomFields();
|
||||
}
|
||||
}
|
||||
|
||||
private function persistDraft(): void
|
||||
{
|
||||
session()->put($this->draftSessionKey(), [
|
||||
'currentStep' => $this->currentStep,
|
||||
'categorySearch' => $this->categorySearch,
|
||||
'selectedCategoryId' => $this->selectedCategoryId,
|
||||
'activeParentCategoryId' => $this->activeParentCategoryId,
|
||||
'detectedCategoryId' => $this->detectedCategoryId,
|
||||
'detectedConfidence' => $this->detectedConfidence,
|
||||
'detectedReason' => $this->detectedReason,
|
||||
'detectedError' => $this->detectedError,
|
||||
'detectedAlternatives' => $this->detectedAlternatives,
|
||||
'listingTitle' => $this->listingTitle,
|
||||
'price' => $this->price,
|
||||
'description' => $this->description,
|
||||
'selectedCountryId' => $this->selectedCountryId,
|
||||
'selectedCityId' => $this->selectedCityId,
|
||||
'customFieldValues' => $this->customFieldValues,
|
||||
]);
|
||||
}
|
||||
|
||||
private function clearDraft(): void
|
||||
{
|
||||
$this->shouldPersistDraft = false;
|
||||
session()->forget($this->draftSessionKey());
|
||||
}
|
||||
|
||||
private function draftSessionKey(): string
|
||||
{
|
||||
$userId = auth()->id() ?: 'guest';
|
||||
|
||||
return self::DRAFT_SESSION_KEY.'.'.$userId;
|
||||
}
|
||||
|
||||
private function categoryPathParts(int $categoryId): array
|
||||
{
|
||||
$byId = collect($this->categories)->keyBy('id');
|
||||
|
||||
@ -201,10 +201,11 @@ final class RequestAppData
|
||||
->orderBy('sort_order')
|
||||
->orderBy('name')
|
||||
->limit(8)
|
||||
->get(['id', 'name'])
|
||||
->get(['id', 'name', 'icon'])
|
||||
->map(fn (Category $category): array => [
|
||||
'id' => (int) $category->id,
|
||||
'name' => (string) $category->name,
|
||||
'icon_url' => $category->iconUrl(),
|
||||
])
|
||||
->values()
|
||||
->all();
|
||||
|
||||
@ -17,5 +17,8 @@
|
||||
"postcss": "^8.4.31",
|
||||
"tailwindcss": "^3.1.0",
|
||||
"vite": "^7.0.7"
|
||||
},
|
||||
"dependencies": {
|
||||
"animejs": "^4.3.6"
|
||||
}
|
||||
}
|
||||
|
||||
BIN
public/img/category/car.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
public/img/category/education.png
Normal file
|
After Width: | Height: | Size: 6.4 KiB |
BIN
public/img/category/electronics.png
Normal file
|
After Width: | Height: | Size: 6.9 KiB |
BIN
public/img/category/home_garden.png
Normal file
|
After Width: | Height: | Size: 9.0 KiB |
BIN
public/img/category/home_tools.png
Normal file
|
After Width: | Height: | Size: 8.2 KiB |
BIN
public/img/category/laptop.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
public/img/category/pet.png
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
public/img/category/phone.png
Normal file
|
After Width: | Height: | Size: 6.8 KiB |
BIN
public/img/category/sports.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
@ -86,12 +86,14 @@ h6 {
|
||||
background: rgba(251, 251, 253, 0.88);
|
||||
backdrop-filter: saturate(180%) blur(18px);
|
||||
border-bottom: 1px solid var(--oc-border);
|
||||
overflow-x: clip;
|
||||
}
|
||||
|
||||
.oc-nav-wrap {
|
||||
max-width: 1320px;
|
||||
margin: 0 auto;
|
||||
padding: 10px 16px 12px;
|
||||
overflow-x: clip;
|
||||
}
|
||||
|
||||
.oc-nav-main {
|
||||
@ -454,6 +456,7 @@ h6 {
|
||||
.oc-category-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-height: 46px;
|
||||
padding: 0 18px;
|
||||
border-radius: 999px;
|
||||
@ -464,6 +467,21 @@ h6 {
|
||||
transition: background 0.2s ease, color 0.2s ease;
|
||||
}
|
||||
|
||||
.oc-category-link-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.oc-category-link-icon img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.oc-category-link:hover,
|
||||
.oc-category-pill:hover,
|
||||
.oc-pill:hover {
|
||||
|
||||
@ -1 +1,176 @@
|
||||
import './bootstrap';
|
||||
import { animate, createTimeline, stagger } from 'animejs';
|
||||
|
||||
const prefersReducedMotion = () => window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
|
||||
const onReady = (callback) => {
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', callback, { once: true });
|
||||
return;
|
||||
}
|
||||
|
||||
callback();
|
||||
};
|
||||
|
||||
const animateHeader = () => {
|
||||
const nav = document.querySelector('[data-anim-nav]');
|
||||
const categoryRow = document.querySelector('[data-anim-header-row]');
|
||||
|
||||
if (nav) {
|
||||
animate(nav, {
|
||||
opacity: [0, 1],
|
||||
translateY: [-18, 0],
|
||||
duration: 700,
|
||||
ease: 'outExpo',
|
||||
});
|
||||
}
|
||||
|
||||
if (categoryRow) {
|
||||
animate(categoryRow.querySelectorAll('.oc-category-pill, .oc-category-link'), {
|
||||
opacity: [0, 1],
|
||||
translateY: [-10, 0],
|
||||
delay: stagger(45, { start: 140 }),
|
||||
duration: 520,
|
||||
ease: 'outQuad',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const animateHomeHero = () => {
|
||||
const hero = document.querySelector('[data-home-hero]');
|
||||
|
||||
if (!hero) {
|
||||
return;
|
||||
}
|
||||
|
||||
const copy = hero.querySelector('[data-home-hero-copy]');
|
||||
const visual = hero.querySelector('[data-home-hero-visual]');
|
||||
const timeline = createTimeline({
|
||||
defaults: {
|
||||
duration: 720,
|
||||
ease: 'outExpo',
|
||||
},
|
||||
});
|
||||
|
||||
if (copy) {
|
||||
timeline.add(copy.querySelectorAll('[data-home-slide] > *'), {
|
||||
opacity: [0, 1],
|
||||
translateY: [28, 0],
|
||||
delay: stagger(80),
|
||||
});
|
||||
}
|
||||
|
||||
if (visual) {
|
||||
timeline.add(visual, {
|
||||
opacity: [0, 1],
|
||||
translateX: [36, 0],
|
||||
scale: [0.96, 1],
|
||||
}, '<+=120');
|
||||
|
||||
animate(visual, {
|
||||
translateY: [
|
||||
{ to: -8, duration: 2800, ease: 'inOutSine' },
|
||||
{ to: 0, duration: 2800, ease: 'inOutSine' },
|
||||
],
|
||||
loop: true,
|
||||
alternate: true,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const animateOnView = (selector, itemSelector, options = {}) => {
|
||||
const sections = document.querySelectorAll(selector);
|
||||
|
||||
if (!sections.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const observer = new IntersectionObserver((entries) => {
|
||||
entries.forEach((entry) => {
|
||||
if (!entry.isIntersecting) {
|
||||
return;
|
||||
}
|
||||
|
||||
const section = entry.target;
|
||||
const items = itemSelector ? section.querySelectorAll(itemSelector) : [section];
|
||||
|
||||
animate(items, {
|
||||
opacity: [0, 1],
|
||||
translateY: [24, 0],
|
||||
delay: stagger(options.stagger ?? 70),
|
||||
duration: options.duration ?? 650,
|
||||
ease: options.ease ?? 'outExpo',
|
||||
});
|
||||
|
||||
observer.unobserve(section);
|
||||
});
|
||||
}, {
|
||||
threshold: 0.18,
|
||||
rootMargin: '0px 0px -8% 0px',
|
||||
});
|
||||
|
||||
sections.forEach((section) => observer.observe(section));
|
||||
};
|
||||
|
||||
const bindHoverLift = (selector, distance = 6) => {
|
||||
document.querySelectorAll(selector).forEach((element) => {
|
||||
element.addEventListener('mouseenter', () => {
|
||||
animate(element, {
|
||||
translateY: -distance,
|
||||
scale: 1.015,
|
||||
duration: 260,
|
||||
ease: 'outQuad',
|
||||
});
|
||||
});
|
||||
|
||||
element.addEventListener('mouseleave', () => {
|
||||
animate(element, {
|
||||
translateY: 0,
|
||||
scale: 1,
|
||||
duration: 320,
|
||||
ease: 'outExpo',
|
||||
});
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const animateFooter = () => {
|
||||
const footer = document.querySelector('[data-anim-footer]');
|
||||
|
||||
if (!footer) {
|
||||
return;
|
||||
}
|
||||
|
||||
const observer = new IntersectionObserver((entries) => {
|
||||
entries.forEach((entry) => {
|
||||
if (!entry.isIntersecting) {
|
||||
return;
|
||||
}
|
||||
|
||||
animate(footer.querySelectorAll('[data-anim-footer-item]'), {
|
||||
opacity: [0, 1],
|
||||
translateY: [26, 0],
|
||||
delay: stagger(90),
|
||||
duration: 620,
|
||||
ease: 'outExpo',
|
||||
});
|
||||
|
||||
observer.disconnect();
|
||||
});
|
||||
}, { threshold: 0.2 });
|
||||
|
||||
observer.observe(footer);
|
||||
};
|
||||
|
||||
onReady(() => {
|
||||
if (prefersReducedMotion()) {
|
||||
return;
|
||||
}
|
||||
|
||||
animateHeader();
|
||||
animateHomeHero();
|
||||
animateOnView('[data-home-section]', '[data-home-category-card], [data-home-listing-card], h2, a, article, section > div');
|
||||
animateFooter();
|
||||
bindHoverLift('[data-home-category-card]', 5);
|
||||
bindHoverLift('[data-home-listing-card]', 4);
|
||||
});
|
||||
|
||||
@ -58,26 +58,6 @@
|
||||
]);
|
||||
}
|
||||
|
||||
$trendSkins = [
|
||||
['gradient' => 'from-emerald-800 via-emerald-700 to-emerald-600', 'glow' => 'bg-emerald-200/45'],
|
||||
['gradient' => 'from-rose-700 via-rose-600 to-pink-500', 'glow' => 'bg-rose-200/40'],
|
||||
['gradient' => 'from-rose-700 via-pink-600 to-fuchsia-500', 'glow' => 'bg-pink-200/40'],
|
||||
['gradient' => 'from-rose-700 via-rose-600 to-orange-500', 'glow' => 'bg-orange-200/40'],
|
||||
['gradient' => 'from-rose-700 via-pink-600 to-red-500', 'glow' => 'bg-rose-200/40'],
|
||||
['gradient' => 'from-fuchsia-700 via-pink-600 to-rose-500', 'glow' => 'bg-fuchsia-200/40'],
|
||||
['gradient' => 'from-rose-700 via-rose-600 to-pink-500', 'glow' => 'bg-rose-200/40'],
|
||||
['gradient' => 'from-red-700 via-rose-600 to-pink-500', 'glow' => 'bg-red-200/40'],
|
||||
];
|
||||
$trendIcons = [
|
||||
'gift',
|
||||
'computer',
|
||||
'bike',
|
||||
'sparkles',
|
||||
'coffee',
|
||||
'laptop',
|
||||
'fitness',
|
||||
'game',
|
||||
];
|
||||
@endphp
|
||||
|
||||
@if($demoLandingMode && $prepareDemoRoute)
|
||||
@ -99,11 +79,11 @@
|
||||
</div>
|
||||
@else
|
||||
<div class="max-w-[1320px] mx-auto px-4 py-5 md:py-7 space-y-7">
|
||||
<section class="relative overflow-hidden rounded-[28px] bg-gradient-to-r from-blue-900 via-blue-700 to-blue-600 text-white shadow-xl">
|
||||
<section class="relative overflow-hidden rounded-[28px] bg-gradient-to-r from-blue-900 via-blue-700 to-blue-600 text-white shadow-xl" data-home-hero>
|
||||
<div class="absolute -top-20 -left-24 w-80 h-80 rounded-full bg-blue-400/20 blur-3xl"></div>
|
||||
<div class="absolute -bottom-24 right-10 w-80 h-80 rounded-full bg-cyan-300/20 blur-3xl"></div>
|
||||
<div class="relative grid lg:grid-cols-[1fr,1.1fr] gap-6 items-center px-8 md:px-12 py-12 md:py-14">
|
||||
<div data-home-slider>
|
||||
<div data-home-slider data-home-hero-copy>
|
||||
<div class="relative min-h-[250px]">
|
||||
@foreach($homeSlides as $index => $slide)
|
||||
<div
|
||||
@ -169,7 +149,7 @@
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
<div class="relative h-[310px] md:h-[360px]">
|
||||
<div class="relative h-[310px] md:h-[360px]" data-home-hero-visual>
|
||||
<div class="absolute left-6 md:left-10 bottom-0 w-32 md:w-40 h-[250px] md:h-[300px] bg-slate-950 rounded-[32px] shadow-2xl p-2 rotate-[-8deg]">
|
||||
<div class="w-full h-full rounded-[24px] bg-white overflow-hidden">
|
||||
<div class="px-3 py-2 border-b border-slate-100">
|
||||
@ -214,7 +194,7 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<section data-home-section>
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<h2 class="text-3xl font-extrabold tracking-tight text-slate-900">Trending Categories</h2>
|
||||
<a href="{{ route('categories.index') }}" class="hidden sm:inline-flex text-sm font-semibold text-rose-500 hover:text-rose-600 transition">
|
||||
@ -235,59 +215,19 @@
|
||||
<div data-trend-track class="flex items-stretch gap-2 overflow-x-auto pb-2 pr-1 scroll-smooth snap-x snap-mandatory [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
|
||||
@foreach($menuCategories as $index => $category)
|
||||
@php
|
||||
$trendSkin = $trendSkins[$index % count($trendSkins)];
|
||||
$trendIcon = $trendIcons[$index % count($trendIcons)];
|
||||
$categoryIconUrl = $category->iconUrl();
|
||||
$fallbackLabel = strtoupper(\Illuminate\Support\Str::substr($category->name, 0, 1));
|
||||
@endphp
|
||||
<a href="{{ route('listings.index', ['category' => $category->id]) }}" class="group shrink-0 w-[170px] rounded-xl overflow-hidden border border-slate-300/80 bg-white hover:shadow-md transition snap-start">
|
||||
<div class="h-[68px] bg-gradient-to-r {{ $trendSkin['gradient'] }} relative overflow-hidden">
|
||||
<span class="absolute -left-5 top-2 w-20 h-20 rounded-full {{ $trendSkin['glow'] }} blur-2xl"></span>
|
||||
<span class="absolute left-5 bottom-2 h-2.5 w-24 rounded-full bg-black/20"></span>
|
||||
<span class="absolute right-3 bottom-2 w-11 h-11 rounded-lg border border-white/35 bg-white/10 backdrop-blur-sm text-white grid place-items-center shadow-sm">
|
||||
@switch($trendIcon)
|
||||
@case('gift')
|
||||
<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="M20 12v8a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1v-8m16 0H4m16 0V8a1 1 0 0 0-1-1h-3.5M4 12V8a1 1 0 0 1 1-1h3.5m0 0a2 2 0 1 1 0-4c2.5 0 3.5 4 3.5 4m-3.5 0h7m0 0a2 2 0 1 0 0-4c-2.5 0-3.5 4-3.5 4"/>
|
||||
</svg>
|
||||
@break
|
||||
@case('computer')
|
||||
<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 5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5Zm4 16h10m-8 0 1.5-3m4 3L13 18"/>
|
||||
</svg>
|
||||
@break
|
||||
@case('bike')
|
||||
<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="M5.5 17.5A3.5 3.5 0 1 0 5.5 10a3.5 3.5 0 0 0 0 7.5Zm13 0A3.5 3.5 0 1 0 18.5 10a3.5 3.5 0 0 0 0 7.5ZM8.5 14l3-5h3l2 5m-5-5L9 6h3"/>
|
||||
</svg>
|
||||
@break
|
||||
@case('sparkles')
|
||||
<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="m9 5 1.4 2.6L13 9l-2.6 1.4L9 13l-1.4-2.6L5 9l2.6-1.4L9 5Zm8 4 1 1.9L20 12l-2 1.1-1 1.9-1-1.9-2-1.1 2-1.1L17 9ZM7 16l1.5 2.8L11.5 20 8.5 21.2 7 24l-1.5-2.8L2.5 20l3-1.2L7 16Z"/>
|
||||
</svg>
|
||||
@break
|
||||
@case('coffee')
|
||||
<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="M7 8v7a3 3 0 0 0 3 3h4a3 3 0 0 0 3-3V8H7Zm10 2h1a2 2 0 1 1 0 4h-1M8 4v2m4-2v2m4-2v2M6 21h12"/>
|
||||
</svg>
|
||||
@break
|
||||
@case('laptop')
|
||||
<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 6a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v8H4V6Zm-1 10h18l-1.2 3H4.2L3 16Z"/>
|
||||
</svg>
|
||||
@break
|
||||
@case('fitness')
|
||||
<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 10h2v4H3v-4Zm16 0h2v4h-2v-4ZM7 8h2v8H7V8Zm8 0h2v8h-2V8Zm-4 2h2v4h-2v-4Z"/>
|
||||
</svg>
|
||||
@break
|
||||
@default
|
||||
<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="M15 6h3a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2h-3m-6 0H6a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h3m3 4h.01m-1 8h2a2 2 0 0 0 2-2v-3a2 2 0 0 0-2-2h-2a2 2 0 0 0-2 2v3a2 2 0 0 0 2 2Z"/>
|
||||
</svg>
|
||||
@endswitch
|
||||
</span>
|
||||
<a href="{{ route('listings.index', ['category' => $category->id]) }}" class="group shrink-0 w-[170px] rounded-[22px] overflow-hidden border border-slate-200/80 bg-white/95 p-4 hover:-translate-y-0.5 hover:shadow-[0_18px_36px_rgba(15,23,42,0.08)] transition snap-start" data-home-category-card>
|
||||
<div class="flex items-center justify-center h-[92px] rounded-[20px] bg-[linear-gradient(180deg,#f8fbff_0%,#eef5ff_100%)]">
|
||||
@if($categoryIconUrl)
|
||||
<img src="{{ $categoryIconUrl }}" alt="{{ $category->name }}" class="h-14 w-14 object-contain">
|
||||
@else
|
||||
<span class="inline-flex h-14 w-14 items-center justify-center rounded-full bg-white text-xl font-semibold text-slate-700 shadow-sm">{{ $fallbackLabel }}</span>
|
||||
@endif
|
||||
</div>
|
||||
<div class="px-3 py-2.5">
|
||||
<p class="text-[12px] sm:text-[13px] font-semibold text-slate-900 leading-tight truncate">{{ $category->name }}</p>
|
||||
<div class="pt-4">
|
||||
<p class="text-[13px] sm:text-[14px] font-semibold text-slate-900 leading-tight">{{ $category->name }}</p>
|
||||
</div>
|
||||
</a>
|
||||
@endforeach
|
||||
@ -305,7 +245,7 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<section data-home-section>
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h2 class="text-2xl font-bold text-slate-900">Popular Listings</h2>
|
||||
<div class="hidden sm:flex items-center gap-2 text-sm text-slate-500">
|
||||
@ -321,7 +261,7 @@
|
||||
$locationLabel = trim(collect([$listing->city, $listing->country])->filter()->join(', '));
|
||||
$isFavorited = in_array($listing->id, $favoriteListingIds ?? [], true);
|
||||
@endphp
|
||||
<article class="rounded-2xl border border-slate-200 bg-white overflow-hidden shadow-sm hover:shadow-md transition">
|
||||
<article class="rounded-2xl border border-slate-200 bg-white overflow-hidden shadow-sm hover:shadow-md transition" data-home-listing-card>
|
||||
<div class="relative h-64 md:h-[290px] bg-slate-100">
|
||||
<a href="{{ route('listings.show', $listing) }}" class="block h-full w-full" aria-label="{{ $listing->title }}">
|
||||
@if($listingImage)
|
||||
@ -373,7 +313,7 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="rounded-3xl bg-slate-900 text-white px-8 py-10 md:p-12">
|
||||
<section class="rounded-3xl bg-slate-900 text-white px-8 py-10 md:p-12" data-home-section>
|
||||
<div class="grid md:grid-cols-[1fr,auto] gap-6 items-center">
|
||||
<div>
|
||||
<h2 class="text-3xl md:text-4xl font-extrabold">{{ __('messages.sell_something') }}</h2>
|
||||
|
||||
@ -103,7 +103,7 @@
|
||||
</div>
|
||||
</nav>
|
||||
@else
|
||||
<nav class="market-nav-surface sticky top-0 z-50">
|
||||
<nav class="market-nav-surface sticky top-0 z-50" data-anim-nav>
|
||||
<div class="oc-nav-wrap">
|
||||
<div class="oc-nav-main">
|
||||
<div class="oc-topbar">
|
||||
@ -316,7 +316,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="oc-category-row">
|
||||
<div class="oc-category-row" data-anim-header-row>
|
||||
<div class="oc-category-track">
|
||||
<a href="{{ route('categories.index') }}" class="oc-category-pill">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
@ -326,6 +326,11 @@
|
||||
</a>
|
||||
@forelse($headerCategories as $headerCategory)
|
||||
<a href="{{ route('listings.index', ['category' => $headerCategory['id']]) }}" class="oc-category-link">
|
||||
@if(! empty($headerCategory['icon_url']))
|
||||
<span class="oc-category-link-icon">
|
||||
<img src="{{ $headerCategory['icon_url'] }}" alt="{{ $headerCategory['name'] }}">
|
||||
</span>
|
||||
@endif
|
||||
{{ $headerCategory['name'] }}
|
||||
</a>
|
||||
@empty
|
||||
@ -359,14 +364,14 @@
|
||||
'min-h-screen' => $demoLandingMode,
|
||||
])>@yield('content')</main>
|
||||
@if(!$simplePage)
|
||||
<footer class="mt-14 bg-slate-100 text-slate-600 border-t border-slate-200">
|
||||
<footer class="mt-14 bg-slate-100 text-slate-600 border-t border-slate-200" data-anim-footer>
|
||||
<div class="max-w-[1320px] mx-auto px-4 py-12">
|
||||
<div class="grid grid-cols-1 md:grid-cols-4 gap-8">
|
||||
<div>
|
||||
<div data-anim-footer-item>
|
||||
<h3 class="text-slate-900 font-semibold text-lg mb-3">{{ $siteName }}</h3>
|
||||
<p class="text-sm text-slate-500 leading-relaxed">{{ $siteDescription }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<div data-anim-footer-item>
|
||||
<h4 class="text-slate-900 font-medium mb-4">Quick Links</h4>
|
||||
<ul class="space-y-2 text-sm">
|
||||
<li><a href="{{ route('home') }}" class="hover:text-slate-900 transition">Home</a></li>
|
||||
@ -374,14 +379,14 @@
|
||||
<li><a href="{{ route('listings.index') }}" class="hover:text-slate-900 transition">All Listings</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<div data-anim-footer-item>
|
||||
<h4 class="text-slate-900 font-medium mb-4">Account</h4>
|
||||
<ul class="space-y-2 text-sm">
|
||||
<li><a href="{{ $loginRoute }}" class="hover:text-slate-900 transition">{{ __('messages.login') }}</a></li>
|
||||
<li><a href="{{ $registerRoute }}" class="hover:text-slate-900 transition">{{ __('messages.register') }}</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<div data-anim-footer-item>
|
||||
<h4 class="text-slate-900 font-medium mb-4">Links</h4>
|
||||
<ul class="space-y-2 text-sm mb-4">
|
||||
@if($linkedinUrl)
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
@php
|
||||
$maxPhotoCount = (int) config('quick-listing.max_photo_count', 20);
|
||||
$visiblePhotoSlotCount = min($maxPhotoCount, 8);
|
||||
$maxVideoCount = (int) config('video.max_listing_videos', 5);
|
||||
$currency = \Modules\Listing\Support\ListingPanelHelper::defaultCurrency();
|
||||
$displayPrice = is_numeric($price) ? number_format((float) $price, 0, ',', '.') : $price;
|
||||
@ -737,6 +738,8 @@
|
||||
.qc-publish-stack {
|
||||
display: grid;
|
||||
gap: 0.7rem;
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.qc-button,
|
||||
@ -841,6 +844,14 @@
|
||||
</div>
|
||||
|
||||
<div class="qc-card">
|
||||
@if ($publishError)
|
||||
<div class="px-4 pt-4">
|
||||
<div class="rounded-[18px] border border-rose-200 bg-rose-50 px-4 py-3 text-sm font-semibold text-rose-700">
|
||||
{{ $publishError }}
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if ($currentStep === 1)
|
||||
<div class="qc-body">
|
||||
<div class="qc-stack">
|
||||
@ -880,7 +891,7 @@
|
||||
</div>
|
||||
|
||||
<div class="qc-photo-grid">
|
||||
@for ($index = 0; $index < $maxPhotoCount; $index++)
|
||||
@for ($index = 0; $index < $visiblePhotoSlotCount; $index++)
|
||||
<div class="qc-photo-slot">
|
||||
@if (isset($photos[$index]))
|
||||
<img src="{{ $photos[$index]->temporaryUrl() }}" alt="Uploaded photo {{ $index + 1 }}">
|
||||
@ -894,6 +905,10 @@
|
||||
</div>
|
||||
@endfor
|
||||
</div>
|
||||
|
||||
@if (count($photos) > $visiblePhotoSlotCount)
|
||||
<p class="qc-meta-copy mt-3">{{ count($photos) - $visiblePhotoSlotCount }} more photos added.</p>
|
||||
@endif
|
||||
</div>
|
||||
@else
|
||||
<div class="qc-empty">Add one cover photo to continue.</div>
|
||||
@ -1326,12 +1341,14 @@
|
||||
<button
|
||||
type="button"
|
||||
class="qc-button"
|
||||
wire:click="publishListing"
|
||||
@disabled($isPublishing)
|
||||
wire:click.prevent="publishListing"
|
||||
wire:loading.attr="disabled"
|
||||
wire:target="publishListing"
|
||||
>
|
||||
{{ $isPublishing ? 'Publishing...' : 'Publish listing' }}
|
||||
<span wire:loading.remove wire:target="publishListing">Publish listing</span>
|
||||
<span wire:loading wire:target="publishListing">Publishing...</span>
|
||||
</button>
|
||||
<button type="button" class="qc-button-secondary" wire:click="goToStep(4)">Back</button>
|
||||
<button type="button" class="qc-button-secondary" wire:click="goToStep(4)" wire:loading.attr="disabled" wire:target="publishListing">Back</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||