Add auto-filled listing form

This commit is contained in:
fatihalp 2026-03-03 21:34:18 +03:00
parent cf313e750f
commit a33f4f42bb
33 changed files with 1893 additions and 19 deletions

View File

@ -51,3 +51,8 @@ MAIL_FROM_ADDRESS="hello@openclassify.com"
MAIL_FROM_NAME="${APP_NAME}" MAIL_FROM_NAME="${APP_NAME}"
VITE_APP_NAME="${APP_NAME}" VITE_APP_NAME="${APP_NAME}"
OPENAI_API_KEY=
GEMINI_API_KEY=
QUICK_LISTING_AI_PROVIDER=openai
QUICK_LISTING_AI_MODEL=gpt-5.2

View File

@ -0,0 +1,74 @@
<?php
namespace Modules\Admin\Filament\Resources;
use BackedEnum;
use Filament\Actions\Action;
use Filament\Actions\DeleteAction;
use Filament\Actions\EditAction;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Toggle;
use Filament\Resources\Resource;
use Filament\Schemas\Schema;
use Filament\Tables\Columns\IconColumn;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Filters\TernaryFilter;
use Filament\Tables\Table;
use Modules\Admin\Filament\Resources\CityResource\Pages;
use Modules\Location\Models\City;
use UnitEnum;
class CityResource extends Resource
{
protected static ?string $model = City::class;
protected static string | BackedEnum | null $navigationIcon = 'heroicon-o-building-office-2';
protected static string | UnitEnum | null $navigationGroup = 'Settings';
protected static ?string $label = 'City';
protected static ?string $pluralLabel = 'Cities';
protected static ?int $navigationSort = 3;
public static function form(Schema $schema): Schema
{
return $schema->schema([
TextInput::make('name')->required()->maxLength(120),
Select::make('country_id')->relationship('country', 'name')->label('Country')->searchable()->preload()->required(),
Toggle::make('is_active')->default(true),
]);
}
public static function table(Table $table): Table
{
return $table->columns([
TextColumn::make('id')->sortable(),
TextColumn::make('name')->searchable()->sortable(),
TextColumn::make('country.name')->label('Country')->searchable()->sortable(),
TextColumn::make('districts_count')->counts('districts')->label('Districts')->sortable(),
IconColumn::make('is_active')->boolean(),
TextColumn::make('created_at')->dateTime()->sortable()->toggleable(isToggledHiddenByDefault: true),
])->filters([
SelectFilter::make('country_id')
->label('Country')
->relationship('country', 'name')
->searchable()
->preload(),
TernaryFilter::make('is_active')->label('Active'),
])->actions([
EditAction::make(),
Action::make('activities')
->icon('heroicon-o-clock')
->url(fn (City $record): string => static::getUrl('activities', ['record' => $record])),
DeleteAction::make(),
]);
}
public static function getPages(): array
{
return [
'index' => Pages\ListCities::route('/'),
'create' => Pages\CreateCity::route('/create'),
'activities' => Pages\ListCityActivities::route('/{record}/activities'),
'edit' => Pages\EditCity::route('/{record}/edit'),
];
}
}

View File

@ -0,0 +1,10 @@
<?php
namespace Modules\Admin\Filament\Resources\CityResource\Pages;
use Filament\Resources\Pages\CreateRecord;
use Modules\Admin\Filament\Resources\CityResource;
class CreateCity extends CreateRecord
{
protected static string $resource = CityResource::class;
}

View File

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

View File

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

View File

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

View File

@ -0,0 +1,80 @@
<?php
namespace Modules\Admin\Filament\Resources;
use BackedEnum;
use Filament\Actions\Action;
use Filament\Actions\DeleteAction;
use Filament\Actions\EditAction;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Toggle;
use Filament\Resources\Resource;
use Filament\Schemas\Schema;
use Filament\Tables\Columns\IconColumn;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Filters\TernaryFilter;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
use Modules\Admin\Filament\Resources\DistrictResource\Pages;
use Modules\Location\Models\Country;
use Modules\Location\Models\District;
use UnitEnum;
class DistrictResource extends Resource
{
protected static ?string $model = District::class;
protected static string | BackedEnum | null $navigationIcon = 'heroicon-o-map';
protected static string | UnitEnum | null $navigationGroup = 'Settings';
protected static ?string $label = 'District';
protected static ?string $pluralLabel = 'Districts';
protected static ?int $navigationSort = 4;
public static function form(Schema $schema): Schema
{
return $schema->schema([
TextInput::make('name')->required()->maxLength(120),
Select::make('city_id')->relationship('city', 'name')->label('City')->searchable()->preload()->required(),
Toggle::make('is_active')->default(true),
]);
}
public static function table(Table $table): Table
{
return $table->columns([
TextColumn::make('id')->sortable(),
TextColumn::make('name')->searchable()->sortable(),
TextColumn::make('city.name')->label('City')->searchable()->sortable(),
TextColumn::make('city.country.name')->label('Country'),
IconColumn::make('is_active')->boolean(),
TextColumn::make('created_at')->dateTime()->sortable()->toggleable(isToggledHiddenByDefault: true),
])->filters([
SelectFilter::make('country_id')
->label('Country')
->options(fn (): array => Country::query()->orderBy('name')->pluck('name', 'id')->all())
->query(fn (Builder $query, array $data): Builder => $query->when($data['value'] ?? null, fn (Builder $query, string $countryId): Builder => $query->whereHas('city', fn (Builder $cityQuery): Builder => $cityQuery->where('country_id', $countryId)))),
SelectFilter::make('city_id')
->label('City')
->relationship('city', 'name')
->searchable()
->preload(),
TernaryFilter::make('is_active')->label('Active'),
])->actions([
EditAction::make(),
Action::make('activities')
->icon('heroicon-o-clock')
->url(fn (District $record): string => static::getUrl('activities', ['record' => $record])),
DeleteAction::make(),
]);
}
public static function getPages(): array
{
return [
'index' => Pages\ListDistricts::route('/'),
'create' => Pages\CreateDistrict::route('/create'),
'activities' => Pages\ListDistrictActivities::route('/{record}/activities'),
'edit' => Pages\EditDistrict::route('/{record}/edit'),
];
}
}

View File

@ -0,0 +1,10 @@
<?php
namespace Modules\Admin\Filament\Resources\DistrictResource\Pages;
use Filament\Resources\Pages\CreateRecord;
use Modules\Admin\Filament\Resources\DistrictResource;
class CreateDistrict extends CreateRecord
{
protected static string $resource = DistrictResource::class;
}

View File

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

View File

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

View File

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

View File

@ -0,0 +1,46 @@
<?php
namespace Modules\Admin\Filament\Widgets;
use Filament\Widgets\StatsOverviewWidget;
use Filament\Widgets\StatsOverviewWidget\Stat;
use Modules\Listing\Models\Listing;
class ListingOverview extends StatsOverviewWidget
{
protected static ?int $sort = 1;
protected ?string $heading = 'Listing Overview';
protected function getStats(): array
{
$totalListings = Listing::query()->count();
$activeListings = Listing::query()->where('status', 'active')->count();
$pendingListings = Listing::query()->where('status', 'pending')->count();
$featuredListings = Listing::query()->where('is_featured', true)->count();
$createdToday = Listing::query()->where('created_at', '>=', now()->startOfDay())->count();
$featuredRatio = $totalListings > 0
? number_format(($featuredListings / $totalListings) * 100, 1).'% of all listings'
: '0.0% of all listings';
return [
Stat::make('Total Listings', number_format($totalListings))
->description('All listings in the system')
->icon('heroicon-o-clipboard-document-list')
->color('primary'),
Stat::make('Active Listings', number_format($activeListings))
->description(number_format($pendingListings).' pending review')
->descriptionIcon('heroicon-o-clock')
->icon('heroicon-o-check-circle')
->color('success'),
Stat::make('Created Today', number_format($createdToday))
->description('New listings added today')
->icon('heroicon-o-calendar-days')
->color('info'),
Stat::make('Featured Listings', number_format($featuredListings))
->description($featuredRatio)
->icon('heroicon-o-star')
->color('warning'),
];
}
}

View File

@ -0,0 +1,67 @@
<?php
namespace Modules\Admin\Filament\Widgets;
use Filament\Widgets\ChartWidget;
use Modules\Listing\Models\Listing;
class ListingsTrendChart extends ChartWidget
{
protected static ?int $sort = 2;
protected ?string $heading = 'Listing Creation Trend';
protected ?string $description = 'Daily listing volume by selected period.';
protected function getFilters(): ?array
{
return [
'7' => 'Last 7 days',
'30' => 'Last 30 days',
'90' => 'Last 90 days',
];
}
protected function getData(): array
{
$days = (int) ($this->filter ?? '30');
$startDate = now()->startOfDay()->subDays($days - 1);
$countsByDate = Listing::query()
->selectRaw('DATE(created_at) as day, COUNT(*) as total')
->where('created_at', '>=', $startDate)
->groupBy('day')
->orderBy('day')
->pluck('total', 'day')
->all();
$labels = [];
$data = [];
for ($index = 0; $index < $days; $index++) {
$date = $startDate->copy()->addDays($index);
$dateKey = $date->toDateString();
$labels[] = $date->format('M j');
$data[] = (int) ($countsByDate[$dateKey] ?? 0);
}
return [
'datasets' => [
[
'label' => 'Listings',
'data' => $data,
'fill' => true,
'borderColor' => '#2563eb',
'backgroundColor' => 'rgba(37, 99, 235, 0.12)',
'tension' => 0.35,
],
],
'labels' => $labels,
];
}
protected function getType(): string
{
return 'line';
}
}

View File

@ -2,6 +2,8 @@
namespace Modules\Listing\Http\Controllers; namespace Modules\Listing\Http\Controllers;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Models\FavoriteSearch;
use Modules\Category\Models\Category;
use Modules\Listing\Models\Listing; use Modules\Listing\Models\Listing;
class ListingController extends Controller class ListingController extends Controller
@ -9,9 +11,12 @@ class ListingController extends Controller
public function index() public function index()
{ {
$search = trim((string) request('search', '')); $search = trim((string) request('search', ''));
$categoryId = request()->integer('category');
$categoryId = $categoryId > 0 ? $categoryId : null;
$listings = Listing::query() $listings = Listing::query()
->publicFeed() ->publicFeed()
->with('category:id,name')
->when($search !== '', function ($query) use ($search): void { ->when($search !== '', function ($query) use ($search): void {
$query->where(function ($searchQuery) use ($search): void { $query->where(function ($searchQuery) use ($search): void {
$searchQuery $searchQuery
@ -21,15 +26,70 @@ class ListingController extends Controller
->orWhere('country', 'like', "%{$search}%"); ->orWhere('country', 'like', "%{$search}%");
}); });
}) })
->when($categoryId, fn ($query) => $query->where('category_id', $categoryId))
->paginate(12) ->paginate(12)
->withQueryString(); ->withQueryString();
return view('listing::index', compact('listings', 'search')); $categories = Category::query()
->where('is_active', true)
->orderBy('name')
->get(['id', 'name']);
$favoriteListingIds = [];
$isCurrentSearchSaved = false;
if (auth()->check()) {
$favoriteListingIds = auth()->user()
->favoriteListings()
->pluck('listings.id')
->all();
$filters = FavoriteSearch::normalizeFilters([
'search' => $search,
'category' => $categoryId,
]);
if ($filters !== []) {
$signature = FavoriteSearch::signatureFor($filters);
$isCurrentSearchSaved = auth()->user()
->favoriteSearches()
->where('signature', $signature)
->exists();
}
}
return view('listing::index', compact(
'listings',
'search',
'categoryId',
'categories',
'favoriteListingIds',
'isCurrentSearchSaved',
));
} }
public function show(Listing $listing) public function show(Listing $listing)
{ {
return view('listing::show', compact('listing')); $listing->loadMissing('user:id,name,email');
$isListingFavorited = false;
$isSellerFavorited = false;
if (auth()->check()) {
$isListingFavorited = auth()->user()
->favoriteListings()
->whereKey($listing->getKey())
->exists();
if ($listing->user_id) {
$isSellerFavorited = auth()->user()
->favoriteSellers()
->whereKey($listing->user_id)
->exists();
}
}
return view('listing::show', compact('listing', 'isListingFavorited', 'isSellerFavorited'));
} }
public function create() public function create()

View File

@ -55,6 +55,12 @@ class Listing extends Model implements HasMedia
return $this->belongsTo(\App\Models\User::class); return $this->belongsTo(\App\Models\User::class);
} }
public function favoritedByUsers()
{
return $this->belongsToMany(\App\Models\User::class, 'favorite_listings')
->withTimestamps();
}
public function scopePublicFeed(Builder $query): Builder public function scopePublicFeed(Builder $query): Builder
{ {
return $query return $query

View File

@ -1,12 +1,78 @@
@extends('app::layouts.app') @extends('app::layouts.app')
@section('content') @section('content')
<div class="container mx-auto px-4 py-8"> <div class="max-w-[1320px] mx-auto px-4 py-8">
<h1 class="text-3xl font-bold mb-6">{{ __('messages.listings') }}</h1> <div class="flex flex-col lg:flex-row lg:items-center gap-4 mb-6">
<h1 class="text-3xl font-bold text-slate-900 mr-auto">{{ __('messages.listings') }}</h1>
<form method="GET" action="{{ route('listings.index') }}" class="flex flex-wrap items-center gap-2">
@if($search !== '')
<input type="hidden" name="search" value="{{ $search }}">
@endif
<select name="category" class="h-10 min-w-44 border border-slate-300 rounded-lg px-3 text-sm text-slate-700">
<option value="">Kategori</option>
@foreach($categories as $category)
<option value="{{ $category->id }}" @selected((int) $categoryId === (int) $category->id)>{{ $category->name }}</option>
@endforeach
</select>
<button type="submit" class="h-10 px-4 bg-slate-700 text-white text-sm font-semibold rounded-lg hover:bg-slate-800 transition">Filtrele</button>
@if($categoryId)
<a href="{{ route('listings.index', array_filter(['search' => $search])) }}" class="h-10 px-4 inline-flex items-center border border-slate-300 text-sm font-semibold rounded-lg hover:bg-slate-50 transition">
Sıfırla
</a>
@endif
</form>
</div>
@auth
@php
$canSaveSearch = $search !== '' || !is_null($categoryId);
@endphp
<div class="bg-slate-50 border border-slate-200 rounded-xl p-4 mb-6 flex flex-col sm:flex-row sm:items-center gap-3">
<div class="mr-auto text-sm text-slate-600">
Bu aramayı favorilere ekleyerek daha sonra hızlıca açabilirsin.
</div>
<form method="POST" action="{{ route('favorites.searches.store') }}">
@csrf
<input type="hidden" name="search" value="{{ $search }}">
<input type="hidden" name="category_id" value="{{ $categoryId }}">
<button type="submit" class="h-10 px-4 rounded-lg text-sm font-semibold {{ $isCurrentSearchSaved ? 'bg-emerald-100 text-emerald-700 cursor-default' : ($canSaveSearch ? 'bg-rose-500 text-white hover:bg-rose-600 transition' : 'bg-slate-200 text-slate-400 cursor-not-allowed') }}" @disabled($isCurrentSearchSaved || ! $canSaveSearch)>
{{ $isCurrentSearchSaved ? 'Arama Favorilerde' : ($canSaveSearch ? 'Aramayı Favorilere Ekle' : 'Filtre seç') }}
</button>
</form>
<a href="{{ route('favorites.index', ['tab' => 'searches']) }}" class="h-10 px-4 inline-flex items-center border border-slate-300 text-sm font-semibold rounded-lg hover:bg-white transition">
Favori Aramalar
</a>
</div>
@endauth
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6"> <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
@foreach($listings as $listing) @foreach($listings as $listing)
<div class="bg-white rounded-lg shadow-md overflow-hidden hover:shadow-lg transition"> @php
<div class="bg-gray-200 h-48 flex items-center justify-center"> $listingImage = $listing->getFirstMediaUrl('listing-images');
$isFavorited = in_array($listing->id, $favoriteListingIds ?? [], true);
@endphp
<div class="bg-white rounded-lg shadow-md overflow-hidden hover:shadow-lg transition border border-slate-200">
<div class="bg-gray-200 h-48 flex items-center justify-center relative">
@if($listingImage)
<img src="{{ $listingImage }}" alt="{{ $listing->title }}" class="w-full h-full object-cover">
@else
<svg class="w-16 h-16 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"></path></svg> <svg class="w-16 h-16 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"></path></svg>
@endif
<div class="absolute top-3 right-3">
@auth
<form method="POST" action="{{ route('favorites.listings.toggle', $listing) }}">
@csrf
<button type="submit" class="w-9 h-9 rounded-full grid place-items-center transition {{ $isFavorited ? 'bg-rose-500 text-white' : 'bg-white/95 text-slate-500 hover:text-rose-500' }}" aria-label="Favoriye ekle">
</button>
</form>
@else
<a href="{{ route('filament.partner.auth.login') }}" class="w-9 h-9 rounded-full bg-white/95 text-slate-500 hover:text-rose-500 grid place-items-center transition" aria-label="Giriş yap">
</a>
@endauth
</div>
</div> </div>
<div class="p-4"> <div class="p-4">
@if($listing->is_featured) @if($listing->is_featured)
@ -16,10 +82,15 @@
<p class="text-green-600 font-bold text-lg mt-1"> <p class="text-green-600 font-bold text-lg mt-1">
@if($listing->price) {{ number_format($listing->price, 0) }} {{ $listing->currency }} @else Free @endif @if($listing->price) {{ number_format($listing->price, 0) }} {{ $listing->currency }} @else Free @endif
</p> </p>
<p class="text-xs text-slate-500 mt-1 truncate">{{ $listing->category?->name ?: 'Kategori yok' }}</p>
<p class="text-gray-500 text-sm mt-1">{{ $listing->city }}, {{ $listing->country }}</p> <p class="text-gray-500 text-sm mt-1">{{ $listing->city }}, {{ $listing->country }}</p>
<a href="{{ route('listings.show', $listing) }}" class="mt-3 block text-center bg-blue-600 text-white py-2 rounded hover:bg-blue-700 transition">View</a> <a href="{{ route('listings.show', $listing) }}" class="mt-3 block text-center bg-blue-600 text-white py-2 rounded hover:bg-blue-700 transition">View</a>
</div> </div>
</div> </div>
@empty
<div class="md:col-span-2 lg:col-span-3 xl:col-span-4 border border-dashed border-slate-300 rounded-xl py-14 text-center text-slate-500">
Bu filtreye uygun ilan bulunamadı.
</div>
@endforeach @endforeach
</div> </div>
<div class="mt-8">{{ $listings->links() }}</div> <div class="mt-8">{{ $listings->links() }}</div>

View File

@ -37,6 +37,28 @@
@endif @endif
</span> </span>
</div> </div>
<div class="mt-3 flex flex-wrap items-center gap-2">
@auth
<form method="POST" action="{{ route('favorites.listings.toggle', $listing) }}">
@csrf
<button type="submit" class="inline-flex items-center px-4 py-2 rounded-full text-sm font-semibold transition {{ $isListingFavorited ? 'bg-rose-100 text-rose-700' : 'bg-slate-100 text-slate-700 hover:bg-slate-200' }}">
{{ $isListingFavorited ? '♥ Favorilerde' : '♡ Favoriye Ekle' }}
</button>
</form>
@if($listing->user && (int) $listing->user->id !== (int) auth()->id())
<form method="POST" action="{{ route('favorites.sellers.toggle', $listing->user) }}">
@csrf
<button type="submit" class="inline-flex items-center px-4 py-2 rounded-full text-sm font-semibold transition {{ $isSellerFavorited ? 'bg-blue-100 text-blue-700' : 'bg-slate-100 text-slate-700 hover:bg-slate-200' }}">
{{ $isSellerFavorited ? 'Satıcı Favorilerde' : 'Satıcıyı Takip Et' }}
</button>
</form>
@endif
@else
<a href="{{ route('filament.partner.auth.login') }}" class="inline-flex items-center px-4 py-2 rounded-full text-sm font-semibold bg-slate-100 text-slate-700 hover:bg-slate-200 transition">
Giriş yap ve favorile
</a>
@endauth
</div>
<p class="text-gray-500 mt-2">{{ $location !== '' ? $location : 'Location not specified' }}</p> <p class="text-gray-500 mt-2">{{ $location !== '' ? $location : 'Location not specified' }}</p>
<p class="text-gray-500 text-sm">Posted {{ $listing->created_at?->diffForHumans() ?? 'recently' }}</p> <p class="text-gray-500 text-sm">Posted {{ $listing->created_at?->diffForHumans() ?? 'recently' }}</p>
<div class="mt-4 border-t pt-4"> <div class="mt-4 border-t pt-4">
@ -45,6 +67,9 @@
</div> </div>
<div class="mt-6 bg-gray-50 rounded-lg p-4"> <div class="mt-6 bg-gray-50 rounded-lg p-4">
<h2 class="font-semibold text-lg mb-3">Contact Seller</h2> <h2 class="font-semibold text-lg mb-3">Contact Seller</h2>
@if($listing->user)
<p class="text-gray-700"><span class="font-medium">Name:</span> {{ $listing->user->name }}</p>
@endif
@if($listing->contact_phone) @if($listing->contact_phone)
<p class="text-gray-700"><span class="font-medium">Phone:</span> {{ $listing->contact_phone }}</p> <p class="text-gray-700"><span class="font-medium">Phone:</span> {{ $listing->contact_phone }}</p>
@endif @endif

View File

@ -11,21 +11,27 @@ use Filament\Actions\Action;
use Filament\Actions\DeleteAction; use Filament\Actions\DeleteAction;
use Filament\Actions\EditAction; use Filament\Actions\EditAction;
use Filament\Facades\Filament; use Filament\Facades\Filament;
use Filament\Forms\Components\DatePicker;
use Filament\Forms\Components\Select; use Filament\Forms\Components\Select;
use Filament\Forms\Components\SpatieMediaLibraryFileUpload; use Filament\Forms\Components\SpatieMediaLibraryFileUpload;
use Filament\Forms\Components\Textarea; use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\TextInput; use Filament\Forms\Components\TextInput;
use Filament\Resources\Resource; use Filament\Resources\Resource;
use Filament\Schemas\Components\Utilities\Get;
use Filament\Schemas\Schema; use Filament\Schemas\Schema;
use Filament\Tables\Columns\SpatieMediaLibraryImageColumn; use Filament\Tables\Columns\SpatieMediaLibraryImageColumn;
use Filament\Tables\Columns\TextColumn; 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 Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Modules\Category\Models\Category; use Modules\Category\Models\Category;
use Modules\Listing\Models\Listing; use Modules\Listing\Models\Listing;
use Modules\Listing\Support\ListingPanelHelper; use Modules\Listing\Support\ListingPanelHelper;
use Modules\Location\Models\City;
use Modules\Location\Models\Country;
use Modules\Partner\Filament\Resources\ListingResource\Pages; use Modules\Partner\Filament\Resources\ListingResource\Pages;
use Tapp\FilamentCountryCodeField\Forms\Components\CountryCodeSelect;
use Ysfkaya\FilamentPhoneInput\Forms\PhoneInput; use Ysfkaya\FilamentPhoneInput\Forms\PhoneInput;
class ListingResource extends Resource class ListingResource extends Resource
@ -36,8 +42,33 @@ class ListingResource extends Resource
public static function form(Schema $schema): Schema public static function form(Schema $schema): Schema
{ {
return $schema->schema([ return $schema->schema([
TextInput::make('title')->required()->maxLength(255)->live(onBlur: true)->afterStateUpdated(fn ($state, $set) => $set('slug', \Illuminate\Support\Str::slug($state) . '-' . \Illuminate\Support\Str::random(4))), TextInput::make('title')
TextInput::make('slug')->required()->maxLength(255)->unique(ignoreRecord: true), ->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), Textarea::make('description')->rows(4),
TextInput::make('price') TextInput::make('price')
->numeric() ->numeric()
@ -46,16 +77,53 @@ class ListingResource extends Resource
->options(fn () => ListingPanelHelper::currencyOptions()) ->options(fn () => ListingPanelHelper::currencyOptions())
->default(fn () => ListingPanelHelper::defaultCurrency()) ->default(fn () => ListingPanelHelper::defaultCurrency())
->required(), ->required(),
Select::make('category_id')->label('Category')->options(fn () => Category::where('is_active', true)->pluck('name', 'id'))->searchable()->nullable(), 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()
->nullable(),
StateFusionSelect::make('status')->required(), StateFusionSelect::make('status')->required(),
PhoneInput::make('contact_phone')->defaultCountry(CountryCodeManager::defaultCountryIso2())->nullable(), PhoneInput::make('contact_phone')->defaultCountry(CountryCodeManager::defaultCountryIso2())->nullable(),
TextInput::make('contact_email')->email()->maxLength(255), TextInput::make('contact_email')
TextInput::make('city')->maxLength(100), ->email()
CountryCodeSelect::make('country') ->maxLength(255)
->default(fn (): ?string => Filament::auth()->user()?->email),
Select::make('country')
->label('Country') ->label('Country')
->default(fn () => CountryCodeManager::defaultCountryCode()) ->options(fn (): array => Country::query()
->formatStateUsing(fn ($state): ?string => CountryCodeManager::countryCodeFromLabelOrCode($state)) ->where('is_active', true)
->dehydrateStateUsing(fn ($state, ?Listing $record): ?string => CountryCodeManager::normalizeStoredCountry($state ?? $record?->country)), ->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') Map::make('location')
->label('Location') ->label('Location')
->visible(fn (): bool => ListingPanelHelper::googleMapsEnabled()) ->visible(fn (): bool => ListingPanelHelper::googleMapsEnabled())
@ -94,6 +162,42 @@ class ListingResource extends Resource
TextColumn::make('created_at')->dateTime()->sortable(), TextColumn::make('created_at')->dateTime()->sortable(),
])->filters([ ])->filters([
StateFusionSelectFilter::make('status'), 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([ ])->actions([
EditAction::make(), EditAction::make(),
Action::make('activities') Action::make('activities')
@ -113,6 +217,7 @@ class ListingResource extends Resource
return [ return [
'index' => Pages\ListListings::route('/'), 'index' => Pages\ListListings::route('/'),
'create' => Pages\CreateListing::route('/create'), 'create' => Pages\CreateListing::route('/create'),
'quick-create' => Pages\QuickCreateListing::route('/quick-create'),
'activities' => Pages\ListListingActivities::route('/{record}/activities'), 'activities' => Pages\ListListingActivities::route('/{record}/activities'),
'edit' => Pages\EditListing::route('/{record}/edit'), 'edit' => Pages\EditListing::route('/{record}/edit'),
]; ];

View File

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

View File

@ -0,0 +1,282 @@
<?php
namespace Modules\Partner\Filament\Resources\ListingResource\Pages;
use App\Support\QuickListingCategorySuggester;
use Filament\Resources\Pages\Page;
use Illuminate\Support\Collection;
use Modules\Category\Models\Category;
use Modules\Partner\Filament\Resources\ListingResource;
use Livewire\Features\SupportFileUploads\TemporaryUploadedFile;
use Livewire\Features\SupportFileUploads\WithFileUploads;
class QuickCreateListing extends Page
{
use WithFileUploads;
protected static string $resource = ListingResource::class;
protected string $view = 'filament.partner.listings.quick-create';
protected static ?string $title = 'Hızlı İlan Ver';
protected static ?string $slug = 'quick-create';
protected static bool $shouldRegisterNavigation = false;
/**
* @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 = [];
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 function mount(): void
{
$this->loadCategories();
}
public function updatedPhotos(): void
{
$this->validatePhotos();
}
public function removePhoto(int $index): void
{
if (! isset($this->photos[$index])) {
return;
}
unset($this->photos[$index]);
$this->photos = array_values($this->photos);
}
public function goToCategoryStep(): void
{
$this->validatePhotos();
$this->currentStep = 2;
if (! $this->isDetecting && ! $this->detectedCategoryId) {
$this->detectCategoryFromImage();
}
}
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;
}
public function continueToManualCreate()
{
if (! $this->selectedCategoryId) {
return null;
}
$url = ListingResource::getUrl(
name: 'create',
parameters: [
'category_id' => $this->selectedCategoryId,
'quick' => 1,
],
shouldGuessMissingParameters: true,
);
return redirect()->to($url);
}
/**
* @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 getSelectedCategoryNameProperty(): ?string
{
if (! $this->selectedCategoryId) {
return null;
}
$category = collect($this->categories)
->firstWhere('id', $this->selectedCategoryId);
return $category['name'] ?? null;
}
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 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 categoryExists(int $categoryId): bool
{
return collect($this->categories)
->contains(fn (array $category): bool => $category['id'] === $categoryId);
}
}

View File

@ -0,0 +1,180 @@
<?php
namespace App\Http\Controllers;
use App\Models\FavoriteSearch;
use App\Models\User;
use Illuminate\Http\Request;
use Modules\Category\Models\Category;
use Modules\Listing\Models\Listing;
class FavoriteController extends Controller
{
public function index(Request $request)
{
$activeTab = (string) $request->string('tab', 'listings');
if (! in_array($activeTab, ['listings', 'searches', 'sellers'], true)) {
$activeTab = 'listings';
}
$statusFilter = (string) $request->string('status', 'all');
if (! in_array($statusFilter, ['all', 'active'], true)) {
$statusFilter = 'all';
}
$selectedCategoryId = $request->integer('category');
if ($selectedCategoryId <= 0) {
$selectedCategoryId = null;
}
$user = $request->user();
$categories = Category::query()
->where('is_active', true)
->orderBy('name')
->get(['id', 'name']);
$favoriteListings = null;
$favoriteSearches = null;
$favoriteSellers = null;
if ($activeTab === 'listings') {
$favoriteListings = $user->favoriteListings()
->with(['category:id,name', 'user:id,name'])
->wherePivot('created_at', '>=', now()->subYear())
->when($statusFilter === 'active', fn ($query) => $query->where('status', 'active'))
->when($selectedCategoryId, fn ($query) => $query->where('category_id', $selectedCategoryId))
->orderByPivot('created_at', 'desc')
->paginate(10)
->withQueryString();
}
if ($activeTab === 'searches') {
$favoriteSearches = $user->favoriteSearches()
->with('category:id,name')
->latest()
->paginate(10)
->withQueryString();
}
if ($activeTab === 'sellers') {
$favoriteSellers = $user->favoriteSellers()
->withCount([
'listings as active_listings_count' => fn ($query) => $query->where('status', 'active'),
])
->orderByPivot('created_at', 'desc')
->paginate(10)
->withQueryString();
}
return view('favorites.index', [
'activeTab' => $activeTab,
'statusFilter' => $statusFilter,
'selectedCategoryId' => $selectedCategoryId,
'categories' => $categories,
'favoriteListings' => $favoriteListings,
'favoriteSearches' => $favoriteSearches,
'favoriteSellers' => $favoriteSellers,
]);
}
public function toggleListing(Request $request, Listing $listing)
{
$user = $request->user();
$isFavorite = $user->favoriteListings()->whereKey($listing->getKey())->exists();
if ($isFavorite) {
$user->favoriteListings()->detach($listing->getKey());
return back()->with('success', 'İlan favorilerden kaldırıldı.');
}
$user->favoriteListings()->syncWithoutDetaching([$listing->getKey()]);
return back()->with('success', 'İlan favorilere eklendi.');
}
public function toggleSeller(Request $request, User $seller)
{
$user = $request->user();
if ((int) $user->getKey() === (int) $seller->getKey()) {
return back()->with('error', 'Kendi hesabını favorilere ekleyemezsin.');
}
$isFavorite = $user->favoriteSellers()->whereKey($seller->getKey())->exists();
if ($isFavorite) {
$user->favoriteSellers()->detach($seller->getKey());
return back()->with('success', 'Satıcı favorilerden kaldırıldı.');
}
$user->favoriteSellers()->syncWithoutDetaching([$seller->getKey()]);
return back()->with('success', 'Satıcı favorilere eklendi.');
}
public function storeSearch(Request $request)
{
$data = $request->validate([
'search' => ['nullable', 'string', 'max:120'],
'category_id' => ['nullable', 'integer', 'exists:categories,id'],
]);
$filters = FavoriteSearch::normalizeFilters([
'search' => $data['search'] ?? null,
'category' => $data['category_id'] ?? null,
]);
if ($filters === []) {
return back()->with('error', 'Favoriye eklemek için en az bir filtre seçmelisin.');
}
$signature = FavoriteSearch::signatureFor($filters);
$categoryName = null;
if (isset($filters['category'])) {
$categoryName = Category::query()->whereKey($filters['category'])->value('name');
}
$labelParts = [];
if (! empty($filters['search'])) {
$labelParts[] = '"'.$filters['search'].'"';
}
if ($categoryName) {
$labelParts[] = $categoryName;
}
$label = $labelParts !== [] ? implode(' · ', $labelParts) : 'Filtreli arama';
$favoriteSearch = $request->user()->favoriteSearches()->firstOrCreate(
['signature' => $signature],
[
'label' => $label,
'search_term' => $filters['search'] ?? null,
'category_id' => $filters['category'] ?? null,
'filters' => $filters,
]
);
if (! $favoriteSearch->wasRecentlyCreated) {
return back()->with('success', 'Bu arama zaten favorilerinde.');
}
return back()->with('success', 'Arama favorilere eklendi.');
}
public function destroySearch(Request $request, FavoriteSearch $favoriteSearch)
{
if ((int) $favoriteSearch->user_id !== (int) $request->user()->getKey()) {
abort(403);
}
$favoriteSearch->delete();
return back()->with('success', 'Favori arama silindi.');
}
}

View File

@ -16,6 +16,18 @@ class HomeController extends Controller
$listingCount = Listing::where('status', 'active')->count(); $listingCount = Listing::where('status', 'active')->count();
$categoryCount = Category::where('is_active', true)->count(); $categoryCount = Category::where('is_active', true)->count();
$userCount = User::count(); $userCount = User::count();
return view('home', compact('categories', 'featuredListings', 'recentListings', 'listingCount', 'categoryCount', 'userCount')); $favoriteListingIds = auth()->check()
? auth()->user()->favoriteListings()->pluck('listings.id')->all()
: [];
return view('home', compact(
'categories',
'featuredListings',
'recentListings',
'listingCount',
'categoryCount',
'userCount',
'favoriteListingIds',
));
} }
} }

View File

@ -0,0 +1,48 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class FavoriteSearch extends Model
{
protected $fillable = [
'user_id',
'label',
'search_term',
'category_id',
'filters',
'signature',
];
protected $casts = [
'filters' => 'array',
];
public function user()
{
return $this->belongsTo(User::class);
}
public function category()
{
return $this->belongsTo(\Modules\Category\Models\Category::class);
}
public static function normalizeFilters(array $filters): array
{
return collect($filters)
->map(fn ($value) => is_string($value) ? trim($value) : $value)
->filter(fn ($value) => $value !== null && $value !== '' && $value !== [])
->sortKeys()
->all();
}
public static function signatureFor(array $filters): string
{
$normalized = static::normalizeFilters($filters);
$payload = json_encode($normalized);
return hash('sha256', is_string($payload) ? $payload : '');
}
}

View File

@ -68,6 +68,23 @@ class User extends Authenticatable implements FilamentUser, HasTenants, HasAvata
return $this->hasMany(\Modules\Listing\Models\Listing::class); return $this->hasMany(\Modules\Listing\Models\Listing::class);
} }
public function favoriteListings()
{
return $this->belongsToMany(\Modules\Listing\Models\Listing::class, 'favorite_listings')
->withTimestamps();
}
public function favoriteSellers()
{
return $this->belongsToMany(self::class, 'favorite_sellers', 'user_id', 'seller_id')
->withTimestamps();
}
public function favoriteSearches()
{
return $this->hasMany(FavoriteSearch::class);
}
public function canImpersonate(): bool public function canImpersonate(): bool
{ {
return $this->hasRole('admin'); return $this->hasRole('admin');

View File

@ -0,0 +1,159 @@
<?php
namespace App\Support;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Collection;
use Modules\Category\Models\Category;
use Throwable;
use function Laravel\Ai\agent;
class QuickListingCategorySuggester
{
/**
* @return array{
* detected: bool,
* category_id: int|null,
* confidence: float|null,
* reason: string,
* alternatives: array<int>,
* error: string|null
* }
*/
public function suggestFromImage(UploadedFile $image): array
{
$provider = (string) config('quick-listing.ai_provider', 'openai');
$model = config('quick-listing.ai_model');
$providerKey = config("ai.providers.{$provider}.key");
if (blank($providerKey)) {
return [
'detected' => false,
'category_id' => null,
'confidence' => null,
'reason' => 'AI provider key is missing.',
'alternatives' => [],
'error' => 'AI provider key is missing.',
];
}
$categories = Category::query()
->where('is_active', true)
->orderBy('sort_order')
->orderBy('name')
->get(['id', 'name', 'parent_id']);
if ($categories->isEmpty()) {
return [
'detected' => false,
'category_id' => null,
'confidence' => null,
'reason' => 'No active categories available.',
'alternatives' => [],
'error' => 'No active categories available.',
];
}
$catalog = $this->buildCatalog($categories);
$categoryIds = $catalog->pluck('id')->values()->all();
$catalogText = $catalog
->map(fn (array $category): string => "{$category['id']}: {$category['path']}")
->implode("\n");
try {
$response = agent(
instructions: 'You are an e-commerce listing assistant. Classify the product image into the best matching category ID from the provided catalog. Never invent IDs.',
schema: fn (JsonSchema $schema): array => [
'detected' => $schema->boolean()->required(),
'category_id' => $schema->integer()->enum($categoryIds)->nullable(),
'confidence' => $schema->number()->min(0)->max(1)->nullable(),
'reason' => $schema->string()->required(),
'alternatives' => $schema->array()->items(
$schema->integer()->enum($categoryIds)
)->max(3)->default([]),
],
)->prompt(
prompt: <<<PROMPT
Classify the uploaded image into one category from this catalog.
Catalog:
{$catalogText}
Rules:
- Use only IDs listed above.
- If unsure, set detected=false and category_id=null.
- Confidence must be between 0 and 1.
PROMPT,
attachments: [$image],
provider: $provider,
model: is_string($model) && $model !== '' ? $model : null,
);
$categoryId = isset($response['category_id']) && is_numeric($response['category_id'])
? (int) $response['category_id']
: null;
$confidence = isset($response['confidence']) && is_numeric($response['confidence'])
? (float) $response['confidence']
: null;
$alternatives = collect($response['alternatives'] ?? [])
->filter(fn ($value): bool => is_numeric($value))
->map(fn ($value): int => (int) $value)
->filter(fn (int $id): bool => in_array($id, $categoryIds, true))
->unique()
->values()
->all();
$detected = (bool) ($response['detected'] ?? false) && $categoryId !== null;
return [
'detected' => $detected,
'category_id' => $detected ? $categoryId : null,
'confidence' => $confidence,
'reason' => (string) ($response['reason'] ?? 'No reason provided.'),
'alternatives' => $alternatives,
'error' => null,
];
} catch (Throwable $exception) {
report($exception);
return [
'detected' => false,
'category_id' => null,
'confidence' => null,
'reason' => 'Category could not be detected automatically.',
'alternatives' => [],
'error' => $exception->getMessage(),
];
}
}
/**
* @param Collection<int, Category> $categories
* @return Collection<int, array{id: int, path: string}>
*/
private function buildCatalog(Collection $categories): Collection
{
$byId = $categories->keyBy('id');
return $categories->map(function (Category $category) use ($byId): array {
$path = [$category->name];
$parentId = $category->parent_id;
while ($parentId && $byId->has($parentId)) {
$parent = $byId->get($parentId);
$path[] = $parent->name;
$parentId = $parent->parent_id;
}
return [
'id' => (int) $category->id,
'path' => implode(' > ', array_reverse($path)),
];
});
}
}

View File

@ -17,6 +17,7 @@
"filament/spatie-laravel-media-library-plugin": "^5.3", "filament/spatie-laravel-media-library-plugin": "^5.3",
"filament/spatie-laravel-settings-plugin": "^5.3", "filament/spatie-laravel-settings-plugin": "^5.3",
"jeffgreco13/filament-breezy": "^3.2", "jeffgreco13/filament-breezy": "^3.2",
"laravel/ai": "^0.2.5",
"laravel/framework": "^12.0", "laravel/framework": "^12.0",
"laravel/sanctum": "^4.3", "laravel/sanctum": "^4.3",
"laravel/tinker": "^2.10.1", "laravel/tinker": "^2.10.1",

9
config/quick-listing.php Normal file
View File

@ -0,0 +1,9 @@
<?php
return [
'ai_provider' => env('QUICK_LISTING_AI_PROVIDER', 'openai'),
'ai_model' => env('QUICK_LISTING_AI_MODEL', 'gpt-5.2'),
'max_photo_count' => 20,
'max_photo_size_kb' => 5120,
];

View File

@ -0,0 +1,49 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('favorite_listings', function (Blueprint $table): void {
$table->id();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->foreignId('listing_id')->constrained('listings')->cascadeOnDelete();
$table->timestamps();
$table->unique(['user_id', 'listing_id']);
});
Schema::create('favorite_sellers', function (Blueprint $table): void {
$table->id();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->foreignId('seller_id')->constrained('users')->cascadeOnDelete();
$table->timestamps();
$table->unique(['user_id', 'seller_id']);
});
Schema::create('favorite_searches', function (Blueprint $table): void {
$table->id();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->string('label')->nullable();
$table->string('search_term')->nullable();
$table->foreignId('category_id')->nullable()->constrained('categories')->nullOnDelete();
$table->json('filters')->nullable();
$table->string('signature', 64);
$table->timestamps();
$table->unique(['user_id', 'signature']);
});
}
public function down(): void
{
Schema::dropIfExists('favorite_searches');
Schema::dropIfExists('favorite_sellers');
Schema::dropIfExists('favorite_listings');
}
};

View File

@ -0,0 +1,196 @@
@extends('app::layouts.app')
@section('title', 'Favoriler')
@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">
<aside class="bg-white border border-slate-200">
<a href="{{ route('favorites.index', ['tab' => 'listings']) }}" class="block px-5 py-4 text-base{{ $activeTab === 'listings' ? ' bg-blue-50 text-blue-700 font-semibold' : ' text-slate-700 hover:bg-slate-50' }}">
Favori İlanlar
</a>
<a href="{{ route('favorites.index', ['tab' => 'searches']) }}" class="block px-5 py-4 border-t border-slate-200{{ $activeTab === 'searches' ? ' bg-blue-50 text-blue-700 font-semibold' : ' text-slate-700 hover:bg-slate-50' }}">
Favori Aramalar
</a>
<a href="{{ route('favorites.index', ['tab' => 'sellers']) }}" class="block px-5 py-4 border-t border-slate-200{{ $activeTab === 'sellers' ? ' bg-blue-50 text-blue-700 font-semibold' : ' text-slate-700 hover:bg-slate-50' }}">
Favori Satıcılar
</a>
</aside>
<section class="bg-white border border-slate-200">
@if($activeTab === 'listings')
<div class="border-b-2 border-blue-900 px-4 py-3 flex flex-wrap items-center gap-3">
<h1 class="text-3xl font-bold text-slate-800 mr-auto">Favori Listem</h1>
<div class="inline-flex border border-slate-300 overflow-hidden">
<a href="{{ route('favorites.index', ['tab' => 'listings', 'status' => 'all', 'category' => $selectedCategoryId]) }}" class="px-5 py-2 text-sm font-semibold {{ $statusFilter === 'all' ? 'bg-slate-700 text-white' : 'bg-white text-slate-700 hover:bg-slate-100' }}">
Tümü
</a>
<a href="{{ route('favorites.index', ['tab' => 'listings', 'status' => 'active', 'category' => $selectedCategoryId]) }}" class="px-5 py-2 text-sm font-semibold border-l border-slate-300 {{ $statusFilter === 'active' ? 'bg-slate-700 text-white' : 'bg-white text-slate-700 hover:bg-slate-100' }}">
Yayında
</a>
</div>
<form method="GET" action="{{ route('favorites.index') }}" class="flex items-center gap-2">
<input type="hidden" name="tab" value="listings">
<input type="hidden" name="status" value="{{ $statusFilter }}">
<select name="category" class="h-10 min-w-44 border border-slate-300 px-3 text-sm text-slate-700">
<option value="">Kategori</option>
@foreach($categories as $category)
<option value="{{ $category->id }}" @selected((int) $selectedCategoryId === (int) $category->id)>{{ $category->name }}</option>
@endforeach
</select>
<button type="submit" class="h-10 px-4 bg-slate-700 text-white text-sm font-semibold hover:bg-slate-800 transition">Filtrele</button>
</form>
</div>
<div class="w-full overflow-x-auto">
<table class="w-full min-w-[760px]">
<thead>
<tr class="bg-slate-50 text-slate-700 text-sm">
<th class="text-left px-4 py-3 w-[70%]">İlan Başlığı</th>
<th class="text-left px-4 py-3 w-[20%]">Fiyat</th>
<th class="text-right px-4 py-3 w-[10%]"></th>
</tr>
</thead>
<tbody>
@forelse($favoriteListings as $listing)
@php
$listingImage = $listing->getFirstMediaUrl('listing-images');
$priceLabel = $listing->price ? number_format((float) $listing->price, 0).' '.$listing->currency : 'Ücretsiz';
$meta = collect([
$listing->category?->name,
$listing->city,
$listing->country,
])->filter()->join(' ');
@endphp
<tr class="border-t border-slate-200">
<td class="px-4 py-4">
<div class="flex gap-3">
<a href="{{ route('listings.show', $listing) }}" class="w-36 h-24 shrink-0 bg-slate-100 border border-slate-200 overflow-hidden">
@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>
<a href="{{ route('listings.show', $listing) }}" class="font-semibold text-2xl text-slate-800 hover:text-blue-700 transition leading-6">
{{ $listing->title }}
</a>
<p class="text-sm text-slate-500 mt-2">{{ $meta !== '' ? $meta : 'Kategori / konum bilgisi yok' }}</p>
<p class="text-xs text-slate-400 mt-1">Favoriye eklenme: {{ $listing->pivot->created_at?->format('d.m.Y') }}</p>
</div>
</div>
</td>
<td class="px-4 py-4 text-2xl font-bold text-slate-700 whitespace-nowrap">{{ $priceLabel }}</td>
<td class="px-4 py-4 text-right">
<form method="POST" action="{{ route('favorites.listings.toggle', $listing) }}">
@csrf
<button type="submit" class="text-sm font-semibold text-rose-500 hover:text-rose-600 transition">Kaldır</button>
</form>
</td>
</tr>
@empty
<tr class="border-t border-slate-200">
<td colspan="3" class="px-4 py-10 text-center text-slate-500">
Henüz favori ilan bulunmuyor.
</td>
</tr>
@endforelse
</tbody>
</table>
</div>
<div class="px-4 py-4 border-t border-slate-200 text-sm text-slate-500">
* Son 1 yıl içinde favoriye eklediğiniz ilanlar listelenmektedir.
</div>
@if($favoriteListings?->hasPages())
<div class="px-4 pb-4">{{ $favoriteListings->links() }}</div>
@endif
@endif
@if($activeTab === 'searches')
<div class="px-4 py-4 border-b border-slate-200">
<h1 class="text-3xl font-bold text-slate-800">Favori Aramalar</h1>
<p class="text-sm text-slate-500 mt-1">Kayıtlı aramalarına tek tıkla geri dön.</p>
</div>
<div class="divide-y divide-slate-200">
@forelse($favoriteSearches as $favoriteSearch)
@php
$searchUrl = route('listings.index', array_filter([
'search' => $favoriteSearch->search_term,
'category' => $favoriteSearch->category_id,
]));
@endphp
<article class="px-4 py-4 flex flex-col md:flex-row md:items-center gap-3">
<div class="flex-1">
<h2 class="font-semibold text-slate-800">{{ $favoriteSearch->label ?: 'Kayıtlı arama' }}</h2>
<p class="text-sm text-slate-500 mt-1">
@if($favoriteSearch->search_term) Arama: "{{ $favoriteSearch->search_term }}" · @endif
@if($favoriteSearch->category) Kategori: {{ $favoriteSearch->category->name }} · @endif
Kaydedilme: {{ $favoriteSearch->created_at?->format('d.m.Y H:i') }}
</p>
</div>
<div class="flex items-center gap-3">
<a href="{{ $searchUrl }}" class="inline-flex items-center h-10 px-4 bg-blue-600 text-white text-sm font-semibold rounded hover:bg-blue-700 transition">
Aramayı
</a>
<form method="POST" action="{{ route('favorites.searches.destroy', $favoriteSearch) }}">
@csrf
@method('DELETE')
<button type="submit" class="inline-flex items-center h-10 px-4 border border-slate-300 text-sm font-semibold text-slate-700 hover:bg-slate-50 transition">
Sil
</button>
</form>
</div>
</article>
@empty
<div class="px-4 py-10 text-center text-slate-500">
Henüz favori arama eklenmedi.
</div>
@endforelse
</div>
@if($favoriteSearches?->hasPages())
<div class="px-4 py-4 border-t border-slate-200">{{ $favoriteSearches->links() }}</div>
@endif
@endif
@if($activeTab === 'sellers')
<div class="px-4 py-4 border-b border-slate-200">
<h1 class="text-3xl font-bold text-slate-800">Favori Satıcılar</h1>
<p class="text-sm text-slate-500 mt-1">Takip etmek istediğin satıcıları burada yönetebilirsin.</p>
</div>
<div class="divide-y divide-slate-200">
@forelse($favoriteSellers as $seller)
<article class="px-4 py-4 flex flex-col md:flex-row md:items-center gap-3">
<div class="flex items-center gap-3 flex-1">
<div class="w-12 h-12 rounded-full bg-blue-100 text-blue-700 font-bold grid place-items-center">
{{ strtoupper(substr((string) $seller->name, 0, 1)) }}
</div>
<div>
<h2 class="font-semibold text-slate-800">{{ $seller->name }}</h2>
<p class="text-sm text-slate-500">{{ $seller->email }}</p>
<p class="text-xs text-slate-400 mt-1">Aktif ilan: {{ (int) $seller->active_listings_count }}</p>
</div>
</div>
<form method="POST" action="{{ route('favorites.sellers.toggle', $seller) }}">
@csrf
<button type="submit" class="inline-flex items-center h-10 px-4 border border-rose-200 text-sm font-semibold text-rose-600 hover:bg-rose-50 transition">
Favoriden Kaldır
</button>
</form>
</article>
@empty
<div class="px-4 py-10 text-center text-slate-500">
Henüz favori satıcı eklenmedi.
</div>
@endforelse
</div>
@if($favoriteSellers?->hasPages())
<div class="px-4 py-4 border-t border-slate-200">{{ $favoriteSellers->links() }}</div>
@endif
@endif
</section>
</div>
</div>
@endsection

View File

@ -0,0 +1,262 @@
<x-filament-panels::page>
<style>
.ql-shell { --ql-bg: #ececec; --ql-card: #f3f3f3; --ql-border: #d5d5d5; --ql-text: #121212; --ql-muted: #5f5f5f; --ql-primary: #ff3d59; --ql-primary-soft: #ffe0e6; --ql-warn: #f5e8b3; max-width: 760px; margin: 0 auto; color: var(--ql-text); }
.ql-head { display: flex; justify-content: space-between; align-items: center; gap: 1rem; margin-bottom: 1rem; }
.ql-title { font-size: 2rem; font-weight: 800; letter-spacing: -.02em; }
.ql-progress-wrap { display: flex; align-items: center; gap: 1rem; }
.ql-progress { display: grid; grid-template-columns: repeat(6, 1fr); gap: .3rem; width: 260px; }
.ql-progress > span { height: .32rem; border-radius: 999px; background: #d1d1d1; }
.ql-progress > span.is-on { background: var(--ql-primary); }
.ql-step-text { font-weight: 800; font-size: 2rem; }
.ql-card { border: 1px solid var(--ql-border); border-radius: .8rem; background: var(--ql-card); overflow: hidden; }
.ql-content { padding: 2rem; min-height: 560px; }
.ql-upload-zone { display: flex; flex-direction: column; align-items: center; gap: .9rem; text-align: center; border: 2px dashed #ababab; border-radius: .8rem; padding: 2.2rem 1rem; cursor: pointer; background: #f7f7f7; }
.ql-upload-title { font-size: 1.9rem; font-weight: 800; line-height: 1.2; }
.ql-upload-desc { color: #303030; max-width: 540px; line-height: 1.35; font-size: 1.12rem; }
.ql-upload-btn { display: inline-flex; align-items: center; justify-content: center; min-width: 220px; background: var(--ql-primary); color: #fff; border-radius: 999px; padding: .95rem 1.7rem; font-size: 1.2rem; font-weight: 700; }
.ql-help { text-align: center; color: #444; margin: 1rem 0 0; font-size: 1rem; line-height: 1.45; }
.ql-help strong { color: #111; }
.ql-ai-note { display: flex; flex-direction: column; align-items: center; gap: .65rem; margin-top: 2.2rem; text-align: center; }
.ql-ai-note h3 { font-size: 2.05rem; line-height: 1.2; font-weight: 800; }
.ql-ai-note p { color: #303030; line-height: 1.5; font-size: 1.15rem; }
.ql-error { color: #b42318; margin-top: .6rem; font-size: .9rem; text-align: center; }
.ql-photos-title { margin-top: 2rem; text-align: center; font-size: 2.1rem; font-weight: 800; }
.ql-photos-sub { margin: .8rem auto 1rem; background: #e0e0e0; border-radius: .8rem; width: fit-content; padding: .55rem 1.2rem; color: #515151; font-size: .95rem; }
.ql-grid { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: .75rem; }
.ql-slot { border-radius: .5rem; aspect-ratio: 1; background: #dcdcdc; border: 1px solid #d0d0d0; position: relative; overflow: hidden; display: flex; align-items: center; justify-content: center; }
.ql-slot img { width: 100%; height: 100%; object-fit: cover; }
.ql-remove { position: absolute; top: .25rem; right: .25rem; border: 0; background: #2e2e2ecc; color: #fff; width: 1.3rem; height: 1.3rem; border-radius: 999px; font-size: .75rem; font-weight: 700; cursor: pointer; }
.ql-cover { position: absolute; left: 0; right: 0; bottom: 0; background: var(--ql-primary); color: #fff; font-size: .7rem; text-align: center; font-weight: 700; padding: .2rem 0; letter-spacing: .02em; }
.ql-footer { border-top: 1px solid #cbcbcb; background: #ededed; padding: 1.1rem; display: flex; justify-content: center; }
.ql-continue { border: 0; border-radius: 999px; min-width: 210px; padding: .9rem 1.4rem; font-size: 1.35rem; font-weight: 700; background: var(--ql-primary); color: #fff; cursor: pointer; }
.ql-continue[disabled] { background: #d4d4d4; color: #efefef; cursor: not-allowed; }
.ql-warning { display: flex; align-items: center; gap: .6rem; background: var(--ql-warn); padding: .9rem 1.1rem; border-bottom: 1px solid #eadf9f; font-size: .98rem; font-weight: 600; }
.ql-browser-header { display: flex; align-items: center; justify-content: space-between; gap: 1rem; border-bottom: 1px solid #d9d9d9; padding: .95rem 1.1rem; font-weight: 700; }
.ql-back-btn { border: 0; background: transparent; padding: 0; color: #222; cursor: pointer; display: inline-flex; align-items: center; gap: .3rem; font-weight: 600; }
.ql-root-grid { padding: 1.2rem; display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 1rem; }
.ql-root-item { border: 1px solid transparent; background: transparent; border-radius: .7rem; padding: .8rem .4rem; text-align: center; cursor: pointer; }
.ql-root-item:hover { border-color: #cecece; background: #f9f9f9; }
.ql-root-item.is-selected { border-color: var(--ql-primary); background: var(--ql-primary-soft); }
.ql-root-icon { width: 4.2rem; height: 4.2rem; border-radius: 999px; margin: 0 auto .6rem; background: #ede1cf; display: inline-flex; align-items: center; justify-content: center; color: #3b3b3b; }
.ql-root-name { font-size: 1.05rem; font-weight: 700; line-height: 1.3; }
.ql-search { padding: .9rem 1.1rem; border-bottom: 1px solid #dfdfdf; }
.ql-search input { width: 100%; border: 1px solid #d4d4d4; border-radius: .6rem; background: #f2f2f2; padding: .72rem .9rem; font-size: .98rem; }
.ql-list { padding: 0 1.1rem 1.2rem; }
.ql-row { border-bottom: 1px solid #dddddd; padding: .85rem .1rem; display: grid; grid-template-columns: 1fr auto auto; gap: .55rem; align-items: center; }
.ql-row button { border: 0; background: transparent; cursor: pointer; text-align: left; }
.ql-row-main { font-size: 1.05rem; color: #212121; }
.ql-row-main.is-selected { font-weight: 700; }
.ql-row-child { color: #8a8a8a; }
.ql-row-check { color: var(--ql-primary); }
.ql-selection { padding: .8rem 1.1rem 0; color: #3a3a3a; font-size: .95rem; }
@media (max-width: 900px) {
.ql-title { font-size: 1.7rem; }
.ql-step-text { font-size: 1.7rem; }
.ql-content { padding: 1.2rem; min-height: 460px; }
.ql-upload-title, .ql-ai-note h3, .ql-photos-title { font-size: 1.5rem; }
.ql-grid { grid-template-columns: repeat(4, minmax(0, 1fr)); }
.ql-root-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
</style>
<div class="ql-shell">
<div class="ql-head">
<div class="ql-title">{{ $currentStep === 1 ? 'Fotoğraf' : 'Kategori Seçimi' }}</div>
<div class="ql-progress-wrap">
<div class="ql-progress" aria-hidden="true">
@for ($step = 1; $step <= 6; $step++)
<span @class(['is-on' => $step <= $currentStep])></span>
@endfor
</div>
<div class="ql-step-text">{{ $currentStep }}/6</div>
</div>
</div>
<div class="ql-card">
@if ($currentStep === 1)
<div class="ql-content">
<label class="ql-upload-zone" for="quick-listing-photo-input">
<x-heroicon-o-photo class="h-10 w-10 text-gray-700" />
<div class="ql-upload-title">Ürün fotoğraflarını yükle</div>
<div class="ql-upload-desc">
Yüklemeye başlamak için ürün fotoğraflarını
<strong>bu alana sürükleyip bırakın</strong> veya
</div>
<span class="ql-upload-btn">Fotoğraf Seç</span>
</label>
<input
id="quick-listing-photo-input"
type="file"
wire:model="photos"
accept="image/jpeg,image/jpg,image/png"
multiple
class="hidden"
/>
<p class="ql-help">
<strong>İpucu:</strong> En az 1 fotoğraf, en çok {{ (int) config('quick-listing.max_photo_count', 20) }} fotoğraf yükleyebilirsin.<br>
Desteklenen formatlar: <strong>.jpg, .jpeg ve .png</strong>
</p>
@error('photos')
<div class="ql-error">{{ $message }}</div>
@enderror
@error('photos.*')
<div class="ql-error">{{ $message }}</div>
@enderror
@if (count($photos) > 0)
<h3 class="ql-photos-title">Seçtiğin Fotoğraflar</h3>
<div class="ql-photos-sub">Fotoğrafları sıralamak için tut ve sürükle</div>
<div class="ql-grid">
@for ($index = 0; $index < (int) config('quick-listing.max_photo_count', 20); $index++)
<div class="ql-slot">
@if (isset($photos[$index]))
<img src="{{ $photos[$index]->temporaryUrl() }}" alt="Yüklenen fotoğraf {{ $index + 1 }}">
<button type="button" class="ql-remove" wire:click="removePhoto({{ $index }})">×</button>
@if ($index === 0)
<div class="ql-cover">KAPAK</div>
@endif
@else
<x-heroicon-o-photo class="h-9 w-9 text-gray-400" />
@endif
</div>
@endfor
</div>
@else
<div class="ql-ai-note">
<x-heroicon-o-sparkles class="h-10 w-10 text-pink-500" />
<h3>Ürün fotoğraflarını yükle</h3>
<p>
Hızlı ilan vermek için en az 1 fotoğraf yükleyin.<br>
<strong>letgo AI</strong> sizin için otomatik kategori önerileri sunar.
</p>
</div>
@endif
</div>
<div class="ql-footer">
<button
type="button"
class="ql-continue"
wire:click="goToCategoryStep"
@disabled(count($photos) === 0 || $isDetecting)
>
Devam Et
</button>
</div>
@endif
@if ($currentStep === 2)
@if ($isDetecting)
<div class="ql-warning">
<x-heroicon-o-arrow-path class="h-5 w-5 animate-spin text-gray-700" />
<span>Fotoğraf analiz ediliyor, kategori önerisi hazırlanıyor...</span>
</div>
@elseif ($detectedCategoryId)
<div class="ql-warning">
<x-heroicon-o-sparkles class="h-5 w-5 text-pink-500" />
<span>
letgo AI kategori önerdi:
<strong>{{ $this->selectedCategoryName }}</strong>
@if ($detectedConfidence)
(Güven: {{ number_format($detectedConfidence * 100, 0) }}%)
@endif
</span>
</div>
@else
<div class="ql-warning">
<x-heroicon-o-sparkles class="h-5 w-5 text-pink-500" />
<span>letgo AI ile ilan kategorisi tespit edilemedi, lütfen kategori seçimi yapın.</span>
</div>
@endif
@if (is_null($activeParentCategoryId))
<div class="ql-browser-header">
<span></span>
<strong>Ne Satıyorsun?</strong>
<span></span>
</div>
<div class="ql-root-grid">
@foreach ($this->rootCategories as $category)
<button
type="button"
class="ql-root-item {{ $selectedCategoryId === $category['id'] ? 'is-selected' : '' }}"
wire:click="enterCategory({{ $category['id'] }})"
>
<span class="ql-root-icon">
<x-dynamic-component :component="$this->categoryIconComponent($category['icon'])" class="h-8 w-8" />
</span>
<div class="ql-root-name">{{ $category['name'] }}</div>
</button>
@endforeach
</div>
@else
<div class="ql-browser-header">
<button type="button" class="ql-back-btn" wire:click="backToRootCategories">
<x-heroicon-o-arrow-left class="h-5 w-5" />
Geri
</button>
<strong>{{ $this->currentParentName }}</strong>
<span></span>
</div>
<div class="ql-search">
<input type="text" placeholder="Kategori Ara" wire:model.live.debounce.300ms="categorySearch">
</div>
<div class="ql-list">
@forelse ($this->currentCategories as $category)
<div class="ql-row">
<button
type="button"
class="ql-row-main {{ $selectedCategoryId === $category['id'] ? 'is-selected' : '' }}"
wire:click="selectCategory({{ $category['id'] }})"
>
{{ $category['name'] }}
</button>
@if ($category['has_children'] && $category['id'] !== $activeParentCategoryId)
<button type="button" class="ql-row-child" wire:click="enterCategory({{ $category['id'] }})">
<x-heroicon-o-chevron-right class="h-5 w-5" />
</button>
@else
<span></span>
@endif
<span class="ql-row-check">
@if ($selectedCategoryId === $category['id'])
<x-heroicon-o-check-circle class="h-5 w-5" />
@endif
</span>
</div>
@empty
<div class="ql-row">
<span class="ql-row-main">Aramaya uygun kategori bulunamadı.</span>
</div>
@endforelse
</div>
@endif
@if ($this->selectedCategoryName)
<div class="ql-selection">Seçilen kategori: <strong>{{ $this->selectedCategoryName }}</strong></div>
@endif
<div class="ql-footer">
<button
type="button"
class="ql-continue"
wire:click="continueToManualCreate"
@disabled(! $selectedCategoryId)
>
Devam Et
</button>
</div>
@endif
</div>
</div>
</x-filament-panels::page>

View File

@ -138,6 +138,7 @@
$listingImage = $listing->getFirstMediaUrl('listing-images'); $listingImage = $listing->getFirstMediaUrl('listing-images');
$priceLabel = $listing->price ? number_format((float) $listing->price, 0).' '.$listing->currency : __('messages.free'); $priceLabel = $listing->price ? number_format((float) $listing->price, 0).' '.$listing->currency : __('messages.free');
$locationLabel = trim(collect([$listing->city, $listing->country])->filter()->join(', ')); $locationLabel = trim(collect([$listing->city, $listing->country])->filter()->join(', '));
$isFavorited = in_array($listing->id, $favoriteListingIds ?? [], true);
@endphp @endphp
<article class="rounded-2xl border border-slate-200 bg-white overflow-hidden shadow-sm hover:shadow-md transition"> <article class="rounded-2xl border border-slate-200 bg-white overflow-hidden shadow-sm hover:shadow-md transition">
<div class="relative h-64 md:h-[290px] bg-slate-100"> <div class="relative h-64 md:h-[290px] bg-slate-100">
@ -156,7 +157,16 @@
@endif @endif
<span class="bg-sky-500 text-white text-xs font-semibold px-2.5 py-1 rounded-full">Büyük İlan</span> <span class="bg-sky-500 text-white text-xs font-semibold px-2.5 py-1 rounded-full">Büyük İlan</span>
</div> </div>
<button type="button" class="absolute top-3 right-3 w-9 h-9 rounded-full bg-white/90 text-slate-500 grid place-items-center hover:text-rose-500 transition"></button> <div class="absolute top-3 right-3">
@auth
<form method="POST" action="{{ route('favorites.listings.toggle', $listing) }}">
@csrf
<button type="submit" class="w-9 h-9 rounded-full grid place-items-center transition {{ $isFavorited ? 'bg-rose-500 text-white' : 'bg-white/90 text-slate-500 hover:text-rose-500' }}"></button>
</form>
@else
<a href="{{ route('filament.partner.auth.login') }}" class="w-9 h-9 rounded-full bg-white/90 text-slate-500 hover:text-rose-500 grid place-items-center transition"></a>
@endauth
</div>
</div> </div>
<div class="p-4"> <div class="p-4">
<div class="rounded-lg bg-emerald-50 text-emerald-700 text-xs font-semibold px-3 py-1.5 text-center mb-3"> <div class="rounded-lg bg-emerald-50 text-emerald-700 text-xs font-semibold px-3 py-1.5 text-center mb-3">

View File

@ -138,6 +138,7 @@
</div> </div>
</details> </details>
@auth @auth
<a href="{{ route('favorites.index') }}" class="hidden sm:inline-flex text-sm font-medium text-slate-600 hover:text-slate-900 transition">Favorilerim</a>
<a href="{{ $partnerDashboardRoute }}" class="hidden sm:inline-flex text-sm font-medium text-slate-600 hover:text-slate-900 transition">Panel</a> <a href="{{ $partnerDashboardRoute }}" class="hidden sm:inline-flex text-sm font-medium text-slate-600 hover:text-slate-900 transition">Panel</a>
<a href="{{ $partnerCreateRoute }}" class="btn-primary px-4 md:px-5 py-2 text-sm font-semibold shadow-sm hover:brightness-95 transition"> <a href="{{ $partnerCreateRoute }}" class="btn-primary px-4 md:px-5 py-2 text-sm font-semibold shadow-sm hover:brightness-95 transition">
+ {{ __('messages.post_listing') }} + {{ __('messages.post_listing') }}

View File

@ -1,5 +1,6 @@
<?php <?php
use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Route;
use App\Http\Controllers\FavoriteController;
use App\Http\Controllers\HomeController; use App\Http\Controllers\HomeController;
use App\Http\Controllers\LanguageController; use App\Http\Controllers\LanguageController;
@ -26,4 +27,12 @@ Route::get('/partner/listings', fn () => $redirectToPartner('filament.partner.re
Route::get('/partner/listings/create', fn () => $redirectToPartner('filament.partner.resources.listings.create')) Route::get('/partner/listings/create', fn () => $redirectToPartner('filament.partner.resources.listings.create'))
->name('partner.listings.create'); ->name('partner.listings.create');
Route::middleware('auth')->prefix('favorites')->name('favorites.')->group(function () {
Route::get('/', [FavoriteController::class, 'index'])->name('index');
Route::post('/listings/{listing}/toggle', [FavoriteController::class, 'toggleListing'])->name('listings.toggle');
Route::post('/sellers/{seller}/toggle', [FavoriteController::class, 'toggleSeller'])->name('sellers.toggle');
Route::post('/searches', [FavoriteController::class, 'storeSearch'])->name('searches.store');
Route::delete('/searches/{favoriteSearch}', [FavoriteController::class, 'destroySearch'])->name('searches.destroy');
});
require __DIR__.'/auth.php'; require __DIR__.'/auth.php';