mirror of
https://github.com/openclassify/openclassify.git
synced 2026-04-14 11:12:09 -05:00
Improve partner panel UX
This commit is contained in:
parent
d5f88c79af
commit
b1293d3960
@ -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',
|
||||
));
|
||||
}
|
||||
|
||||
|
||||
@ -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'));
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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()]; }
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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)),
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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()]);
|
||||
}
|
||||
}
|
||||
@ -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 {}
|
||||
}
|
||||
@ -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"));
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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": []
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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');
|
||||
});
|
||||
|
||||
@ -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'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -3,5 +3,4 @@
|
||||
return [
|
||||
App\Providers\AppServiceProvider::class,
|
||||
Modules\Admin\Providers\AdminPanelProvider::class,
|
||||
Modules\Partner\Providers\PartnerPanelProvider::class,
|
||||
];
|
||||
|
||||
@ -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
@ -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>
|
||||
|
||||
@ -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');
|
||||
|
||||
@ -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>
|
||||
|
||||
|
||||
165
resources/views/panel/edit-listing.blade.php
Normal file
165
resources/views/panel/edit-listing.blade.php
Normal 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
|
||||
@ -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>
|
||||
|
||||
|
||||
@ -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>
|
||||
|
||||
113
resources/views/panel/profile.blade.php
Normal file
113
resources/views/panel/profile.blade.php
Normal 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
|
||||
127
resources/views/panel/video-edit.blade.php
Normal file
127
resources/views/panel/video-edit.blade.php
Normal 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
|
||||
123
resources/views/panel/videos.blade.php
Normal file
123
resources/views/panel/videos.blade.php
Normal 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
|
||||
23
resources/views/vendor/pagination/simple-tailwind.blade.php
vendored
Normal file
23
resources/views/vendor/pagination/simple-tailwind.blade.php
vendored
Normal 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
|
||||
97
resources/views/vendor/pagination/tailwind.blade.php
vendored
Normal file
97
resources/views/vendor/pagination/tailwind.blade.php
vendored
Normal 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
|
||||
@ -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';
|
||||
|
||||
Loading…
Reference in New Issue
Block a user