mirror of
https://github.com/openclassify/openclassify.git
synced 2026-04-14 11:12:09 -05:00
Add auto-filled listing form
This commit is contained in:
parent
cf313e750f
commit
a33f4f42bb
@ -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
|
||||||
|
|||||||
74
Modules/Admin/Filament/Resources/CityResource.php
Normal file
74
Modules/Admin/Filament/Resources/CityResource.php
Normal 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'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
}
|
||||||
@ -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()]; }
|
||||||
|
}
|
||||||
@ -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()]; }
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
}
|
||||||
80
Modules/Admin/Filament/Resources/DistrictResource.php
Normal file
80
Modules/Admin/Filament/Resources/DistrictResource.php
Normal 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'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
}
|
||||||
@ -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()]; }
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
}
|
||||||
@ -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()]; }
|
||||||
|
}
|
||||||
46
Modules/Admin/Filament/Widgets/ListingOverview.php
Normal file
46
Modules/Admin/Filament/Widgets/ListingOverview.php
Normal 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'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
67
Modules/Admin/Filament/Widgets/ListingsTrendChart.php
Normal file
67
Modules/Admin/Filament/Widgets/ListingsTrendChart.php
Normal 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';
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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()
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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'),
|
||||||
];
|
];
|
||||||
|
|||||||
@ -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)),
|
||||||
|
];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
180
app/Http/Controllers/FavoriteController.php
Normal file
180
app/Http/Controllers/FavoriteController.php
Normal 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.');
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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',
|
||||||
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
48
app/Models/FavoriteSearch.php
Normal file
48
app/Models/FavoriteSearch.php
Normal 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 : '');
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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');
|
||||||
|
|||||||
159
app/Support/QuickListingCategorySuggester.php
Normal file
159
app/Support/QuickListingCategorySuggester.php
Normal 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)),
|
||||||
|
];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@ -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
9
config/quick-listing.php
Normal 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,
|
||||||
|
];
|
||||||
|
|
||||||
@ -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');
|
||||||
|
}
|
||||||
|
};
|
||||||
196
resources/views/favorites/index.blade.php
Normal file
196
resources/views/favorites/index.blade.php
Normal 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ç
|
||||||
|
</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
|
||||||
262
resources/views/filament/partner/listings/quick-create.blade.php
Normal file
262
resources/views/filament/partner/listings/quick-create.blade.php
Normal 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>
|
||||||
@ -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">
|
||||||
|
|||||||
@ -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') }}
|
||||||
|
|||||||
@ -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';
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user