Refactor modules UI and seeders

This commit is contained in:
fatihalp 2026-03-07 16:37:03 +03:00
parent 63f2c95fd7
commit 6ee6da3d83
13 changed files with 957 additions and 313 deletions

View File

@ -57,7 +57,6 @@ final class HomeSlideFormSchema
->reorderableWithButtons()
->addActionLabel('Add Slide')
->itemLabel(fn (array $state): string => filled($state['title'] ?? null) ? (string) $state['title'] : 'New Slide')
->afterStateHydrated(fn (Repeater $component, $state) => $component->state($normalizeSlides($state)))
->dehydrateStateUsing(fn ($state) => $normalizeSlides($state));
}
}

View File

@ -95,6 +95,16 @@ class Category extends Model
->get(['id', 'name', 'slug']);
}
public static function seedableListingFieldCategories(): Collection
{
return static::query()
->active()
->with('parent:id,name,slug,parent_id')
->ordered()
->get()
->values();
}
public static function rootTreeWithActiveChildren(): Collection
{
return static::query()

View File

@ -8,7 +8,6 @@ use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Support\Facades\Schema;
use Modules\Category\Models\Category;
use Modules\Conversation\App\Models\Conversation;
use Modules\Conversation\App\Support\QuickMessageCatalog;
use Modules\Favorite\App\Models\FavoriteSearch;
use Modules\Listing\Models\Listing;
use Modules\User\App\Models\User;
@ -28,11 +27,6 @@ class FavoriteController extends Controller
$statusFilter = 'all';
}
$messageFilter = (string) $request->string('message_filter', 'all');
if (! in_array($messageFilter, ['all', 'unread', 'important'], true)) {
$messageFilter = 'all';
}
$selectedCategoryId = $request->integer('category');
if ($selectedCategoryId <= 0) {
$selectedCategoryId = null;
@ -52,8 +46,6 @@ class FavoriteController extends Controller
$favoriteListings = $this->emptyPaginator();
$favoriteSearches = $this->emptyPaginator();
$favoriteSellers = $this->emptyPaginator();
$conversations = collect();
$selectedConversation = null;
$buyerConversationListingMap = [];
if ($user && $activeTab === 'listings') {
@ -69,34 +61,20 @@ class FavoriteController extends Controller
->withQueryString();
}
if ($this->tableExists('conversations') && $this->tableExists('conversation_messages')) {
if (
$favoriteListings->isNotEmpty()
&& $this->tableExists('conversations')
) {
$userId = (int) $user->getKey();
$conversations = Conversation::inboxForUser($userId, $messageFilter);
$buyerConversationListingMap = $conversations
$buyerConversationListingMap = Conversation::query()
->where('buyer_id', $userId)
->whereIn('listing_id', $favoriteListings->pluck('id')->all())
->pluck('id', 'listing_id')
->map(fn ($conversationId) => (int) $conversationId)
->all();
$selectedConversation = Conversation::resolveSelected($conversations, $request->integer('conversation'));
if ($selectedConversation) {
$selectedConversation->loadThread();
$selectedConversation->markAsReadFor($userId);
$conversations = $conversations->map(function (Conversation $conversation) use ($selectedConversation): Conversation {
if ((int) $conversation->getKey() === (int) $selectedConversation->getKey()) {
$conversation->unread_count = 0;
}
return $conversation;
});
}
}
} catch (Throwable) {
$favoriteListings = $this->emptyPaginator();
$conversations = collect();
$selectedConversation = null;
$buyerConversationListingMap = [];
}
}
@ -135,15 +113,11 @@ class FavoriteController extends Controller
'activeTab' => $activeTab,
'statusFilter' => $statusFilter,
'selectedCategoryId' => $selectedCategoryId,
'messageFilter' => $messageFilter,
'categories' => $categories,
'favoriteListings' => $favoriteListings,
'favoriteSearches' => $favoriteSearches,
'favoriteSellers' => $favoriteSellers,
'conversations' => $conversations,
'selectedConversation' => $selectedConversation,
'buyerConversationListingMap' => $buyerConversationListingMap,
'quickMessages' => QuickMessageCatalog::all(),
'requiresLogin' => $requiresLogin,
]);
}

View File

@ -26,7 +26,6 @@
'tab' => 'listings',
'status' => $statusFilter,
'category' => $selectedCategoryId,
'message_filter' => $messageFilter,
], fn ($value) => !is_null($value) && $value !== '');
@endphp
<div class="border-b-2 border-blue-900 px-4 py-3 flex flex-wrap items-center gap-3">
@ -42,7 +41,6 @@
<form method="GET" action="{{ route('favorites.index') }}" class="flex items-center gap-2">
<input type="hidden" name="tab" value="listings">
<input type="hidden" name="status" value="{{ $statusFilter }}">
<input type="hidden" name="message_filter" value="{{ $messageFilter }}">
<select name="category" class="h-10 min-w-44 border border-slate-300 px-3 text-sm text-slate-700">
<option value="">Kategori</option>
@foreach($categories as $category)
@ -106,11 +104,6 @@
@else
<form method="POST" action="{{ route('conversations.start', $listing) }}">
@csrf
<input type="hidden" name="status" value="{{ $statusFilter }}">
@if($selectedCategoryId)
<input type="hidden" name="category" value="{{ $selectedCategoryId }}">
@endif
<input type="hidden" name="message_filter" value="{{ $messageFilter }}">
<button type="submit" class="inline-flex items-center h-10 px-4 bg-rose-500 text-white text-sm font-semibold rounded-full hover:bg-rose-600 transition">
Mesaj Gönder
</button>
@ -145,176 +138,6 @@
@if($favoriteListings?->hasPages())
<div class="px-4 pb-4">{{ $favoriteListings->links() }}</div>
@endif
<div class="border-t border-slate-200 bg-slate-50 p-4 sm:p-5">
<div class="border border-slate-200 bg-white rounded-2xl overflow-hidden shadow-sm">
<div class="grid grid-cols-1 xl:grid-cols-[420px,1fr] min-h-[620px]">
<div class="border-b xl:border-b-0 xl:border-r border-slate-200">
<div class="px-6 py-5 border-b border-slate-200 flex items-center justify-between gap-3">
<h2 class="text-3xl font-bold text-slate-900">Gelen Kutusu</h2>
<svg class="w-6 h-6 text-slate-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-4.35-4.35m1.6-5.05a7.25 7.25 0 11-14.5 0 7.25 7.25 0 0114.5 0z"/>
</svg>
</div>
<div class="px-6 py-4 border-b border-slate-200">
<p class="text-sm font-semibold text-slate-600 mb-2">Hızlı Filtreler</p>
<div class="flex flex-wrap items-center gap-2">
<a href="{{ route('favorites.index', array_merge($listingTabQuery, ['message_filter' => 'all'])) }}" class="inline-flex items-center px-4 py-2 rounded-full text-sm font-semibold border {{ $messageFilter === 'all' ? 'border-rose-400 bg-rose-50 text-rose-600' : 'border-slate-300 text-slate-600 hover:bg-slate-100' }}">
Hepsi
</a>
<a href="{{ route('favorites.index', array_merge($listingTabQuery, ['message_filter' => 'unread'])) }}" class="inline-flex items-center px-4 py-2 rounded-full text-sm font-semibold border {{ $messageFilter === 'unread' ? 'border-rose-400 bg-rose-50 text-rose-600' : 'border-slate-300 text-slate-600 hover:bg-slate-100' }}">
Okunmamış
</a>
<a href="{{ route('favorites.index', array_merge($listingTabQuery, ['message_filter' => 'important'])) }}" class="inline-flex items-center px-4 py-2 rounded-full text-sm font-semibold border {{ $messageFilter === 'important' ? 'border-rose-400 bg-rose-50 text-rose-600' : 'border-slate-300 text-slate-600 hover:bg-slate-100' }}">
Önemli
</a>
</div>
</div>
<div class="max-h-[480px] overflow-y-auto divide-y divide-slate-200">
@forelse($conversations as $conversation)
@php
$conversationListing = $conversation->listing;
$partner = (int) $conversation->buyer_id === (int) auth()->id() ? $conversation->seller : $conversation->buyer;
$isSelected = $selectedConversation && (int) $selectedConversation->id === (int) $conversation->id;
$conversationImage = $conversationListing?->getFirstMediaUrl('listing-images');
$lastMessage = trim((string) ($conversation->lastMessage?->body ?? ''));
@endphp
<a href="{{ route('favorites.index', array_merge($listingTabQuery, ['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="w-14 h-14 rounded-xl bg-slate-100 border border-slate-200 overflow-hidden shrink-0">
@if($conversationImage)
<img src="{{ $conversationImage }}" alt="{{ $conversationListing?->title }}" class="w-full h-full object-cover">
@else
<div class="w-full h-full grid place-items-center text-slate-400 text-xs">İlan</div>
@endif
</div>
<div class="min-w-0 flex-1">
<div class="flex items-start gap-2">
<p class="font-semibold text-2xl text-slate-900 truncate">{{ $partner?->name ?? 'Kullanıcı' }}</p>
<p class="text-xs text-slate-500 whitespace-nowrap ml-auto">{{ $conversation->last_message_at?->format('d.m.Y') }}</p>
</div>
<p class="text-sm text-slate-500 truncate mt-1">{{ $conversationListing?->title ?? 'İlan silinmiş' }}</p>
<p class="text-sm {{ $conversation->unread_count > 0 ? 'text-slate-900 font-semibold' : 'text-slate-500' }} truncate mt-1">
{{ $lastMessage !== '' ? $lastMessage : 'Henüz mesaj yok' }}
</p>
</div>
@if($conversation->unread_count > 0)
<span class="inline-flex items-center justify-center min-w-6 h-6 px-2 rounded-full bg-rose-500 text-white text-xs font-semibold">
{{ $conversation->unread_count }}
</span>
@endif
</div>
</a>
@empty
<div class="px-6 py-16 text-center text-slate-500">
Henüz bir sohbetin yok.
</div>
@endforelse
</div>
</div>
<div class="flex flex-col min-h-[620px]">
@if($selectedConversation)
@php
$activeListing = $selectedConversation->listing;
$activePartner = (int) $selectedConversation->buyer_id === (int) auth()->id()
? $selectedConversation->seller
: $selectedConversation->buyer;
$activePriceLabel = $activeListing && !is_null($activeListing->price)
? number_format((float) $activeListing->price, 0).' '.($activeListing->currency ?? 'TL')
: null;
@endphp
<div class="h-24 px-6 border-b border-slate-200 flex items-center gap-4">
<div class="w-12 h-12 rounded-full bg-slate-600 text-white grid place-items-center font-semibold text-lg">
{{ strtoupper(substr((string) ($activePartner?->name ?? 'K'), 0, 1)) }}
</div>
<div class="min-w-0">
<p class="text-3xl font-bold text-slate-900 truncate">{{ $activePartner?->name ?? 'Kullanıcı' }}</p>
<p class="text-sm text-slate-500 truncate">{{ $activeListing?->title ?? 'İlan silinmiş' }}</p>
</div>
@if($activePriceLabel)
<div class="ml-auto text-3xl font-semibold text-slate-800 whitespace-nowrap">{{ $activePriceLabel }}</div>
@endif
</div>
<div class="flex-1 px-6 py-6 bg-slate-100/60 overflow-y-auto max-h-[390px]">
@forelse($selectedConversation->messages as $message)
@php $isMine = (int) $message->sender_id === (int) auth()->id(); @endphp
<div class="mb-4 flex {{ $isMine ? 'justify-end' : 'justify-start' }}">
<div class="max-w-[80%]">
<div class="{{ $isMine ? 'bg-amber-100 text-slate-900' : 'bg-white text-slate-900 border border-slate-200' }} rounded-2xl px-4 py-2 text-base shadow-sm">
{{ $message->body }}
</div>
<p class="text-xs text-slate-500 mt-1 {{ $isMine ? 'text-right' : 'text-left' }}">
{{ $message->created_at?->format('H:i') }}
</p>
</div>
</div>
@empty
<div class="h-full grid place-items-center text-slate-500 text-center px-8">
<div>
<p class="font-semibold text-slate-700">Henüz mesaj yok.</p>
<p class="text-sm mt-1">Aşağıdaki hazır metinlerden birini seçebilir veya yeni mesaj yazabilirsin.</p>
</div>
</div>
@endforelse
</div>
<div class="px-4 py-3 border-t border-slate-200 bg-white">
<div class="flex items-center gap-2 overflow-x-auto pb-2">
@foreach($quickMessages as $quickMessage)
<form method="POST" action="{{ route('conversations.messages.send', $selectedConversation) }}" class="shrink-0">
@csrf
<input type="hidden" name="status" value="{{ $statusFilter }}">
@if($selectedCategoryId)
<input type="hidden" name="category" value="{{ $selectedCategoryId }}">
@endif
<input type="hidden" name="message_filter" value="{{ $messageFilter }}">
<input type="hidden" name="message" value="{{ $quickMessage }}">
<button type="submit" class="inline-flex items-center h-11 px-5 rounded-full border border-rose-300 text-rose-600 font-semibold text-sm hover:bg-rose-50 transition">
{{ $quickMessage }}
</button>
</form>
@endforeach
</div>
<form method="POST" action="{{ route('conversations.messages.send', $selectedConversation) }}" class="flex items-center gap-2 border-t border-slate-200 pt-3 mt-1">
@csrf
<input type="hidden" name="status" value="{{ $statusFilter }}">
@if($selectedCategoryId)
<input type="hidden" name="category" value="{{ $selectedCategoryId }}">
@endif
<input type="hidden" name="message_filter" value="{{ $messageFilter }}">
<input
type="text"
name="message"
value="{{ old('message') }}"
placeholder="Bir mesaj yaz"
maxlength="2000"
class="h-12 flex-1 rounded-full border border-slate-300 px-5 text-sm focus:outline-none focus:ring-2 focus:ring-rose-300"
required
>
<button type="submit" class="h-12 w-12 rounded-full bg-black text-white grid place-items-center hover:bg-slate-800 transition" aria-label="Gönder">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 12h13m0 0l-5-5m5 5l-5 5"/>
</svg>
</button>
</form>
@error('message')
<p class="text-xs text-rose-600 mt-2 px-2">{{ $message }}</p>
@enderror
</div>
@else
<div class="h-full min-h-[620px] grid place-items-center px-8 text-center text-slate-500">
<div>
<p class="text-2xl font-semibold text-slate-700">Mesajlaşma için bir sohbet seç.</p>
<p class="mt-2 text-sm">İlan detayından veya favori ilan satırındaki "Mesaj Gönder" butonundan yeni sohbet başlatabilirsin.</p>
</div>
</div>
@endif
</div>
</div>
</div>
</div>
@endif
@if($activeTab === 'searches')

View File

@ -0,0 +1,28 @@
<?php
namespace Modules\Listing\Database\Seeders;
use Illuminate\Database\Seeder;
use Modules\Category\Models\Category;
use Modules\Listing\Models\ListingCustomField;
use Modules\Listing\Support\ListingCustomFieldSeedCatalog;
class ListingCustomFieldSeeder extends Seeder
{
public function run(): void
{
$categories = Category::seedableListingFieldCategories();
$seededCount = 0;
foreach ($categories as $category) {
foreach (ListingCustomFieldSeedCatalog::payloadsFor($category) as $payload) {
ListingCustomField::upsertSeeded($category, $payload);
$seededCount++;
}
}
if ($this->command) {
$this->command->info("Seeded {$seededCount} listing custom fields for {$categories->count()} categories.");
}
}
}

View File

@ -4,6 +4,7 @@ namespace Modules\Listing\Models;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Modules\Category\Models\Category;
class ListingCustomField extends Model
{
@ -81,4 +82,22 @@ class ListingCustomField extends Model
return collect($options)->mapWithKeys(fn (string $option): array => [$option => $option])->all();
}
public static function upsertSeeded(Category $category, array $attributes): self
{
return static::query()->updateOrCreate(
['name' => (string) ($attributes['name'] ?? '')],
[
'label' => (string) ($attributes['label'] ?? ''),
'type' => (string) ($attributes['type'] ?? self::TYPE_TEXT),
'category_id' => (int) $category->getKey(),
'placeholder' => $attributes['placeholder'] ?? null,
'help_text' => $attributes['help_text'] ?? null,
'options' => $attributes['options'] ?? null,
'is_required' => (bool) ($attributes['is_required'] ?? false),
'is_active' => (bool) ($attributes['is_active'] ?? true),
'sort_order' => (int) ($attributes['sort_order'] ?? 0),
],
);
}
}

View File

@ -0,0 +1,296 @@
<?php
namespace Modules\Listing\Support;
use Illuminate\Support\Str;
use Modules\Category\Models\Category;
use Modules\Listing\Models\ListingCustomField;
final class ListingCustomFieldSeedCatalog
{
public static function payloadsFor(Category $category): array
{
$definitions = array_merge(
self::familyDefinitions(self::rootSlug($category)),
self::categoryDefinitions((string) $category->slug),
);
return collect($definitions)
->values()
->map(function (array $definition, int $index) use ($category): array {
return [
'name' => self::fieldName((string) $category->slug, (string) $definition['name']),
'label' => (string) $definition['label'],
'type' => (string) $definition['type'],
'placeholder' => $definition['placeholder'] ?? null,
'help_text' => $definition['help_text'] ?? null,
'options' => $definition['options'] ?? null,
'is_required' => (bool) ($definition['is_required'] ?? false),
'is_active' => true,
'sort_order' => ($index + 1) * 10,
];
})
->all();
}
private static function rootSlug(Category $category): string
{
$current = $category;
while ($current->parent) {
$current = $current->parent;
}
return (string) $current->slug;
}
private static function fieldName(string $categorySlug, string $name): string
{
return Str::slug($categorySlug . '_' . $name, '_');
}
private static function familyDefinitions(string $rootSlug): array
{
return match ($rootSlug) {
'electronics' => [
self::text('brand', 'Brand', 'Apple, Samsung, Sony'),
self::text('model', 'Model', 'iPhone 14, XPS 13'),
self::select('condition', 'Condition', ['New', 'Like New', 'Used', 'For Parts']),
self::boolean('warranty_available', 'Warranty Available'),
],
'vehicles' => [
self::text('brand', 'Brand', 'Toyota, Honda, Ford'),
self::text('model', 'Model', 'Corolla, Civic'),
self::number('year', 'Year', '2021'),
self::select('condition', 'Condition', ['New', 'Excellent', 'Good', 'Fair']),
],
'real-estate' => [
self::select('property_type', 'Property Type', ['Apartment', 'House', 'Villa', 'Office', 'Land', 'Shop']),
self::number('area_sqm', 'Area (sqm)', '120'),
self::boolean('furnished', 'Furnished'),
self::date('available_from', 'Available From'),
],
'fashion' => [
self::text('brand', 'Brand', 'Nike, Zara, H&M'),
self::select('size', 'Size', ['XS', 'S', 'M', 'L', 'XL', 'XXL']),
self::select('condition', 'Condition', ['New with Tags', 'Like New', 'Used']),
self::text('color', 'Color', 'Black'),
],
'home-garden' => [
self::text('brand', 'Brand', 'IKEA, Bosch, Philips'),
self::select('condition', 'Condition', ['New', 'Like New', 'Used']),
self::text('material', 'Material', 'Wood, Steel, Fabric'),
self::boolean('delivery_available', 'Delivery Available'),
],
'sports' => [
self::text('brand', 'Brand', 'Adidas, Decathlon, Wilson'),
self::select('condition', 'Condition', ['New', 'Like New', 'Used']),
self::select('age_group', 'Age Group', ['Kids', 'Teen', 'Adult']),
self::text('sport_level', 'Skill Level', 'Beginner, Intermediate'),
],
'jobs' => [
self::text('company_name', 'Company Name', 'OpenClassify'),
self::select('experience_level', 'Experience Level', ['Entry', 'Mid', 'Senior', 'Lead']),
self::boolean('remote', 'Remote'),
self::date('start_date', 'Start Date'),
],
'services' => [
self::text('provider_name', 'Provider Name', 'Company or individual name'),
self::text('response_time', 'Response Time', 'Within 2 hours'),
self::boolean('on_site', 'On Site Service'),
self::select('pricing_model', 'Pricing Model', ['Fixed', 'Hourly', 'Per Project']),
],
default => [],
};
}
private static function categoryDefinitions(string $categorySlug): array
{
return match ($categorySlug) {
'electronics-phones' => [
self::number('storage_gb', 'Storage (GB)', '128'),
self::number('battery_health', 'Battery Health (%)', '92'),
self::text('color', 'Color', 'Midnight Black'),
],
'electronics-computers' => [
self::text('processor', 'Processor', 'Apple M2, Intel i7'),
self::number('ram_gb', 'RAM (GB)', '16'),
self::number('storage_gb', 'Storage (GB)', '512'),
],
'electronics-tablets' => [
self::number('screen_size_inch', 'Screen Size (inch)', '11'),
self::number('storage_gb', 'Storage (GB)', '256'),
self::select('connectivity', 'Connectivity', ['Wi-Fi', 'Wi-Fi + Cellular']),
],
'electronics-tvs' => [
self::number('screen_size_inch', 'Screen Size (inch)', '55'),
self::select('resolution', 'Resolution', ['HD', 'Full HD', '4K', '8K']),
self::boolean('smart_tv', 'Smart TV'),
],
'vehicles-cars' => [
self::number('mileage_km', 'Mileage (km)', '85000'),
self::select('transmission', 'Transmission', ['Manual', 'Automatic', 'Semi Automatic']),
self::select('fuel_type', 'Fuel Type', ['Gasoline', 'Diesel', 'Hybrid', 'Electric', 'LPG']),
],
'vehicles-motorcycles' => [
self::number('engine_cc', 'Engine (cc)', '650'),
self::number('mileage_km', 'Mileage (km)', '24000'),
self::select('fuel_type', 'Fuel Type', ['Gasoline', 'Electric']),
],
'vehicles-trucks' => [
self::number('mileage_km', 'Mileage (km)', '120000'),
self::number('payload_kg', 'Payload (kg)', '3500'),
self::number('axle_count', 'Axle Count', '2'),
],
'vehicles-boats' => [
self::number('length_ft', 'Length (ft)', '24'),
self::number('engine_hours', 'Engine Hours', '430'),
self::text('hull_material', 'Hull Material', 'Fiberglass'),
],
'real-estate-for-sale' => [
self::number('bedrooms', 'Bedrooms', '3'),
self::number('bathrooms', 'Bathrooms', '2'),
self::boolean('title_deed_ready', 'Title Deed Ready'),
],
'real-estate-for-rent' => [
self::number('bedrooms', 'Bedrooms', '2'),
self::number('bathrooms', 'Bathrooms', '1'),
self::number('deposit_amount', 'Deposit Amount', '25000'),
],
'real-estate-commercial' => [
self::select('property_use', 'Property Use', ['Office', 'Shop', 'Warehouse', 'Workshop']),
self::number('parking_spaces', 'Parking Spaces', '3'),
self::text('heating', 'Heating', 'Central, VRF'),
],
'fashion-men', 'fashion-women', 'fashion-kids' => [
self::text('material', 'Material', 'Cotton'),
self::select('season', 'Season', ['Spring', 'Summer', 'Autumn', 'Winter']),
self::boolean('original_packaging', 'Original Packaging'),
],
'fashion-shoes' => [
self::number('eu_size', 'EU Size', '42'),
self::text('material', 'Material', 'Leather'),
self::boolean('box_included', 'Box Included'),
],
'home-garden-furniture' => [
self::number('width_cm', 'Width (cm)', '180'),
self::number('height_cm', 'Height (cm)', '85'),
self::boolean('assembly_required', 'Assembly Required'),
],
'home-garden-garden' => [
self::select('power_source', 'Power Source', ['Manual', 'Electric', 'Battery', 'Fuel']),
self::number('usage_area_sqm', 'Usage Area (sqm)', '450'),
self::boolean('included_tools', 'Included Tools'),
],
'home-garden-appliances' => [
self::select('energy_rating', 'Energy Rating', ['A', 'A+', 'A++', 'A+++', 'B', 'C']),
self::boolean('installation_available', 'Installation Available'),
self::boolean('warranty_available', 'Warranty Available'),
],
'sports-outdoor' => [
self::select('activity_type', 'Activity Type', ['Camping', 'Hiking', 'Cycling', 'Fishing']),
self::number('weight_kg', 'Weight (kg)', '12'),
self::boolean('waterproof', 'Waterproof'),
],
'sports-fitness' => [
self::text('equipment_type', 'Equipment Type', 'Treadmill, Bench'),
self::number('max_weight_kg', 'Max Weight (kg)', '150'),
self::boolean('foldable', 'Foldable'),
],
'sports-team-sports' => [
self::select('sport_type', 'Sport Type', ['Football', 'Basketball', 'Volleyball', 'Handball']),
self::select('official_size', 'Official Size', ['Yes', 'No']),
self::boolean('team_set_included', 'Team Set Included'),
],
'jobs-full-time' => [
self::number('salary_monthly', 'Monthly Salary', '60000'),
self::select('contract_type', 'Contract Type', ['Permanent', 'Contract']),
self::textarea('benefits', 'Benefits', 'Health insurance, meal card, bonus'),
],
'jobs-part-time' => [
self::number('hourly_rate', 'Hourly Rate', '350'),
self::number('weekly_hours', 'Weekly Hours', '24'),
self::text('schedule', 'Schedule', 'Weekday evenings'),
],
'jobs-freelance' => [
self::text('project_length', 'Project Length', '3 months'),
self::number('budget', 'Project Budget', '45000'),
self::text('payment_terms', 'Payment Terms', '50% upfront, 50% on delivery'),
],
'services-cleaning' => [
self::select('service_scope', 'Service Scope', ['Home', 'Office', 'Move-out', 'Deep Cleaning']),
self::boolean('eco_friendly', 'Eco Friendly Products'),
self::boolean('same_day_available', 'Same Day Available'),
],
'services-repair' => [
self::text('repair_type', 'Repair Type', 'Phone, appliance, AC'),
self::boolean('emergency_service', 'Emergency Service'),
self::number('warranty_days', 'Warranty (days)', '90'),
],
'services-education' => [
self::text('subject', 'Subject', 'Math, English, Coding'),
self::select('delivery_mode', 'Delivery Mode', ['Online', 'In Person', 'Hybrid']),
self::number('lesson_duration_minutes', 'Lesson Duration (minutes)', '60'),
],
default => [],
};
}
private static function text(string $name, string $label, ?string $placeholder = null): array
{
return [
'name' => $name,
'label' => $label,
'type' => ListingCustomField::TYPE_TEXT,
'placeholder' => $placeholder,
];
}
private static function textarea(string $name, string $label, ?string $placeholder = null): array
{
return [
'name' => $name,
'label' => $label,
'type' => ListingCustomField::TYPE_TEXTAREA,
'placeholder' => $placeholder,
];
}
private static function number(string $name, string $label, ?string $placeholder = null): array
{
return [
'name' => $name,
'label' => $label,
'type' => ListingCustomField::TYPE_NUMBER,
'placeholder' => $placeholder,
];
}
private static function select(string $name, string $label, array $options): array
{
return [
'name' => $name,
'label' => $label,
'type' => ListingCustomField::TYPE_SELECT,
'options' => $options,
];
}
private static function boolean(string $name, string $label): array
{
return [
'name' => $name,
'label' => $label,
'type' => ListingCustomField::TYPE_BOOLEAN,
];
}
private static function date(string $name, string $label): array
{
return [
'name' => $name,
'label' => $label,
'type' => ListingCustomField::TYPE_DATE,
];
}
}

View File

@ -255,11 +255,23 @@ class PanelQuickListingForm extends Component
{
return match ($this->currentStep) {
1 => 'Photos',
2 => 'Category Selection',
3 => 'Listing Details',
4 => 'Attributes',
5 => 'Preview',
default => 'Create Listing',
2 => 'Category',
3 => 'Basics',
4 => 'Details',
5 => 'Review',
default => 'New Listing',
};
}
public function getCurrentStepHintProperty(): string
{
return match ($this->currentStep) {
1 => 'Add photos first.',
2 => 'Pick the right category.',
3 => 'Add the basics.',
4 => 'Add extra details if needed.',
5 => 'Check everything before publishing.',
default => 'Create a new listing.',
};
}

View File

@ -32,6 +32,7 @@ class DatabaseSeeder extends Seeder
HomeSliderSettingsSeeder::class,
\Modules\Location\Database\Seeders\LocationSeeder::class,
\Modules\Category\Database\Seeders\CategorySeeder::class,
\Modules\Listing\Database\Seeders\ListingCustomFieldSeeder::class,
\Modules\Listing\Database\Seeders\ListingSeeder::class,
]);
}

View File

@ -87,14 +87,21 @@ h6 {
.oc-nav-wrap {
max-width: 1320px;
margin: 0 auto;
padding: 18px 16px 14px;
padding: 12px 16px;
}
.oc-nav-main {
display: grid;
grid-template-columns: auto minmax(320px, 1fr) auto;
grid-template-columns: minmax(0, 1fr);
align-items: stretch;
gap: 12px;
}
.oc-topbar {
display: flex;
align-items: center;
gap: 18px;
justify-content: space-between;
gap: 12px;
}
.oc-brand {
@ -104,11 +111,16 @@ h6 {
min-width: 0;
}
.oc-topbar .oc-brand {
flex: 1 1 auto;
}
.oc-search {
display: flex;
align-items: center;
gap: 12px;
min-height: 56px;
width: 100%;
min-height: 52px;
padding: 0 14px 0 18px;
border: 1px solid rgba(29, 29, 31, 0.08);
border-radius: 999px;
@ -116,6 +128,10 @@ h6 {
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.75);
}
.oc-search-main {
grid-column: 1 / -1;
}
.oc-search-icon {
color: #6e6e73;
flex-shrink: 0;
@ -146,18 +162,37 @@ h6 {
}
.oc-actions {
display: inline-flex;
display: flex;
align-items: center;
justify-content: flex-end;
gap: 12px;
gap: 10px;
flex-wrap: wrap;
}
.oc-location {
position: relative;
flex: 1 1 0;
min-width: 0;
}
.oc-location-trigger {
justify-content: space-between;
width: 100%;
}
.oc-location-label {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.oc-pill {
display: inline-flex;
align-items: center;
gap: 10px;
min-height: 48px;
padding: 0 18px;
min-height: 44px;
padding: 0 16px;
border: 1px solid rgba(29, 29, 31, 0.08);
border-radius: 999px;
background: rgba(255, 255, 255, 0.84);
@ -177,10 +212,11 @@ h6 {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 48px;
padding: 0 22px;
min-height: 44px;
padding: 0 18px;
font-size: 0.96rem;
font-weight: 600;
flex-shrink: 0;
}
.oc-text-link {
@ -194,20 +230,182 @@ h6 {
color: var(--oc-text);
}
.oc-mobile-tools {
display: grid;
gap: 12px;
margin-top: 14px;
.oc-auth-link {
display: none;
align-items: center;
min-height: 44px;
}
.oc-mobile-pills {
.oc-logout {
display: none;
}
.oc-mobile-menu-shell {
position: fixed;
inset: 0;
z-index: 80;
opacity: 0;
visibility: hidden;
pointer-events: none;
transition: opacity 0.22s ease;
}
.oc-mobile-menu-shell.is-open {
opacity: 1;
visibility: visible;
pointer-events: auto;
}
.oc-mobile-menu-backdrop {
position: absolute;
inset: 0;
border: 0;
background: rgba(15, 23, 42, 0.22);
backdrop-filter: saturate(150%) blur(16px);
}
.oc-mobile-menu-panel {
position: absolute;
top: 12px;
left: 12px;
right: 12px;
max-height: calc(100vh - 24px);
overflow: auto;
padding: 18px;
border: 1px solid rgba(255, 255, 255, 0.75);
border-radius: 28px;
background: rgba(255, 255, 255, 0.9);
box-shadow: 0 24px 64px rgba(15, 23, 42, 0.22);
transform: translateY(-12px) scale(0.985);
transition: transform 0.24s ease;
}
.oc-mobile-menu-shell.is-open .oc-mobile-menu-panel {
transform: translateY(0) scale(1);
}
.oc-mobile-menu-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 14px;
}
.oc-mobile-menu-title {
font-size: 1.45rem;
font-weight: 700;
letter-spacing: -0.03em;
line-height: 1;
}
.oc-mobile-menu-close {
display: inline-flex;
flex-shrink: 0;
}
.oc-mobile-menu-actions {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
overflow-x: auto;
padding-bottom: 2px;
margin-top: 16px;
}
.oc-mobile-menu-primary {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 48px;
padding: 0 16px;
border-radius: 16px;
border: 1px solid rgba(29, 29, 31, 0.08);
background: rgba(255, 255, 255, 0.92);
color: var(--oc-text);
font-size: 0.95rem;
font-weight: 700;
}
.oc-mobile-menu-primary-strong {
background: linear-gradient(180deg, #2997ff, var(--oc-primary));
color: #fff;
border-color: transparent;
}
.oc-mobile-menu-section {
margin-top: 16px;
}
.oc-mobile-menu-label {
margin-bottom: 8px;
font-size: 0.78rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: #6b7280;
}
.oc-mobile-menu-list {
display: grid;
gap: 8px;
}
.oc-mobile-menu-link {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
min-height: 54px;
padding: 0 18px;
border: 1px solid rgba(29, 29, 31, 0.08);
border-radius: 18px;
background: rgba(255, 255, 255, 0.92);
font-size: 0.98rem;
font-weight: 600;
color: var(--oc-text);
}
.oc-mobile-menu-languages {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.oc-mobile-menu-language {
display: inline-flex;
align-items: center;
min-height: 38px;
padding: 0 12px;
border-radius: 999px;
border: 1px solid rgba(29, 29, 31, 0.08);
background: rgba(255, 255, 255, 0.92);
color: #475569;
font-size: 0.82rem;
font-weight: 600;
}
.oc-mobile-menu-language.is-active {
color: var(--oc-primary);
border-color: var(--oc-primary-soft-border);
background: var(--oc-primary-soft);
}
.oc-mobile-menu-logout {
margin-top: 18px;
}
.oc-mobile-menu-logout-btn {
width: 100%;
min-height: 52px;
border: 0;
border-radius: 18px;
background: #111827;
color: #fff;
font-size: 0.96rem;
font-weight: 700;
cursor: pointer;
}
.oc-category-row {
display: none;
margin-top: 14px;
padding-top: 14px;
border-top: 1px solid rgba(29, 29, 31, 0.08);
@ -284,23 +482,35 @@ h6 {
.header-utility {
width: 2.75rem;
height: 2.75rem;
flex: 0 0 2.75rem;
border-radius: 999px;
border: 1px solid var(--oc-border);
background: #ffffff;
display: inline-flex;
display: none;
align-items: center;
justify-content: center;
color: #4b5563;
transition: all 0.2s ease;
}
.header-utility svg {
width: 1.125rem;
height: 1.125rem;
}
.header-utility.oc-compact-menu-trigger,
.header-utility.oc-mobile-menu-close {
display: inline-flex;
flex-shrink: 0;
}
.header-utility:hover {
border-color: var(--oc-primary-soft-border);
color: var(--oc-primary);
}
.location-panel {
width: min(90vw, 360px);
width: min(calc(100vw - 32px), 360px);
}
.location-panel select {
@ -312,55 +522,120 @@ h6 {
font-size: 0.875rem;
}
@media (max-width: 1279px) {
.oc-nav-main {
grid-template-columns: auto minmax(0, 1fr);
}
.oc-actions {
grid-column: 1 / -1;
justify-content: space-between;
flex-wrap: wrap;
}
}
@media (max-width: 1023px) {
@media (min-width: 768px) {
.oc-nav-wrap {
padding-top: 14px;
padding-bottom: 12px;
}
.oc-nav-main {
grid-template-columns: 1fr auto;
gap: 12px;
}
.brand-text {
font-size: 1.42rem;
}
.oc-actions {
grid-column: auto;
justify-content: flex-end;
flex-wrap: nowrap;
}
.oc-location {
flex: 0 1 240px;
}
.oc-mobile-menu-panel {
top: 16px;
right: 16px;
left: auto;
width: min(420px, calc(100vw - 32px));
max-height: calc(100vh - 32px);
}
}
@media (max-width: 767px) {
.oc-nav-main {
grid-template-columns: 1fr;
@media (min-width: 1024px) {
.oc-nav-wrap {
padding: 18px 16px 14px;
}
.oc-brand {
justify-content: center;
.oc-nav-main {
grid-template-columns: auto minmax(0, 1fr) auto;
align-items: center;
gap: 16px;
}
.oc-topbar {
min-width: 0;
gap: 16px;
}
.oc-search-main {
grid-column: auto;
}
.oc-search {
min-height: 3.35rem;
padding: 0 16px 0 18px;
}
.oc-search-submit {
min-width: 5.25rem;
text-align: center;
}
.oc-actions {
justify-content: flex-end;
gap: 12px;
flex-wrap: nowrap;
}
.oc-location {
flex: 0 0 192px;
}
.oc-pill,
.oc-cta {
min-height: 3rem;
}
.oc-cta {
padding: 0 22px;
}
.header-utility {
width: 3rem;
height: 3rem;
flex-basis: 3rem;
}
.oc-text-link {
min-height: 3rem;
display: inline-flex;
align-items: center;
}
.oc-auth-link {
display: inline-flex;
}
.oc-desktop-utility {
display: inline-flex;
}
.oc-logout {
display: block;
}
.header-utility.oc-compact-menu-trigger,
.oc-mobile-menu-shell {
display: none;
}
.oc-category-row {
margin-top: 12px;
padding-top: 12px;
display: block;
}
}
@media (min-width: 1280px) {
.brand-text {
font-size: 1.7rem;
}
}

View File

@ -28,6 +28,11 @@
'ja' => '日本語',
];
$headerCategories = collect($headerNavCategories ?? [])->values();
$menuBrowseLinks = collect([
['label' => 'Home', 'url' => route('home')],
['label' => 'All Listings', 'url' => route('listings.index')],
['label' => 'Categories', 'url' => route('categories.index')],
]);
$locationCountries = collect($headerLocationCountries ?? [])->values();
$defaultCountryIso2 = strtoupper((string) config('app.default_country_iso2', 'TR'));
$citiesRouteTemplate = \Illuminate\Support\Facades\Route::has('locations.cities')
@ -48,6 +53,7 @@
<nav class="market-nav-surface sticky top-0 z-50">
<div class="oc-nav-wrap">
<div class="oc-nav-main">
<div class="oc-topbar">
<a href="{{ route('home') }}" class="oc-brand">
@if($siteLogoUrl)
<img src="{{ $siteLogoUrl }}" alt="{{ $siteName }}" class="h-9 w-auto rounded-xl">
@ -57,7 +63,21 @@
<span class="brand-text leading-none">{{ $siteName }}</span>
</a>
<form action="{{ route('listings.index') }}" method="GET" class="oc-search hidden lg:flex">
<button
type="button"
class="header-utility oc-compact-menu-trigger"
data-mobile-menu-open
aria-label="Open navigation menu"
aria-controls="oc-mobile-menu"
aria-expanded="false"
>
<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 7h16M7 12h10M10 17h4"/>
</svg>
</button>
</div>
<form action="{{ route('listings.index') }}" method="GET" class="oc-search oc-search-main">
<svg class="w-5 h-5 oc-search-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.8" d="M21 21l-4.35-4.35m1.6-5.05a7.25 7.25 0 11-14.5 0 7.25 7.25 0 0114.5 0z"/>
</svg>
@ -74,13 +94,13 @@
</form>
<div class="oc-actions">
<details class="relative hidden md:block" data-location-widget data-cities-url-template="{{ $citiesRouteTemplate }}">
<summary class="oc-pill list-none cursor-pointer">
<details class="relative oc-location" data-location-widget data-cities-url-template="{{ $citiesRouteTemplate }}">
<summary class="oc-pill oc-location-trigger list-none cursor-pointer">
<svg class="w-4 h-4 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.8" d="M12 21s7-6.2 7-11a7 7 0 10-14 0c0 4.8 7 11 7 11z"/>
<circle cx="12" cy="10" r="2.3" stroke-width="1.8" />
</svg>
<span data-location-label class="max-w-40 truncate">Choose location</span>
<span data-location-label class="oc-location-label">Choose location</span>
<svg class="w-4 h-4 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.8" d="M6 9l6 6 6-6"/>
</svg>
@ -118,18 +138,18 @@
</details>
@auth
<a href="{{ $favoritesRoute }}" class="header-utility hidden xl:inline-flex" aria-label="Favorites">
<a href="{{ $favoritesRoute }}" class="header-utility oc-desktop-utility" aria-label="Favorites">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.8" d="M12 21l-1.45-1.32C5.4 15.03 2 12.01 2 8.31 2 5.3 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.08A6.04 6.04 0 0116.5 3C19.58 3 22 5.3 22 8.31c0 3.7-3.4 6.72-8.55 11.39L12 21z"/>
</svg>
</a>
<a href="{{ $inboxRoute }}" class="header-utility hidden xl:inline-flex" aria-label="Inbox">
<a href="{{ $inboxRoute }}" class="header-utility oc-desktop-utility" aria-label="Inbox">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.8" d="M4 6h16a1 1 0 011 1v10a1 1 0 01-1 1H4a1 1 0 01-1-1V7a1 1 0 011-1z"/>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.8" d="M3 8l9 6 9-6"/>
</svg>
</a>
<a href="{{ $panelListingsRoute }}" class="header-utility hidden xl:inline-flex" aria-label="Dashboard">
<a href="{{ $panelListingsRoute }}" class="header-utility oc-desktop-utility" aria-label="Dashboard">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.8" d="M3 12l9-9 9 9M5 10v10h14V10"/>
</svg>
@ -137,12 +157,12 @@
<a href="{{ $panelCreateRoute }}" class="btn-primary oc-cta">
Sell
</a>
<form method="POST" action="{{ $logoutRoute }}" class="hidden xl:block">
<form method="POST" action="{{ $logoutRoute }}" class="oc-logout">
@csrf
<button type="submit" class="oc-text-link">{{ __('messages.logout') }}</button>
</form>
@else
<a href="{{ $loginRoute }}" class="oc-text-link hidden md:inline-flex">
<a href="{{ $loginRoute }}" class="oc-text-link oc-auth-link">
{{ __('messages.login') }}
</a>
<a href="{{ $panelCreateRoute }}" class="btn-primary oc-cta">
@ -152,24 +172,94 @@
</div>
</div>
<div class="oc-mobile-tools lg:hidden">
<form action="{{ route('listings.index') }}" method="GET" class="oc-search">
<svg class="w-5 h-5 oc-search-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.8" d="M21 21l-4.35-4.35m1.6-5.05a7.25 7.25 0 11-14.5 0 7.25 7.25 0 0114.5 0z"/>
</svg>
<input
type="text"
name="search"
value="{{ request('search') }}"
placeholder="{{ __('messages.search_placeholder') }}"
class="oc-search-input"
>
<button type="submit" class="oc-search-submit">{{ __('messages.search') }}</button>
</form>
<div class="oc-mobile-menu-shell" id="oc-mobile-menu" data-mobile-menu>
<button type="button" class="oc-mobile-menu-backdrop" data-mobile-menu-close aria-label="Close navigation menu"></button>
<div class="oc-mobile-pills">
<span class="oc-pill" data-location-label-mobile>Choose location</span>
<a href="{{ $panelCreateRoute }}" class="oc-pill oc-pill-strong">Sell</a>
<div class="oc-mobile-menu-panel" role="dialog" aria-modal="true" aria-label="Navigation menu">
<div class="oc-mobile-menu-header">
<h2 class="oc-mobile-menu-title">Menu</h2>
<button type="button" class="header-utility oc-mobile-menu-close" data-mobile-menu-close aria-label="Close navigation menu">
<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="M6 6l12 12M18 6L6 18"/>
</svg>
</button>
</div>
<div class="oc-mobile-menu-actions">
<a href="{{ route('listings.index') }}" class="oc-mobile-menu-primary">Browse</a>
<a href="{{ $panelCreateRoute }}" class="oc-mobile-menu-primary oc-mobile-menu-primary-strong">Sell</a>
</div>
<div class="oc-mobile-menu-section">
<p class="oc-mobile-menu-label">Browse</p>
<div class="oc-mobile-menu-list">
@foreach($menuBrowseLinks as $menuBrowseLink)
<a href="{{ $menuBrowseLink['url'] }}" class="oc-mobile-menu-link">
<span>{{ $menuBrowseLink['label'] }}</span>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.8" d="M9 6l6 6-6 6"/>
</svg>
</a>
@endforeach
</div>
</div>
<div class="oc-mobile-menu-section">
<p class="oc-mobile-menu-label">Account</p>
<div class="oc-mobile-menu-list">
@auth
<a href="{{ $panelListingsRoute }}" class="oc-mobile-menu-link">
<span>Dashboard</span>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.8" d="M9 6l6 6-6 6"/>
</svg>
</a>
<a href="{{ $favoritesRoute }}" class="oc-mobile-menu-link">
<span>Favorites</span>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.8" d="M9 6l6 6-6 6"/>
</svg>
</a>
<a href="{{ $inboxRoute }}" class="oc-mobile-menu-link">
<span>Inbox</span>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.8" d="M9 6l6 6-6 6"/>
</svg>
</a>
@else
<a href="{{ $loginRoute }}" class="oc-mobile-menu-link">
<span>Login</span>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.8" d="M9 6l6 6-6 6"/>
</svg>
</a>
<a href="{{ $registerRoute }}" class="oc-mobile-menu-link">
<span>Register</span>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.8" d="M9 6l6 6-6 6"/>
</svg>
</a>
@endauth
</div>
</div>
<div class="oc-mobile-menu-section">
<p class="oc-mobile-menu-label">Languages</p>
<div class="oc-mobile-menu-languages">
@foreach($availableLocales as $locale)
<a href="{{ route('lang.switch', $locale) }}" class="oc-mobile-menu-language {{ app()->getLocale() === $locale ? 'is-active' : '' }}">
{{ $localeLabels[$locale] ?? strtoupper($locale) }}
</a>
@endforeach
</div>
</div>
@auth
<form method="POST" action="{{ $logoutRoute }}" class="oc-mobile-menu-logout">
@csrf
<button type="submit" class="oc-mobile-menu-logout-btn">Logout</button>
</form>
@endif
</div>
</div>
@ -259,10 +349,9 @@
<script>
(() => {
const widgetRoots = Array.from(document.querySelectorAll('[data-location-widget]'));
const mobileLabels = Array.from(document.querySelectorAll('[data-location-label-mobile]'));
const storageKey = 'oc2.header.location';
if (widgetRoots.length === 0 && mobileLabels.length === 0) {
if (widgetRoots.length === 0) {
return;
}
@ -317,9 +406,6 @@
target.textContent = label;
}
});
mobileLabels.forEach((target) => {
target.textContent = label;
});
};
const fetchCityOptions = async (url) => {
@ -696,6 +782,56 @@
}
});
})();
(() => {
const menu = document.querySelector('[data-mobile-menu]');
const openButtons = Array.from(document.querySelectorAll('[data-mobile-menu-open]'));
const closeButtons = Array.from(document.querySelectorAll('[data-mobile-menu-close]'));
if (!menu || openButtons.length === 0) {
return;
}
const setOpen = (shouldOpen) => {
menu.classList.toggle('is-open', shouldOpen);
document.documentElement.classList.toggle('oc-menu-open', shouldOpen);
document.body.style.overflow = shouldOpen ? 'hidden' : '';
openButtons.forEach((button) => {
button.setAttribute('aria-expanded', shouldOpen ? 'true' : 'false');
});
if (shouldOpen) {
document.querySelectorAll('[data-location-widget][open]').forEach((details) => {
details.removeAttribute('open');
});
}
};
openButtons.forEach((button) => {
button.addEventListener('click', () => setOpen(true));
});
closeButtons.forEach((button) => {
button.addEventListener('click', () => setOpen(false));
});
menu.querySelectorAll('a').forEach((link) => {
link.addEventListener('click', () => setOpen(false));
});
document.addEventListener('keydown', (event) => {
if (event.key === 'Escape') {
setOpen(false);
}
});
window.addEventListener('resize', () => {
if (window.innerWidth >= 1024) {
setOpen(false);
}
});
})();
</script>
<x-impersonate::banner />
</body>

View File

@ -1,6 +1,6 @@
@extends('app::layouts.app')
@section('title', 'Create Listing')
@section('title', 'New Listing')
@section('simple_page', '1')

View File

@ -1221,17 +1221,88 @@
grid-template-columns: minmax(0, 1fr) 320px;
}
}
@media (max-width: 640px) {
.qc-hero {
gap: .7rem;
margin-bottom: .75rem;
}
.qc-eyebrow,
.qc-subtitle {
display: none;
}
.qc-title {
margin-top: 0;
font-size: 1.55rem;
}
.qc-step-label {
font-size: .82rem;
}
.qc-progress-wrap {
gap: .6rem;
}
.qc-card {
border-radius: .85rem;
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.05);
}
.qc-body,
.qc-footer {
padding: .85rem;
}
.qc-upload-zone {
min-height: 176px;
padding: 1rem .85rem;
gap: .55rem;
}
.qc-upload-title,
.qc-ai-note h3,
.qc-photo-title {
font-size: 1.08rem;
}
.qc-upload-desc,
.qc-help,
.qc-ai-note p {
font-size: .9rem;
}
.qc-help {
margin-top: .65rem;
}
.qc-photo-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: .55rem;
}
.qc-root-grid {
grid-template-columns: 1fr;
padding: .85rem;
}
.qc-strip {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
</style>
<div class="qc-shell">
<div class="qc-hero">
<div class="qc-hero-copy">
<span class="qc-eyebrow">Create listing</span>
<span class="qc-eyebrow">New listing</span>
<h1 class="qc-title">{{ $this->currentStepTitle }}</h1>
<p class="qc-subtitle">A clean, simple flow to publish faster.</p>
<p class="qc-subtitle">{{ $this->currentStepHint }}</p>
</div>
<div class="qc-head">
<div class="qc-step-label">Step {{ $currentStep }} of 5</div>
<div class="qc-step-label">Step {{ $currentStep }}/5</div>
<div class="qc-progress-wrap">
<div class="qc-progress" aria-hidden="true">
@for ($step = 1; $step <= 5; $step++)
@ -1248,9 +1319,9 @@
<div class="qc-body">
<label class="qc-upload-zone" for="quick-listing-photo-input">
<x-heroicon-o-photo class="h-10 w-10 text-gray-700" />
<div class="qc-upload-title">Start with photos</div>
<div class="qc-upload-desc">Add clear images first.</div>
<span class="qc-upload-btn">Choose Photos</span>
<div class="qc-upload-title">Add photos</div>
<div class="qc-upload-desc">Clear photos work best.</div>
<span class="qc-upload-btn">Select photos</span>
</label>
<input
@ -1262,7 +1333,7 @@
class="hidden"
/>
<p class="qc-help">1 to {{ (int) config('quick-listing.max_photo_count', 20) }} images. JPG and PNG only.</p>
<p class="qc-help">1-{{ (int) config('quick-listing.max_photo_count', 20) }} photos. JPG or PNG.</p>
@error('photos')
<div class="qc-error">{{ $message }}</div>
@ -1273,8 +1344,8 @@
@enderror
@if (count($photos) > 0)
<h3 class="qc-photo-title">Selected photos</h3>
<div class="qc-photo-sub">Drag to reorder</div>
<h3 class="qc-photo-title">Your photos</h3>
<div class="qc-photo-sub">First photo is the cover</div>
<div class="qc-photo-grid">
@for ($index = 0; $index < (int) config('quick-listing.max_photo_count', 20); $index++)
@ -1294,8 +1365,8 @@
@else
<div class="qc-ai-note">
<x-heroicon-o-sparkles class="h-10 w-10 text-pink-500" />
<h3>Add at least one photo</h3>
<p>We can suggest a category after the first image.</p>
<h3>Add one photo</h3>
<p>We suggest a category after the first upload.</p>
</div>
@endif
</div>
@ -1307,7 +1378,7 @@
wire:click="goToCategoryStep"
@disabled(count($photos) === 0 || $isDetecting)
>
Continue
Next
</button>
</div>
@endif