mirror of
https://github.com/openclassify/openclassify.git
synced 2026-04-14 11:12:09 -05:00
İlanlara mesajlaşma ekle
This commit is contained in:
parent
a33f4f42bb
commit
d603de62ec
123
Modules/Admin/Filament/Resources/ListingCustomFieldResource.php
Normal file
123
Modules/Admin/Filament/Resources/ListingCustomFieldResource.php
Normal 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'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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()];
|
||||
}
|
||||
}
|
||||
@ -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()];
|
||||
}
|
||||
}
|
||||
@ -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),
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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');
|
||||
}
|
||||
};
|
||||
@ -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()
|
||||
|
||||
@ -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
|
||||
|
||||
84
Modules/Listing/Models/ListingCustomField.php
Normal file
84
Modules/Listing/Models/ListingCustomField.php
Normal 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();
|
||||
}
|
||||
}
|
||||
129
Modules/Listing/Support/ListingCustomFieldSchemaBuilder.php
Normal file
129
Modules/Listing/Support/ListingCustomFieldSchemaBuilder.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@ -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>
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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')
|
||||
|
||||
@ -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)),
|
||||
];
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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')
|
||||
|
||||
116
app/Http/Controllers/ConversationController.php
Normal file
116
app/Http/Controllers/ConversationController.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
58
app/Models/Conversation.php
Normal file
58
app/Models/Conversation.php
Normal 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);
|
||||
});
|
||||
}
|
||||
}
|
||||
32
app/Models/ConversationMessage.php
Normal file
32
app/Models/ConversationMessage.php
Normal 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');
|
||||
}
|
||||
}
|
||||
@ -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');
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
);
|
||||
|
||||
@ -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
1
public/themes/minimal
Symbolic link
@ -0,0 +1 @@
|
||||
/Users/alp/Documents/projects/oc2/vendor/tallcms/cms/resources/themes/minimal/public
|
||||
@ -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')
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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';
|
||||
|
||||
Loading…
Reference in New Issue
Block a user