Improve partner panel UX

This commit is contained in:
fatihalp 2026-03-07 20:19:00 +03:00
parent d5f88c79af
commit b1293d3960
45 changed files with 2418 additions and 2134 deletions

View File

@ -89,6 +89,8 @@ class ListingController extends Controller
])
->applyBrowseSort($sort);
$filteredListingsTotal = (clone $listingsQuery)->count();
$listings = $listingsQuery
->paginate(16)
->withQueryString();
@ -146,6 +148,7 @@ class ListingController extends Controller
'isCurrentSearchSaved',
'conversationListingMap',
'allListingsTotal',
'filteredListingsTotal',
));
}

View File

@ -5,6 +5,7 @@ use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Support\Arr;
use Illuminate\Support\Carbon;
use Illuminate\Support\Collection;
use Illuminate\Support\Str;
@ -90,6 +91,19 @@ class Listing extends Model implements HasMedia
return $query->where('status', 'active');
}
public function scopeOwnedByUser(Builder $query, int | string | null $userId): Builder
{
return $query->where('user_id', $userId);
}
public function scopeForPanelStatus(Builder $query, string $status): Builder
{
return match ($status) {
'sold', 'expired', 'pending', 'active' => $query->where('status', $status),
default => $query,
};
}
public function scopeSearchTerm(Builder $query, string $search): Builder
{
$search = trim($search);
@ -206,6 +220,73 @@ class Listing extends Model implements HasMedia
return $primary->concat($fallback)->values();
}
public static function panelStatusOptions(): array
{
return [
'pending' => 'Pending',
'active' => 'Active',
'sold' => 'Sold',
'expired' => 'Expired',
];
}
public static function panelStatusCountsForUser(int | string $userId): array
{
$counts = static::query()
->ownedByUser($userId)
->selectRaw('status, COUNT(*) as aggregate')
->groupBy('status')
->pluck('aggregate', 'status');
return [
'all' => (int) $counts->sum(),
'sold' => (int) ($counts['sold'] ?? 0),
'expired' => (int) ($counts['expired'] ?? 0),
];
}
public function statusValue(): string
{
return $this->status instanceof ListingStatus
? $this->status->getValue()
: (string) $this->status;
}
public function statusLabel(): string
{
return match ($this->statusValue()) {
'sold' => 'Sold',
'expired' => 'Expired',
'pending' => 'Pending',
default => 'Active',
};
}
public function updateFromPanel(array $attributes): void
{
$payload = Arr::only($attributes, [
'title',
'description',
'price',
'status',
'contact_phone',
'contact_email',
'country',
'city',
'expires_at',
]);
if (array_key_exists('currency', $attributes)) {
$payload['currency'] = ListingPanelHelper::normalizeCurrency($attributes['currency']);
}
if (array_key_exists('custom_fields', $attributes)) {
$payload['custom_fields'] = $attributes['custom_fields'];
}
$this->forceFill($payload)->save();
}
public static function createFromFrontend(array $data, null | int | string $userId): self
{
$baseSlug = Str::slug((string) ($data['title'] ?? 'listing'));

View File

@ -1,7 +1,8 @@
@extends('app::layouts.app')
@section('content')
@php
$totalListings = isset($allListingsTotal) ? (int) $allListingsTotal : (int) $listings->total();
$allListingsCount = isset($allListingsTotal) ? (int) $allListingsTotal : (int) $listings->total();
$resultListingsCount = isset($filteredListingsTotal) ? (int) $filteredListingsTotal : (int) $listings->total();
$activeCategoryName = $selectedCategory?->name ? trim((string) $selectedCategory->name) : '';
$pageTitle = $activeCategoryName !== ''
? 'İkinci El '.$activeCategoryName.' İlanları ve Fiyatları'
@ -38,7 +39,7 @@
@endphp
<a href="{{ $allCategoriesLink }}" class="flex items-center justify-between rounded-lg px-2.5 py-2 text-sm font-semibold {{ is_null($categoryId) ? 'bg-slate-900 text-white' : 'text-slate-700 hover:bg-slate-100' }}">
<span>Tüm İlanlar</span>
<span>{{ number_format($totalListings, 0, ',', '.') }}</span>
<span>{{ number_format($allListingsCount, 0, ',', '.') }}</span>
</a>
@foreach($categories as $category)
@ -161,7 +162,7 @@
<div class="listing-filter-card px-4 py-3 flex flex-col xl:flex-row xl:items-center gap-3">
<p class="text-sm text-slate-700 mr-auto">
{{ $activeCategoryName !== '' ? 'İkinci El '.$activeCategoryName.' kategorisinde' : 'İkinci El Araba kategorisinde' }}
<strong>{{ number_format($totalListings, 0, ',', '.') }}</strong>
<strong>{{ number_format($resultListingsCount, 0, ',', '.') }}</strong>
ilan bulundu
</p>
<div class="flex flex-wrap items-center gap-2">

View File

@ -1,7 +1,8 @@
@extends('app::layouts.app')
@section('content')
@php
$totalListings = isset($allListingsTotal) ? (int) $allListingsTotal : (int) $listings->total();
$allListingsCount = isset($allListingsTotal) ? (int) $allListingsTotal : (int) $listings->total();
$resultListingsCount = isset($filteredListingsTotal) ? (int) $filteredListingsTotal : (int) $listings->total();
$activeCategoryName = $selectedCategory?->name ? trim((string) $selectedCategory->name) : '';
$pageTitle = $activeCategoryName !== ''
? 'İkinci El '.$activeCategoryName.' İlanları ve Fiyatları'
@ -38,7 +39,7 @@
@endphp
<a href="{{ $allCategoriesLink }}" class="flex items-center justify-between rounded-lg px-2.5 py-2 text-sm font-semibold {{ is_null($categoryId) ? 'bg-slate-900 text-white' : 'text-slate-700 hover:bg-slate-100' }}">
<span>Tüm İlanlar</span>
<span>{{ number_format($totalListings, 0, ',', '.') }}</span>
<span>{{ number_format($allListingsCount, 0, ',', '.') }}</span>
</a>
@foreach($categories as $category)
@ -161,7 +162,7 @@
<div class="listing-filter-card px-4 py-3 flex flex-col xl:flex-row xl:items-center gap-3">
<p class="text-sm text-slate-700 mr-auto">
{{ $activeCategoryName !== '' ? 'İkinci El '.$activeCategoryName.' kategorisinde' : 'İkinci El Araba kategorisinde' }}
<strong>{{ number_format($totalListings, 0, ',', '.') }}</strong>
<strong>{{ number_format($resultListingsCount, 0, ',', '.') }}</strong>
ilan bulundu
</p>
<div class="flex flex-wrap items-center gap-2">

View File

@ -1,7 +1,8 @@
@extends('app::layouts.app')
@section('content')
@php
$totalListings = isset($allListingsTotal) ? (int) $allListingsTotal : (int) $listings->total();
$allListingsCount = isset($allListingsTotal) ? (int) $allListingsTotal : (int) $listings->total();
$resultListingsCount = isset($filteredListingsTotal) ? (int) $filteredListingsTotal : (int) $listings->total();
$activeCategoryName = $selectedCategory?->name ? trim((string) $selectedCategory->name) : '';
$pageTitle = $activeCategoryName !== ''
? 'İkinci El '.$activeCategoryName.' İlanları ve Fiyatları'
@ -38,7 +39,7 @@
@endphp
<a href="{{ $allCategoriesLink }}" class="flex items-center justify-between rounded-lg px-2.5 py-2 text-sm font-semibold {{ is_null($categoryId) ? 'bg-slate-900 text-white' : 'text-slate-700 hover:bg-slate-100' }}">
<span>Tüm İlanlar</span>
<span>{{ number_format($totalListings, 0, ',', '.') }}</span>
<span>{{ number_format($allListingsCount, 0, ',', '.') }}</span>
</a>
@foreach($categories as $category)
@ -161,7 +162,7 @@
<div class="listing-filter-card px-4 py-3 flex flex-col xl:flex-row xl:items-center gap-3">
<p class="text-sm text-slate-700 mr-auto">
{{ $activeCategoryName !== '' ? 'İkinci El '.$activeCategoryName.' kategorisinde' : 'İkinci El Araba kategorisinde' }}
<strong>{{ number_format($totalListings, 0, ',', '.') }}</strong>
<strong>{{ number_format($resultListingsCount, 0, ',', '.') }}</strong>
ilan bulundu
</p>
<div class="flex flex-wrap items-center gap-2">

View File

@ -3,70 +3,149 @@
@section('content')
@php
$title = trim((string) ($listing->title ?? ''));
$displayTitle = $title !== '' ? $title : 'İlan başlığı yok';
$displayTitle = $title !== '' ? $title : 'Untitled listing';
$priceLabel = 'Fiyat sorunuz';
$priceLabel = 'Price on request';
if (! is_null($listing->price)) {
$priceValue = (float) $listing->price;
$formattedPrice = number_format($priceValue, 2, '.', ',');
$formattedPrice = rtrim(rtrim($formattedPrice, '0'), '.');
$priceLabel = $priceValue > 0
? number_format($priceValue, 0, ',', '.').' '.($listing->currency ?: 'TL')
: 'Ücretsiz';
? $formattedPrice.' '.($listing->currency ?: 'TRY')
: 'Free';
}
$locationLabel = collect([$listing->city, $listing->country])
->filter(fn ($value) => is_string($value) && trim($value) !== '')
->implode(', ');
$publishedAt = $listing->created_at?->format('d M Y');
$galleryImages = collect($gallery ?? [])->values()->all();
$publishedAt = $listing->created_at?->format('M j, Y') ?? 'Recently';
$postedAgo = $listing->created_at?->diffForHumans() ?? 'Listed recently';
$galleryImages = collect($gallery ?? [])
->filter(fn ($value) => is_string($value) && trim($value) !== '')
->values()
->all();
$initialGalleryImage = $galleryImages[0] ?? null;
$galleryCount = count($galleryImages);
$sellerName = trim((string) ($listing->user?->name ?? 'Satıcı'));
$sellerInitial = strtoupper(substr($sellerName, 0, 1));
$description = trim((string) ($listing->description ?? ''));
$displayDescription = $description !== '' ? $description : 'No description added for this listing.';
$sellerName = trim((string) ($listing->user?->name ?? 'Marketplace Seller'));
$sellerInitial = mb_strtoupper(mb_substr($sellerName, 0, 1));
$sellerMemberText = $listing->user?->created_at
? $listing->user->created_at->format('M Y').' tarihinden beri üye'
: 'Yeni üye';
? 'Member since '.$listing->user->created_at->format('M Y')
: 'New seller';
$canContactSeller = $listing->user && (! auth()->check() || (int) auth()->id() !== (int) $listing->user_id);
$isOwnListing = auth()->check() && (int) auth()->id() === (int) $listing->user_id;
$primaryContactHref = null;
$primaryContactLabel = 'Call';
if (filled($listing->contact_phone)) {
$primaryContactHref = 'tel:'.preg_replace('/\s+/', '', (string) $listing->contact_phone);
$primaryContactLabel = 'Call';
} elseif (filled($listing->contact_email)) {
$primaryContactHref = 'mailto:'.$listing->contact_email;
$primaryContactLabel = 'Email';
}
$mapQuery = filled($listing->latitude) && filled($listing->longitude)
? trim((string) $listing->latitude).','.trim((string) $listing->longitude)
: $locationLabel;
$mapUrl = $mapQuery !== ''
? 'https://www.google.com/maps/search/?api=1&query='.urlencode($mapQuery)
: null;
$reportEmail = config('mail.from.address', 'support@example.com');
$reportUrl = 'mailto:'.$reportEmail.'?subject='.rawurlencode('Report listing #'.$listing->getKey());
$overviewItems = collect([
['label' => 'Listing ID', 'value' => '#'.$listing->getKey()],
['label' => 'Category', 'value' => $listing->category?->name ?? 'General'],
['label' => 'Location', 'value' => $locationLabel !== '' ? $locationLabel : 'Not specified'],
['label' => 'Published', 'value' => $publishedAt],
])
->filter(fn (array $item) => trim((string) $item['value']) !== '')
->values();
$detailItems = collect($presentableCustomFields ?? [])
->map(fn (array $field) => [
'label' => trim((string) ($field['label'] ?? '')),
'value' => trim((string) ($field['value'] ?? '')),
])
->filter(fn (array $field) => $field['label'] !== '' && $field['value'] !== '')
->values();
if ($detailItems->isEmpty()) {
$detailItems = $overviewItems;
}
@endphp
<div class="lt-wrap">
<nav class="lt-breadcrumb" aria-label="breadcrumb">
<a href="{{ route('home') }}">Anasayfa</a>
<nav class="lt-breadcrumb" aria-label="Breadcrumb">
<a href="{{ route('home') }}">Home</a>
@foreach(($breadcrumbCategories ?? collect()) as $crumb)
<span></span>
<span>/</span>
<a href="{{ route('listings.index', ['category' => $crumb->id]) }}">{{ $crumb->name }}</a>
@endforeach
<span></span>
<span>/</span>
<span>{{ $displayTitle }}</span>
</nav>
<div class="lt-grid">
<div>
<div class="lt-main-column">
<section class="lt-card lt-media-card" data-gallery>
<div class="lt-gallery-main">
<div class="lt-gallery-top">
<span class="lt-badge">Öne Çıkan</span>
<div class="lt-gallery-pills">
<span class="lt-badge lt-badge-soft">{{ $listing->category?->name ?? 'Listing' }}</span>
@if($listing->is_featured)
<span class="lt-badge">Featured</span>
@endif
@if($galleryCount > 0)
<span class="lt-badge lt-badge-muted">{{ $galleryCount }} {{ \Illuminate\Support\Str::plural('photo', $galleryCount) }}</span>
@endif
</div>
@auth
<form method="POST" action="{{ route('favorites.listings.toggle', $listing) }}">
@csrf
<button type="submit" class="lt-icon-btn" aria-label="Favoriye ekle">
<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 21l-1.4-1.3C5.4 15 2 12 2 8.4 2 5.5 4.3 3.2 7.2 3.2c1.7 0 3.3.8 4.4 2.1 1.1-1.3 2.8-2.1 4.4-2.1C18.9 3.2 21.2 5.5 21.2 8.4c0 3.6-3.4 6.6-8.6 11.3L12 21z"/></svg>
<button
type="submit"
class="lt-icon-btn {{ $isListingFavorited ? 'is-active' : '' }}"
aria-label="{{ $isListingFavorited ? 'Remove from saved listings' : 'Save listing' }}"
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9">
<path d="M12 21l-1.45-1.32C5.4 15.03 2 12.01 2 8.31 2 5.3 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.08A6.04 6.04 0 0116.5 3C19.58 3 22 5.3 22 8.31c0 3.7-3.4 6.72-8.55 11.39L12 21z"/>
</svg>
</button>
</form>
@else
<a href="{{ route('login') }}" class="lt-icon-btn" aria-label="Sign in to save this listing">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9">
<path d="M12 21l-1.45-1.32C5.4 15.03 2 12.01 2 8.31 2 5.3 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.08A6.04 6.04 0 0116.5 3C19.58 3 22 5.3 22 8.31c0 3.7-3.4 6.72-8.55 11.39L12 21z"/>
</svg>
</a>
@endauth
</div>
@if($initialGalleryImage)
<img src="{{ $initialGalleryImage }}" alt="{{ $displayTitle }}" data-gallery-main>
@else
<div class="lt-gallery-main-empty" data-gallery-main-empty>Görsel bulunamadı</div>
<div class="lt-gallery-main-empty">No photos uploaded yet.</div>
@endif
@if(count($galleryImages) > 1)
<button type="button" class="lt-gallery-nav" data-gallery-prev aria-label="Önceki">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="m15 18-6-6 6-6"/></svg>
@if($galleryCount > 1)
<button type="button" class="lt-gallery-nav" data-gallery-prev aria-label="Previous photo">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="m15 18-6-6 6-6"/>
</svg>
</button>
<button type="button" class="lt-gallery-nav" data-gallery-next aria-label="Sonraki">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="m9 18 6-6-6-6"/></svg>
<button type="button" class="lt-gallery-nav" data-gallery-next aria-label="Next photo">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="m9 18 6-6-6-6"/>
</svg>
</button>
@endif
</div>
@ -80,6 +159,7 @@
data-gallery-thumb
data-gallery-index="{{ $index }}"
data-gallery-src="{{ $image }}"
aria-label="Open photo {{ $index + 1 }}"
>
<img src="{{ $image }}" alt="{{ $displayTitle }} {{ $index + 1 }}">
</button>
@ -88,53 +168,73 @@
@endif
</section>
<section class="lt-card lt-detail-card">
<div class="lt-price-row">
<div>
<div class="lt-price">{{ $priceLabel }}</div>
<div class="lt-title">{{ $displayTitle }}</div>
</div>
<div class="lt-meta">
<div><strong>{{ $locationLabel !== '' ? $locationLabel : 'Konum belirtilmedi' }}</strong></div>
<div>{{ $publishedAt ?? '-' }}</div>
</div>
<section class="lt-card lt-summary-card">
<div class="lt-summary-copy">
<p class="lt-overline">{{ $listing->category?->name ?? 'Marketplace listing' }}</p>
<h1 class="lt-title">{{ $displayTitle }}</h1>
<div class="lt-price">{{ $priceLabel }}</div>
<p class="lt-subtitle">
<span>{{ $locationLabel !== '' ? $locationLabel : 'Location not specified' }}</span>
<span aria-hidden="true">·</span>
<span>{{ $postedAgo }}</span>
</p>
</div>
<div class="lt-credit">
<div>
<h4>Acil kredi mi lazım?</h4>
<p>Kredi fırsatlarını hemen incele.</p>
</div>
<span class="lt-tag">Yeni</span>
</div>
<h2 class="lt-section-title">İlan Özellikleri</h2>
<div class="lt-features">
<div class="lt-feature-row">
<div class="lt-f-item"><span>İlan No</span><strong>{{ $listing->id }}</strong></div>
<div class="lt-f-item"><span>Marka</span><strong>{{ $listing->category?->name ?? '-' }}</strong></div>
</div>
<div class="lt-feature-row">
<div class="lt-f-item"><span>Model</span><strong>{{ $listing->slug ?? '-' }}</strong></div>
<div class="lt-f-item"><span>Yayın Tarihi</span><strong>{{ $publishedAt ?? '-' }}</strong></div>
</div>
@foreach(($presentableCustomFields ?? []) as $chunk)
<div class="lt-feature-row">
<div class="lt-f-item"><span>{{ $chunk['label'] ?? '-' }}</span><strong>{{ $chunk['value'] ?? '-' }}</strong></div>
<div class="lt-f-item"><span>Konum</span><strong>{{ $locationLabel !== '' ? $locationLabel : '-' }}</strong></div>
<div class="lt-overview-grid">
@foreach($overviewItems as $item)
<div class="lt-overview-item">
<span class="lt-overview-label">{{ $item['label'] }}</span>
<strong class="lt-overview-value">{{ $item['value'] }}</strong>
</div>
@endforeach
</div>
</section>
<section class="lt-card lt-detail-card">
<div class="lt-section-head">
<div>
<h2 class="lt-section-title">Listing details</h2>
<p class="lt-section-copy">A quick view of the important information.</p>
</div>
</div>
<div class="lt-feature-grid">
@foreach($detailItems as $field)
<div class="lt-feature-item">
<span>{{ $field['label'] }}</span>
<strong>{{ $field['value'] }}</strong>
</div>
@endforeach
</div>
</section>
<section class="lt-card lt-detail-card">
<div class="lt-section-head">
<div>
<h2 class="lt-section-title">Description</h2>
<p class="lt-section-copy">Condition notes, usage details, and seller context.</p>
</div>
</div>
<div class="lt-description">
{!! nl2br(e($displayDescription)) !!}
</div>
</section>
@if(($listingVideos ?? collect())->isNotEmpty())
<section class="lt-card lt-detail-card">
<h2 class="lt-section-title">Videolar</h2>
<div class="grid gap-4 md:grid-cols-2 mt-4">
<div class="lt-section-head">
<div>
<h2 class="lt-section-title">Videos</h2>
<p class="lt-section-copy">Extra media attached to this listing.</p>
</div>
</div>
<div class="lt-video-grid">
@foreach($listingVideos as $video)
<div class="rounded-2xl border border-slate-200 bg-white p-3">
<video class="w-full rounded-xl bg-slate-950" controls preload="metadata" src="{{ $video->playableUrl() }}"></video>
<p class="mt-3 text-sm font-semibold text-slate-800">{{ $video->titleLabel() }}</p>
<div class="lt-video-card">
<video class="lt-video-player" controls preload="metadata" src="{{ $video->playableUrl() }}"></video>
<p class="lt-video-title">{{ $video->titleLabel() }}</p>
</div>
@endforeach
</div>
@ -143,118 +243,180 @@
</div>
<aside class="lt-card lt-side-card">
<div class="lt-seller-head">
<div class="lt-avatar">{{ $sellerInitial !== '' ? $sellerInitial : 'S' }}</div>
<div>
<p class="lt-seller-name">{{ $sellerName }}</p>
<div class="lt-seller-meta">{{ $sellerMemberText }}</div>
<div class="lt-seller-panel">
<div class="lt-seller-head">
<div class="lt-avatar">{{ $sellerInitial !== '' ? $sellerInitial : 'S' }}</div>
<div>
<p class="lt-seller-kicker">Seller</p>
<p class="lt-seller-name">{{ $sellerName }}</p>
<div class="lt-seller-meta">{{ $sellerMemberText }}</div>
</div>
</div>
</div>
<div class="lt-actions">
<div class="lt-row-2">
@if($listing->user && auth()->check() && (int) auth()->id() !== (int) $listing->user_id)
@if($existingConversationId)
<a href="{{ route('panel.inbox.index', ['conversation' => $existingConversationId]) }}" class="lt-btn">Sohbet</a>
<div class="lt-actions">
<div class="lt-row-2">
@if(! $listing->user)
<button type="button" class="lt-btn" disabled>Unavailable</button>
@elseif($canContactSeller)
@if($existingConversationId)
<a href="{{ route('panel.inbox.index', ['conversation' => $existingConversationId]) }}" class="lt-btn">
Message
</a>
@else
<form method="POST" action="{{ route('conversations.start', $listing) }}" class="lt-action-form">
@csrf
<button type="submit" class="lt-btn">Message</button>
</form>
@endif
@elseif($isOwnListing)
<button type="button" class="lt-btn" disabled>Your listing</button>
@else
<form method="POST" action="{{ route('conversations.start', $listing) }}">
<a href="{{ route('login') }}" class="lt-btn">Message</a>
@endif
@if($primaryContactHref)
<a href="{{ $primaryContactHref }}" class="lt-btn lt-btn-outline">{{ $primaryContactLabel }}</a>
@else
<button type="button" class="lt-btn lt-btn-outline" disabled>No contact</button>
@endif
</div>
@if(! $listing->user)
<button type="button" class="lt-btn lt-btn-main" disabled>Unavailable</button>
@elseif($canContactSeller)
@if($existingConversationId)
<a href="{{ route('panel.inbox.index', ['conversation' => $existingConversationId]) }}" class="lt-btn lt-btn-main">
Make offer
</a>
@else
<form method="POST" action="{{ route('conversations.start', $listing) }}" class="lt-action-form">
@csrf
<button type="submit" class="lt-btn" style="width:100%;">Sohbet</button>
<button type="submit" class="lt-btn lt-btn-main">Make offer</button>
</form>
@endif
@elseif($isOwnListing)
<button type="button" class="lt-btn lt-btn-main" disabled>Manage listing</button>
@else
@if(auth()->check())
<button type="button" class="lt-btn" disabled>Sohbet</button>
@else
<a href="{{ route('login') }}" class="lt-btn">Sohbet</a>
@endif
<a href="{{ route('login') }}" class="lt-btn lt-btn-main">Make offer</a>
@endif
@if($listing->contact_phone)
<a href="tel:{{ preg_replace('/\s+/', '', (string) $listing->contact_phone) }}" class="lt-btn lt-btn-soft">Ara</a>
@else
<button type="button" class="lt-btn lt-btn-soft" disabled>Ara</button>
@endif
<div class="lt-row-2">
@if($mapUrl)
<a href="{{ $mapUrl }}" target="_blank" rel="noreferrer" class="lt-btn lt-btn-outline">
View map
</a>
@else
<button type="button" class="lt-btn lt-btn-outline" disabled>View map</button>
@endif
@if($listing->user && ! $isOwnListing)
@auth
<form method="POST" action="{{ route('favorites.sellers.toggle', $listing->user) }}" class="lt-action-form">
@csrf
<button type="submit" class="lt-btn lt-btn-outline">
{{ $isSellerFavorited ? 'Saved seller' : 'Save seller' }}
</button>
</form>
@else
<a href="{{ route('login') }}" class="lt-btn lt-btn-outline">Save seller</a>
@endauth
@else
<button type="button" class="lt-btn lt-btn-outline" disabled>{{ $isOwnListing ? 'Your account' : 'Save seller' }}</button>
@endif
</div>
</div>
@if($listing->user && auth()->check() && (int) auth()->id() !== (int) $listing->user_id)
@if($existingConversationId)
<a href="{{ route('panel.inbox.index', ['conversation' => $existingConversationId]) }}" class="lt-btn lt-btn-main">Teklif Yap</a>
@else
<form method="POST" action="{{ route('conversations.start', $listing) }}">
@csrf
<button type="submit" class="lt-btn lt-btn-main">Teklif Yap</button>
</form>
@endif
@else
<button type="button" class="lt-btn lt-btn-main" disabled>Teklif Yap</button>
@if(filled($listing->contact_phone) || filled($listing->contact_email))
<div class="lt-contact-strip">
@if(filled($listing->contact_phone))
<a href="tel:{{ preg_replace('/\s+/', '', (string) $listing->contact_phone) }}" class="lt-contact-link">
{{ $listing->contact_phone }}
</a>
@endif
@if(filled($listing->contact_email))
<a href="mailto:{{ $listing->contact_email }}" class="lt-contact-link">
{{ $listing->contact_email }}
</a>
@endif
</div>
@endif
<div class="lt-row-2">
<a href="#" class="lt-btn lt-btn-outline">Harita</a>
@if($listing->user)
<a href="{{ route('favorites.index', ['tab' => 'sellers']) }}" class="lt-btn lt-btn-outline">Satıcı Profili</a>
@else
<a href="#" class="lt-btn lt-btn-outline">Satıcı Profili</a>
@endif
</div>
<a href="{{ $reportUrl }}" class="lt-report">Report this listing</a>
<div class="lt-policy">Buyer protection depends on the final agreement with the seller.</div>
</div>
<a href="#" class="lt-report">İlan ile ilgili şikayetim var</a>
<div class="lt-policy">İade ve Geri Ödeme Politikası</div>
</aside>
</div>
<section class="lt-related">
<div class="lt-related-head">
<h3 class="lt-related-title">İlgini çekebilecek diğer ilanlar</h3>
</div>
@if(($relatedListings ?? collect())->isNotEmpty() || ($themePillCategories ?? collect())->isNotEmpty())
<section class="lt-related">
@if(($relatedListings ?? collect())->isNotEmpty())
<div class="lt-related-head">
<h3 class="lt-related-title">Similar listings</h3>
<p class="lt-related-copy">More listings with a similar feel and category mix.</p>
</div>
<div class="lt-scroll-wrap" data-theme-scroll>
<button type="button" class="lt-scroll-btn prev" data-theme-scroll-prev aria-label="Önceki">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="m15 18-6-6 6-6"/></svg>
</button>
<div class="lt-scroll-wrap" data-theme-scroll>
<button type="button" class="lt-scroll-btn prev" data-theme-scroll-prev aria-label="Previous listings">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="m15 18-6-6 6-6"/>
</svg>
</button>
<div class="lt-scroll-track" data-theme-scroll-track>
@foreach(($relatedListings ?? collect()) as $related)
@php
$relatedImage = $related->getFirstMediaUrl('listing-images');
if (! $relatedImage && is_array($related->images ?? null)) {
$relatedImage = collect($related->images)->first();
}
$relatedPrice = ! is_null($related->price)
? (((float) $related->price > 0) ? number_format((float) $related->price, 0, ',', '.').' '.($related->currency ?: 'TL') : 'Ücretsiz')
: 'Fiyat sorunuz';
@endphp
<a href="{{ route('listings.show', $related) }}" class="lt-rel-card">
<div class="lt-rel-photo">
@if($relatedImage)
<img src="{{ $relatedImage }}" alt="{{ $related->title }}">
@endif
</div>
<div class="lt-rel-body">
<div class="lt-rel-price">{{ $relatedPrice }}</div>
<div class="lt-rel-title">{{ $related->title }}</div>
<div class="lt-rel-city">{{ trim(collect([$related->city, $related->country])->filter()->implode(', ')) }}</div>
</div>
</a>
@endforeach
</div>
<div class="lt-scroll-track" data-theme-scroll-track>
@foreach(($relatedListings ?? collect()) as $related)
@php
$relatedImage = $related->getFirstMediaUrl('listing-images');
if (! $relatedImage && is_array($related->images ?? null)) {
$relatedImage = collect($related->images)->first();
}
<button type="button" class="lt-scroll-btn next" data-theme-scroll-next aria-label="Sonraki">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="m9 18 6-6-6-6"/></svg>
</button>
</div>
$relatedPrice = 'Price on request';
if (! is_null($related->price)) {
$relatedPriceValue = (float) $related->price;
$relatedFormattedPrice = number_format($relatedPriceValue, 2, '.', ',');
$relatedFormattedPrice = rtrim(rtrim($relatedFormattedPrice, '0'), '.');
$relatedPrice = $relatedPriceValue > 0
? $relatedFormattedPrice.' '.($related->currency ?: 'TRY')
: 'Free';
}
@endphp
<div class="lt-pill-wrap">
<h4 class="lt-pill-title">Daha fazla kategori</h4>
<div class="lt-pills">
@foreach(($themePillCategories ?? collect()) as $pillCategory)
<a href="{{ route('listings.index', ['category' => $pillCategory->id]) }}" class="lt-pill">{{ $pillCategory->name }}</a>
@endforeach
</div>
</div>
</section>
<a href="{{ route('listings.show', $related) }}" class="lt-rel-card">
<div class="lt-rel-photo">
@if($relatedImage)
<img src="{{ $relatedImage }}" alt="{{ $related->title }}">
@endif
</div>
<div class="lt-rel-body">
<div class="lt-rel-price">{{ $relatedPrice }}</div>
<div class="lt-rel-title">{{ $related->title }}</div>
<div class="lt-rel-city">{{ trim(collect([$related->city, $related->country])->filter()->implode(', ')) }}</div>
</div>
</a>
@endforeach
</div>
<button type="button" class="lt-scroll-btn next" data-theme-scroll-next aria-label="Next listings">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="m9 18 6-6-6-6"/>
</svg>
</button>
</div>
@endif
@if(($themePillCategories ?? collect())->isNotEmpty())
<div class="lt-pill-wrap">
<h4 class="lt-pill-title">Explore categories</h4>
<div class="lt-pills">
@foreach(($themePillCategories ?? collect()) as $pillCategory)
<a href="{{ route('listings.index', ['category' => $pillCategory->id]) }}" class="lt-pill">{{ $pillCategory->name }}</a>
@endforeach
</div>
</div>
@endif
</section>
@endif
</div>
<script>

View File

@ -1,241 +0,0 @@
<?php
namespace Modules\Partner\Filament\Resources;
use A909M\FilamentStateFusion\Forms\Components\StateFusionSelect;
use A909M\FilamentStateFusion\Tables\Columns\StateFusionSelectColumn;
use A909M\FilamentStateFusion\Tables\Filters\StateFusionSelectFilter;
use App\Support\CountryCodeManager;
use BackedEnum;
use Cheesegrits\FilamentGoogleMaps\Fields\Map;
use Filament\Actions\Action;
use Filament\Actions\DeleteAction;
use Filament\Actions\EditAction;
use Filament\Facades\Filament;
use Filament\Forms\Components\DatePicker;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\SpatieMediaLibraryFileUpload;
use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\TextInput;
use Filament\Resources\Resource;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Components\Utilities\Get;
use Filament\Schemas\Schema;
use Filament\Tables\Columns\SpatieMediaLibraryImageColumn;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Filters\Filter;
use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Filters\TernaryFilter;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
use Modules\Category\Models\Category;
use Modules\Listing\Models\Listing;
use Modules\Listing\Support\ListingCustomFieldSchemaBuilder;
use Modules\Listing\Support\ListingPanelHelper;
use Modules\Location\Models\City;
use Modules\Location\Models\Country;
use Modules\Partner\Filament\Resources\ListingResource\Pages;
use Modules\Video\Support\Filament\VideoFormSchema;
use Ysfkaya\FilamentPhoneInput\Forms\PhoneInput;
class ListingResource extends Resource
{
protected static ?string $model = Listing::class;
protected static string | BackedEnum | null $navigationIcon = 'heroicon-o-clipboard-document-list';
public static function form(Schema $schema): Schema
{
return $schema->schema([
TextInput::make('title')
->required()
->maxLength(255)
->live(onBlur: true)
->afterStateUpdated(function ($state, $set, ?Listing $record): void {
$baseSlug = \Illuminate\Support\Str::slug((string) $state);
$baseSlug = $baseSlug !== '' ? $baseSlug : 'listing';
$slug = $baseSlug;
$counter = 1;
while (Listing::query()
->where('slug', $slug)
->when($record, fn (Builder $query): Builder => $query->whereKeyNot($record->getKey()))
->exists()) {
$slug = "{$baseSlug}-{$counter}";
$counter++;
}
$set('slug', $slug);
}),
TextInput::make('slug')
->required()
->maxLength(255)
->unique(ignoreRecord: true)
->readOnly()
->helperText('Slug is generated automatically from title.'),
Textarea::make('description')->rows(4),
TextInput::make('price')
->numeric()
->currencyMask(thousandSeparator: ',', decimalSeparator: '.', precision: 2),
Select::make('currency')
->options(fn () => ListingPanelHelper::currencyOptions())
->default(fn () => ListingPanelHelper::defaultCurrency())
->required(),
Select::make('category_id')
->label('Category')
->options(fn () => Category::where('is_active', true)->pluck('name', 'id'))
->default(fn (): ?int => request()->integer('category_id') ?: null)
->searchable()
->live()
->afterStateUpdated(fn ($state, $set) => $set('custom_fields', []))
->nullable(),
Section::make('Custom Fields')
->description('Category specific listing attributes.')
->schema(fn (Get $get): array => ListingCustomFieldSchemaBuilder::formComponents(
($categoryId = $get('category_id')) ? (int) $categoryId : null
))
->columns(2)
->columnSpanFull()
->visible(fn (Get $get): bool => ListingCustomFieldSchemaBuilder::hasFields(
($categoryId = $get('category_id')) ? (int) $categoryId : null
)),
StateFusionSelect::make('status')->required(),
PhoneInput::make('contact_phone')->defaultCountry(CountryCodeManager::defaultCountryIso2())->nullable(),
TextInput::make('contact_email')
->email()
->maxLength(255)
->default(fn (): ?string => Filament::auth()->user()?->email),
Select::make('country')
->label('Country')
->options(fn (): array => Country::query()
->where('is_active', true)
->orderBy('name')
->pluck('name', 'name')
->all())
->default(fn (): ?string => Country::query()
->where('code', CountryCodeManager::defaultCountryIso2())
->value('name'))
->searchable()
->preload()
->live()
->afterStateUpdated(fn ($state, $set) => $set('city', null))
->nullable(),
Select::make('city')
->label('City')
->options(function (Get $get): array {
$country = $get('country');
if (blank($country)) {
return [];
}
return City::query()
->where('is_active', true)
->whereHas('country', fn (Builder $query): Builder => $query->where('name', $country))
->orderBy('name')
->pluck('name', 'name')
->all();
})
->searchable()
->preload()
->disabled(fn (Get $get): bool => blank($get('country')))
->nullable(),
Map::make('location')
->label('Location')
->visible(fn (): bool => ListingPanelHelper::googleMapsEnabled())
->draggable()
->clickable()
->autocomplete('city')
->autocompleteReverse(true)
->reverseGeocode([
'city' => '%L',
])
->defaultLocation([41.0082, 28.9784])
->defaultZoom(10)
->height('320px')
->columnSpanFull(),
SpatieMediaLibraryFileUpload::make('images')
->collection('listing-images')
->multiple()
->image()
->reorderable(),
VideoFormSchema::listingSection(),
]);
}
public static function table(Table $table): Table
{
return $table->columns([
SpatieMediaLibraryImageColumn::make('images')
->collection('listing-images')
->circular(),
TextColumn::make('title')->searchable()->sortable()->limit(40),
TextColumn::make('category.name')->label('Category'),
TextColumn::make('price')
->currency(fn (Listing $record): string => $record->currency ?: ListingPanelHelper::defaultCurrency())
->sortable(),
StateFusionSelectColumn::make('status'),
TextColumn::make('city'),
TextColumn::make('created_at')->dateTime()->sortable(),
])->defaultSort('id', 'desc')->filters([
StateFusionSelectFilter::make('status'),
SelectFilter::make('category_id')
->label('Category')
->relationship('category', 'name')
->searchable()
->preload(),
SelectFilter::make('country')
->options(fn (): array => Country::query()
->orderBy('name')
->pluck('name', 'name')
->all())
->searchable(),
SelectFilter::make('city')
->options(fn (): array => City::query()
->orderBy('name')
->pluck('name', 'name')
->all())
->searchable(),
TernaryFilter::make('is_featured')->label('Featured'),
Filter::make('created_at')
->label('Created Date')
->schema([
DatePicker::make('from')->label('From'),
DatePicker::make('until')->label('Until'),
])
->query(fn (Builder $query, array $data): Builder => $query
->when($data['from'] ?? null, fn (Builder $query, string $date): Builder => $query->whereDate('created_at', '>=', $date))
->when($data['until'] ?? null, fn (Builder $query, string $date): Builder => $query->whereDate('created_at', '<=', $date))),
Filter::make('price')
->label('Price Range')
->schema([
TextInput::make('min')->numeric()->label('Min'),
TextInput::make('max')->numeric()->label('Max'),
])
->query(fn (Builder $query, array $data): Builder => $query
->when($data['min'] ?? null, fn (Builder $query, string $amount): Builder => $query->where('price', '>=', (float) $amount))
->when($data['max'] ?? null, fn (Builder $query, string $amount): Builder => $query->where('price', '<=', (float) $amount))),
])->actions([
EditAction::make(),
Action::make('activities')
->icon('heroicon-o-clock')
->url(fn (Listing $record): string => static::getUrl('activities', ['record' => $record])),
DeleteAction::make(),
]);
}
public static function getEloquentQuery(): Builder
{
return parent::getEloquentQuery()->where('user_id', Filament::auth()->id());
}
public static function getPages(): array
{
return [
'index' => Pages\ListListings::route('/'),
'create' => Pages\CreateListing::route('/create'),
'quick-create' => Pages\QuickCreateListing::route('/quick-create'),
'activities' => Pages\ListListingActivities::route('/{record}/activities'),
'edit' => Pages\EditListing::route('/{record}/edit'),
];
}
}

View File

@ -1,17 +0,0 @@
<?php
namespace Modules\Partner\Filament\Resources\ListingResource\Pages;
use Filament\Resources\Pages\CreateRecord;
use Modules\Partner\Filament\Resources\ListingResource;
class CreateListing extends CreateRecord
{
protected static string $resource = ListingResource::class;
protected function mutateFormDataBeforeCreate(array $data): array
{
$data['user_id'] = \Filament\Facades\Filament::auth()->id();
$data['status'] = 'pending';
return $data;
}
}

View File

@ -1,12 +0,0 @@
<?php
namespace Modules\Partner\Filament\Resources\ListingResource\Pages;
use Filament\Actions\DeleteAction;
use Filament\Resources\Pages\EditRecord;
use Modules\Partner\Filament\Resources\ListingResource;
class EditListing extends EditRecord
{
protected static string $resource = ListingResource::class;
protected function getHeaderActions(): array { return [DeleteAction::make()]; }
}

View File

@ -1,10 +0,0 @@
<?php
namespace Modules\Partner\Filament\Resources\ListingResource\Pages;
use Modules\Partner\Filament\Resources\ListingResource;
use pxlrbt\FilamentActivityLog\Pages\ListActivities;
class ListListingActivities extends ListActivities
{
protected static string $resource = ListingResource::class;
}

View File

@ -1,24 +0,0 @@
<?php
namespace Modules\Partner\Filament\Resources\ListingResource\Pages;
use Filament\Actions\Action;
use Filament\Actions\CreateAction;
use Filament\Resources\Pages\ListRecords;
use Modules\Partner\Filament\Resources\ListingResource;
class ListListings extends ListRecords
{
protected static string $resource = ListingResource::class;
protected function getHeaderActions(): array
{
return [
CreateAction::make()
->label('Manuel İlan Ekle'),
Action::make('quickCreate')
->label('AI ile Hızlı İlan Ver')
->icon('heroicon-o-sparkles')
->color('danger')
->url(ListingResource::getUrl('quick-create', shouldGuessMissingParameters: true)),
];
}
}

View File

@ -1,771 +0,0 @@
<?php
namespace Modules\Partner\Filament\Resources\ListingResource\Pages;
use App\Support\QuickListingCategorySuggester;
use Filament\Facades\Filament;
use Filament\Notifications\Notification;
use Filament\Resources\Pages\Page;
use Filament\Support\Enums\Width;
use Illuminate\Support\Collection;
use Illuminate\Support\Str;
use Illuminate\Validation\Rule;
use Livewire\Features\SupportFileUploads\TemporaryUploadedFile;
use Livewire\Features\SupportFileUploads\WithFileUploads;
use Modules\Category\Models\Category;
use Modules\Listing\Models\Listing;
use Modules\Listing\Models\ListingCustomField;
use Modules\Listing\Support\ListingCustomFieldSchemaBuilder;
use Modules\Listing\Support\ListingPanelHelper;
use Modules\Location\Models\City;
use Modules\Location\Models\Country;
use Modules\Partner\Filament\Resources\ListingResource;
use Modules\User\App\Models\Profile;
use Throwable;
class QuickCreateListing extends Page
{
use WithFileUploads;
private const TOTAL_STEPS = 5;
protected static string $resource = ListingResource::class;
protected string $view = 'filament.partner.listings.quick-create';
protected static ?string $title = 'AI ile Hızlı İlan Ver';
protected static ?string $slug = 'quick-create';
protected static bool $shouldRegisterNavigation = false;
protected Width | string | null $maxContentWidth = Width::Full;
/**
* @var array<int, TemporaryUploadedFile>
*/
public array $photos = [];
/**
* @var array<int, array{id: int, name: string, parent_id: int|null, icon: string|null, has_children: bool}>
*/
public array $categories = [];
/**
* @var array<int, array{id: int, name: string}>
*/
public array $countries = [];
/**
* @var array<int, array{id: int, name: string, country_id: int}>
*/
public array $cities = [];
/**
* @var array<int, array{name: string, label: string, type: string, is_required: bool, placeholder: string|null, help_text: string|null, options: array<int, string>}>
*/
public array $listingCustomFields = [];
/**
* @var array<string, mixed>
*/
public array $customFieldValues = [];
public int $currentStep = 1;
public string $categorySearch = '';
public ?int $selectedCategoryId = null;
public ?int $activeParentCategoryId = null;
public ?int $detectedCategoryId = null;
public ?float $detectedConfidence = null;
public ?string $detectedReason = null;
public ?string $detectedError = null;
/**
* @var array<int>
*/
public array $detectedAlternatives = [];
public bool $isDetecting = false;
public string $listingTitle = '';
public string $price = '';
public string $description = '';
public ?int $selectedCountryId = null;
public ?int $selectedCityId = null;
public bool $isPublishing = false;
public function mount(): void
{
$this->loadCategories();
$this->loadLocations();
$this->hydrateLocationDefaultsFromProfile();
}
public function updatedPhotos(): void
{
$this->validatePhotos();
}
public function updatedSelectedCountryId(): void
{
$this->selectedCityId = null;
}
public function removePhoto(int $index): void
{
if (! isset($this->photos[$index])) {
return;
}
unset($this->photos[$index]);
$this->photos = array_values($this->photos);
}
public function goToStep(int $step): void
{
$this->currentStep = max(1, min(self::TOTAL_STEPS, $step));
}
public function goToCategoryStep(): void
{
$this->validatePhotos();
$this->currentStep = 2;
if (! $this->isDetecting && ! $this->detectedCategoryId) {
$this->detectCategoryFromImage();
}
}
public function goToDetailsStep(): void
{
$this->validateCategoryStep();
$this->currentStep = 3;
}
public function goToFeaturesStep(): void
{
$this->validateCategoryStep();
$this->validateDetailsStep();
$this->currentStep = 4;
}
public function goToPreviewStep(): void
{
$this->validateCategoryStep();
$this->validateDetailsStep();
$this->validateCustomFieldsStep();
$this->currentStep = 5;
}
public function detectCategoryFromImage(): void
{
if ($this->photos === []) {
return;
}
$this->isDetecting = true;
$this->detectedError = null;
$this->detectedReason = null;
$this->detectedAlternatives = [];
$result = app(QuickListingCategorySuggester::class)->suggestFromImage($this->photos[0]);
$this->isDetecting = false;
$this->detectedCategoryId = $result['category_id'];
$this->detectedConfidence = $result['confidence'];
$this->detectedReason = $result['reason'];
$this->detectedError = $result['error'];
$this->detectedAlternatives = $result['alternatives'];
if ($this->detectedCategoryId) {
$this->selectCategory($this->detectedCategoryId);
}
}
public function enterCategory(int $categoryId): void
{
if (! $this->categoryExists($categoryId)) {
return;
}
$this->activeParentCategoryId = $categoryId;
$this->categorySearch = '';
}
public function backToRootCategories(): void
{
$this->activeParentCategoryId = null;
$this->categorySearch = '';
}
public function selectCategory(int $categoryId): void
{
if (! $this->categoryExists($categoryId)) {
return;
}
$this->selectedCategoryId = $categoryId;
$this->loadListingCustomFields();
}
public function publishListing()
{
if ($this->isPublishing) {
return null;
}
$this->isPublishing = true;
$this->validatePhotos();
$this->validateCategoryStep();
$this->validateDetailsStep();
$this->validateCustomFieldsStep();
try {
$listing = $this->createListing();
} catch (Throwable $exception) {
report($exception);
$this->isPublishing = false;
Notification::make()
->title('İlan oluşturulamadı')
->body('Bir hata oluştu. Lütfen tekrar deneyin.')
->danger()
->send();
return null;
}
$this->isPublishing = false;
Notification::make()
->title('İlan başarıyla oluşturuldu')
->success()
->send();
return redirect()->to(ListingResource::getUrl(
name: 'edit',
parameters: ['record' => $listing],
shouldGuessMissingParameters: true,
));
}
/**
* @return array<int, array{id: int, name: string, parent_id: int|null, icon: string|null, has_children: bool}>
*/
public function getRootCategoriesProperty(): array
{
return collect($this->categories)
->whereNull('parent_id')
->values()
->all();
}
/**
* @return array<int, array{id: int, name: string, parent_id: int|null, icon: string|null, has_children: bool}>
*/
public function getCurrentCategoriesProperty(): array
{
if (! $this->activeParentCategoryId) {
return [];
}
$search = trim((string) $this->categorySearch);
$all = collect($this->categories);
$parent = $all->firstWhere('id', $this->activeParentCategoryId);
$children = $all->where('parent_id', $this->activeParentCategoryId)->values();
$combined = collect();
if (is_array($parent)) {
$combined->push($parent);
}
$combined = $combined->concat($children);
return $combined
->when(
$search !== '',
fn (Collection $categories): Collection => $categories->filter(
fn (array $category): bool => str_contains(
mb_strtolower($category['name']),
mb_strtolower($search)
)
)
)
->values()
->all();
}
public function getCurrentParentNameProperty(): string
{
if (! $this->activeParentCategoryId) {
return 'Kategori Seçimi';
}
$category = collect($this->categories)
->firstWhere('id', $this->activeParentCategoryId);
return (string) ($category['name'] ?? 'Kategori Seçimi');
}
public function getCurrentStepTitleProperty(): string
{
return match ($this->currentStep) {
1 => 'Fotoğraf',
2 => 'Kategori Seçimi',
3 => 'İlan Bilgileri',
4 => 'İlan Özellikleri',
5 => 'İlan Önizlemesi',
default => 'AI ile Hızlı İlan Ver',
};
}
public function getSelectedCategoryNameProperty(): ?string
{
if (! $this->selectedCategoryId) {
return null;
}
$category = collect($this->categories)
->firstWhere('id', $this->selectedCategoryId);
return $category['name'] ?? null;
}
public function getSelectedCategoryPathProperty(): string
{
if (! $this->selectedCategoryId) {
return '';
}
return implode(' ', $this->categoryPathParts($this->selectedCategoryId));
}
/**
* @return array<int, string>
*/
public function getDetectedAlternativeNamesProperty(): array
{
if ($this->detectedAlternatives === []) {
return [];
}
$categoriesById = collect($this->categories)->keyBy('id');
return collect($this->detectedAlternatives)
->map(fn (int $id): ?string => $categoriesById[$id]['name'] ?? null)
->filter()
->values()
->all();
}
/**
* @return array<int, array{id: int, name: string, country_id: int}>
*/
public function getAvailableCitiesProperty(): array
{
if (! $this->selectedCountryId) {
return [];
}
return collect($this->cities)
->where('country_id', $this->selectedCountryId)
->values()
->all();
}
public function getSelectedCountryNameProperty(): ?string
{
if (! $this->selectedCountryId) {
return null;
}
$country = collect($this->countries)->firstWhere('id', $this->selectedCountryId);
return $country['name'] ?? null;
}
public function getSelectedCityNameProperty(): ?string
{
if (! $this->selectedCityId) {
return null;
}
$city = collect($this->cities)->firstWhere('id', $this->selectedCityId);
return $city['name'] ?? null;
}
/**
* @return array<int, array{label: string, value: string}>
*/
public function getPreviewCustomFieldsProperty(): array
{
return ListingCustomFieldSchemaBuilder::presentableValues(
$this->selectedCategoryId,
$this->sanitizedCustomFieldValues(),
);
}
public function getTitleCharactersProperty(): int
{
return mb_strlen($this->listingTitle);
}
public function getDescriptionCharactersProperty(): int
{
return mb_strlen($this->description);
}
public function getCurrentUserNameProperty(): string
{
return (string) (Filament::auth()->user()?->name ?: 'Kullanıcı');
}
public function getCurrentUserInitialProperty(): string
{
return Str::upper(Str::substr($this->currentUserName, 0, 1));
}
public function categoryIconComponent(?string $icon): string
{
return match ($icon) {
'car' => 'heroicon-o-truck',
'laptop', 'computer' => 'heroicon-o-computer-desktop',
'shirt' => 'heroicon-o-swatch',
'home', 'sofa' => 'heroicon-o-home-modern',
'briefcase' => 'heroicon-o-briefcase',
'wrench' => 'heroicon-o-wrench-screwdriver',
'football' => 'heroicon-o-trophy',
'phone', 'mobile' => 'heroicon-o-device-phone-mobile',
default => 'heroicon-o-tag',
};
}
private function validatePhotos(): void
{
$this->validate([
'photos' => [
'required',
'array',
'min:1',
'max:'.config('quick-listing.max_photo_count', 20),
],
'photos.*' => [
'required',
'image',
'mimes:jpg,jpeg,png',
'max:'.config('quick-listing.max_photo_size_kb', 5120),
],
]);
}
private function validateCategoryStep(): void
{
$this->validate([
'selectedCategoryId' => [
'required',
'integer',
Rule::in(collect($this->categories)->pluck('id')->all()),
],
], [
'selectedCategoryId.required' => 'Lütfen bir kategori seçin.',
'selectedCategoryId.in' => 'Geçerli bir kategori seçin.',
]);
}
private function validateDetailsStep(): void
{
$this->validate([
'listingTitle' => ['required', 'string', 'max:70'],
'price' => ['required', 'numeric', 'min:0'],
'description' => ['required', 'string', 'max:1450'],
'selectedCountryId' => ['required', 'integer', Rule::in(collect($this->countries)->pluck('id')->all())],
'selectedCityId' => [
'nullable',
'integer',
function (string $attribute, mixed $value, \Closure $fail): void {
if (is_null($value) || $value === '') {
return;
}
$cityExists = collect($this->availableCities)
->contains(fn (array $city): bool => $city['id'] === (int) $value);
if (! $cityExists) {
$fail('Seçtiğiniz şehir, seçilen ülkeye ait değil.');
}
},
],
], [
'listingTitle.required' => 'İlan başlığı zorunludur.',
'listingTitle.max' => 'İlan başlığı en fazla 70 karakter olabilir.',
'price.required' => 'Fiyat zorunludur.',
'price.numeric' => 'Fiyat sayısal olmalıdır.',
'description.required' => 'Açıklama zorunludur.',
'description.max' => 'Açıklama en fazla 1450 karakter olabilir.',
'selectedCountryId.required' => 'Ülke seçimi zorunludur.',
]);
}
private function validateCustomFieldsStep(): void
{
$rules = [];
foreach ($this->listingCustomFields as $field) {
$fieldRules = [];
$name = $field['name'];
$statePath = "customFieldValues.{$name}";
$type = $field['type'];
$isRequired = (bool) $field['is_required'];
if ($type === ListingCustomField::TYPE_BOOLEAN) {
$fieldRules[] = 'nullable';
$fieldRules[] = 'boolean';
} else {
$fieldRules[] = $isRequired ? 'required' : 'nullable';
}
$fieldRules[] = match ($type) {
ListingCustomField::TYPE_TEXT => 'string|max:255',
ListingCustomField::TYPE_TEXTAREA => 'string|max:2000',
ListingCustomField::TYPE_NUMBER => 'numeric',
ListingCustomField::TYPE_DATE => 'date',
default => 'sometimes',
};
if ($type === ListingCustomField::TYPE_SELECT) {
$options = collect($field['options'] ?? [])->map(fn ($option): string => (string) $option)->all();
$fieldRules[] = Rule::in($options);
}
$rules[$statePath] = $fieldRules;
}
if ($rules !== []) {
$this->validate($rules);
}
}
private function createListing(): Listing
{
$user = Filament::auth()->user();
if (! $user) {
abort(403);
}
$profilePhone = Profile::query()
->where('user_id', $user->getKey())
->value('phone');
$payload = [
'title' => trim($this->listingTitle),
'description' => trim($this->description),
'price' => (float) $this->price,
'currency' => ListingPanelHelper::defaultCurrency(),
'category_id' => $this->selectedCategoryId,
'status' => 'pending',
'custom_fields' => $this->sanitizedCustomFieldValues(),
'contact_email' => (string) $user->email,
'contact_phone' => $profilePhone,
'country' => $this->selectedCountryName,
'city' => $this->selectedCityName,
];
$listing = Listing::createFromFrontend($payload, $user->getKey());
foreach ($this->photos as $photo) {
if (! $photo instanceof TemporaryUploadedFile) {
continue;
}
$listing
->addMedia($photo->getRealPath())
->usingFileName($photo->getClientOriginalName())
->toMediaCollection('listing-images');
}
return $listing;
}
/**
* @return array<string, mixed>
*/
private function sanitizedCustomFieldValues(): array
{
$fieldsByName = collect($this->listingCustomFields)->keyBy('name');
return collect($this->customFieldValues)
->filter(fn ($value, $key): bool => $fieldsByName->has((string) $key))
->map(function ($value, $key) use ($fieldsByName): mixed {
$field = $fieldsByName->get((string) $key);
$type = (string) ($field['type'] ?? ListingCustomField::TYPE_TEXT);
return match ($type) {
ListingCustomField::TYPE_NUMBER => is_numeric($value) ? (float) $value : null,
ListingCustomField::TYPE_BOOLEAN => (bool) $value,
default => is_string($value) ? trim($value) : $value,
};
})
->filter(function ($value, $key) use ($fieldsByName): bool {
$field = $fieldsByName->get((string) $key);
$type = (string) ($field['type'] ?? ListingCustomField::TYPE_TEXT);
if ($type === ListingCustomField::TYPE_BOOLEAN) {
return true;
}
return ! is_null($value) && $value !== '';
})
->all();
}
private function loadCategories(): void
{
$all = Category::query()
->where('is_active', true)
->orderBy('sort_order')
->orderBy('name')
->get(['id', 'name', 'parent_id', 'icon']);
$childrenCount = Category::query()
->where('is_active', true)
->selectRaw('parent_id, count(*) as aggregate')
->whereNotNull('parent_id')
->groupBy('parent_id')
->pluck('aggregate', 'parent_id');
$this->categories = $all
->map(fn (Category $category): array => [
'id' => (int) $category->id,
'name' => (string) $category->name,
'parent_id' => $category->parent_id ? (int) $category->parent_id : null,
'icon' => $category->icon,
'has_children' => ((int) ($childrenCount[$category->id] ?? 0)) > 0,
])
->values()
->all();
}
private function loadLocations(): void
{
$this->countries = Country::query()
->where('is_active', true)
->orderBy('name')
->get(['id', 'name'])
->map(fn (Country $country): array => [
'id' => (int) $country->id,
'name' => (string) $country->name,
])
->all();
$this->cities = City::query()
->where('is_active', true)
->orderBy('name')
->get(['id', 'name', 'country_id'])
->map(fn (City $city): array => [
'id' => (int) $city->id,
'name' => (string) $city->name,
'country_id' => (int) $city->country_id,
])
->all();
}
private function loadListingCustomFields(): void
{
$this->listingCustomFields = ListingCustomField::query()
->active()
->forCategory($this->selectedCategoryId)
->ordered()
->get(['name', 'label', 'type', 'is_required', 'placeholder', 'help_text', 'options'])
->map(fn (ListingCustomField $field): array => [
'name' => (string) $field->name,
'label' => (string) $field->label,
'type' => (string) $field->type,
'is_required' => (bool) $field->is_required,
'placeholder' => $field->placeholder,
'help_text' => $field->help_text,
'options' => collect($field->options ?? [])
->map(fn ($option): string => (string) $option)
->values()
->all(),
])
->all();
$allowed = collect($this->listingCustomFields)->pluck('name')->all();
$this->customFieldValues = collect($this->customFieldValues)
->only($allowed)
->all();
foreach ($this->listingCustomFields as $field) {
if ($field['type'] === ListingCustomField::TYPE_BOOLEAN && ! array_key_exists($field['name'], $this->customFieldValues)) {
$this->customFieldValues[$field['name']] = false;
}
}
}
private function hydrateLocationDefaultsFromProfile(): void
{
$user = Filament::auth()->user();
if (! $user) {
return;
}
$profile = Profile::query()->where('user_id', $user->getKey())->first();
if (! $profile) {
return;
}
$profileCountry = trim((string) ($profile->country ?? ''));
$profileCity = trim((string) ($profile->city ?? ''));
if ($profileCountry !== '') {
$country = collect($this->countries)->first(function (array $country) use ($profileCountry): bool {
return mb_strtolower($country['name']) === mb_strtolower($profileCountry);
});
if (is_array($country)) {
$this->selectedCountryId = $country['id'];
}
}
if ($profileCity !== '' && $this->selectedCountryId) {
$city = collect($this->availableCities)->first(function (array $city) use ($profileCity): bool {
return mb_strtolower($city['name']) === mb_strtolower($profileCity);
});
if (is_array($city)) {
$this->selectedCityId = $city['id'];
}
}
}
/**
* @return array<int, string>
*/
private function categoryPathParts(int $categoryId): array
{
$byId = collect($this->categories)->keyBy('id');
$parts = [];
$currentId = $categoryId;
while ($currentId && $byId->has($currentId)) {
$category = $byId->get($currentId);
if (! is_array($category)) {
break;
}
$parts[] = (string) $category['name'];
$currentId = $category['parent_id'] ?? null;
}
return array_reverse($parts);
}
private function categoryExists(int $categoryId): bool
{
return collect($this->categories)
->contains(fn (array $category): bool => $category['id'] === $categoryId);
}
}

View File

@ -1,129 +0,0 @@
<?php
namespace Modules\Partner\Providers;
use App\Http\Middleware\BootstrapAppData;
use A909M\FilamentStateFusion\FilamentStateFusionPlugin;
use Modules\User\App\Models\User;
use DutchCodingCompany\FilamentDeveloperLogins\FilamentDeveloperLoginsPlugin;
use DutchCodingCompany\FilamentSocialite\FilamentSocialitePlugin;
use Filament\Http\Middleware\Authenticate;
use Filament\Http\Middleware\AuthenticateSession;
use Filament\Http\Middleware\DisableBladeIconComponents;
use Filament\Http\Middleware\DispatchServingFilamentEvent;
use Filament\Pages\Dashboard;
use Filament\Panel;
use Filament\PanelProvider;
use Filament\Support\Colors\Color;
use Filament\View\PanelsRenderHook;
use Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse;
use Illuminate\Cookie\Middleware\EncryptCookies;
use Illuminate\Foundation\Http\Middleware\VerifyCsrfToken;
use Illuminate\Routing\Middleware\SubstituteBindings;
use Illuminate\Session\Middleware\StartSession;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str;
use Illuminate\View\Middleware\ShareErrorsFromSession;
use Jeffgreco13\FilamentBreezy\BreezyCore;
use Laravel\Socialite\Contracts\User as SocialiteUserContract;
use Modules\Demo\App\Http\Middleware\ResolveDemoRequest;
use Modules\Partner\Support\Filament\SocialiteProviderResolver;
use Spatie\Permission\Models\Role;
class PartnerPanelProvider extends PanelProvider
{
public function panel(Panel $panel): Panel
{
return $panel
->id('partner')
->path('partner')
->login()
->darkMode(false)
->colors(['primary' => Color::Emerald])
->tenant(User::class, slugAttribute: 'id')
->discoverResources(in: module_path('Partner', 'Filament/Resources'), for: 'Modules\\Partner\\Filament\\Resources')
->discoverResources(in: module_path('Video', 'Filament/Partner/Resources'), for: 'Modules\\Video\\Filament\\Partner\\Resources')
->discoverPages(in: module_path('Partner', 'Filament/Pages'), for: 'Modules\\Partner\\Filament\\Pages')
->discoverWidgets(in: module_path('Partner', 'Filament/Widgets'), for: 'Modules\\Partner\\Filament\\Widgets')
->renderHook(PanelsRenderHook::BODY_END, fn () => view('video::partials.video-upload-optimizer'))
->plugins([
FilamentStateFusionPlugin::make(),
BreezyCore::make()
->myProfile(
shouldRegisterNavigation: true,
navigationGroup: 'Account',
hasAvatars: true,
userMenuLabel: 'My Profile',
)
->enableTwoFactorAuthentication()
->enableSanctumTokens(),
FilamentDeveloperLoginsPlugin::make()
->enabled(fn (): bool => app()->environment('local'))
->users([
'Partner (Add Listing)' => 'b@b.com',
])
->redirectTo(fn (): ?string => self::partnerCreateListingUrl()),
self::socialitePlugin(),
])
->pages([Dashboard::class])
->middleware([
EncryptCookies::class,
AddQueuedCookiesToResponse::class,
StartSession::class,
ResolveDemoRequest::class,
BootstrapAppData::class,
AuthenticateSession::class,
ShareErrorsFromSession::class,
VerifyCsrfToken::class,
SubstituteBindings::class,
DisableBladeIconComponents::class,
DispatchServingFilamentEvent::class,
])
->authMiddleware([Authenticate::class]);
}
private static function socialitePlugin(): FilamentSocialitePlugin
{
return FilamentSocialitePlugin::make()
->providers(SocialiteProviderResolver::providers())
->registration(true)
->resolveUserUsing(function (string $provider, SocialiteUserContract $oauthUser): ?User {
if (! filled($oauthUser->getEmail())) {
return null;
}
return User::query()->where('email', strtolower(trim((string) $oauthUser->getEmail())))->first();
})
->createUserUsing(function (string $provider, SocialiteUserContract $oauthUser): User {
$email = filled($oauthUser->getEmail())
? strtolower(trim((string) $oauthUser->getEmail()))
: sprintf('%s_%s@social.local', $provider, $oauthUser->getId());
$user = User::query()->firstOrCreate(
['email' => $email],
[
'name' => trim((string) ($oauthUser->getName() ?: $oauthUser->getNickname() ?: ucfirst($provider).' User')),
'password' => Hash::make(Str::random(40)),
'status' => 'active',
],
);
if (class_exists(Role::class)) {
$partnerRole = Role::firstOrCreate(['name' => 'partner', 'guard_name' => 'web']);
$user->syncRoles([$partnerRole->name]);
}
return $user;
});
}
private static function partnerCreateListingUrl(): ?string
{
$partner = User::query()->where('email', 'b@b.com')->first();
if (! $partner) {
return null;
}
return route('filament.partner.resources.listings.create', ['tenant' => $partner->getKey()]);
}
}

View File

@ -1,11 +0,0 @@
<?php
namespace Modules\Partner\Providers;
use Illuminate\Support\ServiceProvider;
class PartnerServiceProvider extends ServiceProvider
{
public function boot(): void {}
public function register(): void {}
}

View File

@ -1,62 +0,0 @@
<?php
namespace Modules\Partner\Support\Filament;
use App\Settings\GeneralSettings;
use DutchCodingCompany\FilamentSocialite\Provider;
use Filament\Support\Colors\Color;
use Throwable;
class SocialiteProviderResolver
{
public static function providers(): array
{
$providers = [];
if (self::enabled('google')) {
$providers[] = Provider::make('google')
->label('Google')
->icon('heroicon-o-globe-alt')
->color(Color::hex('#4285F4'));
}
if (self::enabled('facebook')) {
$providers[] = Provider::make('facebook')
->label('Facebook')
->icon('heroicon-o-users')
->color(Color::hex('#1877F2'));
}
if (self::enabled('apple')) {
$providers[] = Provider::make('apple')
->label('Apple')
->icon('heroicon-o-device-phone-mobile')
->color(Color::Gray)
->stateless(true);
}
return $providers;
}
private static function enabled(string $provider): bool
{
try {
$settings = app(GeneralSettings::class);
$enabled = match ($provider) {
'google' => (bool) $settings->enable_google_login,
'facebook' => (bool) $settings->enable_facebook_login,
'apple' => (bool) $settings->enable_apple_login,
default => false,
};
return $enabled
&& filled(config("services.{$provider}.client_id"))
&& filled(config("services.{$provider}.client_secret"));
} catch (Throwable) {
return (bool) config("services.{$provider}.enabled", false)
&& filled(config("services.{$provider}.client_id"))
&& filled(config("services.{$provider}.client_secret"));
}
}
}

View File

@ -1,13 +0,0 @@
{
"name": "Partner",
"alias": "partner",
"description": "Partner panel for users to manage their own listings",
"keywords": [],
"priority": 0,
"providers": [
"Modules\\Partner\\Providers\\PartnerServiceProvider"
],
"aliases": {},
"files": [],
"requires": []
}

View File

@ -38,7 +38,7 @@ class ProfileController extends Controller
$request->user()->save();
return redirect()->route('profile.edit')->with('status', 'profile-updated');
return redirect()->route('panel.profile.edit')->with('status', 'profile-updated');
}
public function destroy(Request $request): RedirectResponse

View File

@ -4,14 +4,11 @@ namespace Modules\User\App\Models;
use Filament\Models\Contracts\FilamentUser;
use Filament\Models\Contracts\HasAvatar;
use Filament\Models\Contracts\HasTenants;
use Filament\Panel;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Storage;
use Jeffgreco13\FilamentBreezy\Traits\TwoFactorAuthenticatable;
use Laravel\Sanctum\HasApiTokens;
@ -26,7 +23,7 @@ use Spatie\Activitylog\Traits\LogsActivity;
use Spatie\ModelStates\HasStates;
use Spatie\Permission\Traits\HasRoles;
class User extends Authenticatable implements FilamentUser, HasTenants, HasAvatar
class User extends Authenticatable implements FilamentUser, HasAvatar
{
use HasApiTokens;
use HasFactory;
@ -67,21 +64,10 @@ class User extends Authenticatable implements FilamentUser, HasTenants, HasAvata
{
return match ($panel->getId()) {
'admin' => $this->hasRole('admin'),
'partner' => true,
default => false,
};
}
public function getTenants(Panel $panel): Collection
{
return collect([$this]);
}
public function canAccessTenant(Model $tenant): bool
{
return $tenant->getKey() === $this->getKey();
}
public function listings()
{
return $this->hasMany(Listing::class);

View File

@ -1,54 +1,83 @@
<section class="space-y-6">
<header>
<h2 class="text-lg font-medium text-gray-900">
{{ __('Delete Account') }}
</h2>
<section class="space-y-8">
<header class="flex flex-col gap-4 border-b border-rose-200/80 pb-6 lg:flex-row lg:items-start lg:justify-between">
<div>
<p class="account-section-kicker text-rose-500">{{ __('Danger Zone') }}</p>
<h2 class="mt-2 text-2xl font-semibold tracking-[-0.03em] text-slate-950">
{{ __('Delete Account') }}
</h2>
<p class="mt-2 max-w-3xl text-sm leading-6 text-slate-500">
{{ __('Deleting your account permanently removes your listings, favorites, and personal data. This cannot be undone.') }}
</p>
</div>
<p class="mt-1 text-sm text-gray-600">
{{ __('Once your account is deleted, all of its resources and data will be permanently deleted. Before deleting your account, please download any data or information that you wish to retain.') }}
</p>
<button
type="button"
x-data=""
x-on:click.prevent="$dispatch('open-modal', 'confirm-user-deletion')"
class="account-danger-button"
>
{{ __('Delete Account') }}
</button>
</header>
<x-danger-button
x-data=""
x-on:click.prevent="$dispatch('open-modal', 'confirm-user-deletion')"
>{{ __('Delete Account') }}</x-danger-button>
<div class="rounded-[26px] border border-rose-200 bg-rose-50/80 p-5">
<div class="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
<div>
<p class="text-sm font-semibold text-rose-900">{{ __('Before you continue') }}</p>
<p class="mt-1 text-sm leading-6 text-rose-900/80">
{{ __('Download anything you need to keep. Once deletion is confirmed, the account is removed immediately.') }}
</p>
</div>
<button
type="button"
x-data=""
x-on:click.prevent="$dispatch('open-modal', 'confirm-user-deletion')"
class="account-secondary-button border-rose-200 bg-white text-rose-700 hover:border-rose-300 hover:text-rose-800"
>
{{ __('Review Deletion') }}
</button>
</div>
</div>
<x-modal name="confirm-user-deletion" :show="$errors->userDeletion->isNotEmpty()" focusable>
<form method="post" action="{{ route('profile.destroy') }}" class="p-6">
<form method="post" action="{{ route('profile.destroy') }}" class="space-y-6 p-6 sm:p-8">
@csrf
@method('delete')
<h2 class="text-lg font-medium text-gray-900">
{{ __('Are you sure you want to delete your account?') }}
</h2>
<div>
<p class="account-section-kicker text-rose-500">{{ __('Final Confirmation') }}</p>
<h2 class="mt-2 text-2xl font-semibold tracking-[-0.03em] text-slate-950">
{{ __('Are you sure you want to delete your account?') }}
</h2>
<p class="mt-2 text-sm leading-6 text-slate-500">
{{ __('Enter your password to confirm permanent deletion. This action cannot be reversed.') }}
</p>
</div>
<p class="mt-1 text-sm text-gray-600">
{{ __('Once your account is deleted, all of its resources and data will be permanently deleted. Please enter your password to confirm you would like to permanently delete your account.') }}
</p>
<div class="mt-6">
<x-input-label for="password" value="{{ __('Password') }}" class="sr-only" />
<x-text-input
<div class="account-field">
<label for="password" class="account-label">{{ __('Password') }}</label>
<input
id="password"
name="password"
type="password"
class="mt-1 block w-3/4"
placeholder="{{ __('Password') }}"
/>
class="account-input"
>
<x-input-error :messages="$errors->userDeletion->get('password')" class="mt-2" />
@foreach ($errors->userDeletion->get('password') as $message)
<p class="account-error">{{ $message }}</p>
@endforeach
</div>
<div class="mt-6 flex justify-end">
<x-secondary-button x-on:click="$dispatch('close')">
<div class="flex flex-col-reverse gap-3 border-t border-slate-200/80 pt-6 sm:flex-row sm:justify-end">
<button type="button" x-on:click="$dispatch('close')" class="account-secondary-button">
{{ __('Cancel') }}
</x-secondary-button>
</button>
<x-danger-button class="ms-3">
{{ __('Delete Account') }}
</x-danger-button>
<button type="submit" class="account-danger-button">
{{ __('Permanently Delete') }}
</button>
</div>
</form>
</x-modal>

View File

@ -1,48 +1,93 @@
<section>
<header>
<h2 class="text-lg font-medium text-gray-900">
{{ __('Update Password') }}
</h2>
<section class="space-y-8">
<header class="flex flex-col gap-4 border-b border-slate-200/80 pb-6">
<div>
<p class="account-section-kicker">{{ __('Security') }}</p>
<h2 class="mt-2 text-2xl font-semibold tracking-[-0.03em] text-slate-950">
{{ __('Update Password') }}
</h2>
<p class="mt-2 text-sm leading-6 text-slate-500">
{{ __('Use a unique password that you do not reuse anywhere else.') }}
</p>
</div>
<p class="mt-1 text-sm text-gray-600">
{{ __('Ensure your account is using a long, random password to stay secure.') }}
</p>
<div class="rounded-[24px] bg-slate-50 px-4 py-4 ring-1 ring-slate-200">
<p class="text-sm font-semibold text-slate-800">{{ __('Recommended') }}</p>
<p class="mt-1 text-sm leading-6 text-slate-500">
{{ __('Choose at least 8 characters and mix letters, numbers, and symbols for a stronger account.') }}
</p>
</div>
</header>
<form method="post" action="{{ route('password.update') }}" class="mt-6 space-y-6">
<form method="post" action="{{ route('password.update') }}" class="space-y-6">
@csrf
@method('put')
<div>
<x-input-label for="update_password_current_password" :value="__('Current Password')" />
<x-text-input id="update_password_current_password" name="current_password" type="password" class="mt-1 block w-full" autocomplete="current-password" />
<x-input-error :messages="$errors->updatePassword->get('current_password')" class="mt-2" />
<div class="account-field">
<label for="update_password_current_password" class="account-label">{{ __('Current Password') }}</label>
<input
id="update_password_current_password"
name="current_password"
type="password"
autocomplete="current-password"
class="account-input"
>
@foreach ($errors->updatePassword->get('current_password') as $message)
<p class="account-error">{{ $message }}</p>
@endforeach
</div>
<div>
<x-input-label for="update_password_password" :value="__('New Password')" />
<x-text-input id="update_password_password" name="password" type="password" class="mt-1 block w-full" autocomplete="new-password" />
<x-input-error :messages="$errors->updatePassword->get('password')" class="mt-2" />
<div class="grid gap-5 xl:grid-cols-2">
<div class="account-field">
<label for="update_password_password" class="account-label">{{ __('New Password') }}</label>
<input
id="update_password_password"
name="password"
type="password"
autocomplete="new-password"
class="account-input"
>
@foreach ($errors->updatePassword->get('password') as $message)
<p class="account-error">{{ $message }}</p>
@endforeach
</div>
<div class="account-field">
<label for="update_password_password_confirmation" class="account-label">{{ __('Confirm Password') }}</label>
<input
id="update_password_password_confirmation"
name="password_confirmation"
type="password"
autocomplete="new-password"
class="account-input"
>
@foreach ($errors->updatePassword->get('password_confirmation') as $message)
<p class="account-error">{{ $message }}</p>
@endforeach
</div>
</div>
<div>
<x-input-label for="update_password_password_confirmation" :value="__('Confirm Password')" />
<x-text-input id="update_password_password_confirmation" name="password_confirmation" type="password" class="mt-1 block w-full" autocomplete="new-password" />
<x-input-error :messages="$errors->updatePassword->get('password_confirmation')" class="mt-2" />
</div>
<div class="flex flex-col gap-4 border-t border-slate-200/80 pt-6 sm:flex-row sm:items-center sm:justify-between">
<p class="max-w-xl text-sm leading-6 text-slate-500">
{{ __('After saving, use the new password next time you sign in.') }}
</p>
<div class="flex items-center gap-4">
<x-primary-button>{{ __('Save') }}</x-primary-button>
<div class="flex flex-col gap-3 sm:flex-row sm:items-center">
@if (session('status') === 'password-updated')
<p
x-data="{ show: true }"
x-show="show"
x-transition.opacity.duration.300ms
x-init="setTimeout(() => show = false, 2400)"
class="account-inline-badge bg-emerald-50 text-emerald-700 ring-1 ring-emerald-200"
>
{{ __('Saved') }}
</p>
@endif
@if (session('status') === 'password-updated')
<p
x-data="{ show: true }"
x-show="show"
x-transition
x-init="setTimeout(() => show = false, 2000)"
class="text-sm text-gray-600"
>{{ __('Saved.') }}</p>
@endif
<button type="submit" class="account-primary-button">
{{ __('Update Password') }}
</button>
</div>
</div>
</form>
</section>

View File

@ -1,64 +1,107 @@
<section>
<header>
<h2 class="text-lg font-medium text-gray-900">
{{ __('Profile Information') }}
</h2>
@php
$profileErrors = $errors->updateProfile;
@endphp
<p class="mt-1 text-sm text-gray-600">
{{ __("Update your account's profile information and email address.") }}
</p>
<section class="space-y-8">
<header class="flex flex-col gap-4 border-b border-slate-200/80 pb-6 sm:flex-row sm:items-start sm:justify-between">
<div>
<p class="account-section-kicker">{{ __('Public Details') }}</p>
<h2 class="mt-2 text-2xl font-semibold tracking-[-0.03em] text-slate-950">
{{ __('Profile Information') }}
</h2>
<p class="mt-2 max-w-2xl text-sm leading-6 text-slate-500">
{{ __("Update your account's display name and primary email address.") }}
</p>
</div>
@if (session('status') === 'profile-updated')
<p
x-data="{ show: true }"
x-show="show"
x-transition.opacity.duration.300ms
x-init="setTimeout(() => show = false, 2400)"
class="account-inline-badge bg-emerald-50 text-emerald-700 ring-1 ring-emerald-200"
>
{{ __('Saved') }}
</p>
@endif
</header>
<form id="send-verification" method="post" action="{{ route('verification.send') }}">
@csrf
</form>
<form method="post" action="{{ route('profile.update') }}" class="mt-6 space-y-6">
<form method="post" action="{{ route('profile.update') }}" class="space-y-8">
@csrf
@method('patch')
<div>
<x-input-label for="name" :value="__('Name')" />
<x-text-input id="name" name="name" type="text" class="mt-1 block w-full" :value="old('name', $user->name)" required autofocus autocomplete="name" />
<x-input-error class="mt-2" :messages="$errors->get('name')" />
<div class="grid gap-5 lg:grid-cols-2">
<div class="account-field">
<label for="name" class="account-label">{{ __('Name') }}</label>
<input
id="name"
name="name"
type="text"
value="{{ old('name', $user->name) }}"
autocomplete="name"
required
autofocus
class="account-input"
>
@foreach ($profileErrors->get('name') as $message)
<p class="account-error">{{ $message }}</p>
@endforeach
</div>
<div class="account-field">
<label for="email" class="account-label">{{ __('Email') }}</label>
<input
id="email"
name="email"
type="email"
value="{{ old('email', $user->email) }}"
autocomplete="username"
required
class="account-input"
>
<p class="account-helper">{{ __('We use this email for sign-in, alerts, and buyer communication.') }}</p>
@foreach ($profileErrors->get('email') as $message)
<p class="account-error">{{ $message }}</p>
@endforeach
</div>
</div>
<div>
<x-input-label for="email" :value="__('Email')" />
<x-text-input id="email" name="email" type="email" class="mt-1 block w-full" :value="old('email', $user->email)" required autocomplete="username" />
<x-input-error class="mt-2" :messages="$errors->get('email')" />
@if ($user instanceof \Illuminate\Contracts\Auth\MustVerifyEmail && ! $user->hasVerifiedEmail())
<div>
<p class="text-sm mt-2 text-gray-800">
{{ __('Your email address is unverified.') }}
<button form="send-verification" class="underline text-sm text-gray-600 hover:text-gray-900 rounded-md focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500">
{{ __('Click here to re-send the verification email.') }}
</button>
</p>
@if (session('status') === 'verification-link-sent')
<p class="mt-2 font-medium text-sm text-green-600">
{{ __('A new verification link has been sent to your email address.') }}
@if ($user instanceof \Illuminate\Contracts\Auth\MustVerifyEmail && ! $user->hasVerifiedEmail())
<div class="rounded-[24px] border border-amber-200 bg-amber-50/80 p-5">
<div class="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
<div>
<p class="text-sm font-semibold text-amber-900">{{ __('Your email address is unverified.') }}</p>
<p class="mt-1 text-sm leading-6 text-amber-800/80">
{{ __('Verify it to keep your account secure and receive account-related notifications without interruption.') }}
</p>
@endif
</div>
<button type="submit" form="send-verification" class="account-secondary-button">
{{ __('Send verification email') }}
</button>
</div>
@endif
</div>
<div class="flex items-center gap-4">
<x-primary-button>{{ __('Save') }}</x-primary-button>
@if (session('status') === 'verification-link-sent')
<p class="mt-4 rounded-2xl bg-white/70 px-4 py-3 text-sm font-medium text-emerald-700 ring-1 ring-emerald-200">
{{ __('A new verification link has been sent to your email address.') }}
</p>
@endif
</div>
@endif
@if (session('status') === 'profile-updated')
<p
x-data="{ show: true }"
x-show="show"
x-transition
x-init="setTimeout(() => show = false, 2000)"
class="text-sm text-gray-600"
>{{ __('Saved.') }}</p>
@endif
<div class="flex flex-col gap-4 border-t border-slate-200/80 pt-6 sm:flex-row sm:items-center sm:justify-between">
<p class="max-w-2xl text-sm leading-6 text-slate-500">
{{ __('Keep these details accurate so your listings and messages always point back to the right account.') }}
</p>
<button type="submit" class="account-primary-button">
{{ __('Save Changes') }}
</button>
</div>
</form>
</section>

View File

@ -3,8 +3,8 @@
use Illuminate\Support\Facades\Route;
use Modules\User\App\Http\Controllers\ProfileController;
Route::middleware('auth')->prefix('profile')->name('profile.')->group(function () {
Route::get('/', [ProfileController::class, 'edit'])->name('edit');
Route::patch('/', [ProfileController::class, 'update'])->name('update');
Route::delete('/', [ProfileController::class, 'destroy'])->name('destroy');
Route::middleware('auth')->group(function () {
Route::redirect('/profile', '/panel/my-profile')->name('profile.edit');
Route::patch('/panel/my-profile', [ProfileController::class, 'update'])->name('profile.update');
Route::delete('/panel/my-profile', [ProfileController::class, 'destroy'])->name('profile.destroy');
});

View File

@ -1,48 +0,0 @@
<?php
namespace Modules\Video\Filament\Partner\Resources;
use BackedEnum;
use Filament\Facades\Filament;
use Filament\Resources\Resource;
use Filament\Schemas\Schema;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
use Modules\Video\Filament\Partner\Resources\VideoResource\Pages;
use Modules\Video\Models\Video;
use Modules\Video\Support\Filament\VideoFormSchema;
use Modules\Video\Support\Filament\VideoTableSchema;
class VideoResource extends Resource
{
protected static ?string $model = Video::class;
protected static string | BackedEnum | null $navigationIcon = 'heroicon-o-film';
protected static ?string $navigationLabel = 'Videos';
public static function form(Schema $schema): Schema
{
return $schema->schema(VideoFormSchema::resourceSchema(partnerScoped: true));
}
public static function table(Table $table): Table
{
return VideoTableSchema::configure($table, showOwner: false);
}
public static function getEloquentQuery(): Builder
{
return parent::getEloquentQuery()
->whereHas('listing', fn (Builder $query): Builder => $query->where('user_id', Filament::auth()->id()));
}
public static function getPages(): array
{
return [
'index' => Pages\ListVideos::route('/'),
'create' => Pages\CreateVideo::route('/create'),
'edit' => Pages\EditVideo::route('/{record}/edit'),
];
}
}

View File

@ -1,11 +0,0 @@
<?php
namespace Modules\Video\Filament\Partner\Resources\VideoResource\Pages;
use Filament\Resources\Pages\CreateRecord;
use Modules\Video\Filament\Partner\Resources\VideoResource;
class CreateVideo extends CreateRecord
{
protected static string $resource = VideoResource::class;
}

View File

@ -1,19 +0,0 @@
<?php
namespace Modules\Video\Filament\Partner\Resources\VideoResource\Pages;
use Filament\Actions\DeleteAction;
use Filament\Resources\Pages\EditRecord;
use Modules\Video\Filament\Partner\Resources\VideoResource;
class EditVideo extends EditRecord
{
protected static string $resource = VideoResource::class;
protected function getHeaderActions(): array
{
return [
DeleteAction::make(),
];
}
}

View File

@ -1,19 +0,0 @@
<?php
namespace Modules\Video\Filament\Partner\Resources\VideoResource\Pages;
use Filament\Actions\CreateAction;
use Filament\Resources\Pages\ListRecords;
use Modules\Video\Filament\Partner\Resources\VideoResource;
class ListVideos extends ListRecords
{
protected static string $resource = VideoResource::class;
protected function getHeaderActions(): array
{
return [
CreateAction::make(),
];
}
}

View File

@ -4,6 +4,7 @@ namespace Modules\Video\Models;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Livewire\Features\SupportFileUploads\TemporaryUploadedFile;
@ -74,6 +75,11 @@ class Video extends Model
return $query->orderBy('sort_order')->orderBy('id');
}
public function scopeOwnedByUser(Builder $query, int | string | null $userId): Builder
{
return $query->where('user_id', $userId);
}
public function scopeReady(Builder $query): Builder
{
return $query->where('status', VideoStatus::Ready->value);
@ -109,6 +115,29 @@ class Video extends Model
]);
}
public static function createFromUploadedFile(Listing $listing, UploadedFile $file, array $attributes = []): self
{
$disk = (string) 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'),
$disk,
);
return static::query()->create([
'listing_id' => $listing->getKey(),
'user_id' => $listing->user_id,
'title' => trim((string) ($attributes['title'] ?? pathinfo($file->getClientOriginalName(), PATHINFO_FILENAME))),
'description' => $attributes['description'] ?? null,
'upload_disk' => $disk,
'upload_path' => $path,
'mime_type' => $file->getMimeType(),
'size' => $file->getSize(),
'sort_order' => (int) ($attributes['sort_order'] ?? static::nextSortOrderForListing($listing)),
'is_active' => (bool) ($attributes['is_active'] ?? true),
]);
}
public static function nextSortOrderForListing(Listing $listing): int
{
return ((int) $listing->videos()->max('sort_order')) + 1;
@ -273,6 +302,20 @@ class Video extends Model
return number_format($value, $power === 0 ? 0 : 1).' '.$units[$power];
}
public function updateFromPanel(array $attributes): void
{
$this->forceFill([
'listing_id' => $attributes['listing_id'] ?? $this->listing_id,
'title' => array_key_exists('title', $attributes) ? trim((string) $attributes['title']) : $this->title,
'description' => array_key_exists('description', $attributes) ? $attributes['description'] : $this->description,
'is_active' => (bool) ($attributes['is_active'] ?? false),
])->save();
if (($attributes['video_file'] ?? null) instanceof UploadedFile) {
$this->replaceUploadFromUploadedFile($attributes['video_file']);
}
}
public function mobileOutputPath(): string
{
return trim(
@ -389,6 +432,23 @@ class Video extends Model
Storage::disk($disk)->delete($path);
}
protected function replaceUploadFromUploadedFile(UploadedFile $file): void
{
$disk = (string) config('video.disk', MediaStorage::activeDisk());
$path = $file->storeAs(
trim((string) config('video.upload_directory', 'videos/uploads').'/'.$this->listing_id, '/'),
Str::ulid().'.'.($file->getClientOriginalExtension() ?: $file->extension() ?: 'mp4'),
$disk,
);
$this->forceFill([
'upload_disk' => $disk,
'upload_path' => $path,
'mime_type' => $file->getMimeType(),
'size' => $file->getSize(),
])->save();
}
protected function currentStatus(): VideoStatus
{
return $this->status instanceof VideoStatus

View File

@ -167,13 +167,6 @@ Modules/
│ ├── AdminServiceProvider.php
│ └── AdminPanelProvider.php
├── Partner/ # FilamentPHP Tenant Panel
│ ├── Filament/
│ │ └── Resources/ # Tenant-scoped Listing resource
│ └── Providers/
│ ├── PartnerServiceProvider.php
│ └── PartnerPanelProvider.php
├── Category/ # Category management
│ ├── Models/Category.php
│ ├── Http/Controllers/
@ -202,7 +195,7 @@ Modules/
| Panel | URL | Access |
|-------|-----|--------|
| Admin | `/admin` | Users with `admin` role |
| Partner | `/partner/{id}` | All authenticated users (tenant-scoped) |
| Frontend Panel | `/panel` | All authenticated users |
### Roles (Spatie Permission)

View File

@ -4,9 +4,13 @@ namespace App\Http\Controllers;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Validation\Rule;
use Illuminate\View\View;
use Modules\Listing\Models\Listing;
use Modules\Listing\Support\ListingCustomFieldSchemaBuilder;
use Modules\Listing\Support\ListingPanelHelper;
use Modules\Video\Enums\VideoStatus;
use Modules\Video\Models\Video;
class PanelController extends Controller
{
@ -30,7 +34,8 @@ class PanelController extends Controller
$status = 'all';
}
$listings = $user->listings()
$listings = Listing::query()
->ownedByUser($user->getKey())
->with('category:id,name')
->withCount('favoritedByUsers')
->withCount('videos')
@ -42,27 +47,158 @@ class PanelController extends Controller
]),
])
->when($search !== '', fn ($query) => $query->where('title', 'like', "%{$search}%"))
->when($status !== 'all', fn ($query) => $query->where('status', $status))
->forPanelStatus($status)
->latest('id')
->paginate(10)
->withQueryString();
$statusCounts = $user->listings()
->selectRaw('status, COUNT(*) as aggregate')
->groupBy('status')
->pluck('aggregate', 'status');
$counts = [
'all' => (int) $statusCounts->sum(),
'sold' => (int) ($statusCounts['sold'] ?? 0),
'expired' => (int) ($statusCounts['expired'] ?? 0),
];
return view('panel.listings', [
'listings' => $listings,
'status' => $status,
'search' => $search,
'counts' => $counts,
'counts' => Listing::panelStatusCountsForUser($user->getKey()),
]);
}
public function editListing(Request $request, Listing $listing): View
{
$this->guardListingOwner($request, $listing);
return view('panel.edit-listing', [
'listing' => $listing->load(['category:id,name', 'videos:id,listing_id,title,status,is_active,path,upload_path,duration_seconds,size']),
'customFieldValues' => ListingCustomFieldSchemaBuilder::presentableValues(
$listing->category_id ? (int) $listing->category_id : null,
(array) $listing->custom_fields,
),
'statusOptions' => Listing::panelStatusOptions(),
]);
}
public function updateListing(Request $request, Listing $listing): RedirectResponse
{
$this->guardListingOwner($request, $listing);
$validated = $request->validate([
'title' => ['required', 'string', 'max:255'],
'description' => ['nullable', 'string', 'max:5000'],
'price' => ['nullable', 'numeric', 'min:0'],
'status' => ['required', Rule::in(array_keys(Listing::panelStatusOptions()))],
'contact_phone' => ['nullable', 'string', 'max:60'],
'contact_email' => ['nullable', 'email', 'max:255'],
'country' => ['nullable', 'string', 'max:255'],
'city' => ['nullable', 'string', 'max:255'],
'expires_at' => ['nullable', 'date'],
]);
$listing->updateFromPanel($validated + [
'currency' => $listing->currency ?: ListingPanelHelper::defaultCurrency(),
]);
return redirect()
->route('panel.listings.edit', $listing)
->with('success', 'Listing updated.');
}
public function videos(Request $request): View
{
$user = $request->user();
return view('panel.videos', [
'videos' => Video::query()
->ownedByUser($user->getKey())
->with('listing:id,title,user_id')
->latest('id')
->paginate(10)
->withQueryString(),
'listingOptions' => $user->listings()
->latest('id')
->get(['id', 'title', 'status']),
]);
}
public function storeVideo(Request $request): RedirectResponse
{
$validated = $request->validate([
'listing_id' => ['required', 'integer'],
'title' => ['nullable', 'string', 'max:255'],
'description' => ['nullable', 'string', 'max:2000'],
'video_file' => ['required', 'file', 'mimes:mp4,mov,webm,m4v', 'max:256000'],
]);
$listing = $request->user()->listings()->whereKey($validated['listing_id'])->firstOrFail();
$video = Video::createFromUploadedFile($listing, $request->file('video_file'), [
'title' => $validated['title'] ?? null,
'description' => $validated['description'] ?? null,
'sort_order' => Video::nextSortOrderForListing($listing),
'is_active' => true,
]);
return redirect()
->route('panel.videos.edit', $video)
->with('success', 'Video uploaded.');
}
public function editVideo(Request $request, Video $video): View
{
$this->guardVideoOwner($request, $video);
return view('panel.video-edit', [
'video' => $video->load('listing:id,title,user_id'),
'listingOptions' => $request->user()->listings()
->latest('id')
->get(['id', 'title', 'status']),
]);
}
public function updateVideo(Request $request, Video $video): RedirectResponse
{
$this->guardVideoOwner($request, $video);
$validated = $request->validate([
'listing_id' => ['required', 'integer'],
'title' => ['nullable', 'string', 'max:255'],
'description' => ['nullable', 'string', 'max:2000'],
'video_file' => ['nullable', 'file', 'mimes:mp4,mov,webm,m4v', 'max:256000'],
'is_active' => ['nullable', 'boolean'],
]);
$listing = $request->user()->listings()->whereKey($validated['listing_id'])->firstOrFail();
$video->updateFromPanel([
'listing_id' => $listing->getKey(),
'title' => $validated['title'] ?? null,
'description' => $validated['description'] ?? null,
'video_file' => $request->file('video_file'),
'is_active' => $request->boolean('is_active'),
]);
return redirect()
->route('panel.videos.edit', $video)
->with('success', 'Video updated.');
}
public function destroyVideo(Request $request, Video $video): RedirectResponse
{
$this->guardVideoOwner($request, $video);
$video->delete();
return redirect()
->route('panel.videos.index')
->with('success', 'Video deleted.');
}
public function profile(Request $request): View
{
$user = $request->user()->loadCount([
'listings',
'favoriteListings',
'favoriteSearches',
'favoriteSellers',
]);
return view('panel.profile', [
'user' => $user,
]);
}
@ -101,4 +237,11 @@ class PanelController extends Controller
abort(403);
}
}
private function guardVideoOwner(Request $request, Video $video): void
{
if ((int) $video->user_id !== (int) $request->user()->getKey()) {
abort(403);
}
}
}

View File

@ -216,11 +216,8 @@ class PanelQuickListingForm extends Component
$this->isPublishing = false;
session()->flash('success', 'Your listing has been created successfully.');
if (Route::has('filament.partner.resources.listings.edit')) {
$this->redirect(route('filament.partner.resources.listings.edit', [
'tenant' => $listing->user_id,
'record' => $listing,
]), navigate: true);
if (Route::has('panel.listings.edit')) {
$this->redirect(route('panel.listings.edit', $listing), navigate: true);
return;
}

View File

@ -3,5 +3,4 @@
return [
App\Providers\AppServiceProvider::class,
Modules\Admin\Providers\AdminPanelProvider::class,
Modules\Partner\Providers\PartnerPanelProvider::class,
];

View File

@ -3,7 +3,6 @@
"Listing": true,
"Location": true,
"Admin": true,
"Partner": true,
"Theme": true,
"Conversation": true,
"Favorite": true,

File diff suppressed because it is too large Load Diff

View File

@ -7,7 +7,7 @@
<div class="max-w-2xl mx-auto bg-white rounded-xl shadow-sm border border-gray-100 p-8 text-center">
<h1 class="text-2xl font-bold text-gray-900">Registration is currently disabled</h1>
<p class="mt-3 text-gray-600">
Partner registration is available only when at least one social login provider is enabled by the admin.
Registration is available only when at least one social login provider is enabled by the admin.
</p>
<div class="mt-6 flex items-center justify-center gap-3">
@ -15,7 +15,7 @@
Back Home
</a>
<a href="{{ route('login') }}" class="px-4 py-2 rounded-lg bg-blue-600 text-white hover:bg-blue-700">
Giriş Yap
Log in
</a>
</div>
</div>

View File

@ -15,8 +15,6 @@
$inboxRoute = auth()->check() ? route('panel.inbox.index') : $loginRoute;
$favoritesRoute = auth()->check() ? route('favorites.index') : $loginRoute;
$demoEnabled = (bool) config('demo.enabled');
$prepareDemoRoute = $demoEnabled ? route('demo.prepare') : null;
$prepareDemoRedirect = url()->full();
$hasDemoSession = (bool) session('is_demo_session') || filled(session('demo_uuid'));
$demoLandingMode = $demoEnabled && request()->routeIs('home') && !auth()->check() && !$hasDemoSession;
$demoExpiresAt = session('demo_expires_at');
@ -191,13 +189,6 @@
<button type="submit" class="oc-text-link">{{ __('messages.logout') }}</button>
</form>
@else
@if(!$demoLandingMode && $demoEnabled && $prepareDemoRoute)
<form method="POST" action="{{ $prepareDemoRoute }}" class="oc-demo-prepare">
@csrf
<input type="hidden" name="redirect_to" value="{{ $prepareDemoRedirect }}">
<button type="submit" class="oc-text-link oc-auth-link">Prepare Demo</button>
</form>
@endif
@if(!$demoLandingMode)
<a href="{{ $loginRoute }}" class="oc-text-link oc-auth-link">
{{ __('messages.login') }}
@ -279,18 +270,6 @@
</svg>
</a>
@endif
@if($demoEnabled && $prepareDemoRoute)
<form method="POST" action="{{ $prepareDemoRoute }}" class="w-full">
@csrf
<input type="hidden" name="redirect_to" value="{{ $prepareDemoRedirect }}">
<button type="submit" class="oc-mobile-menu-link w-full text-left">
<span>Prepare Demo</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>
</button>
</form>
@endif
@endauth
</div>
</div>
@ -822,7 +801,10 @@
if (!matchedCity && !citySelect.disabled && citySelect.options.length > 1) {
if (statusText) {
statusText.textContent = 'Country was selected, but the city could not be matched automatically. Please choose your city.';
const returnedCity = guessed.cityName || guessed.regionName || guessed.districtName;
statusText.textContent = returnedCity
? `Country was selected, but the returned city "${returnedCity}" could not be matched automatically. Please choose your city.`
: 'Country was selected, but the city could not be matched automatically. Please choose your city.';
}
const details = root.closest('details');

View File

@ -34,7 +34,7 @@
</x-slot>
<x-slot name="content">
<x-dropdown-link :href="route('profile.edit')">
<x-dropdown-link :href="route('panel.profile.edit')">
{{ __('Profile') }}
</x-dropdown-link>
@ -80,7 +80,7 @@
</div>
<div class="mt-3 space-y-1">
<x-responsive-nav-link :href="route('profile.edit')">
<x-responsive-nav-link :href="route('panel.profile.edit')">
{{ __('Profile') }}
</x-responsive-nav-link>

View File

@ -0,0 +1,165 @@
@extends('app::layouts.app')
@section('title', 'Edit Listing')
@section('content')
<div class="max-w-[1320px] mx-auto px-4 py-8">
<div class="grid grid-cols-1 lg:grid-cols-[220px,1fr] gap-4">
@include('panel.partials.sidebar', ['activeMenu' => 'listings'])
<section class="space-y-4">
<div class="panel-surface p-6">
<div class="flex flex-col gap-1 mb-5">
<h1 class="text-2xl font-semibold text-slate-900">Edit Listing</h1>
<p class="text-sm text-slate-500">Update the core listing details without leaving the frontend panel.</p>
</div>
@if (session('success'))
<div class="mb-4 rounded-2xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm font-medium text-emerald-700">
{{ session('success') }}
</div>
@endif
<form method="POST" action="{{ route('panel.listings.update', $listing) }}" class="grid grid-cols-1 xl:grid-cols-2 gap-4">
@csrf
@method('PUT')
<label class="block xl:col-span-2">
<span class="mb-2 block text-sm font-medium text-slate-700">Title</span>
<input type="text" name="title" value="{{ old('title', $listing->title) }}" class="w-full rounded-2xl border border-slate-300 px-4 py-3 text-sm text-slate-800 focus:border-slate-400 focus:outline-none">
@error('title')
<p class="mt-2 text-sm text-rose-600">{{ $message }}</p>
@enderror
</label>
<label class="block xl:col-span-2">
<span class="mb-2 block text-sm font-medium text-slate-700">Description</span>
<textarea name="description" rows="6" class="w-full rounded-3xl border border-slate-300 px-4 py-3 text-sm text-slate-800 focus:border-slate-400 focus:outline-none">{{ old('description', $listing->description) }}</textarea>
@error('description')
<p class="mt-2 text-sm text-rose-600">{{ $message }}</p>
@enderror
</label>
<label class="block">
<span class="mb-2 block text-sm font-medium text-slate-700">Price</span>
<input type="number" step="0.01" min="0" name="price" value="{{ old('price', $listing->price) }}" class="w-full rounded-2xl border border-slate-300 px-4 py-3 text-sm text-slate-800 focus:border-slate-400 focus:outline-none">
@error('price')
<p class="mt-2 text-sm text-rose-600">{{ $message }}</p>
@enderror
</label>
<label class="block">
<span class="mb-2 block text-sm font-medium text-slate-700">Status</span>
<select name="status" class="w-full rounded-2xl border border-slate-300 px-4 py-3 text-sm text-slate-800 focus:border-slate-400 focus:outline-none">
@foreach($statusOptions as $value => $label)
<option value="{{ $value }}" @selected(old('status', $listing->statusValue()) === $value)>{{ $label }}</option>
@endforeach
</select>
@error('status')
<p class="mt-2 text-sm text-rose-600">{{ $message }}</p>
@enderror
</label>
<label class="block">
<span class="mb-2 block text-sm font-medium text-slate-700">Email</span>
<input type="email" name="contact_email" value="{{ old('contact_email', $listing->contact_email) }}" class="w-full rounded-2xl border border-slate-300 px-4 py-3 text-sm text-slate-800 focus:border-slate-400 focus:outline-none">
@error('contact_email')
<p class="mt-2 text-sm text-rose-600">{{ $message }}</p>
@enderror
</label>
<label class="block">
<span class="mb-2 block text-sm font-medium text-slate-700">Phone</span>
<input type="text" name="contact_phone" value="{{ old('contact_phone', $listing->contact_phone) }}" class="w-full rounded-2xl border border-slate-300 px-4 py-3 text-sm text-slate-800 focus:border-slate-400 focus:outline-none">
@error('contact_phone')
<p class="mt-2 text-sm text-rose-600">{{ $message }}</p>
@enderror
</label>
<label class="block">
<span class="mb-2 block text-sm font-medium text-slate-700">Country</span>
<input type="text" name="country" value="{{ old('country', $listing->country) }}" class="w-full rounded-2xl border border-slate-300 px-4 py-3 text-sm text-slate-800 focus:border-slate-400 focus:outline-none">
@error('country')
<p class="mt-2 text-sm text-rose-600">{{ $message }}</p>
@enderror
</label>
<label class="block">
<span class="mb-2 block text-sm font-medium text-slate-700">City</span>
<input type="text" name="city" value="{{ old('city', $listing->city) }}" class="w-full rounded-2xl border border-slate-300 px-4 py-3 text-sm text-slate-800 focus:border-slate-400 focus:outline-none">
@error('city')
<p class="mt-2 text-sm text-rose-600">{{ $message }}</p>
@enderror
</label>
<label class="block">
<span class="mb-2 block text-sm font-medium text-slate-700">Expires at</span>
<input type="date" name="expires_at" value="{{ old('expires_at', $listing->expires_at?->format('Y-m-d')) }}" class="w-full rounded-2xl border border-slate-300 px-4 py-3 text-sm text-slate-800 focus:border-slate-400 focus:outline-none">
@error('expires_at')
<p class="mt-2 text-sm text-rose-600">{{ $message }}</p>
@enderror
</label>
<div class="xl:col-span-2 flex flex-wrap items-center gap-3">
<button type="submit" class="inline-flex items-center justify-center rounded-full bg-slate-900 px-6 py-3 text-sm font-semibold text-white transition hover:bg-slate-800">
Save changes
</button>
<a href="{{ route('panel.videos.index') }}" class="inline-flex items-center justify-center rounded-full border border-slate-300 px-6 py-3 text-sm font-semibold text-slate-700 transition hover:bg-slate-50">
Manage videos
</a>
</div>
</form>
</div>
<div class="grid grid-cols-1 xl:grid-cols-[1.2fr,0.8fr] gap-4">
<div class="panel-surface p-6">
<h2 class="text-lg font-semibold text-slate-900">Photos</h2>
<div class="mt-4 grid grid-cols-2 md:grid-cols-3 gap-3">
@forelse($listing->getMedia('listing-images') as $media)
<img src="{{ $media->getUrl() }}" alt="{{ $listing->title }}" class="h-32 w-full rounded-2xl object-cover">
@empty
<div class="col-span-full rounded-3xl border border-dashed border-slate-300 px-6 py-12 text-center text-sm text-slate-500">
No photos on this listing.
</div>
@endforelse
</div>
</div>
<div class="panel-surface p-6 space-y-5">
<div>
<h2 class="text-lg font-semibold text-slate-900">Listing info</h2>
<dl class="mt-4 space-y-3 text-sm text-slate-600">
<div class="flex items-start justify-between gap-4">
<dt>Category</dt>
<dd class="text-right font-semibold text-slate-900">{{ $listing->category?->name ?? '-' }}</dd>
</div>
<div class="flex items-start justify-between gap-4">
<dt>Status</dt>
<dd class="text-right font-semibold text-slate-900">{{ $listing->statusLabel() }}</dd>
</div>
<div class="flex items-start justify-between gap-4">
<dt>Videos</dt>
<dd class="text-right font-semibold text-slate-900">{{ $listing->videos->count() }}</dd>
</div>
</dl>
</div>
<div>
<h3 class="text-sm font-semibold uppercase tracking-[0.16em] text-slate-400">Custom fields</h3>
<div class="mt-3 space-y-2">
@forelse($customFieldValues as $field)
<div class="rounded-2xl bg-slate-50 px-4 py-3">
<p class="text-xs font-semibold uppercase tracking-[0.14em] text-slate-400">{{ $field['label'] }}</p>
<p class="mt-1 text-sm font-medium text-slate-800">{{ $field['value'] }}</p>
</div>
@empty
<p class="text-sm text-slate-500">No category-specific fields stored on this listing.</p>
@endforelse
</div>
</div>
</div>
</div>
</section>
</div>
</div>
@endsection

View File

@ -37,13 +37,8 @@
$priceLabel = !is_null($listing->price)
? number_format((float) $listing->price, 2, ',', '.').' '.($listing->currency ?? 'TL')
: 'Ücretsiz';
$statusLabel = match ((string) $listing->status) {
'sold' => 'Satıldı',
'expired' => 'Süresi Dolmuş',
'pending' => 'Onay Bekliyor',
default => 'Yayında',
};
$statusBadgeClass = match ((string) $listing->status) {
$statusLabel = $listing->statusLabel();
$statusBadgeClass = match ($listing->statusValue()) {
'sold' => 'bg-emerald-100 text-emerald-700',
'expired' => 'bg-rose-100 text-rose-700',
'pending' => 'bg-amber-100 text-amber-700',
@ -59,11 +54,13 @@
<article class="panel-list-card">
<div class="panel-list-card-body">
<div class="panel-list-media bg-slate-200">
@if($listingImage)
<img src="{{ $listingImage }}" alt="{{ $listing->title }}" class="w-full h-full object-cover">
@else
<div class="w-full h-full grid place-items-center text-slate-400">Görsel yok</div>
@endif
<a href="{{ route('listings.show', $listing) }}" class="block w-full h-full" aria-label="{{ $listing->title }}">
@if($listingImage)
<img src="{{ $listingImage }}" alt="{{ $listing->title }}" class="w-full h-full object-cover">
@else
<div class="w-full h-full grid place-items-center text-slate-400">Görsel yok</div>
@endif
</a>
</div>
<div class="panel-list-main">
@ -74,36 +71,45 @@
<h2 class="panel-list-title text-slate-800">{{ $listing->title }}</h2>
<div class="panel-list-actions">
@if(Route::has('filament.partner.resources.listings.edit'))
<a href="{{ route('filament.partner.resources.listings.edit', ['tenant' => auth()->id(), 'record' => $listing]) }}" class="panel-action-btn panel-action-btn-secondary">
İlanı Düzenle
</a>
@endif
<details class="relative">
<summary class="inline-flex cursor-pointer list-none items-center gap-2 rounded-full border border-slate-300 px-4 py-2 text-sm font-semibold text-slate-700 transition hover:bg-slate-50">
İşlemler
<svg class="h-4 w-4 text-slate-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.8" d="m6 9 6 6 6-6"/>
</svg>
</summary>
<form method="POST" action="{{ route('panel.listings.destroy', $listing) }}">
@csrf
<button type="submit" class="panel-action-btn panel-action-btn-secondary">
İlanı Kaldır
</button>
</form>
<div class="absolute left-0 top-full z-10 mt-2 min-w-52 overflow-hidden rounded-2xl border border-slate-200 bg-white p-2 shadow-xl">
<a href="{{ route('panel.listings.edit', $listing) }}" class="block rounded-xl px-3 py-2 text-sm font-medium text-slate-700 transition hover:bg-slate-50">
İlanı Düzenle
</a>
@if((string) $listing->status !== 'sold')
<form method="POST" action="{{ route('panel.listings.mark-sold', $listing) }}">
@csrf
<button type="submit" class="panel-action-btn panel-action-btn-primary">
Satıldı İşaretle
</button>
</form>
@endif
<form method="POST" action="{{ route('panel.listings.destroy', $listing) }}">
@csrf
<button type="submit" class="block w-full rounded-xl px-3 py-2 text-left text-sm font-medium text-slate-700 transition hover:bg-slate-50">
İlanı Kaldır
</button>
</form>
@if((string) $listing->status === 'expired')
<form method="POST" action="{{ route('panel.listings.republish', $listing) }}">
@csrf
<button type="submit" class="panel-action-btn panel-action-btn-secondary">
Yeniden Yayınla
</button>
</form>
@endif
@if($listing->statusValue() !== 'sold')
<form method="POST" action="{{ route('panel.listings.mark-sold', $listing) }}">
@csrf
<button type="submit" class="block w-full rounded-xl px-3 py-2 text-left text-sm font-medium text-slate-700 transition hover:bg-slate-50">
Satıldı İşaretle
</button>
</form>
@endif
@if($listing->statusValue() === 'expired')
<form method="POST" action="{{ route('panel.listings.republish', $listing) }}">
@csrf
<button type="submit" class="block w-full rounded-xl px-3 py-2 text-left text-sm font-medium text-slate-700 transition hover:bg-slate-50">
Yeniden Yayınla
</button>
</form>
@endif
</div>
</details>
</div>
</div>
@ -126,12 +132,14 @@
</strong>
</p>
@if($videoCount > 0)
<p class="panel-list-dates">
Video Durumu:
<strong class="text-slate-700">
{{ $videoCount }} toplam, {{ $readyVideoCount }} hazır, {{ $pendingVideoCount }} işleniyor
</strong>
</p>
@endif
</div>
</div>

View File

@ -1,33 +1,124 @@
@php
$activeMenu = $activeMenu ?? '';
$activeFavoritesTab = $activeFavoritesTab ?? '';
$primaryItems = [
[
'label' => 'Sell',
'route' => route('panel.listings.create'),
'key' => 'create',
],
[
'label' => 'My Listings',
'route' => route('panel.listings.index'),
'key' => 'listings',
],
[
'label' => 'Videos',
'route' => route('panel.videos.index'),
'key' => 'videos',
],
[
'label' => 'Inbox',
'route' => route('panel.inbox.index'),
'key' => 'inbox',
],
[
'label' => 'My Profile',
'route' => route('panel.profile.edit'),
'key' => 'profile',
],
];
$favoriteItems = [
[
'label' => 'Saved Listings',
'route' => route('favorites.index', ['tab' => 'listings']),
'key' => 'listings',
],
[
'label' => 'Saved Searches',
'route' => route('favorites.index', ['tab' => 'searches']),
'key' => 'searches',
],
[
'label' => 'Saved Sellers',
'route' => route('favorites.index', ['tab' => 'sellers']),
'key' => 'sellers',
],
];
$favoritesActive = $activeMenu === 'favorites' || $activeFavoritesTab !== '';
@endphp
<aside class="bg-white border border-slate-200 rounded-xl overflow-hidden">
<a href="{{ route('panel.listings.create') }}" class="block px-5 py-4 text-base {{ $activeMenu === 'create' ? 'bg-rose-50 text-rose-600 font-semibold' : 'text-slate-700 hover:bg-slate-50' }}">
Sell
</a>
<a href="{{ route('panel.listings.index') }}" class="block px-5 py-4 border-t border-slate-200 text-base {{ $activeMenu === 'listings' ? 'bg-rose-50 text-rose-600 font-semibold' : 'text-slate-700 hover:bg-slate-50' }}">
My Listings
</a>
@if (Route::has('filament.partner.resources.videos.index'))
<a href="{{ route('filament.partner.resources.videos.index', ['tenant' => auth()->id()]) }}" class="block px-5 py-4 border-t border-slate-200 text-base {{ $activeMenu === 'videos' ? 'bg-rose-50 text-rose-600 font-semibold' : 'text-slate-700 hover:bg-slate-50' }}">
Videos
</a>
@endif
<a href="{{ route('favorites.index', ['tab' => 'listings']) }}" class="block px-5 py-4 border-t border-slate-200 text-base {{ $activeMenu === 'favorites' ? 'bg-rose-50 text-rose-600 font-semibold' : 'text-slate-700 hover:bg-slate-50' }}">
Favorites
</a>
<a href="{{ route('favorites.index', ['tab' => 'listings']) }}" class="block px-9 py-3 border-t border-slate-100 text-sm {{ $activeFavoritesTab === 'listings' ? 'bg-rose-50 text-rose-600 font-semibold' : 'text-slate-600 hover:bg-slate-50' }}">
Saved Listings
</a>
<a href="{{ route('favorites.index', ['tab' => 'searches']) }}" class="block px-9 py-3 border-t border-slate-100 text-sm {{ $activeFavoritesTab === 'searches' ? 'bg-rose-50 text-rose-600 font-semibold' : 'text-slate-600 hover:bg-slate-50' }}">
Saved Searches
</a>
<a href="{{ route('favorites.index', ['tab' => 'sellers']) }}" class="block px-9 py-3 border-t border-slate-100 text-sm {{ $activeFavoritesTab === 'sellers' ? 'bg-rose-50 text-rose-600 font-semibold' : 'text-slate-600 hover:bg-slate-50' }}">
Saved Sellers
</a>
<a href="{{ route('panel.inbox.index') }}" class="block px-5 py-4 border-t border-slate-200 text-base {{ $activeMenu === 'inbox' ? 'bg-rose-50 text-rose-600 font-semibold' : 'text-slate-700 hover:bg-slate-50' }}">
Inbox
</a>
<aside class="panel-side-nav rounded-[28px] border border-slate-200/80 bg-white/90 p-3 shadow-[0_20px_48px_rgba(15,23,42,0.08)] backdrop-blur">
<div class="px-3 pb-3 pt-2">
<p class="text-[0.68rem] font-semibold uppercase tracking-[0.26em] text-slate-400">Workspace</p>
<p class="mt-2 text-lg font-semibold text-slate-900">Manage your account</p>
<p class="mt-1 text-sm leading-6 text-slate-500">Listings, saved items, inbox, and profile settings live here.</p>
</div>
<nav class="space-y-1.5">
@foreach ($primaryItems as $item)
<a
href="{{ $item['route'] }}"
data-level="primary"
@class([
'group flex items-center justify-between gap-3 rounded-2xl px-4 py-3.5 text-sm font-semibold transition',
'bg-slate-900 text-white shadow-[0_16px_30px_rgba(15,23,42,0.16)]' => $activeMenu === $item['key'],
'text-slate-700 hover:bg-slate-50 hover:text-slate-900' => $activeMenu !== $item['key'],
])
>
<span>{{ $item['label'] }}</span>
<span
@class([
'inline-flex h-7 min-w-7 items-center justify-center rounded-full px-2 text-[0.65rem] font-bold uppercase tracking-[0.18em]',
'bg-white/16 text-white' => $activeMenu === $item['key'],
'bg-slate-100 text-slate-400 group-hover:bg-white group-hover:text-slate-700' => $activeMenu !== $item['key'],
])
>
{{ $activeMenu === $item['key'] ? 'Open' : 'Go' }}
</span>
</a>
@endforeach
<div class="rounded-[22px] bg-slate-50/80 p-2">
<a
href="{{ route('favorites.index', ['tab' => 'listings']) }}"
data-level="primary"
@class([
'flex items-center justify-between gap-3 rounded-2xl px-4 py-3.5 text-sm font-semibold transition',
'bg-white text-slate-900 shadow-sm ring-1 ring-slate-200' => $favoritesActive,
'text-slate-700 hover:bg-white hover:text-slate-900' => ! $favoritesActive,
])
>
<span>Favorites</span>
<span
@class([
'inline-flex h-7 min-w-7 items-center justify-center rounded-full px-2 text-[0.65rem] font-bold uppercase tracking-[0.18em]',
'bg-sky-100 text-sky-700' => $favoritesActive,
'bg-slate-200 text-slate-500' => ! $favoritesActive,
])
>
{{ $favoritesActive ? 'On' : 'View' }}
</span>
</a>
<div class="mt-2 space-y-1">
@foreach ($favoriteItems as $item)
<a
href="{{ $item['route'] }}"
data-level="secondary"
@class([
'flex items-center justify-between gap-3 rounded-xl px-4 py-2.5 text-sm transition',
'bg-white text-slate-900 ring-1 ring-slate-200' => $activeFavoritesTab === $item['key'],
'text-slate-500 hover:bg-white hover:text-slate-800' => $activeFavoritesTab !== $item['key'],
])
>
<span>{{ $item['label'] }}</span>
<svg class="h-4 w-4 shrink-0" 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>
</nav>
</aside>

View File

@ -0,0 +1,113 @@
@extends('app::layouts.app')
@section('title', 'My Profile')
@section('content')
@php
$displayName = trim((string) ($user->name ?: 'User'));
$initialSeed = trim((string) ($displayName ?: $user->email ?: 'U'));
$initials = collect(preg_split('/\s+/', $initialSeed) ?: [])
->filter()
->take(2)
->map(fn (string $segment): string => mb_strtoupper(mb_substr($segment, 0, 1)))
->implode('');
$memberSince = $user->created_at?->format('M Y');
$stats = [
[
'label' => 'Listings',
'value' => (int) ($user->listings_count ?? 0),
'hint' => 'Ads you manage from your dashboard.',
],
[
'label' => 'Saved Listings',
'value' => (int) ($user->favorite_listings_count ?? 0),
'hint' => 'Items you bookmarked for later.',
],
[
'label' => 'Saved Searches',
'value' => (int) ($user->favorite_searches_count ?? 0),
'hint' => 'Searches you can revisit instantly.',
],
[
'label' => 'Saved Sellers',
'value' => (int) ($user->favorite_sellers_count ?? 0),
'hint' => 'Sellers you want to keep an eye on.',
],
];
@endphp
<div class="profile-page mx-auto max-w-[1320px] px-4 py-6 md:py-8">
<div class="grid gap-6 xl:grid-cols-[300px,minmax(0,1fr)]">
<aside class="profile-side-nav space-y-6">
<div class="relative overflow-hidden rounded-[30px] border border-slate-200/80 bg-white/90 p-6 shadow-[0_20px_55px_rgba(15,23,42,0.08)] backdrop-blur">
<div class="absolute inset-x-0 top-0 h-24 bg-gradient-to-r from-sky-500 via-blue-500 to-cyan-400"></div>
<div class="relative">
<div class="flex items-start gap-4">
<div class="flex h-16 w-16 shrink-0 items-center justify-center rounded-[22px] bg-slate-900 text-xl font-semibold tracking-tight text-white shadow-[0_16px_30px_rgba(15,23,42,0.2)]">
{{ $initials !== '' ? $initials : 'U' }}
</div>
<div class="min-w-0 pt-1">
<p class="text-[0.68rem] font-semibold uppercase tracking-[0.26em] text-slate-400">My account</p>
<h1 class="mt-2 text-[1.9rem] font-semibold leading-tight text-slate-950">{{ $displayName }}</h1>
<p class="mt-1 break-all text-sm text-slate-600">{{ $user->email }}</p>
</div>
</div>
<div class="mt-5 flex flex-wrap gap-2">
<span @class([
'inline-flex items-center rounded-full px-3 py-1.5 text-xs font-semibold',
'bg-emerald-50 text-emerald-700 ring-1 ring-emerald-200' => $user->hasVerifiedEmail(),
'bg-amber-50 text-amber-700 ring-1 ring-amber-200' => ! $user->hasVerifiedEmail(),
])>
{{ $user->hasVerifiedEmail() ? 'Email verified' : 'Verification pending' }}
</span>
@if ($memberSince)
<span class="inline-flex items-center rounded-full bg-slate-100 px-3 py-1.5 text-xs font-semibold text-slate-600 ring-1 ring-slate-200">
Member since {{ $memberSince }}
</span>
@endif
</div>
<div class="mt-6 rounded-[24px] bg-slate-950 px-5 py-4 text-white shadow-[0_18px_38px_rgba(15,23,42,0.22)]">
<p class="text-[0.68rem] font-semibold uppercase tracking-[0.26em] text-slate-300">Profile visibility</p>
<p class="mt-2 text-sm leading-6 text-slate-200">
Keep your name and email current so buyers can recognize you quickly in conversations and listing activity.
</p>
</div>
</div>
</div>
@include('panel.partials.sidebar', ['activeMenu' => 'profile'])
</aside>
<section class="space-y-6">
<div class="grid gap-4 sm:grid-cols-2 2xl:grid-cols-4">
@foreach ($stats as $stat)
<div class="rounded-[26px] border border-slate-200/80 bg-white/90 p-5 shadow-[0_16px_40px_rgba(15,23,42,0.06)] backdrop-blur">
<p class="text-sm font-semibold text-slate-500">{{ $stat['label'] }}</p>
<p class="mt-3 text-4xl font-semibold tracking-[-0.04em] text-slate-950">{{ number_format($stat['value']) }}</p>
<p class="mt-2 text-sm leading-6 text-slate-500">{{ $stat['hint'] }}</p>
</div>
@endforeach
</div>
<div class="grid gap-6 2xl:grid-cols-[minmax(0,1.2fr),minmax(0,0.8fr)]">
<div class="panel-surface profile-card">
@include('user::profile.partials.update-profile-information-form')
</div>
<div class="panel-surface profile-card">
@include('user::profile.partials.update-password-form')
</div>
</div>
<div class="panel-surface profile-card profile-card-danger">
@include('user::profile.partials.delete-user-form')
</div>
</section>
</div>
</div>
@endsection

View File

@ -0,0 +1,127 @@
@extends('app::layouts.app')
@section('title', 'Edit Video')
@section('content')
<div class="max-w-[1320px] mx-auto px-4 py-8">
<div class="grid grid-cols-1 lg:grid-cols-[220px,1fr] gap-4">
@include('panel.partials.sidebar', ['activeMenu' => 'videos'])
<section class="space-y-4">
<div class="panel-surface p-6">
<div class="flex flex-col gap-1 mb-5">
<h1 class="text-2xl font-semibold text-slate-900">Edit Video</h1>
<p class="text-sm text-slate-500">Update listing assignment, title, status, or replace the source file.</p>
</div>
@if (session('success'))
<div class="mb-4 rounded-2xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm font-medium text-emerald-700">
{{ session('success') }}
</div>
@endif
<form method="POST" action="{{ route('panel.videos.update', $video) }}" enctype="multipart/form-data" class="grid grid-cols-1 xl:grid-cols-2 gap-4">
@csrf
@method('PUT')
<label class="block">
<span class="mb-2 block text-sm font-medium text-slate-700">Listing</span>
<select name="listing_id" class="w-full rounded-2xl border border-slate-300 px-4 py-3 text-sm text-slate-800 focus:border-slate-400 focus:outline-none">
@foreach($listingOptions as $listingOption)
<option value="{{ $listingOption->id }}" @selected((int) old('listing_id', $video->listing_id) === (int) $listingOption->id)>
{{ $listingOption->title }}
</option>
@endforeach
</select>
@error('listing_id')
<p class="mt-2 text-sm text-rose-600">{{ $message }}</p>
@enderror
</label>
<label class="block">
<span class="mb-2 block text-sm font-medium text-slate-700">Title</span>
<input type="text" name="title" value="{{ old('title', $video->title) }}" class="w-full rounded-2xl border border-slate-300 px-4 py-3 text-sm text-slate-800 focus:border-slate-400 focus:outline-none">
@error('title')
<p class="mt-2 text-sm text-rose-600">{{ $message }}</p>
@enderror
</label>
<label class="block xl:col-span-2">
<span class="mb-2 block text-sm font-medium text-slate-700">Description</span>
<textarea name="description" rows="4" class="w-full rounded-3xl border border-slate-300 px-4 py-3 text-sm text-slate-800 focus:border-slate-400 focus:outline-none">{{ old('description', $video->description) }}</textarea>
@error('description')
<p class="mt-2 text-sm text-rose-600">{{ $message }}</p>
@enderror
</label>
<label class="block xl:col-span-2">
<span class="mb-2 block text-sm font-medium text-slate-700">Replace file</span>
<input type="file" name="video_file" accept="video/mp4,video/quicktime,video/webm,video/x-m4v,.mp4,.mov,.webm,.m4v" class="block w-full rounded-2xl border border-slate-300 px-4 py-3 text-sm text-slate-800 file:mr-3 file:rounded-full file:border-0 file:bg-slate-100 file:px-3 file:py-2 file:text-sm file:font-semibold file:text-slate-700">
@error('video_file')
<p class="mt-2 text-sm text-rose-600">{{ $message }}</p>
@enderror
</label>
<label class="inline-flex items-center gap-3">
<input type="checkbox" name="is_active" value="1" @checked(old('is_active', $video->is_active)) class="h-5 w-5 rounded border-slate-300 text-slate-900 focus:ring-slate-400">
<span class="text-sm font-medium text-slate-700">Visible on listing page</span>
</label>
<div class="flex flex-wrap items-center gap-3 xl:col-span-2">
<button type="submit" class="inline-flex items-center justify-center rounded-full bg-slate-900 px-6 py-3 text-sm font-semibold text-white transition hover:bg-slate-800">
Save changes
</button>
<a href="{{ route('panel.videos.index') }}" class="inline-flex items-center justify-center rounded-full border border-slate-300 px-6 py-3 text-sm font-semibold text-slate-700 transition hover:bg-slate-50">
Back to videos
</a>
</div>
</form>
</div>
<div class="panel-surface p-6">
<div class="grid grid-cols-1 xl:grid-cols-[1fr,320px] gap-6 items-start">
<div>
<h2 class="text-lg font-semibold text-slate-900">Current file</h2>
<div class="mt-4 rounded-3xl border border-slate-200 bg-slate-100 p-4">
@if($video->playableUrl())
<video controls preload="metadata" class="w-full rounded-2xl bg-black">
<source src="{{ $video->playableUrl() }}" type="{{ $video->previewMimeType() }}">
</video>
@else
<div class="grid min-h-48 place-items-center rounded-2xl border border-dashed border-slate-300 bg-white text-sm text-slate-500">
Video preview is not available yet.
</div>
@endif
</div>
</div>
<div class="rounded-3xl border border-slate-200 bg-slate-50 p-5 text-sm text-slate-600">
<dl class="space-y-3">
<div class="flex items-center justify-between gap-4">
<dt>Status</dt>
<dd class="font-semibold text-slate-900">{{ $video->statusLabel() }}</dd>
</div>
<div class="flex items-center justify-between gap-4">
<dt>Duration</dt>
<dd class="font-semibold text-slate-900">{{ $video->durationLabel() }}</dd>
</div>
<div class="flex items-center justify-between gap-4">
<dt>Resolution</dt>
<dd class="font-semibold text-slate-900">{{ $video->resolutionLabel() }}</dd>
</div>
<div class="flex items-center justify-between gap-4">
<dt>Size</dt>
<dd class="font-semibold text-slate-900">{{ $video->sizeLabel() }}</dd>
</div>
<div class="flex items-center justify-between gap-4">
<dt>Listing</dt>
<dd class="font-semibold text-slate-900 text-right">{{ $video->listing?->title ?? '-' }}</dd>
</div>
</dl>
</div>
</div>
</div>
</section>
</div>
</div>
@endsection

View File

@ -0,0 +1,123 @@
@extends('app::layouts.app')
@section('title', 'Videos')
@section('content')
<div class="max-w-[1320px] mx-auto px-4 py-8">
<div class="grid grid-cols-1 lg:grid-cols-[220px,1fr] gap-4">
@include('panel.partials.sidebar', ['activeMenu' => 'videos'])
<section class="space-y-4">
<div class="panel-surface p-6">
<div class="flex flex-col gap-1 mb-5">
<h1 class="text-2xl font-semibold text-slate-900">Videos</h1>
<p class="text-sm text-slate-500">Upload listing videos and manage processing from the frontend panel.</p>
</div>
@if (session('success'))
<div class="mb-4 rounded-2xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm font-medium text-emerald-700">
{{ session('success') }}
</div>
@endif
<form method="POST" action="{{ route('panel.videos.store') }}" enctype="multipart/form-data" class="grid grid-cols-1 xl:grid-cols-[1.1fr,1.1fr,0.8fr,auto] gap-3 items-end">
@csrf
<label class="block">
<span class="mb-2 block text-sm font-medium text-slate-700">Listing</span>
<select name="listing_id" class="w-full rounded-2xl border border-slate-300 px-4 py-3 text-sm text-slate-800 focus:border-slate-400 focus:outline-none">
<option value="">Select listing</option>
@foreach($listingOptions as $listingOption)
<option value="{{ $listingOption->id }}" @selected((int) old('listing_id') === (int) $listingOption->id)>
{{ $listingOption->title }}
</option>
@endforeach
</select>
@error('listing_id')
<p class="mt-2 text-sm text-rose-600">{{ $message }}</p>
@enderror
</label>
<label class="block">
<span class="mb-2 block text-sm font-medium text-slate-700">Title</span>
<input type="text" name="title" value="{{ old('title') }}" class="w-full rounded-2xl border border-slate-300 px-4 py-3 text-sm text-slate-800 focus:border-slate-400 focus:outline-none" placeholder="Short video title">
@error('title')
<p class="mt-2 text-sm text-rose-600">{{ $message }}</p>
@enderror
</label>
<label class="block">
<span class="mb-2 block text-sm font-medium text-slate-700">Video file</span>
<input type="file" name="video_file" accept="video/mp4,video/quicktime,video/webm,video/x-m4v,.mp4,.mov,.webm,.m4v" class="block w-full rounded-2xl border border-slate-300 px-4 py-3 text-sm text-slate-800 file:mr-3 file:rounded-full file:border-0 file:bg-slate-100 file:px-3 file:py-2 file:text-sm file:font-semibold file:text-slate-700">
@error('video_file')
<p class="mt-2 text-sm text-rose-600">{{ $message }}</p>
@enderror
</label>
<button type="submit" class="inline-flex h-[52px] items-center justify-center rounded-full bg-slate-900 px-6 text-sm font-semibold text-white transition hover:bg-slate-800">
Upload
</button>
</form>
</div>
<div class="panel-surface overflow-hidden">
<div class="border-b border-slate-200 px-6 py-4">
<h2 class="text-lg font-semibold text-slate-900">My videos</h2>
</div>
<div class="divide-y divide-slate-200">
@forelse($videos as $video)
@php
$statusClass = match ((string) $video->status?->value) {
'ready' => 'bg-emerald-100 text-emerald-700',
'failed' => 'bg-rose-100 text-rose-700',
'processing' => 'bg-sky-100 text-sky-700',
default => 'bg-amber-100 text-amber-700',
};
@endphp
<article class="grid grid-cols-1 gap-4 px-6 py-5 xl:grid-cols-[minmax(0,1fr),auto] xl:items-center">
<div class="min-w-0">
<div class="flex flex-wrap items-center gap-2">
<h3 class="text-base font-semibold text-slate-900">{{ $video->titleLabel() }}</h3>
<span class="inline-flex rounded-full px-3 py-1 text-xs font-semibold {{ $statusClass }}">{{ $video->statusLabel() }}</span>
@if($video->is_active)
<span class="inline-flex rounded-full bg-slate-100 px-3 py-1 text-xs font-semibold text-slate-600">Visible</span>
@endif
</div>
<p class="mt-2 text-sm text-slate-500">{{ $video->listing?->title ?? 'Listing removed' }}</p>
<div class="mt-3 flex flex-wrap items-center gap-4 text-sm text-slate-500">
<span>Duration: {{ $video->durationLabel() }}</span>
<span>Size: {{ $video->sizeLabel() }}</span>
<span>Updated: {{ $video->updated_at?->format('d.m.Y H:i') ?? '-' }}</span>
</div>
</div>
<div class="flex flex-wrap items-center gap-2 xl:justify-end">
<a href="{{ route('panel.videos.edit', $video) }}" class="inline-flex items-center justify-center rounded-full border border-slate-300 px-4 py-2 text-sm font-semibold text-slate-700 transition hover:bg-slate-50">
Edit
</a>
<form method="POST" action="{{ route('panel.videos.destroy', $video) }}">
@csrf
@method('DELETE')
<button type="submit" class="inline-flex items-center justify-center rounded-full border border-rose-200 px-4 py-2 text-sm font-semibold text-rose-600 transition hover:bg-rose-50">
Delete
</button>
</form>
</div>
</article>
@empty
<div class="px-6 py-16 text-center text-slate-500">
No videos yet.
</div>
@endforelse
</div>
@if($videos->hasPages())
<div class="border-t border-slate-200 px-6 py-4">
{{ $videos->links() }}
</div>
@endif
</div>
</section>
</div>
</div>
@endsection

View File

@ -0,0 +1,23 @@
@if ($paginator->hasPages())
<nav role="navigation" aria-label="{{ __('Pagination Navigation') }}" class="flex items-center justify-between gap-2">
@if ($paginator->onFirstPage())
<span class="inline-flex items-center rounded-full border border-slate-200 bg-white px-4 py-2 text-sm font-medium text-slate-400 cursor-not-allowed">
{!! __('pagination.previous') !!}
</span>
@else
<a href="{{ $paginator->previousPageUrl() }}" rel="prev" class="inline-flex items-center rounded-full border border-slate-200 bg-white px-4 py-2 text-sm font-medium text-slate-700 transition hover:bg-slate-50">
{!! __('pagination.previous') !!}
</a>
@endif
@if ($paginator->hasMorePages())
<a href="{{ $paginator->nextPageUrl() }}" rel="next" class="inline-flex items-center rounded-full border border-slate-200 bg-white px-4 py-2 text-sm font-medium text-slate-700 transition hover:bg-slate-50">
{!! __('pagination.next') !!}
</a>
@else
<span class="inline-flex items-center rounded-full border border-slate-200 bg-white px-4 py-2 text-sm font-medium text-slate-400 cursor-not-allowed">
{!! __('pagination.next') !!}
</span>
@endif
</nav>
@endif

View File

@ -0,0 +1,97 @@
@if ($paginator->hasPages())
<nav role="navigation" aria-label="{{ __('Pagination Navigation') }}">
<div class="flex items-center justify-between gap-2 sm:hidden">
@if ($paginator->onFirstPage())
<span class="inline-flex items-center rounded-full border border-slate-200 bg-white px-4 py-2 text-sm font-medium text-slate-400 cursor-not-allowed">
{!! __('pagination.previous') !!}
</span>
@else
<a href="{{ $paginator->previousPageUrl() }}" rel="prev" class="inline-flex items-center rounded-full border border-slate-200 bg-white px-4 py-2 text-sm font-medium text-slate-700 transition hover:bg-slate-50">
{!! __('pagination.previous') !!}
</a>
@endif
@if ($paginator->hasMorePages())
<a href="{{ $paginator->nextPageUrl() }}" rel="next" class="inline-flex items-center rounded-full border border-slate-200 bg-white px-4 py-2 text-sm font-medium text-slate-700 transition hover:bg-slate-50">
{!! __('pagination.next') !!}
</a>
@else
<span class="inline-flex items-center rounded-full border border-slate-200 bg-white px-4 py-2 text-sm font-medium text-slate-400 cursor-not-allowed">
{!! __('pagination.next') !!}
</span>
@endif
</div>
<div class="hidden sm:flex sm:items-center sm:justify-between sm:gap-4">
<div>
<p class="text-sm text-slate-600">
{!! __('Showing') !!}
@if ($paginator->firstItem())
<span class="font-semibold text-slate-900">{{ $paginator->firstItem() }}</span>
{!! __('to') !!}
<span class="font-semibold text-slate-900">{{ $paginator->lastItem() }}</span>
@else
{{ $paginator->count() }}
@endif
{!! __('of') !!}
<span class="font-semibold text-slate-900">{{ $paginator->total() }}</span>
{!! __('results') !!}
</p>
</div>
<div>
<span class="inline-flex rounded-full bg-white shadow-sm ring-1 ring-slate-200 overflow-hidden">
@if ($paginator->onFirstPage())
<span aria-disabled="true" aria-label="{{ __('pagination.previous') }}" class="inline-flex items-center px-3 py-2 text-slate-300">
<svg class="h-5 w-5" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M12.707 5.293a1 1 0 010 1.414L9.414 10l3.293 3.293a1 1 0 01-1.414 1.414l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 0z" clip-rule="evenodd" />
</svg>
</span>
@else
<a href="{{ $paginator->previousPageUrl() }}" rel="prev" class="inline-flex items-center px-3 py-2 text-slate-500 transition hover:bg-slate-50 hover:text-slate-700" aria-label="{{ __('pagination.previous') }}">
<svg class="h-5 w-5" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M12.707 5.293a1 1 0 010 1.414L9.414 10l3.293 3.293a1 1 0 01-1.414 1.414l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 0z" clip-rule="evenodd" />
</svg>
</a>
@endif
@foreach ($elements as $element)
@if (is_string($element))
<span aria-disabled="true" class="inline-flex items-center border-l border-slate-200 px-4 py-2 text-sm font-medium text-slate-400">
{{ $element }}
</span>
@endif
@if (is_array($element))
@foreach ($element as $page => $url)
@if ($page == $paginator->currentPage())
<span aria-current="page" class="inline-flex items-center border-l border-slate-200 bg-slate-100 px-4 py-2 text-sm font-semibold text-slate-900">
{{ $page }}
</span>
@else
<a href="{{ $url }}" class="inline-flex items-center border-l border-slate-200 bg-white px-4 py-2 text-sm font-medium text-slate-700 transition hover:bg-slate-50" aria-label="{{ __('Go to page :page', ['page' => $page]) }}">
{{ $page }}
</a>
@endif
@endforeach
@endif
@endforeach
@if ($paginator->hasMorePages())
<a href="{{ $paginator->nextPageUrl() }}" rel="next" class="inline-flex items-center border-l border-slate-200 px-3 py-2 text-slate-500 transition hover:bg-slate-50 hover:text-slate-700" aria-label="{{ __('pagination.next') }}">
<svg class="h-5 w-5" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clip-rule="evenodd" />
</svg>
</a>
@else
<span aria-disabled="true" aria-label="{{ __('pagination.next') }}" class="inline-flex items-center border-l border-slate-200 px-3 py-2 text-slate-300">
<svg class="h-5 w-5" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clip-rule="evenodd" />
</svg>
</span>
@endif
</span>
</div>
</div>
</nav>
@endif

View File

@ -16,9 +16,17 @@ Route::middleware('auth')->prefix('panel')->name('panel.')->group(function () {
Route::get('/', [PanelController::class, 'index'])->name('index');
Route::get('/ilanlarim', [PanelController::class, 'listings'])->name('listings.index');
Route::get('/create-listing', [PanelController::class, 'create'])->name('listings.create');
Route::get('/ilanlarim/{listing}/duzenle', [PanelController::class, 'editListing'])->name('listings.edit');
Route::put('/ilanlarim/{listing}', [PanelController::class, 'updateListing'])->name('listings.update');
Route::post('/ilanlarim/{listing}/kaldir', [PanelController::class, 'destroyListing'])->name('listings.destroy');
Route::post('/ilanlarim/{listing}/satildi', [PanelController::class, 'markListingAsSold'])->name('listings.mark-sold');
Route::post('/ilanlarim/{listing}/yeniden-yayinla', [PanelController::class, 'republishListing'])->name('listings.republish');
Route::get('/videos', [PanelController::class, 'videos'])->name('videos.index');
Route::post('/videos', [PanelController::class, 'storeVideo'])->name('videos.store');
Route::get('/videos/{video}/edit', [PanelController::class, 'editVideo'])->name('videos.edit');
Route::put('/videos/{video}', [PanelController::class, 'updateVideo'])->name('videos.update');
Route::delete('/videos/{video}', [PanelController::class, 'destroyVideo'])->name('videos.destroy');
Route::get('/my-profile', [PanelController::class, 'profile'])->name('profile.edit');
});
require __DIR__.'/auth.php';