Compare commits

..

3 Commits

Author SHA1 Message Date
fatihalp
46b70a91f7 Fix create listing publish flow 2026-03-08 16:45:42 +03:00
fatihalp
6fde32cc8b Add animations and fix publish 2026-03-08 16:11:09 +03:00
fatihalp
222928d1d9 Refactor modular Laravel Filament 2026-03-08 12:17:03 +03:00
23 changed files with 478 additions and 164 deletions

1
.gitignore vendored
View File

@ -28,3 +28,4 @@ Thumbs.db
composer.lock
.codex/config.toml
/public/vendor/
package-lock.json

View File

@ -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]
);

View File

@ -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

View File

@ -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]
);
}
}
}
}

View File

@ -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">

View File

@ -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'),

View File

@ -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->validatePhotos();
$this->validateVideos();
$this->validateCategoryStep();
$this->validateDetailsStep();
$this->validateCustomFieldsStep();
$this->publishError = null;
$this->resetErrorBag();
try {
$this->validatePhotos();
$this->validateVideos();
$this->validateCategoryStep();
$this->validateDetailsStep();
$this->validateCustomFieldsStep();
$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');

View File

@ -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();

View File

@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

BIN
public/img/category/pet.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

View File

@ -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 {

View File

@ -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);
});

View File

@ -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>

View File

@ -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)

View File

@ -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>