diff --git a/.env.example b/.env.example index 9ce159055..5f8ec325a 100644 --- a/.env.example +++ b/.env.example @@ -51,3 +51,8 @@ MAIL_FROM_ADDRESS="hello@openclassify.com" MAIL_FROM_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 diff --git a/Modules/Admin/Filament/Resources/CityResource.php b/Modules/Admin/Filament/Resources/CityResource.php new file mode 100644 index 000000000..877f3f15d --- /dev/null +++ b/Modules/Admin/Filament/Resources/CityResource.php @@ -0,0 +1,74 @@ +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'), + ]; + } +} diff --git a/Modules/Admin/Filament/Resources/CityResource/Pages/CreateCity.php b/Modules/Admin/Filament/Resources/CityResource/Pages/CreateCity.php new file mode 100644 index 000000000..6d0069b90 --- /dev/null +++ b/Modules/Admin/Filament/Resources/CityResource/Pages/CreateCity.php @@ -0,0 +1,10 @@ +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'), + ]; + } +} diff --git a/Modules/Admin/Filament/Resources/DistrictResource/Pages/CreateDistrict.php b/Modules/Admin/Filament/Resources/DistrictResource/Pages/CreateDistrict.php new file mode 100644 index 000000000..099af6f85 --- /dev/null +++ b/Modules/Admin/Filament/Resources/DistrictResource/Pages/CreateDistrict.php @@ -0,0 +1,10 @@ +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'), + ]; + } +} diff --git a/Modules/Admin/Filament/Widgets/ListingsTrendChart.php b/Modules/Admin/Filament/Widgets/ListingsTrendChart.php new file mode 100644 index 000000000..416f32710 --- /dev/null +++ b/Modules/Admin/Filament/Widgets/ListingsTrendChart.php @@ -0,0 +1,67 @@ + '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'; + } +} diff --git a/Modules/Listing/Http/Controllers/ListingController.php b/Modules/Listing/Http/Controllers/ListingController.php index 5ff7611ed..0870c6404 100644 --- a/Modules/Listing/Http/Controllers/ListingController.php +++ b/Modules/Listing/Http/Controllers/ListingController.php @@ -2,6 +2,8 @@ namespace Modules\Listing\Http\Controllers; use App\Http\Controllers\Controller; +use App\Models\FavoriteSearch; +use Modules\Category\Models\Category; use Modules\Listing\Models\Listing; class ListingController extends Controller @@ -9,9 +11,12 @@ class ListingController extends Controller public function index() { $search = trim((string) request('search', '')); + $categoryId = request()->integer('category'); + $categoryId = $categoryId > 0 ? $categoryId : null; $listings = Listing::query() ->publicFeed() + ->with('category:id,name') ->when($search !== '', function ($query) use ($search): void { $query->where(function ($searchQuery) use ($search): void { $searchQuery @@ -21,15 +26,70 @@ class ListingController extends Controller ->orWhere('country', 'like', "%{$search}%"); }); }) + ->when($categoryId, fn ($query) => $query->where('category_id', $categoryId)) ->paginate(12) ->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) { - 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() diff --git a/Modules/Listing/Models/Listing.php b/Modules/Listing/Models/Listing.php index 0139634b2..390393d78 100644 --- a/Modules/Listing/Models/Listing.php +++ b/Modules/Listing/Models/Listing.php @@ -55,6 +55,12 @@ class Listing extends Model implements HasMedia 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 { return $query diff --git a/Modules/Listing/resources/views/index.blade.php b/Modules/Listing/resources/views/index.blade.php index 3b24a924f..8a502ec2b 100644 --- a/Modules/Listing/resources/views/index.blade.php +++ b/Modules/Listing/resources/views/index.blade.php @@ -1,12 +1,78 @@ @extends('app::layouts.app') @section('content') -
-

{{ __('messages.listings') }}

+
+
+

{{ __('messages.listings') }}

+ +
+ @if($search !== '') + + @endif + + + @if($categoryId) + + Sıfırla + + @endif +
+
+ + @auth + @php + $canSaveSearch = $search !== '' || !is_null($categoryId); + @endphp +
+
+ Bu aramayı favorilere ekleyerek daha sonra hızlıca açabilirsin. +
+
+ @csrf + + + +
+ + Favori Aramalar + +
+ @endauth +
@foreach($listings as $listing) -
-
+ @php + $listingImage = $listing->getFirstMediaUrl('listing-images'); + $isFavorited = in_array($listing->id, $favoriteListingIds ?? [], true); + @endphp +
+
+ @if($listingImage) + {{ $listing->title }} + @else + @endif + +
+ @auth +
+ @csrf + +
+ @else + + ♥ + + @endauth +
@if($listing->is_featured) @@ -16,10 +82,15 @@

@if($listing->price) {{ number_format($listing->price, 0) }} {{ $listing->currency }} @else Free @endif

+

{{ $listing->category?->name ?: 'Kategori yok' }}

{{ $listing->city }}, {{ $listing->country }}

View
+ @empty +
+ Bu filtreye uygun ilan bulunamadı. +
@endforeach
{{ $listings->links() }}
diff --git a/Modules/Listing/resources/views/show.blade.php b/Modules/Listing/resources/views/show.blade.php index 56d7e3eb0..4d95cda9f 100644 --- a/Modules/Listing/resources/views/show.blade.php +++ b/Modules/Listing/resources/views/show.blade.php @@ -37,6 +37,28 @@ @endif
+
+ @auth +
+ @csrf + +
+ @if($listing->user && (int) $listing->user->id !== (int) auth()->id()) +
+ @csrf + +
+ @endif + @else + + Giriş yap ve favorile + + @endauth +

{{ $location !== '' ? $location : 'Location not specified' }}

Posted {{ $listing->created_at?->diffForHumans() ?? 'recently' }}

@@ -45,6 +67,9 @@

Contact Seller

+ @if($listing->user) +

Name: {{ $listing->user->name }}

+ @endif @if($listing->contact_phone)

Phone: {{ $listing->contact_phone }}

@endif diff --git a/Modules/Partner/Filament/Resources/ListingResource.php b/Modules/Partner/Filament/Resources/ListingResource.php index 79045e570..fdbbaca8e 100644 --- a/Modules/Partner/Filament/Resources/ListingResource.php +++ b/Modules/Partner/Filament/Resources/ListingResource.php @@ -11,21 +11,27 @@ 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\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\ListingPanelHelper; +use Modules\Location\Models\City; +use Modules\Location\Models\Country; use Modules\Partner\Filament\Resources\ListingResource\Pages; -use Tapp\FilamentCountryCodeField\Forms\Components\CountryCodeSelect; use Ysfkaya\FilamentPhoneInput\Forms\PhoneInput; class ListingResource extends Resource @@ -36,8 +42,33 @@ class ListingResource extends Resource public static function form(Schema $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('slug')->required()->maxLength(255)->unique(ignoreRecord: true), + 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() @@ -46,16 +77,53 @@ class ListingResource extends Resource ->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'))->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(), PhoneInput::make('contact_phone')->defaultCountry(CountryCodeManager::defaultCountryIso2())->nullable(), - TextInput::make('contact_email')->email()->maxLength(255), - TextInput::make('city')->maxLength(100), - CountryCodeSelect::make('country') + TextInput::make('contact_email') + ->email() + ->maxLength(255) + ->default(fn (): ?string => Filament::auth()->user()?->email), + Select::make('country') ->label('Country') - ->default(fn () => CountryCodeManager::defaultCountryCode()) - ->formatStateUsing(fn ($state): ?string => CountryCodeManager::countryCodeFromLabelOrCode($state)) - ->dehydrateStateUsing(fn ($state, ?Listing $record): ?string => CountryCodeManager::normalizeStoredCountry($state ?? $record?->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()) @@ -94,6 +162,42 @@ class ListingResource extends Resource TextColumn::make('created_at')->dateTime()->sortable(), ])->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') @@ -113,6 +217,7 @@ class ListingResource extends Resource 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'), ]; diff --git a/Modules/Partner/Filament/Resources/ListingResource/Pages/ListListings.php b/Modules/Partner/Filament/Resources/ListingResource/Pages/ListListings.php index 5fa8d40f6..8b4ce2225 100644 --- a/Modules/Partner/Filament/Resources/ListingResource/Pages/ListListings.php +++ b/Modules/Partner/Filament/Resources/ListingResource/Pages/ListListings.php @@ -1,6 +1,7 @@ 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)), + ]; + } } diff --git a/Modules/Partner/Filament/Resources/ListingResource/Pages/QuickCreateListing.php b/Modules/Partner/Filament/Resources/ListingResource/Pages/QuickCreateListing.php new file mode 100644 index 000000000..90afd75a3 --- /dev/null +++ b/Modules/Partner/Filament/Resources/ListingResource/Pages/QuickCreateListing.php @@ -0,0 +1,282 @@ + + */ + public array $photos = []; + + /** + * @var array + */ + 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 + */ + 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 + */ + public function getRootCategoriesProperty(): array + { + return collect($this->categories) + ->whereNull('parent_id') + ->values() + ->all(); + } + + /** + * @return array + */ + 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); + } +} diff --git a/app/Http/Controllers/FavoriteController.php b/app/Http/Controllers/FavoriteController.php new file mode 100644 index 000000000..846505bd0 --- /dev/null +++ b/app/Http/Controllers/FavoriteController.php @@ -0,0 +1,180 @@ +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.'); + } +} diff --git a/app/Http/Controllers/HomeController.php b/app/Http/Controllers/HomeController.php index 7f525c50e..4e3698432 100644 --- a/app/Http/Controllers/HomeController.php +++ b/app/Http/Controllers/HomeController.php @@ -16,6 +16,18 @@ class HomeController extends Controller $listingCount = Listing::where('status', 'active')->count(); $categoryCount = Category::where('is_active', true)->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', + )); } } diff --git a/app/Models/FavoriteSearch.php b/app/Models/FavoriteSearch.php new file mode 100644 index 000000000..fc0eecea4 --- /dev/null +++ b/app/Models/FavoriteSearch.php @@ -0,0 +1,48 @@ + '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 : ''); + } +} diff --git a/app/Models/User.php b/app/Models/User.php index 439139757..c100c0a96 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -68,6 +68,23 @@ class User extends Authenticatable implements FilamentUser, HasTenants, HasAvata 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 { return $this->hasRole('admin'); diff --git a/app/Support/QuickListingCategorySuggester.php b/app/Support/QuickListingCategorySuggester.php new file mode 100644 index 000000000..1cc2422d3 --- /dev/null +++ b/app/Support/QuickListingCategorySuggester.php @@ -0,0 +1,159 @@ +, + * 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: <<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 $categories + * @return Collection + */ + 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)), + ]; + }); + } +} + diff --git a/composer.json b/composer.json index f71037373..ed6bb996a 100644 --- a/composer.json +++ b/composer.json @@ -17,6 +17,7 @@ "filament/spatie-laravel-media-library-plugin": "^5.3", "filament/spatie-laravel-settings-plugin": "^5.3", "jeffgreco13/filament-breezy": "^3.2", + "laravel/ai": "^0.2.5", "laravel/framework": "^12.0", "laravel/sanctum": "^4.3", "laravel/tinker": "^2.10.1", diff --git a/config/quick-listing.php b/config/quick-listing.php new file mode 100644 index 000000000..dd713dde7 --- /dev/null +++ b/config/quick-listing.php @@ -0,0 +1,9 @@ + 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, +]; + diff --git a/database/migrations/2026_03_03_190000_create_favorites_tables.php b/database/migrations/2026_03_03_190000_create_favorites_tables.php new file mode 100644 index 000000000..350194c75 --- /dev/null +++ b/database/migrations/2026_03_03_190000_create_favorites_tables.php @@ -0,0 +1,49 @@ +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'); + } +}; diff --git a/resources/views/favorites/index.blade.php b/resources/views/favorites/index.blade.php new file mode 100644 index 000000000..c503fb290 --- /dev/null +++ b/resources/views/favorites/index.blade.php @@ -0,0 +1,196 @@ +@extends('app::layouts.app') + +@section('title', 'Favoriler') + +@section('content') +
+
+ + +
+ @if($activeTab === 'listings') +
+

Favori Listem

+ +
+ + + + +
+
+ +
+ + + + + + + + + + @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 + + + + + + @empty + + + + @endforelse + +
İlan BaşlığıFiyat
+
+ + @if($listingImage) + {{ $listing->title }} + @else +
Görsel yok
+ @endif +
+
+ + {{ $listing->title }} + +

{{ $meta !== '' ? $meta : 'Kategori / konum bilgisi yok' }}

+

Favoriye eklenme: {{ $listing->pivot->created_at?->format('d.m.Y') }}

+
+
+
{{ $priceLabel }} +
+ @csrf + +
+
+ Henüz favori ilan bulunmuyor. +
+
+ +
+ * Son 1 yıl içinde favoriye eklediğiniz ilanlar listelenmektedir. +
+ + @if($favoriteListings?->hasPages()) +
{{ $favoriteListings->links() }}
+ @endif + @endif + + @if($activeTab === 'searches') +
+

Favori Aramalar

+

Kayıtlı aramalarına tek tıkla geri dön.

+
+
+ @forelse($favoriteSearches as $favoriteSearch) + @php + $searchUrl = route('listings.index', array_filter([ + 'search' => $favoriteSearch->search_term, + 'category' => $favoriteSearch->category_id, + ])); + @endphp +
+
+

{{ $favoriteSearch->label ?: 'Kayıtlı arama' }}

+

+ @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') }} +

+
+
+ + Aramayı Aç + +
+ @csrf + @method('DELETE') + +
+
+
+ @empty +
+ Henüz favori arama eklenmedi. +
+ @endforelse +
+ @if($favoriteSearches?->hasPages()) +
{{ $favoriteSearches->links() }}
+ @endif + @endif + + @if($activeTab === 'sellers') +
+

Favori Satıcılar

+

Takip etmek istediğin satıcıları burada yönetebilirsin.

+
+
+ @forelse($favoriteSellers as $seller) +
+
+
+ {{ strtoupper(substr((string) $seller->name, 0, 1)) }} +
+
+

{{ $seller->name }}

+

{{ $seller->email }}

+

Aktif ilan: {{ (int) $seller->active_listings_count }}

+
+
+
+ @csrf + +
+
+ @empty +
+ Henüz favori satıcı eklenmedi. +
+ @endforelse +
+ @if($favoriteSellers?->hasPages()) +
{{ $favoriteSellers->links() }}
+ @endif + @endif +
+
+
+@endsection diff --git a/resources/views/filament/partner/listings/quick-create.blade.php b/resources/views/filament/partner/listings/quick-create.blade.php new file mode 100644 index 000000000..511eaede2 --- /dev/null +++ b/resources/views/filament/partner/listings/quick-create.blade.php @@ -0,0 +1,262 @@ + + + +
+
+
{{ $currentStep === 1 ? 'Fotoğraf' : 'Kategori Seçimi' }}
+
+ +
{{ $currentStep }}/6
+
+
+ +
+ @if ($currentStep === 1) +
+ + + + +

+ İpucu: En az 1 fotoğraf, en çok {{ (int) config('quick-listing.max_photo_count', 20) }} fotoğraf yükleyebilirsin.
+ Desteklenen formatlar: .jpg, .jpeg ve .png +

+ + @error('photos') +
{{ $message }}
+ @enderror + + @error('photos.*') +
{{ $message }}
+ @enderror + + @if (count($photos) > 0) +

Seçtiğin Fotoğraflar

+
Fotoğrafları sıralamak için tut ve sürükle
+ +
+ @for ($index = 0; $index < (int) config('quick-listing.max_photo_count', 20); $index++) +
+ @if (isset($photos[$index])) + Yüklenen fotoğraf {{ $index + 1 }} + + @if ($index === 0) +
KAPAK
+ @endif + @else + + @endif +
+ @endfor +
+ @else +
+ +

Ürün fotoğraflarını yükle

+

+ Hızlı ilan vermek için en az 1 fotoğraf yükleyin.
+ letgo AI sizin için otomatik kategori önerileri sunar. +

+
+ @endif +
+ + + @endif + + @if ($currentStep === 2) + @if ($isDetecting) +
+ + Fotoğraf analiz ediliyor, kategori önerisi hazırlanıyor... +
+ @elseif ($detectedCategoryId) +
+ + + letgo AI kategori önerdi: + {{ $this->selectedCategoryName }} + @if ($detectedConfidence) + (Güven: {{ number_format($detectedConfidence * 100, 0) }}%) + @endif + +
+ @else +
+ + letgo AI ile ilan kategorisi tespit edilemedi, lütfen kategori seçimi yapın. +
+ @endif + + @if (is_null($activeParentCategoryId)) +
+ + Ne Satıyorsun? + +
+ +
+ @foreach ($this->rootCategories as $category) + + @endforeach +
+ @else +
+ + {{ $this->currentParentName }} + +
+ + + +
+ @forelse ($this->currentCategories as $category) +
+ + + @if ($category['has_children'] && $category['id'] !== $activeParentCategoryId) + + @else + + @endif + + + @if ($selectedCategoryId === $category['id']) + + @endif + +
+ @empty +
+ Aramaya uygun kategori bulunamadı. +
+ @endforelse +
+ @endif + + @if ($this->selectedCategoryName) +
Seçilen kategori: {{ $this->selectedCategoryName }}
+ @endif + + + @endif +
+
+
diff --git a/resources/views/home.blade.php b/resources/views/home.blade.php index 446a120f4..c529631dd 100644 --- a/resources/views/home.blade.php +++ b/resources/views/home.blade.php @@ -138,6 +138,7 @@ $listingImage = $listing->getFirstMediaUrl('listing-images'); $priceLabel = $listing->price ? number_format((float) $listing->price, 0).' '.$listing->currency : __('messages.free'); $locationLabel = trim(collect([$listing->city, $listing->country])->filter()->join(', ')); + $isFavorited = in_array($listing->id, $favoriteListingIds ?? [], true); @endphp
@@ -156,7 +157,16 @@ @endif Büyük İlan
- +
+ @auth +
+ @csrf + +
+ @else + + @endauth +