İlanlara mesajlaşma ekle

This commit is contained in:
fatihalp 2026-03-03 22:51:22 +03:00
parent a33f4f42bb
commit d603de62ec
32 changed files with 3216 additions and 184 deletions

View File

@ -0,0 +1,123 @@
<?php
namespace Modules\Admin\Filament\Resources;
use BackedEnum;
use Filament\Actions\DeleteAction;
use Filament\Actions\EditAction;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TagsInput;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Textarea;
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\Table;
use Modules\Admin\Filament\Resources\ListingCustomFieldResource\Pages;
use Modules\Category\Models\Category;
use Modules\Listing\Models\ListingCustomField;
use UnitEnum;
class ListingCustomFieldResource extends Resource
{
protected static ?string $model = ListingCustomField::class;
protected static string | BackedEnum | null $navigationIcon = 'heroicon-o-adjustments-horizontal';
protected static string | UnitEnum | null $navigationGroup = 'Catalog';
protected static ?int $navigationSort = 30;
public static function form(Schema $schema): Schema
{
return $schema->schema([
TextInput::make('label')
->required()
->maxLength(255)
->live(onBlur: true)
->afterStateUpdated(function ($state, $set, ?ListingCustomField $record): void {
$baseName = \Illuminate\Support\Str::slug((string) $state, '_');
$baseName = $baseName !== '' ? $baseName : 'custom_field';
$name = $baseName;
$counter = 1;
while (ListingCustomField::query()
->where('name', $name)
->when($record, fn ($query) => $query->whereKeyNot($record->getKey()))
->exists()) {
$name = "{$baseName}_{$counter}";
$counter++;
}
$set('name', $name);
}),
TextInput::make('name')
->required()
->maxLength(255)
->regex('/^[a-z0-9_]+$/')
->helperText('Only lowercase letters, numbers and underscore.')
->unique(ignoreRecord: true),
Select::make('type')
->required()
->options(ListingCustomField::typeOptions())
->live(),
Select::make('category_id')
->label('Category')
->options(fn (): array => Category::query()
->where('is_active', true)
->orderBy('name')
->pluck('name', 'id')
->all())
->searchable()
->preload()
->nullable()
->helperText('Leave empty to apply this field to all categories.'),
TagsInput::make('options')
->label('Select Options')
->placeholder('Add an option and press Enter')
->visible(fn ($get): bool => $get('type') === ListingCustomField::TYPE_SELECT)
->helperText('Used only for Select type fields.'),
TextInput::make('sort_order')
->numeric()
->default(0),
TextInput::make('placeholder')
->maxLength(255),
Textarea::make('help_text')
->rows(2)
->maxLength(500),
Toggle::make('is_required')
->default(false),
Toggle::make('is_active')
->default(true),
]);
}
public static function table(Table $table): Table
{
return $table
->columns([
TextColumn::make('id')->sortable(),
TextColumn::make('label')->searchable()->sortable(),
TextColumn::make('name')->searchable()->copyable(),
TextColumn::make('type')->sortable(),
TextColumn::make('category.name')->label('Category')->default('All categories'),
IconColumn::make('is_required')->boolean()->label('Required'),
IconColumn::make('is_active')->boolean()->label('Active'),
TextColumn::make('sort_order')->sortable(),
])
->defaultSort('sort_order')
->actions([
EditAction::make(),
DeleteAction::make(),
]);
}
public static function getPages(): array
{
return [
'index' => Pages\ListListingCustomFields::route('/'),
'create' => Pages\CreateListingCustomField::route('/create'),
'edit' => Pages\EditListingCustomField::route('/{record}/edit'),
];
}
}

View File

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

View File

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

View File

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

View File

@ -17,6 +17,7 @@ use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Toggle;
use Filament\Resources\Resource;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Components\Utilities\Get;
use Filament\Schemas\Schema;
use Filament\Tables\Columns\IconColumn;
@ -30,6 +31,7 @@ use Illuminate\Database\Eloquent\Builder;
use Modules\Admin\Filament\Resources\ListingResource\Pages;
use Modules\Category\Models\Category;
use Modules\Listing\Models\Listing;
use Modules\Listing\Support\ListingCustomFieldSchemaBuilder;
use Modules\Listing\Support\ListingPanelHelper;
use Modules\Location\Models\City;
use Modules\Location\Models\Country;
@ -55,8 +57,24 @@ class ListingResource extends Resource
->options(fn () => ListingPanelHelper::currencyOptions())
->default(fn () => ListingPanelHelper::defaultCurrency())
->required(),
Select::make('category_id')->label('Category')->options(fn () => Category::where('is_active', true)->pluck('name', 'id'))->searchable()->nullable(),
Select::make('category_id')
->label('Category')
->options(fn () => Category::where('is_active', true)->pluck('name', 'id'))
->searchable()
->live()
->afterStateUpdated(fn ($state, $set) => $set('custom_fields', []))
->nullable(),
Select::make('user_id')->relationship('user', 'email')->label('Owner')->searchable()->preload()->nullable(),
Section::make('Custom Fields')
->description('Category specific listing attributes.')
->schema(fn (Get $get): array => ListingCustomFieldSchemaBuilder::formComponents(
($categoryId = $get('category_id')) ? (int) $categoryId : null
))
->columns(2)
->columnSpanFull()
->visible(fn (Get $get): bool => ListingCustomFieldSchemaBuilder::hasFields(
($categoryId = $get('category_id')) ? (int) $categoryId : null
)),
StateFusionSelect::make('status')->required(),
PhoneInput::make('contact_phone')->defaultCountry(CountryCodeManager::defaultCountryIso2())->nullable(),
TextInput::make('contact_email')->email()->maxLength(255),

View File

@ -36,4 +36,9 @@ class Category extends Model
{
return $this->hasMany(\Modules\Listing\Models\Listing::class);
}
public function listingCustomFields(): HasMany
{
return $this->hasMany(\Modules\Listing\Models\ListingCustomField::class);
}
}

View File

@ -0,0 +1,39 @@
<?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('listing_custom_fields', function (Blueprint $table): void {
$table->id();
$table->string('name')->unique();
$table->string('label');
$table->string('type', 32);
$table->foreignId('category_id')->nullable()->constrained('categories')->nullOnDelete();
$table->text('placeholder')->nullable();
$table->text('help_text')->nullable();
$table->json('options')->nullable();
$table->boolean('is_required')->default(false);
$table->boolean('is_active')->default(true);
$table->unsignedInteger('sort_order')->default(0);
$table->timestamps();
});
Schema::table('listings', function (Blueprint $table): void {
$table->json('custom_fields')->nullable()->after('images');
});
}
public function down(): void
{
Schema::table('listings', function (Blueprint $table): void {
$table->dropColumn('custom_fields');
});
Schema::dropIfExists('listing_custom_fields');
}
};

View File

@ -2,9 +2,11 @@
namespace Modules\Listing\Http\Controllers;
use App\Http\Controllers\Controller;
use App\Models\Conversation;
use App\Models\FavoriteSearch;
use Modules\Category\Models\Category;
use Modules\Listing\Models\Listing;
use Modules\Listing\Support\ListingCustomFieldSchemaBuilder;
class ListingController extends Controller
{
@ -37,13 +39,22 @@ class ListingController extends Controller
$favoriteListingIds = [];
$isCurrentSearchSaved = false;
$conversationListingMap = [];
if (auth()->check()) {
$userId = (int) auth()->id();
$favoriteListingIds = auth()->user()
->favoriteListings()
->pluck('listings.id')
->all();
$conversationListingMap = Conversation::query()
->where('buyer_id', $userId)
->pluck('id', 'listing_id')
->map(fn ($conversationId) => (int) $conversationId)
->all();
$filters = FavoriteSearch::normalizeFilters([
'search' => $search,
'category' => $categoryId,
@ -65,17 +76,25 @@ class ListingController extends Controller
'categories',
'favoriteListingIds',
'isCurrentSearchSaved',
'conversationListingMap',
));
}
public function show(Listing $listing)
{
$listing->loadMissing('user:id,name,email');
$presentableCustomFields = ListingCustomFieldSchemaBuilder::presentableValues(
$listing->category_id ? (int) $listing->category_id : null,
$listing->custom_fields ?? [],
);
$isListingFavorited = false;
$isSellerFavorited = false;
$existingConversationId = null;
if (auth()->check()) {
$userId = (int) auth()->id();
$isListingFavorited = auth()->user()
->favoriteListings()
->whereKey($listing->getKey())
@ -87,9 +106,22 @@ class ListingController extends Controller
->whereKey($listing->user_id)
->exists();
}
if ($listing->user_id && (int) $listing->user_id !== $userId) {
$existingConversationId = Conversation::query()
->where('listing_id', $listing->getKey())
->where('buyer_id', $userId)
->value('id');
}
}
return view('listing::show', compact('listing', 'isListingFavorited', 'isSellerFavorited'));
return view('listing::show', compact(
'listing',
'isListingFavorited',
'isSellerFavorited',
'presentableCustomFields',
'existingConversationId',
));
}
public function create()

View File

@ -20,13 +20,14 @@ class Listing extends Model implements HasMedia
protected $fillable = [
'title', 'description', 'price', 'currency', 'category_id',
'user_id', 'status', 'images', 'slug',
'user_id', 'status', 'images', 'custom_fields', 'slug',
'contact_phone', 'contact_email', 'is_featured', 'expires_at',
'city', 'country', 'latitude', 'longitude', 'location',
];
protected $casts = [
'images' => 'array',
'custom_fields' => 'array',
'is_featured' => 'boolean',
'expires_at' => 'datetime',
'price' => 'decimal:2',
@ -61,6 +62,11 @@ class Listing extends Model implements HasMedia
->withTimestamps();
}
public function conversations()
{
return $this->hasMany(\App\Models\Conversation::class);
}
public function scopePublicFeed(Builder $query): Builder
{
return $query

View File

@ -0,0 +1,84 @@
<?php
namespace Modules\Listing\Models;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
class ListingCustomField extends Model
{
public const TYPE_TEXT = 'text';
public const TYPE_TEXTAREA = 'textarea';
public const TYPE_NUMBER = 'number';
public const TYPE_SELECT = 'select';
public const TYPE_BOOLEAN = 'boolean';
public const TYPE_DATE = 'date';
protected $fillable = [
'name',
'label',
'type',
'category_id',
'placeholder',
'help_text',
'options',
'is_required',
'is_active',
'sort_order',
];
protected $casts = [
'options' => 'array',
'is_required' => 'boolean',
'is_active' => 'boolean',
];
public function category()
{
return $this->belongsTo(\Modules\Category\Models\Category::class);
}
public function scopeActive(Builder $query): Builder
{
return $query->where('is_active', true);
}
public function scopeOrdered(Builder $query): Builder
{
return $query->orderBy('sort_order')->orderBy('id');
}
public function scopeForCategory(Builder $query, ?int $categoryId): Builder
{
return $query->where(function (Builder $subQuery) use ($categoryId): void {
$subQuery->whereNull('category_id');
if ($categoryId) {
$subQuery->orWhere('category_id', $categoryId);
}
});
}
public static function typeOptions(): array
{
return [
self::TYPE_TEXT => 'Text',
self::TYPE_TEXTAREA => 'Textarea',
self::TYPE_NUMBER => 'Number',
self::TYPE_SELECT => 'Select',
self::TYPE_BOOLEAN => 'Boolean',
self::TYPE_DATE => 'Date',
];
}
public function selectOptions(): array
{
$options = collect($this->options ?? [])
->map(fn ($option) => is_scalar($option) ? trim((string) $option) : null)
->filter(fn (?string $option): bool => filled($option))
->values()
->all();
return collect($options)->mapWithKeys(fn (string $option): array => [$option => $option])->all();
}
}

View File

@ -0,0 +1,129 @@
<?php
namespace Modules\Listing\Support;
use Filament\Forms\Components\DatePicker;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\Toggle;
use Filament\Schemas\Components\Component;
use Illuminate\Support\Carbon;
use Illuminate\Support\Str;
use Modules\Listing\Models\ListingCustomField;
class ListingCustomFieldSchemaBuilder
{
public static function hasFields(?int $categoryId): bool
{
return ListingCustomField::query()
->active()
->forCategory($categoryId)
->exists();
}
/**
* @return array<int, Component>
*/
public static function formComponents(?int $categoryId): array
{
return ListingCustomField::query()
->active()
->forCategory($categoryId)
->ordered()
->get()
->map(fn (ListingCustomField $field): ?Component => self::makeFieldComponent($field))
->filter()
->values()
->all();
}
/**
* @param array<string, mixed> $values
* @return array<int, array{label: string, value: string}>
*/
public static function presentableValues(?int $categoryId, array $values): array
{
if ($values === []) {
return [];
}
$fieldsByName = ListingCustomField::query()
->active()
->forCategory($categoryId)
->ordered()
->get()
->keyBy('name');
$result = [];
foreach ($values as $key => $value) {
if ($value === null || $value === '') {
continue;
}
$field = $fieldsByName->get((string) $key);
$label = $field?->label ?: Str::headline((string) $key);
if (is_bool($value)) {
$displayValue = $value ? 'Evet' : 'Hayır';
} elseif (is_array($value)) {
$displayValue = implode(', ', array_map(fn ($item): string => (string) $item, $value));
} elseif ($field?->type === ListingCustomField::TYPE_DATE) {
try {
$displayValue = Carbon::parse((string) $value)->format('d.m.Y');
} catch (\Throwable) {
$displayValue = (string) $value;
}
} else {
$displayValue = (string) $value;
}
if (trim($displayValue) === '') {
continue;
}
$result[] = [
'label' => $label,
'value' => $displayValue,
];
}
return $result;
}
private static function makeFieldComponent(ListingCustomField $field): ?Component
{
$statePath = "custom_fields.{$field->name}";
$component = match ($field->type) {
ListingCustomField::TYPE_TEXT => TextInput::make($statePath)->maxLength(255),
ListingCustomField::TYPE_TEXTAREA => Textarea::make($statePath)->rows(3)->columnSpanFull(),
ListingCustomField::TYPE_NUMBER => TextInput::make($statePath)->numeric(),
ListingCustomField::TYPE_SELECT => Select::make($statePath)->options($field->selectOptions())->searchable(),
ListingCustomField::TYPE_BOOLEAN => Toggle::make($statePath),
ListingCustomField::TYPE_DATE => DatePicker::make($statePath),
default => null,
};
if (! $component) {
return null;
}
$component = $component->label($field->label);
if ($field->is_required) {
$component = $component->required();
}
if (filled($field->placeholder) && method_exists($component, 'placeholder')) {
$component = $component->placeholder($field->placeholder);
}
if (filled($field->help_text)) {
$component = $component->helperText($field->help_text);
}
return $component;
}
}

View File

@ -46,10 +46,11 @@
@endauth
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
@foreach($listings as $listing)
@forelse($listings as $listing)
@php
$listingImage = $listing->getFirstMediaUrl('listing-images');
$isFavorited = in_array($listing->id, $favoriteListingIds ?? [], true);
$conversationId = $conversationListingMap[$listing->id] ?? null;
@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">
@ -84,14 +85,32 @@
</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>
<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 class="mt-3 grid grid-cols-1 gap-2">
<a href="{{ route('listings.show', $listing) }}" class="block text-center bg-blue-600 text-white py-2 rounded hover:bg-blue-700 transition">View</a>
@auth
@if($listing->user_id && (int) $listing->user_id !== (int) auth()->id())
@if($conversationId)
<a href="{{ route('favorites.index', ['tab' => 'listings', 'conversation' => $conversationId]) }}" class="block text-center border border-rose-300 text-rose-600 py-2 rounded hover:bg-rose-50 transition text-sm font-semibold">
Sohbete Git
</a>
@else
<form method="POST" action="{{ route('conversations.start', $listing) }}">
@csrf
<button type="submit" class="w-full border border-rose-300 text-rose-600 py-2 rounded hover:bg-rose-50 transition text-sm font-semibold">
Mesaj Gönder
</button>
</form>
@endif
@endif
@endauth
</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
@endforelse
</div>
<div class="mt-8">{{ $listings->links() }}</div>
</div>

View File

@ -52,6 +52,18 @@
{{ $isSellerFavorited ? 'Satıcı Favorilerde' : 'Satıcıyı Takip Et' }}
</button>
</form>
@if($existingConversationId)
<a href="{{ route('favorites.index', ['tab' => 'listings', 'conversation' => $existingConversationId]) }}" class="inline-flex items-center px-4 py-2 rounded-full text-sm font-semibold bg-rose-100 text-rose-700 hover:bg-rose-200 transition">
Sohbete Git
</a>
@else
<form method="POST" action="{{ route('conversations.start', $listing) }}">
@csrf
<button type="submit" class="inline-flex items-center px-4 py-2 rounded-full text-sm font-semibold bg-rose-500 text-white hover:bg-rose-600 transition">
Satıcıya Mesaj Gönder
</button>
</form>
@endif
@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">
@ -65,6 +77,19 @@
<h2 class="font-semibold text-lg mb-2">Description</h2>
<p class="text-gray-700">{{ $displayDescription }}</p>
</div>
@if(($presentableCustomFields ?? []) !== [])
<div class="mt-6 border-t pt-4">
<h2 class="font-semibold text-lg mb-3">İlan Özellikleri</h2>
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
@foreach($presentableCustomFields as $field)
<div class="rounded-lg border border-slate-200 bg-slate-50 px-3 py-2">
<p class="text-xs uppercase tracking-wide text-slate-500">{{ $field['label'] }}</p>
<p class="text-sm font-medium text-slate-800 mt-1">{{ $field['value'] }}</p>
</div>
@endforeach
</div>
</div>
@endif
<div class="mt-6 bg-gray-50 rounded-lg p-4">
<h2 class="font-semibold text-lg mb-3">Contact Seller</h2>
@if($listing->user)

View File

@ -2,7 +2,12 @@
use Illuminate\Support\Facades\Route;
Route::get('/locations/cities/{country}', function(\Modules\Location\Models\Country $country) {
return response()->json($country->cities);
return response()->json(
$country->cities()
->where('is_active', true)
->orderBy('name')
->get(['id', 'name', 'country_id'])
);
})->name('locations.cities');
Route::get('/locations/districts/{city}', function(\Modules\Location\Models\City $city) {

View File

@ -17,6 +17,7 @@ use Filament\Forms\Components\SpatieMediaLibraryFileUpload;
use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\TextInput;
use Filament\Resources\Resource;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Components\Utilities\Get;
use Filament\Schemas\Schema;
use Filament\Tables\Columns\SpatieMediaLibraryImageColumn;
@ -28,6 +29,7 @@ use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
use Modules\Category\Models\Category;
use Modules\Listing\Models\Listing;
use Modules\Listing\Support\ListingCustomFieldSchemaBuilder;
use Modules\Listing\Support\ListingPanelHelper;
use Modules\Location\Models\City;
use Modules\Location\Models\Country;
@ -82,7 +84,19 @@ class ListingResource extends Resource
->options(fn () => Category::where('is_active', true)->pluck('name', 'id'))
->default(fn (): ?int => request()->integer('category_id') ?: null)
->searchable()
->live()
->afterStateUpdated(fn ($state, $set) => $set('custom_fields', []))
->nullable(),
Section::make('Custom Fields')
->description('Category specific listing attributes.')
->schema(fn (Get $get): array => ListingCustomFieldSchemaBuilder::formComponents(
($categoryId = $get('category_id')) ? (int) $categoryId : null
))
->columns(2)
->columnSpanFull()
->visible(fn (Get $get): bool => ListingCustomFieldSchemaBuilder::hasFields(
($categoryId = $get('category_id')) ? (int) $categoryId : null
)),
StateFusionSelect::make('status')->required(),
PhoneInput::make('contact_phone')->defaultCountry(CountryCodeManager::defaultCountryIso2())->nullable(),
TextInput::make('contact_email')

View File

@ -15,8 +15,8 @@ class ListListings extends ListRecords
CreateAction::make()
->label('Manuel İlan Ekle'),
Action::make('quickCreate')
->label('Hızlı İlan Ver')
->icon('heroicon-o-bolt')
->label('AI ile Hızlı İlan Ver')
->icon('heroicon-o-sparkles')
->color('danger')
->url(ListingResource::getUrl('quick-create', shouldGuessMissingParameters: true)),
];

View File

@ -1,21 +1,36 @@
<?php
namespace Modules\Partner\Filament\Resources\ListingResource\Pages;
use App\Support\QuickListingCategorySuggester;
use Filament\Facades\Filament;
use Filament\Notifications\Notification;
use Filament\Resources\Pages\Page;
use Illuminate\Support\Collection;
use Modules\Category\Models\Category;
use Modules\Partner\Filament\Resources\ListingResource;
use Illuminate\Support\Str;
use Illuminate\Validation\Rule;
use Livewire\Features\SupportFileUploads\TemporaryUploadedFile;
use Livewire\Features\SupportFileUploads\WithFileUploads;
use Modules\Category\Models\Category;
use Modules\Listing\Models\Listing;
use Modules\Listing\Models\ListingCustomField;
use Modules\Listing\Support\ListingCustomFieldSchemaBuilder;
use Modules\Listing\Support\ListingPanelHelper;
use Modules\Location\Models\City;
use Modules\Location\Models\Country;
use Modules\Partner\Filament\Resources\ListingResource;
use Modules\Profile\Models\Profile;
use Throwable;
class QuickCreateListing extends Page
{
use WithFileUploads;
private const TOTAL_STEPS = 5;
protected static string $resource = ListingResource::class;
protected string $view = 'filament.partner.listings.quick-create';
protected static ?string $title = 'Hızlı İlan Ver';
protected static ?string $title = 'AI ile Hızlı İlan Ver';
protected static ?string $slug = 'quick-create';
protected static bool $shouldRegisterNavigation = false;
@ -29,10 +44,31 @@ class QuickCreateListing extends Page
*/
public array $categories = [];
/**
* @var array<int, array{id: int, name: string}>
*/
public array $countries = [];
/**
* @var array<int, array{id: int, name: string, country_id: int}>
*/
public array $cities = [];
/**
* @var array<int, array{name: string, label: string, type: string, is_required: bool, placeholder: string|null, help_text: string|null, options: array<int, string>}>
*/
public array $listingCustomFields = [];
/**
* @var array<string, mixed>
*/
public array $customFieldValues = [];
public int $currentStep = 1;
public string $categorySearch = '';
public ?int $selectedCategoryId = null;
public ?int $activeParentCategoryId = null;
public ?int $detectedCategoryId = null;
public ?float $detectedConfidence = null;
public ?string $detectedReason = null;
@ -45,9 +81,18 @@ class QuickCreateListing extends Page
public bool $isDetecting = false;
public string $listingTitle = '';
public string $price = '';
public string $description = '';
public ?int $selectedCountryId = null;
public ?int $selectedCityId = null;
public bool $isPublishing = false;
public function mount(): void
{
$this->loadCategories();
$this->loadLocations();
$this->hydrateLocationDefaultsFromProfile();
}
public function updatedPhotos(): void
@ -55,6 +100,11 @@ class QuickCreateListing extends Page
$this->validatePhotos();
}
public function updatedSelectedCountryId(): void
{
$this->selectedCityId = null;
}
public function removePhoto(int $index): void
{
if (! isset($this->photos[$index])) {
@ -65,6 +115,11 @@ class QuickCreateListing extends Page
$this->photos = array_values($this->photos);
}
public function goToStep(int $step): void
{
$this->currentStep = max(1, min(self::TOTAL_STEPS, $step));
}
public function goToCategoryStep(): void
{
$this->validatePhotos();
@ -75,6 +130,27 @@ class QuickCreateListing extends Page
}
}
public function goToDetailsStep(): void
{
$this->validateCategoryStep();
$this->currentStep = 3;
}
public function goToFeaturesStep(): void
{
$this->validateCategoryStep();
$this->validateDetailsStep();
$this->currentStep = 4;
}
public function goToPreviewStep(): void
{
$this->validateCategoryStep();
$this->validateDetailsStep();
$this->validateCustomFieldsStep();
$this->currentStep = 5;
}
public function detectCategoryFromImage(): void
{
if ($this->photos === []) {
@ -123,24 +199,49 @@ class QuickCreateListing extends Page
}
$this->selectedCategoryId = $categoryId;
$this->loadListingCustomFields();
}
public function continueToManualCreate()
public function publishListing()
{
if (! $this->selectedCategoryId) {
if ($this->isPublishing) {
return null;
}
$url = ListingResource::getUrl(
name: 'create',
parameters: [
'category_id' => $this->selectedCategoryId,
'quick' => 1,
],
shouldGuessMissingParameters: true,
);
$this->isPublishing = true;
return redirect()->to($url);
$this->validatePhotos();
$this->validateCategoryStep();
$this->validateDetailsStep();
$this->validateCustomFieldsStep();
try {
$listing = $this->createListing();
} catch (Throwable $exception) {
report($exception);
$this->isPublishing = false;
Notification::make()
->title('İlan oluşturulamadı')
->body('Bir hata oluştu. Lütfen tekrar deneyin.')
->danger()
->send();
return null;
}
$this->isPublishing = false;
Notification::make()
->title('İlan başarıyla oluşturuldu')
->success()
->send();
return redirect()->to(ListingResource::getUrl(
name: 'edit',
parameters: ['record' => $listing],
shouldGuessMissingParameters: true,
));
}
/**
@ -202,6 +303,18 @@ class QuickCreateListing extends Page
return (string) ($category['name'] ?? 'Kategori Seçimi');
}
public function getCurrentStepTitleProperty(): string
{
return match ($this->currentStep) {
1 => 'Fotoğraf',
2 => 'Kategori Seçimi',
3 => 'İlan Bilgileri',
4 => 'İlan Özellikleri',
5 => 'İlan Önizlemesi',
default => 'AI ile Hızlı İlan Ver',
};
}
public function getSelectedCategoryNameProperty(): ?string
{
if (! $this->selectedCategoryId) {
@ -214,6 +327,101 @@ class QuickCreateListing extends Page
return $category['name'] ?? null;
}
public function getSelectedCategoryPathProperty(): string
{
if (! $this->selectedCategoryId) {
return '';
}
return implode(' ', $this->categoryPathParts($this->selectedCategoryId));
}
/**
* @return array<int, string>
*/
public function getDetectedAlternativeNamesProperty(): array
{
if ($this->detectedAlternatives === []) {
return [];
}
$categoriesById = collect($this->categories)->keyBy('id');
return collect($this->detectedAlternatives)
->map(fn (int $id): ?string => $categoriesById[$id]['name'] ?? null)
->filter()
->values()
->all();
}
/**
* @return array<int, array{id: int, name: string, country_id: int}>
*/
public function getAvailableCitiesProperty(): array
{
if (! $this->selectedCountryId) {
return [];
}
return collect($this->cities)
->where('country_id', $this->selectedCountryId)
->values()
->all();
}
public function getSelectedCountryNameProperty(): ?string
{
if (! $this->selectedCountryId) {
return null;
}
$country = collect($this->countries)->firstWhere('id', $this->selectedCountryId);
return $country['name'] ?? null;
}
public function getSelectedCityNameProperty(): ?string
{
if (! $this->selectedCityId) {
return null;
}
$city = collect($this->cities)->firstWhere('id', $this->selectedCityId);
return $city['name'] ?? null;
}
/**
* @return array<int, array{label: string, value: string}>
*/
public function getPreviewCustomFieldsProperty(): array
{
return ListingCustomFieldSchemaBuilder::presentableValues(
$this->selectedCategoryId,
$this->sanitizedCustomFieldValues(),
);
}
public function getTitleCharactersProperty(): int
{
return mb_strlen($this->listingTitle);
}
public function getDescriptionCharactersProperty(): int
{
return mb_strlen($this->description);
}
public function getCurrentUserNameProperty(): string
{
return (string) (Filament::auth()->user()?->name ?: 'Kullanıcı');
}
public function getCurrentUserInitialProperty(): string
{
return Str::upper(Str::substr($this->currentUserName, 0, 1));
}
public function categoryIconComponent(?string $icon): string
{
return match ($icon) {
@ -247,6 +455,164 @@ class QuickCreateListing extends Page
]);
}
private function validateCategoryStep(): void
{
$this->validate([
'selectedCategoryId' => [
'required',
'integer',
Rule::in(collect($this->categories)->pluck('id')->all()),
],
], [
'selectedCategoryId.required' => 'Lütfen bir kategori seçin.',
'selectedCategoryId.in' => 'Geçerli bir kategori seçin.',
]);
}
private function validateDetailsStep(): void
{
$this->validate([
'listingTitle' => ['required', 'string', 'max:70'],
'price' => ['required', 'numeric', 'min:0'],
'description' => ['required', 'string', 'max:1450'],
'selectedCountryId' => ['required', 'integer', Rule::in(collect($this->countries)->pluck('id')->all())],
'selectedCityId' => [
'required',
'integer',
function (string $attribute, mixed $value, \Closure $fail): void {
$cityExists = collect($this->availableCities)
->contains(fn (array $city): bool => $city['id'] === (int) $value);
if (! $cityExists) {
$fail('Seçtiğiniz şehir, seçilen ülkeye ait değil.');
}
},
],
], [
'listingTitle.required' => 'İlan başlığı zorunludur.',
'listingTitle.max' => 'İlan başlığı en fazla 70 karakter olabilir.',
'price.required' => 'Fiyat zorunludur.',
'price.numeric' => 'Fiyat sayısal olmalıdır.',
'description.required' => 'Açıklama zorunludur.',
'description.max' => 'Açıklama en fazla 1450 karakter olabilir.',
'selectedCountryId.required' => 'Ülke seçimi zorunludur.',
'selectedCityId.required' => 'Şehir seçimi zorunludur.',
]);
}
private function validateCustomFieldsStep(): void
{
$rules = [];
foreach ($this->listingCustomFields as $field) {
$fieldRules = [];
$name = $field['name'];
$statePath = "customFieldValues.{$name}";
$type = $field['type'];
$isRequired = (bool) $field['is_required'];
if ($type === ListingCustomField::TYPE_BOOLEAN) {
$fieldRules[] = 'nullable';
$fieldRules[] = 'boolean';
} else {
$fieldRules[] = $isRequired ? 'required' : 'nullable';
}
$fieldRules[] = match ($type) {
ListingCustomField::TYPE_TEXT => 'string|max:255',
ListingCustomField::TYPE_TEXTAREA => 'string|max:2000',
ListingCustomField::TYPE_NUMBER => 'numeric',
ListingCustomField::TYPE_DATE => 'date',
default => 'sometimes',
};
if ($type === ListingCustomField::TYPE_SELECT) {
$options = collect($field['options'] ?? [])->map(fn ($option): string => (string) $option)->all();
$fieldRules[] = Rule::in($options);
}
$rules[$statePath] = $fieldRules;
}
if ($rules !== []) {
$this->validate($rules);
}
}
private function createListing(): Listing
{
$user = Filament::auth()->user();
if (! $user) {
abort(403);
}
$profilePhone = Profile::query()
->where('user_id', $user->getKey())
->value('phone');
$payload = [
'title' => trim($this->listingTitle),
'description' => trim($this->description),
'price' => (float) $this->price,
'currency' => ListingPanelHelper::defaultCurrency(),
'category_id' => $this->selectedCategoryId,
'status' => 'pending',
'custom_fields' => $this->sanitizedCustomFieldValues(),
'contact_email' => (string) $user->email,
'contact_phone' => $profilePhone,
'country' => $this->selectedCountryName,
'city' => $this->selectedCityName,
];
$listing = Listing::createFromFrontend($payload, $user->getKey());
foreach ($this->photos as $photo) {
if (! $photo instanceof TemporaryUploadedFile) {
continue;
}
$listing
->addMedia($photo->getRealPath())
->usingFileName($photo->getClientOriginalName())
->toMediaCollection('listing-images');
}
return $listing;
}
/**
* @return array<string, mixed>
*/
private function sanitizedCustomFieldValues(): array
{
$fieldsByName = collect($this->listingCustomFields)->keyBy('name');
return collect($this->customFieldValues)
->filter(fn ($value, $key): bool => $fieldsByName->has((string) $key))
->map(function ($value, $key) use ($fieldsByName): mixed {
$field = $fieldsByName->get((string) $key);
$type = (string) ($field['type'] ?? ListingCustomField::TYPE_TEXT);
return match ($type) {
ListingCustomField::TYPE_NUMBER => is_numeric($value) ? (float) $value : null,
ListingCustomField::TYPE_BOOLEAN => (bool) $value,
default => is_string($value) ? trim($value) : $value,
};
})
->filter(function ($value, $key) use ($fieldsByName): bool {
$field = $fieldsByName->get((string) $key);
$type = (string) ($field['type'] ?? ListingCustomField::TYPE_TEXT);
if ($type === ListingCustomField::TYPE_BOOLEAN) {
return true;
}
return ! is_null($value) && $value !== '';
})
->all();
}
private function loadCategories(): void
{
$all = Category::query()
@ -274,6 +640,124 @@ class QuickCreateListing extends Page
->all();
}
private function loadLocations(): void
{
$this->countries = Country::query()
->where('is_active', true)
->orderBy('name')
->get(['id', 'name'])
->map(fn (Country $country): array => [
'id' => (int) $country->id,
'name' => (string) $country->name,
])
->all();
$this->cities = City::query()
->where('is_active', true)
->orderBy('name')
->get(['id', 'name', 'country_id'])
->map(fn (City $city): array => [
'id' => (int) $city->id,
'name' => (string) $city->name,
'country_id' => (int) $city->country_id,
])
->all();
}
private function loadListingCustomFields(): void
{
$this->listingCustomFields = ListingCustomField::query()
->active()
->forCategory($this->selectedCategoryId)
->ordered()
->get(['name', 'label', 'type', 'is_required', 'placeholder', 'help_text', 'options'])
->map(fn (ListingCustomField $field): array => [
'name' => (string) $field->name,
'label' => (string) $field->label,
'type' => (string) $field->type,
'is_required' => (bool) $field->is_required,
'placeholder' => $field->placeholder,
'help_text' => $field->help_text,
'options' => collect($field->options ?? [])
->map(fn ($option): string => (string) $option)
->values()
->all(),
])
->all();
$allowed = collect($this->listingCustomFields)->pluck('name')->all();
$this->customFieldValues = collect($this->customFieldValues)
->only($allowed)
->all();
foreach ($this->listingCustomFields as $field) {
if ($field['type'] === ListingCustomField::TYPE_BOOLEAN && ! array_key_exists($field['name'], $this->customFieldValues)) {
$this->customFieldValues[$field['name']] = false;
}
}
}
private function hydrateLocationDefaultsFromProfile(): void
{
$user = Filament::auth()->user();
if (! $user) {
return;
}
$profile = Profile::query()->where('user_id', $user->getKey())->first();
if (! $profile) {
return;
}
$profileCountry = trim((string) ($profile->country ?? ''));
$profileCity = trim((string) ($profile->city ?? ''));
if ($profileCountry !== '') {
$country = collect($this->countries)->first(function (array $country) use ($profileCountry): bool {
return mb_strtolower($country['name']) === mb_strtolower($profileCountry);
});
if (is_array($country)) {
$this->selectedCountryId = $country['id'];
}
}
if ($profileCity !== '' && $this->selectedCountryId) {
$city = collect($this->availableCities)->first(function (array $city) use ($profileCity): bool {
return mb_strtolower($city['name']) === mb_strtolower($profileCity);
});
if (is_array($city)) {
$this->selectedCityId = $city['id'];
}
}
}
/**
* @return array<int, string>
*/
private function categoryPathParts(int $categoryId): array
{
$byId = collect($this->categories)->keyBy('id');
$parts = [];
$currentId = $categoryId;
while ($currentId && $byId->has($currentId)) {
$category = $byId->get($currentId);
if (! is_array($category)) {
break;
}
$parts[] = (string) $category['name'];
$currentId = $category['parent_id'] ?? null;
}
return array_reverse($parts);
}
private function categoryExists(int $categoryId): bool
{
return collect($this->categories)

View File

@ -36,6 +36,7 @@ class PartnerPanelProvider extends PanelProvider
->id('partner')
->path('partner')
->login()
->darkMode(false)
->colors(['primary' => Color::Emerald])
->tenant(User::class, slugAttribute: 'id')
->discoverResources(in: module_path('Partner', 'Filament/Resources'), for: 'Modules\\Partner\\Filament\\Resources')

View File

@ -0,0 +1,116 @@
<?php
namespace App\Http\Controllers;
use App\Models\Conversation;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Modules\Listing\Models\Listing;
class ConversationController extends Controller
{
public function start(Request $request, Listing $listing): RedirectResponse
{
$user = $request->user();
if (! $listing->user_id) {
return back()->with('error', 'Bu ilan için mesajlaşma açılamadı.');
}
if ((int) $listing->user_id === (int) $user->getKey()) {
return back()->with('error', 'Kendi ilanına mesaj gönderemezsin.');
}
$conversation = Conversation::query()->firstOrCreate(
[
'listing_id' => $listing->getKey(),
'buyer_id' => $user->getKey(),
],
[
'seller_id' => $listing->user_id,
],
);
if ((int) $conversation->seller_id !== (int) $listing->user_id) {
$conversation->forceFill([
'seller_id' => $listing->user_id,
])->save();
}
$user->favoriteListings()->syncWithoutDetaching([$listing->getKey()]);
$messageBody = trim((string) $request->string('message'));
if ($messageBody !== '') {
$message = $conversation->messages()->create([
'sender_id' => $user->getKey(),
'body' => $messageBody,
]);
$conversation->forceFill([
'last_message_at' => $message->created_at,
])->save();
}
return redirect()
->route('favorites.index', array_merge(
$this->listingTabFilters($request),
['conversation' => $conversation->getKey()],
))
->with('success', $messageBody !== '' ? 'Mesaj gönderildi.' : 'Sohbet açıldı.');
}
public function send(Request $request, Conversation $conversation): RedirectResponse
{
$user = $request->user();
$userId = (int) $user->getKey();
if ((int) $conversation->buyer_id !== $userId && (int) $conversation->seller_id !== $userId) {
abort(403);
}
$payload = $request->validate([
'message' => ['required', 'string', 'max:2000'],
]);
$message = $conversation->messages()->create([
'sender_id' => $userId,
'body' => trim($payload['message']),
]);
$conversation->forceFill([
'last_message_at' => $message->created_at,
])->save();
return redirect()
->route('favorites.index', array_merge(
$this->listingTabFilters($request),
['conversation' => $conversation->getKey()],
))
->with('success', 'Mesaj gönderildi.');
}
private function listingTabFilters(Request $request): array
{
$filters = [
'tab' => 'listings',
];
$status = (string) $request->string('status');
if (in_array($status, ['all', 'active'], true)) {
$filters['status'] = $status;
}
$categoryId = $request->integer('category');
if ($categoryId > 0) {
$filters['category'] = $categoryId;
}
$messageFilter = (string) $request->string('message_filter');
if (in_array($messageFilter, ['all', 'unread', 'important'], true)) {
$filters['message_filter'] = $messageFilter;
}
return $filters;
}
}

View File

@ -2,6 +2,8 @@
namespace App\Http\Controllers;
use App\Models\Conversation;
use App\Models\ConversationMessage;
use App\Models\FavoriteSearch;
use App\Models\User;
use Illuminate\Http\Request;
@ -30,6 +32,11 @@ class FavoriteController extends Controller
$selectedCategoryId = null;
}
$messageFilter = (string) $request->string('message_filter', 'all');
if (! in_array($messageFilter, ['all', 'unread', 'important'], true)) {
$messageFilter = 'all';
}
$user = $request->user();
$categories = Category::query()
->where('is_active', true)
@ -39,6 +46,15 @@ class FavoriteController extends Controller
$favoriteListings = null;
$favoriteSearches = null;
$favoriteSellers = null;
$conversations = collect();
$selectedConversation = null;
$buyerConversationListingMap = [];
$quickMessages = [
'Merhaba',
'İlan hâlâ satışta mı?',
'Son fiyat nedir?',
'Teşekkürler',
];
if ($activeTab === 'listings') {
$favoriteListings = $user->favoriteListings()
@ -49,6 +65,70 @@ class FavoriteController extends Controller
->orderByPivot('created_at', 'desc')
->paginate(10)
->withQueryString();
$userId = (int) $user->getKey();
$conversations = Conversation::query()
->forUser($userId)
->when(in_array($messageFilter, ['unread', 'important'], true), fn ($query) => $query->whereHas('messages', fn ($messageQuery) => $messageQuery
->where('sender_id', '!=', $userId)
->whereNull('read_at')))
->with([
'listing:id,title,price,currency,user_id',
'buyer:id,name',
'seller:id,name',
'lastMessage:id,conversation_id,sender_id,body,created_at',
'lastMessage.sender:id,name',
])
->withCount([
'messages as unread_count' => fn ($query) => $query
->where('sender_id', '!=', $userId)
->whereNull('read_at'),
])
->orderByDesc('last_message_at')
->orderByDesc('updated_at')
->get();
$buyerConversationListingMap = $conversations
->where('buyer_id', $userId)
->pluck('id', 'listing_id')
->map(fn ($conversationId) => (int) $conversationId)
->all();
$selectedConversationId = $request->integer('conversation');
if ($selectedConversationId <= 0 && $conversations->isNotEmpty()) {
$selectedConversationId = (int) $conversations->first()->getKey();
}
if ($selectedConversationId > 0) {
$selectedConversation = $conversations->firstWhere('id', $selectedConversationId);
if ($selectedConversation) {
$selectedConversation->load([
'listing:id,title,price,currency,user_id',
'messages' => fn ($query) => $query
->with('sender:id,name')
->orderBy('created_at'),
]);
ConversationMessage::query()
->where('conversation_id', $selectedConversation->getKey())
->where('sender_id', '!=', $userId)
->whereNull('read_at')
->update([
'read_at' => now(),
'updated_at' => now(),
]);
$conversations = $conversations->map(function (Conversation $conversation) use ($selectedConversation): Conversation {
if ((int) $conversation->getKey() === (int) $selectedConversation->getKey()) {
$conversation->unread_count = 0;
}
return $conversation;
});
}
}
}
if ($activeTab === 'searches') {
@ -73,10 +153,15 @@ class FavoriteController extends Controller
'activeTab' => $activeTab,
'statusFilter' => $statusFilter,
'selectedCategoryId' => $selectedCategoryId,
'messageFilter' => $messageFilter,
'categories' => $categories,
'favoriteListings' => $favoriteListings,
'favoriteSearches' => $favoriteSearches,
'favoriteSellers' => $favoriteSellers,
'conversations' => $conversations,
'selectedConversation' => $selectedConversation,
'buyerConversationListingMap' => $buyerConversationListingMap,
'quickMessages' => $quickMessages,
]);
}

View File

@ -0,0 +1,58 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Modules\Listing\Models\Listing;
class Conversation extends Model
{
use HasFactory;
protected $fillable = [
'listing_id',
'seller_id',
'buyer_id',
'last_message_at',
];
protected $casts = [
'last_message_at' => 'datetime',
];
public function listing()
{
return $this->belongsTo(Listing::class);
}
public function seller()
{
return $this->belongsTo(User::class, 'seller_id');
}
public function buyer()
{
return $this->belongsTo(User::class, 'buyer_id');
}
public function messages()
{
return $this->hasMany(ConversationMessage::class);
}
public function lastMessage()
{
return $this->hasOne(ConversationMessage::class)->latestOfMany();
}
public function scopeForUser(Builder $query, int $userId): Builder
{
return $query->where(function (Builder $participantQuery) use ($userId): void {
$participantQuery
->where('buyer_id', $userId)
->orWhere('seller_id', $userId);
});
}
}

View File

@ -0,0 +1,32 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class ConversationMessage extends Model
{
use HasFactory;
protected $fillable = [
'conversation_id',
'sender_id',
'body',
'read_at',
];
protected $casts = [
'read_at' => 'datetime',
];
public function conversation()
{
return $this->belongsTo(Conversation::class);
}
public function sender()
{
return $this->belongsTo(User::class, 'sender_id');
}
}

View File

@ -85,6 +85,21 @@ class User extends Authenticatable implements FilamentUser, HasTenants, HasAvata
return $this->hasMany(FavoriteSearch::class);
}
public function buyerConversations()
{
return $this->hasMany(Conversation::class, 'buyer_id');
}
public function sellerConversations()
{
return $this->hasMany(Conversation::class, 'seller_id');
}
public function sentConversationMessages()
{
return $this->hasMany(ConversationMessage::class, 'sender_id');
}
public function canImpersonate(): bool
{
return $this->hasRole('admin');

View File

@ -12,6 +12,7 @@ use Illuminate\Support\Facades\Route;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Facades\View;
use Modules\Location\Models\Country;
use SocialiteProviders\Manager\SocialiteWasCalled;
use Throwable;
@ -209,7 +210,30 @@ class AppServiceProvider extends ServiceProvider
->visible(insidePanels: count($availableLocales) > 1, outsidePanels: false);
});
$headerLocationCountries = [];
try {
if (Schema::hasTable('countries') && Schema::hasTable('cities')) {
$headerLocationCountries = Country::query()
->where('is_active', true)
->orderBy('name')
->get(['id', 'name', 'code'])
->map(function (Country $country): array {
return [
'id' => (int) $country->id,
'name' => (string) $country->name,
'code' => strtoupper((string) $country->code),
];
})
->values()
->all();
}
} catch (Throwable) {
$headerLocationCountries = [];
}
View::share('generalSettings', $generalSettings);
View::share('headerLocationCountries', $headerLocationCountries);
}
private function normalizeCurrencies(array $currencies): array

View File

@ -1,8 +1,8 @@
<?php
return [
'active' => env('TALLCMS_THEME_ACTIVE', 'talldaisy'),
'themes_path' => base_path('themes'),
'cache_themes' => env('TALLCMS_THEME_CACHE', true),
'auto_discover' => true,
];
return array (
'active' => 'minimal',
'themes_path' => '/Users/alp/Documents/projects/oc2/themes',
'cache_themes' => true,
'auto_discover' => true,
);

View File

@ -0,0 +1,42 @@
<?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('conversations', function (Blueprint $table): void {
$table->id();
$table->foreignId('listing_id')->constrained('listings')->cascadeOnDelete();
$table->foreignId('seller_id')->constrained('users')->cascadeOnDelete();
$table->foreignId('buyer_id')->constrained('users')->cascadeOnDelete();
$table->timestamp('last_message_at')->nullable();
$table->timestamps();
$table->unique(['listing_id', 'buyer_id']);
$table->index(['seller_id', 'last_message_at']);
$table->index(['buyer_id', 'last_message_at']);
});
Schema::create('conversation_messages', function (Blueprint $table): void {
$table->id();
$table->foreignId('conversation_id')->constrained('conversations')->cascadeOnDelete();
$table->foreignId('sender_id')->constrained('users')->cascadeOnDelete();
$table->text('body');
$table->timestamp('read_at')->nullable();
$table->timestamps();
$table->index(['conversation_id', 'created_at']);
$table->index(['conversation_id', 'read_at']);
});
}
public function down(): void
{
Schema::dropIfExists('conversation_messages');
Schema::dropIfExists('conversations');
}
};

1
public/themes/minimal Symbolic link
View File

@ -0,0 +1 @@
/Users/alp/Documents/projects/oc2/vendor/tallcms/cms/resources/themes/minimal/public

View File

@ -19,19 +19,28 @@
<section class="bg-white border border-slate-200">
@if($activeTab === 'listings')
@php
$listingTabQuery = array_filter([
'tab' => 'listings',
'status' => $statusFilter,
'category' => $selectedCategoryId,
'message_filter' => $messageFilter,
], fn ($value) => !is_null($value) && $value !== '');
@endphp
<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' }}">
<a href="{{ route('favorites.index', array_merge($listingTabQuery, ['status' => 'all'])) }}" 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' }}">
<a href="{{ route('favorites.index', array_merge($listingTabQuery, ['status' => 'active'])) }}" 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 }}">
<input type="hidden" name="message_filter" value="{{ $messageFilter }}">
<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)
@ -43,12 +52,13 @@
</div>
<div class="w-full overflow-x-auto">
<table class="w-full min-w-[760px]">
<table class="w-full min-w-[860px]">
<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>
<th class="text-left px-4 py-3 w-[58%]">İlan Başlığı</th>
<th class="text-left px-4 py-3 w-[16%]">Fiyat</th>
<th class="text-left px-4 py-3 w-[14%]">Mesajlaşma</th>
<th class="text-right px-4 py-3 w-[12%]"></th>
</tr>
</thead>
<tbody>
@ -61,6 +71,9 @@
$listing->city,
$listing->country,
])->filter()->join(' ');
$conversationId = $buyerConversationListingMap[$listing->id] ?? null;
$isOwnListing = (int) $listing->user_id === (int) auth()->id();
$canMessageListing = !is_null($listing->user_id) && ! $isOwnListing;
@endphp
<tr class="border-t border-slate-200">
<td class="px-4 py-4">
@ -82,6 +95,29 @@
</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">
@if($canMessageListing)
@if($conversationId)
<a href="{{ route('favorites.index', array_merge($listingTabQuery, ['conversation' => $conversationId])) }}" class="inline-flex items-center h-10 px-4 border border-rose-300 text-rose-600 text-sm font-semibold rounded-full hover:bg-rose-50 transition">
Sohbete Git
</a>
@else
<form method="POST" action="{{ route('conversations.start', $listing) }}">
@csrf
<input type="hidden" name="status" value="{{ $statusFilter }}">
@if($selectedCategoryId)
<input type="hidden" name="category" value="{{ $selectedCategoryId }}">
@endif
<input type="hidden" name="message_filter" value="{{ $messageFilter }}">
<button type="submit" class="inline-flex items-center h-10 px-4 bg-rose-500 text-white text-sm font-semibold rounded-full hover:bg-rose-600 transition">
Mesaj Gönder
</button>
</form>
@endif
@else
<span class="text-xs text-slate-400">{{ $isOwnListing ? 'Kendi ilanın' : 'Satıcı bilgisi yok' }}</span>
@endif
</td>
<td class="px-4 py-4 text-right">
<form method="POST" action="{{ route('favorites.listings.toggle', $listing) }}">
@csrf
@ -91,7 +127,7 @@
</tr>
@empty
<tr class="border-t border-slate-200">
<td colspan="3" class="px-4 py-10 text-center text-slate-500">
<td colspan="4" class="px-4 py-10 text-center text-slate-500">
Henüz favori ilan bulunmuyor.
</td>
</tr>
@ -107,6 +143,176 @@
@if($favoriteListings?->hasPages())
<div class="px-4 pb-4">{{ $favoriteListings->links() }}</div>
@endif
<div class="border-t border-slate-200 bg-slate-50 p-4 sm:p-5">
<div class="border border-slate-200 bg-white rounded-2xl overflow-hidden shadow-sm">
<div class="grid grid-cols-1 xl:grid-cols-[420px,1fr] min-h-[620px]">
<div class="border-b xl:border-b-0 xl:border-r border-slate-200">
<div class="px-6 py-5 border-b border-slate-200 flex items-center justify-between gap-3">
<h2 class="text-3xl font-bold text-slate-900">Gelen Kutusu</h2>
<svg class="w-6 h-6 text-slate-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-4.35-4.35m1.6-5.05a7.25 7.25 0 11-14.5 0 7.25 7.25 0 0114.5 0z"/>
</svg>
</div>
<div class="px-6 py-4 border-b border-slate-200">
<p class="text-sm font-semibold text-slate-600 mb-2">Hızlı Filtreler</p>
<div class="flex flex-wrap items-center gap-2">
<a href="{{ route('favorites.index', array_merge($listingTabQuery, ['message_filter' => 'all'])) }}" class="inline-flex items-center px-4 py-2 rounded-full text-sm font-semibold border {{ $messageFilter === 'all' ? 'border-rose-400 bg-rose-50 text-rose-600' : 'border-slate-300 text-slate-600 hover:bg-slate-100' }}">
Hepsi
</a>
<a href="{{ route('favorites.index', array_merge($listingTabQuery, ['message_filter' => 'unread'])) }}" class="inline-flex items-center px-4 py-2 rounded-full text-sm font-semibold border {{ $messageFilter === 'unread' ? 'border-rose-400 bg-rose-50 text-rose-600' : 'border-slate-300 text-slate-600 hover:bg-slate-100' }}">
Okunmamış
</a>
<a href="{{ route('favorites.index', array_merge($listingTabQuery, ['message_filter' => 'important'])) }}" class="inline-flex items-center px-4 py-2 rounded-full text-sm font-semibold border {{ $messageFilter === 'important' ? 'border-rose-400 bg-rose-50 text-rose-600' : 'border-slate-300 text-slate-600 hover:bg-slate-100' }}">
Önemli
</a>
</div>
</div>
<div class="max-h-[480px] overflow-y-auto divide-y divide-slate-200">
@forelse($conversations as $conversation)
@php
$conversationListing = $conversation->listing;
$partner = (int) $conversation->buyer_id === (int) auth()->id() ? $conversation->seller : $conversation->buyer;
$isSelected = $selectedConversation && (int) $selectedConversation->id === (int) $conversation->id;
$conversationImage = $conversationListing?->getFirstMediaUrl('listing-images');
$lastMessage = trim((string) ($conversation->lastMessage?->body ?? ''));
@endphp
<a href="{{ route('favorites.index', array_merge($listingTabQuery, ['conversation' => $conversation->id])) }}" class="block px-6 py-4 transition {{ $isSelected ? 'bg-rose-50' : 'hover:bg-slate-50' }}">
<div class="flex gap-3">
<div class="w-14 h-14 rounded-xl bg-slate-100 border border-slate-200 overflow-hidden shrink-0">
@if($conversationImage)
<img src="{{ $conversationImage }}" alt="{{ $conversationListing?->title }}" class="w-full h-full object-cover">
@else
<div class="w-full h-full grid place-items-center text-slate-400 text-xs">İlan</div>
@endif
</div>
<div class="min-w-0 flex-1">
<div class="flex items-start gap-2">
<p class="font-semibold text-2xl text-slate-900 truncate">{{ $partner?->name ?? 'Kullanıcı' }}</p>
<p class="text-xs text-slate-500 whitespace-nowrap ml-auto">{{ $conversation->last_message_at?->format('d.m.Y') }}</p>
</div>
<p class="text-sm text-slate-500 truncate mt-1">{{ $conversationListing?->title ?? 'İlan silinmiş' }}</p>
<p class="text-sm {{ $conversation->unread_count > 0 ? 'text-slate-900 font-semibold' : 'text-slate-500' }} truncate mt-1">
{{ $lastMessage !== '' ? $lastMessage : 'Henüz mesaj yok' }}
</p>
</div>
@if($conversation->unread_count > 0)
<span class="inline-flex items-center justify-center min-w-6 h-6 px-2 rounded-full bg-rose-500 text-white text-xs font-semibold">
{{ $conversation->unread_count }}
</span>
@endif
</div>
</a>
@empty
<div class="px-6 py-16 text-center text-slate-500">
Henüz bir sohbetin yok.
</div>
@endforelse
</div>
</div>
<div class="flex flex-col min-h-[620px]">
@if($selectedConversation)
@php
$activeListing = $selectedConversation->listing;
$activePartner = (int) $selectedConversation->buyer_id === (int) auth()->id()
? $selectedConversation->seller
: $selectedConversation->buyer;
$activePriceLabel = $activeListing && !is_null($activeListing->price)
? number_format((float) $activeListing->price, 0).' '.($activeListing->currency ?? 'TL')
: null;
@endphp
<div class="h-24 px-6 border-b border-slate-200 flex items-center gap-4">
<div class="w-12 h-12 rounded-full bg-slate-600 text-white grid place-items-center font-semibold text-lg">
{{ strtoupper(substr((string) ($activePartner?->name ?? 'K'), 0, 1)) }}
</div>
<div class="min-w-0">
<p class="text-3xl font-bold text-slate-900 truncate">{{ $activePartner?->name ?? 'Kullanıcı' }}</p>
<p class="text-sm text-slate-500 truncate">{{ $activeListing?->title ?? 'İlan silinmiş' }}</p>
</div>
@if($activePriceLabel)
<div class="ml-auto text-3xl font-semibold text-slate-800 whitespace-nowrap">{{ $activePriceLabel }}</div>
@endif
</div>
<div class="flex-1 px-6 py-6 bg-slate-100/60 overflow-y-auto max-h-[390px]">
@forelse($selectedConversation->messages as $message)
@php $isMine = (int) $message->sender_id === (int) auth()->id(); @endphp
<div class="mb-4 flex {{ $isMine ? 'justify-end' : 'justify-start' }}">
<div class="max-w-[80%]">
<div class="{{ $isMine ? 'bg-amber-100 text-slate-900' : 'bg-white text-slate-900 border border-slate-200' }} rounded-2xl px-4 py-2 text-base shadow-sm">
{{ $message->body }}
</div>
<p class="text-xs text-slate-500 mt-1 {{ $isMine ? 'text-right' : 'text-left' }}">
{{ $message->created_at?->format('H:i') }}
</p>
</div>
</div>
@empty
<div class="h-full grid place-items-center text-slate-500 text-center px-8">
<div>
<p class="font-semibold text-slate-700">Henüz mesaj yok.</p>
<p class="text-sm mt-1">Aşağıdaki hazır metinlerden birini seçebilir veya yeni mesaj yazabilirsin.</p>
</div>
</div>
@endforelse
</div>
<div class="px-4 py-3 border-t border-slate-200 bg-white">
<div class="flex items-center gap-2 overflow-x-auto pb-2">
@foreach($quickMessages as $quickMessage)
<form method="POST" action="{{ route('conversations.messages.send', $selectedConversation) }}" class="shrink-0">
@csrf
<input type="hidden" name="status" value="{{ $statusFilter }}">
@if($selectedCategoryId)
<input type="hidden" name="category" value="{{ $selectedCategoryId }}">
@endif
<input type="hidden" name="message_filter" value="{{ $messageFilter }}">
<input type="hidden" name="message" value="{{ $quickMessage }}">
<button type="submit" class="inline-flex items-center h-11 px-5 rounded-full border border-rose-300 text-rose-600 font-semibold text-sm hover:bg-rose-50 transition">
{{ $quickMessage }}
</button>
</form>
@endforeach
</div>
<form method="POST" action="{{ route('conversations.messages.send', $selectedConversation) }}" class="flex items-center gap-2 border-t border-slate-200 pt-3 mt-1">
@csrf
<input type="hidden" name="status" value="{{ $statusFilter }}">
@if($selectedCategoryId)
<input type="hidden" name="category" value="{{ $selectedCategoryId }}">
@endif
<input type="hidden" name="message_filter" value="{{ $messageFilter }}">
<input
type="text"
name="message"
value="{{ old('message') }}"
placeholder="Bir mesaj yaz"
maxlength="2000"
class="h-12 flex-1 rounded-full border border-slate-300 px-5 text-sm focus:outline-none focus:ring-2 focus:ring-rose-300"
required
>
<button type="submit" class="h-12 w-12 rounded-full bg-black text-white grid place-items-center hover:bg-slate-800 transition" aria-label="Gönder">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 12h13m0 0l-5-5m5 5l-5 5"/>
</svg>
</button>
</form>
@error('message')
<p class="text-xs text-rose-600 mt-2 px-2">{{ $message }}</p>
@enderror
</div>
@else
<div class="h-full min-h-[620px] grid place-items-center px-8 text-center text-slate-500">
<div>
<p class="text-2xl font-semibold text-slate-700">Mesajlaşma için bir sohbet seç.</p>
<p class="mt-2 text-sm">İlan detayından veya favori ilan satırındaki "Mesaj Gönder" butonundan yeni sohbet başlatabilirsin.</p>
</div>
</div>
@endif
</div>
</div>
</div>
</div>
@endif
@if($activeTab === 'searches')

View File

@ -18,25 +18,6 @@
@endphp
<div class="max-w-[1320px] mx-auto px-4 py-5 md:py-7 space-y-7">
<section class="bg-white border border-slate-200 rounded-2xl px-2 py-2 overflow-x-auto">
<div class="flex items-center gap-2 min-w-max">
<a href="{{ route('categories.index') }}" class="inline-flex items-center gap-2 px-4 py-2.5 rounded-full bg-slate-900 text-white text-sm font-semibold">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.8" d="M4 6h16M4 12h16M4 18h16"/>
</svg>
Tüm Kategoriler
</a>
@foreach($menuCategories as $category)
<a href="{{ route('categories.show', $category) }}" class="px-4 py-2.5 rounded-full text-sm font-medium text-slate-700 hover:bg-slate-100 transition whitespace-nowrap">
{{ $category->name }}
</a>
@endforeach
<a href="{{ route('listings.index') }}" class="px-4 py-2.5 rounded-full text-sm font-medium text-slate-700 hover:bg-slate-100 transition whitespace-nowrap">
{{ __('messages.listings') }}
</a>
</div>
</section>
<section class="relative overflow-hidden rounded-[28px] bg-gradient-to-r from-blue-900 via-blue-700 to-blue-600 text-white shadow-xl">
<div class="absolute -top-20 -left-24 w-80 h-80 rounded-full bg-blue-400/20 blur-3xl"></div>
<div class="absolute -bottom-24 right-10 w-80 h-80 rounded-full bg-cyan-300/20 blur-3xl"></div>

View File

@ -11,6 +11,9 @@
$partnerRegisterRoute = route('register');
$partnerLogoutRoute = route('filament.partner.auth.logout');
$partnerCreateRoute = route('partner.listings.create');
$partnerQuickCreateRoute = auth()->check()
? route('filament.partner.resources.listings.quick-create', ['tenant' => auth()->id()])
: $partnerLoginRoute;
$partnerDashboardRoute = auth()->check()
? route('filament.partner.pages.dashboard', ['tenant' => auth()->id()])
: $partnerLoginRoute;
@ -28,6 +31,12 @@
'ja' => '日本語',
];
$isHomePage = request()->routeIs('home');
$homeHeaderCategories = isset($categories) ? collect($categories)->take(8) : collect();
$locationCountries = collect($headerLocationCountries ?? [])->values();
$defaultCountryIso2 = strtoupper((string) config('app.default_country_iso2', 'TR'));
$citiesRouteTemplate = \Illuminate\Support\Facades\Route::has('locations.cities')
? route('locations.cities', ['country' => '__COUNTRY__'])
: '';
@endphp
<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}" dir="{{ in_array(app()->getLocale(), ['ar']) ? 'rtl' : 'ltr' }}">
@ -84,6 +93,41 @@
border-radius: 999px;
}
.header-utility {
width: 2.75rem;
height: 2.75rem;
border-radius: 999px;
border: 1px solid #d9ddea;
background: #fff;
display: inline-flex;
align-items: center;
justify-content: center;
color: #64748b;
transition: all 0.2s ease;
}
.header-utility:hover {
border-color: #fda4af;
color: #f43f5e;
}
.location-panel {
width: min(90vw, 360px);
}
.location-panel select {
border: 1px solid #d9ddea;
border-radius: 0.75rem;
background: #f8fafc;
color: #334155;
padding: 0.55rem 0.75rem;
font-size: 0.875rem;
}
summary::-webkit-details-marker {
display: none;
}
[dir="rtl"] {
text-align: right;
}
@ -99,6 +143,7 @@
@endif
<span class="brand-mark text-3xl text-rose-500 leading-none">{{ $siteName }}</span>
</a>
<form action="{{ route('listings.index') }}" method="GET" class="hidden lg:flex flex-1 search-shell items-center gap-2 px-4 py-2.5">
<svg class="w-5 h-5 text-rose-500 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.8" d="M21 21l-4.35-4.35m1.6-5.05a7.25 7.25 0 11-14.5 0 7.25 7.25 0 0114.5 0z"/>
@ -114,17 +159,65 @@
{{ __('messages.search') }}
</button>
</form>
<button type="button" class="chip-btn hidden md:flex items-center gap-2 px-4 py-2 text-sm text-slate-700">
<svg class="w-4 h-4 text-slate-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.8" d="M12 21s7-6.2 7-11a7 7 0 10-14 0c0 4.8 7 11 7 11z"/>
<circle cx="12" cy="10" r="2.3" stroke-width="1.8" />
</svg>
<span>Istanbul, Türkiye</span>
<svg class="w-4 h-4 text-slate-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.8" d="M6 9l6 6 6-6"/>
</svg>
</button>
<details class="relative hidden md:block" data-location-widget data-cities-url-template="{{ $citiesRouteTemplate }}">
<summary class="chip-btn list-none cursor-pointer px-4 py-2.5 text-sm text-slate-700 inline-flex items-center gap-2">
<svg class="w-4 h-4 text-slate-500 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.8" d="M12 21s7-6.2 7-11a7 7 0 10-14 0c0 4.8 7 11 7 11z"/>
<circle cx="12" cy="10" r="2.3" stroke-width="1.8" />
</svg>
<span data-location-label class="max-w-44 truncate">Konum seç</span>
<svg class="w-4 h-4 text-slate-500 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.8" d="M6 9l6 6 6-6"/>
</svg>
</summary>
<div class="location-panel absolute right-0 mt-2 bg-white border border-slate-200 shadow-xl rounded-2xl p-4 space-y-3">
<div class="flex items-center justify-between gap-3">
<p class="text-sm font-semibold text-slate-900">Konum Tercihi</p>
<button type="button" data-location-detect class="text-xs font-semibold text-rose-500 hover:text-rose-600 transition">Konumumu Bul</button>
</div>
<p data-location-status class="text-xs text-slate-500">Tarayıcı konumuna göre ülke ve şehir otomatik seçilebilir.</p>
<div class="space-y-2">
<label class="block text-xs font-semibold text-slate-600">Ülke</label>
<select data-location-country class="w-full">
<option value="">Ülke seç</option>
@foreach($locationCountries as $country)
<option
value="{{ $country['id'] }}"
data-code="{{ strtoupper($country['code'] ?? '') }}"
data-name="{{ $country['name'] }}"
data-default="{{ strtoupper($country['code'] ?? '') === $defaultCountryIso2 ? '1' : '0' }}"
>
{{ $country['name'] }}
</option>
@endforeach
</select>
</div>
<div class="space-y-2">
<label class="block text-xs font-semibold text-slate-600">Şehir</label>
<select data-location-city class="w-full" disabled>
<option value="">Önce ülke seç</option>
</select>
</div>
<button type="button" data-location-save class="w-full btn-primary px-4 py-2.5 text-sm font-semibold hover:brightness-95 transition">Uygula</button>
</div>
</details>
<div class="ml-auto flex items-center gap-2 md:gap-3">
@auth
<a href="{{ route('favorites.index') }}" class="header-utility hidden xl:inline-flex" aria-label="Favoriler">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.8" d="M12 21l-1.45-1.32C5.4 15.03 2 12.01 2 8.31 2 5.3 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.08A6.04 6.04 0 0116.5 3C19.58 3 22 5.3 22 8.31c0 3.7-3.4 6.72-8.55 11.39L12 21z"/>
</svg>
</a>
<a href="{{ $partnerDashboardRoute }}" class="header-utility hidden xl:inline-flex" aria-label="Panel">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.8" d="M3 12l9-9 9 9M5 10v10h14V10"/>
</svg>
</a>
<a href="{{ $partnerQuickCreateRoute }}" class="hidden md:inline-flex px-4 py-2.5 text-sm font-semibold rounded-full border border-rose-200 text-rose-600 bg-rose-50 hover:bg-rose-100 transition">
Post Fast
</a>
<details class="relative">
<summary class="chip-btn list-none cursor-pointer px-3 py-2 text-xs md:text-sm text-slate-700">
{{ strtoupper(app()->getLocale()) }}
@ -137,27 +230,28 @@
@endforeach
</div>
</details>
@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="{{ $partnerCreateRoute }}" class="btn-primary px-4 md:px-5 py-2 text-sm font-semibold shadow-sm hover:brightness-95 transition">
+ {{ __('messages.post_listing') }}
<a href="{{ $partnerCreateRoute }}" class="btn-primary px-4 md:px-5 py-2.5 text-sm font-semibold shadow-sm hover:brightness-95 transition">
Sat
</a>
<form method="POST" action="{{ $partnerLogoutRoute }}" class="hidden sm:block">
<form method="POST" action="{{ $partnerLogoutRoute }}" class="hidden xl:block">
@csrf
<button type="submit" class="text-sm text-slate-500 hover:text-rose-500 transition">{{ __('messages.logout') }}</button>
</form>
@else
<a href="{{ $partnerLoginRoute }}" class="bg-rose-50 text-rose-500 px-4 md:px-5 py-2 rounded-full text-sm font-semibold hover:bg-rose-100 transition">
<a href="{{ $partnerQuickCreateRoute }}" class="hidden md:inline-flex px-4 py-2.5 text-sm font-semibold rounded-full border border-rose-200 text-rose-600 bg-rose-50 hover:bg-rose-100 transition">
Post Fast
</a>
<a href="{{ $partnerLoginRoute }}" class="bg-rose-50 text-rose-500 px-4 md:px-5 py-2.5 rounded-full text-sm font-semibold hover:bg-rose-100 transition">
{{ __('messages.login') }}
</a>
<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') }}
<a href="{{ $partnerCreateRoute }}" class="btn-primary px-4 md:px-5 py-2.5 text-sm font-semibold shadow-sm hover:brightness-95 transition">
Sat
</a>
@endauth
</div>
</div>
<div class="mt-3 lg:hidden">
<div class="mt-3 space-y-2 lg:hidden">
<form action="{{ route('listings.index') }}" method="GET" class="search-shell flex items-center gap-2 px-3 py-2.5">
<svg class="w-4 h-4 text-rose-500 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.8" d="M21 21l-4.35-4.35m1.6-5.05a7.25 7.25 0 11-14.5 0 7.25 7.25 0 0114.5 0z"/>
@ -171,8 +265,29 @@
>
<button type="submit" class="text-xs text-slate-500">{{ __('messages.search') }}</button>
</form>
<div class="flex items-center gap-2 overflow-x-auto pb-1">
<span class="chip-btn whitespace-nowrap px-4 py-2 text-sm text-slate-700" data-location-label-mobile>Konum seç</span>
<a href="{{ $partnerQuickCreateRoute }}" class="chip-btn whitespace-nowrap px-4 py-2 text-sm text-rose-600 font-semibold">Post Fast</a>
</div>
</div>
@if(! $isHomePage)
@if($isHomePage && $homeHeaderCategories->isNotEmpty())
<div class="mt-4 border-t border-slate-200 pt-3 overflow-x-auto">
<div class="flex items-center gap-2 min-w-max pb-1">
<a href="{{ route('categories.index') }}" class="chip-btn inline-flex items-center gap-2 px-4 py-2.5 text-sm font-semibold text-slate-700 hover:bg-slate-100 transition">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.8" d="M4 6h16M4 12h16M4 18h16"/>
</svg>
Tüm Kategoriler
</a>
@foreach($homeHeaderCategories as $headerCategory)
<a href="{{ route('categories.show', $headerCategory) }}" class="px-4 py-2.5 rounded-full text-sm font-medium text-slate-700 hover:bg-slate-100 transition whitespace-nowrap">
{{ $headerCategory->name }}
</a>
@endforeach
</div>
</div>
@elseif(! $isHomePage)
<div class="mt-3 flex items-center gap-2 text-sm overflow-x-auto pb-1">
<a href="{{ route('home') }}" class="chip-btn whitespace-nowrap px-4 py-2 hover:bg-slate-100 transition">{{ __('messages.home') }}</a>
<a href="{{ route('categories.index') }}" class="chip-btn whitespace-nowrap px-4 py-2 hover:bg-slate-100 transition">{{ __('messages.categories') }}</a>
@ -243,6 +358,303 @@
</div>
</div>
</footer>
<script>
(() => {
const widgetRoots = Array.from(document.querySelectorAll('[data-location-widget]'));
const mobileLabels = Array.from(document.querySelectorAll('[data-location-label-mobile]'));
const storageKey = 'oc2.header.location';
if (widgetRoots.length === 0 && mobileLabels.length === 0) {
return;
}
const normalize = (value) => (value ?? '')
.toString()
.toLocaleLowerCase('tr-TR')
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.trim();
const readStored = () => {
try {
const raw = localStorage.getItem(storageKey);
if (!raw) {
return null;
}
return JSON.parse(raw);
} catch (error) {
return null;
}
};
const writeStored = (value) => {
localStorage.setItem(storageKey, JSON.stringify(value));
};
const formatLocationLabel = (location) => {
if (!location || typeof location !== 'object') {
return 'Konum seç';
}
const cityName = (location.cityName ?? '').toString().trim();
const countryName = (location.countryName ?? '').toString().trim();
if (cityName && countryName) {
return cityName + ', ' + countryName;
}
if (countryName) {
return countryName;
}
return 'Konum seç';
};
const updateLabels = (location) => {
const label = formatLocationLabel(location);
widgetRoots.forEach((root) => {
const target = root.querySelector('[data-location-label]');
if (target) {
target.textContent = label;
}
});
mobileLabels.forEach((target) => {
target.textContent = label;
});
};
const loadCities = async (root, countryId, selectedCityId = null, selectedCityName = null) => {
const citySelect = root.querySelector('[data-location-city]');
const countrySelect = root.querySelector('[data-location-country]');
const statusText = root.querySelector('[data-location-status]');
const template = root.dataset.citiesUrlTemplate ?? '';
if (!citySelect || !countrySelect) {
return;
}
if (!countryId || template === '') {
citySelect.innerHTML = '<option value="">Önce ülke seç</option>';
citySelect.disabled = true;
return;
}
citySelect.disabled = true;
citySelect.innerHTML = '<option value="">Şehir yükleniyor...</option>';
try {
const response = await fetch(template.replace('__COUNTRY__', encodeURIComponent(String(countryId))), {
headers: {
'X-Requested-With': 'XMLHttpRequest',
'Accept': 'application/json',
},
});
if (!response.ok) {
throw new Error('city_fetch_failed');
}
const cities = await response.json();
const cityOptions = Array.isArray(cities) ? cities : [];
citySelect.innerHTML = '<option value="">Şehir seç</option>';
cityOptions.forEach((city) => {
const option = document.createElement('option');
option.value = String(city.id ?? '');
option.textContent = city.name ?? '';
option.dataset.name = city.name ?? '';
citySelect.appendChild(option);
});
citySelect.disabled = false;
if (selectedCityId) {
citySelect.value = String(selectedCityId);
} else if (selectedCityName) {
const matched = Array.from(citySelect.options).find((option) => normalize(option.dataset.name) === normalize(selectedCityName));
if (matched) {
citySelect.value = matched.value;
}
}
} catch (error) {
citySelect.innerHTML = '<option value="">Şehir yüklenemedi</option>';
citySelect.disabled = true;
if (statusText) {
statusText.textContent = 'Şehir listesi alınamadı. Lütfen tekrar deneyin.';
}
}
};
const saveFromInputs = (root, extra = {}) => {
const countrySelect = root.querySelector('[data-location-country]');
const citySelect = root.querySelector('[data-location-city]');
const details = root.closest('details');
if (!countrySelect || !citySelect || !countrySelect.value) {
return;
}
const countryOption = countrySelect.options[countrySelect.selectedIndex];
const cityOption = citySelect.options[citySelect.selectedIndex];
const hasCitySelection = citySelect.value !== '';
const location = {
countryId: Number(countrySelect.value),
countryName: countryOption?.dataset.name ?? countryOption?.textContent ?? '',
countryCode: (countryOption?.dataset.code ?? '').toUpperCase(),
cityId: hasCitySelection ? Number(citySelect.value) : null,
cityName: hasCitySelection ? (cityOption?.dataset.name ?? cityOption?.textContent ?? '') : '',
updatedAt: new Date().toISOString(),
...extra,
};
writeStored(location);
updateLabels(location);
if (details && details.hasAttribute('open')) {
details.removeAttribute('open');
}
};
const reverseLookup = async (latitude, longitude) => {
const language = (document.documentElement.lang || 'tr').split('-')[0];
const url = new URL('https://nominatim.openstreetmap.org/reverse');
url.searchParams.set('format', 'jsonv2');
url.searchParams.set('lat', String(latitude));
url.searchParams.set('lon', String(longitude));
url.searchParams.set('accept-language', language);
const response = await fetch(url.toString(), {
headers: {
'Accept': 'application/json',
},
});
if (!response.ok) {
throw new Error('reverse_lookup_failed');
}
const payload = await response.json();
const address = payload.address ?? {};
return {
countryCode: (address.country_code ?? '').toUpperCase(),
countryName: address.country ?? '',
cityName: address.city ?? address.town ?? address.village ?? address.municipality ?? address.state_district ?? address.state ?? '',
};
};
const geolocationPosition = () => new Promise((resolve, reject) => {
if (!window.isSecureContext) {
reject(new Error('secure_context_required'));
return;
}
if (!('geolocation' in navigator)) {
reject(new Error('geolocation_not_supported'));
return;
}
navigator.geolocation.getCurrentPosition(resolve, reject, {
enableHighAccuracy: true,
timeout: 15000,
maximumAge: 120000,
});
});
updateLabels(readStored());
widgetRoots.forEach((root) => {
const countrySelect = root.querySelector('[data-location-country]');
const citySelect = root.querySelector('[data-location-city]');
const saveButton = root.querySelector('[data-location-save]');
const detectButton = root.querySelector('[data-location-detect]');
const statusText = root.querySelector('[data-location-status]');
const stored = readStored();
if (!countrySelect || !citySelect || !saveButton) {
return;
}
const applyStored = async () => {
if (stored?.countryId) {
countrySelect.value = String(stored.countryId);
await loadCities(root, stored.countryId, stored.cityId, stored.cityName);
return;
}
const defaultOption = Array.from(countrySelect.options).find((option) => option.dataset.default === '1');
if (defaultOption) {
countrySelect.value = defaultOption.value;
await loadCities(root, defaultOption.value, null, null);
}
};
void applyStored();
countrySelect.addEventListener('change', async () => {
if (statusText) {
statusText.textContent = 'Ülkeye göre şehirler güncelleniyor...';
}
await loadCities(root, countrySelect.value, null, null);
if (statusText) {
statusText.textContent = 'Şehir seçimini tamamlayıp uygulayabilirsiniz.';
}
});
saveButton.addEventListener('click', () => {
saveFromInputs(root);
if (statusText) {
statusText.textContent = 'Konum kaydedildi.';
}
});
if (detectButton) {
detectButton.addEventListener('click', async () => {
if (statusText) {
statusText.textContent = 'Konumunuz alınıyor...';
}
try {
const position = await geolocationPosition();
const latitude = position.coords.latitude;
const longitude = position.coords.longitude;
const guessed = await reverseLookup(latitude, longitude);
let matchedCountry = Array.from(countrySelect.options).find((option) => option.dataset.code === guessed.countryCode);
if (!matchedCountry && guessed.countryName) {
matchedCountry = Array.from(countrySelect.options).find((option) => normalize(option.dataset.name) === normalize(guessed.countryName));
}
if (!matchedCountry) {
if (statusText) {
statusText.textContent = 'Ülke eşleşmesi bulunamadı, lütfen manuel seçim yapın.';
}
return;
}
countrySelect.value = matchedCountry.value;
await loadCities(root, matchedCountry.value, null, guessed.cityName);
saveFromInputs(root, { latitude, longitude });
if (statusText) {
statusText.textContent = 'Konum otomatik seçildi.';
}
} catch (error) {
if (statusText) {
statusText.textContent = error?.message === 'secure_context_required'
? 'Tarayıcı konumu için HTTPS gerekli. Lütfen siteyi güvenli bağlantıdan açın.'
: 'Konum alınamadı. Tarayıcı izinlerini kontrol edin.';
}
}
});
}
});
})();
</script>
<x-impersonate::banner />
</body>
</html>

View File

@ -1,5 +1,6 @@
<?php
use Illuminate\Support\Facades\Route;
use App\Http\Controllers\ConversationController;
use App\Http\Controllers\FavoriteController;
use App\Http\Controllers\HomeController;
use App\Http\Controllers\LanguageController;
@ -35,4 +36,9 @@ Route::middleware('auth')->prefix('favorites')->name('favorites.')->group(functi
Route::delete('/searches/{favoriteSearch}', [FavoriteController::class, 'destroySearch'])->name('searches.destroy');
});
Route::middleware('auth')->name('conversations.')->group(function () {
Route::post('/listings/{listing}/conversation', [ConversationController::class, 'start'])->name('start');
Route::post('/conversations/{conversation}/messages', [ConversationController::class, 'send'])->name('messages.send');
});
require __DIR__.'/auth.php';