From d603de62ec8cb4f78c7c75f69177452c554e483a Mon Sep 17 00:00:00 2001 From: fatihalp Date: Tue, 3 Mar 2026 22:51:22 +0300 Subject: [PATCH] =?UTF-8?q?=C4=B0lanlara=20mesajla=C5=9Fma=20ekle?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Resources/ListingCustomFieldResource.php | 123 ++ .../Pages/CreateListingCustomField.php | 11 + .../Pages/EditListingCustomField.php | 17 + .../Pages/ListListingCustomFields.php | 17 + .../Filament/Resources/ListingResource.php | 20 +- Modules/Category/Models/Category.php | 5 + ...nd_add_custom_fields_to_listings_table.php | 39 + .../Http/Controllers/ListingController.php | 34 +- Modules/Listing/Models/Listing.php | 8 +- Modules/Listing/Models/ListingCustomField.php | 84 ++ .../ListingCustomFieldSchemaBuilder.php | 129 ++ .../Listing/resources/views/index.blade.php | 25 +- .../Listing/resources/views/show.blade.php | 25 + Modules/Location/routes/web.php | 7 +- .../Filament/Resources/ListingResource.php | 14 + .../ListingResource/Pages/ListListings.php | 4 +- .../Pages/QuickCreateListing.php | 512 ++++++- .../Providers/PartnerPanelProvider.php | 1 + .../Controllers/ConversationController.php | 116 ++ app/Http/Controllers/FavoriteController.php | 85 ++ app/Models/Conversation.php | 58 + app/Models/ConversationMessage.php | 32 + app/Models/User.php | 15 + app/Providers/AppServiceProvider.php | 24 + config/theme.php | 12 +- ..._03_230000_create_conversations_tables.php | 42 + public/themes/minimal | 1 + resources/views/favorites/index.blade.php | 220 ++- .../partner/listings/quick-create.blade.php | 1241 +++++++++++++++-- resources/views/home.blade.php | 19 - resources/views/layouts/app.blade.php | 454 +++++- routes/web.php | 6 + 32 files changed, 3216 insertions(+), 184 deletions(-) create mode 100644 Modules/Admin/Filament/Resources/ListingCustomFieldResource.php create mode 100644 Modules/Admin/Filament/Resources/ListingCustomFieldResource/Pages/CreateListingCustomField.php create mode 100644 Modules/Admin/Filament/Resources/ListingCustomFieldResource/Pages/EditListingCustomField.php create mode 100644 Modules/Admin/Filament/Resources/ListingCustomFieldResource/Pages/ListListingCustomFields.php create mode 100644 Modules/Listing/Database/migrations/2026_03_03_200000_create_listing_custom_fields_table_and_add_custom_fields_to_listings_table.php create mode 100644 Modules/Listing/Models/ListingCustomField.php create mode 100644 Modules/Listing/Support/ListingCustomFieldSchemaBuilder.php create mode 100644 app/Http/Controllers/ConversationController.php create mode 100644 app/Models/Conversation.php create mode 100644 app/Models/ConversationMessage.php create mode 100644 database/migrations/2026_03_03_230000_create_conversations_tables.php create mode 120000 public/themes/minimal diff --git a/Modules/Admin/Filament/Resources/ListingCustomFieldResource.php b/Modules/Admin/Filament/Resources/ListingCustomFieldResource.php new file mode 100644 index 000000000..55742f18a --- /dev/null +++ b/Modules/Admin/Filament/Resources/ListingCustomFieldResource.php @@ -0,0 +1,123 @@ +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'), + ]; + } +} diff --git a/Modules/Admin/Filament/Resources/ListingCustomFieldResource/Pages/CreateListingCustomField.php b/Modules/Admin/Filament/Resources/ListingCustomFieldResource/Pages/CreateListingCustomField.php new file mode 100644 index 000000000..6bcaf8216 --- /dev/null +++ b/Modules/Admin/Filament/Resources/ListingCustomFieldResource/Pages/CreateListingCustomField.php @@ -0,0 +1,11 @@ +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), diff --git a/Modules/Category/Models/Category.php b/Modules/Category/Models/Category.php index 2e7d30738..0ef96af8c 100644 --- a/Modules/Category/Models/Category.php +++ b/Modules/Category/Models/Category.php @@ -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); + } } diff --git a/Modules/Listing/Database/migrations/2026_03_03_200000_create_listing_custom_fields_table_and_add_custom_fields_to_listings_table.php b/Modules/Listing/Database/migrations/2026_03_03_200000_create_listing_custom_fields_table_and_add_custom_fields_to_listings_table.php new file mode 100644 index 000000000..2e872fcc8 --- /dev/null +++ b/Modules/Listing/Database/migrations/2026_03_03_200000_create_listing_custom_fields_table_and_add_custom_fields_to_listings_table.php @@ -0,0 +1,39 @@ +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'); + } +}; diff --git a/Modules/Listing/Http/Controllers/ListingController.php b/Modules/Listing/Http/Controllers/ListingController.php index 0870c6404..b9e79b305 100644 --- a/Modules/Listing/Http/Controllers/ListingController.php +++ b/Modules/Listing/Http/Controllers/ListingController.php @@ -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() diff --git a/Modules/Listing/Models/Listing.php b/Modules/Listing/Models/Listing.php index 390393d78..c35181a73 100644 --- a/Modules/Listing/Models/Listing.php +++ b/Modules/Listing/Models/Listing.php @@ -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 diff --git a/Modules/Listing/Models/ListingCustomField.php b/Modules/Listing/Models/ListingCustomField.php new file mode 100644 index 000000000..493c5c6c1 --- /dev/null +++ b/Modules/Listing/Models/ListingCustomField.php @@ -0,0 +1,84 @@ + '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(); + } +} diff --git a/Modules/Listing/Support/ListingCustomFieldSchemaBuilder.php b/Modules/Listing/Support/ListingCustomFieldSchemaBuilder.php new file mode 100644 index 000000000..a5ef96481 --- /dev/null +++ b/Modules/Listing/Support/ListingCustomFieldSchemaBuilder.php @@ -0,0 +1,129 @@ +active() + ->forCategory($categoryId) + ->exists(); + } + + /** + * @return array + */ + 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 $values + * @return array + */ + 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; + } +} diff --git a/Modules/Listing/resources/views/index.blade.php b/Modules/Listing/resources/views/index.blade.php index 8a502ec2b..1db745c9f 100644 --- a/Modules/Listing/resources/views/index.blade.php +++ b/Modules/Listing/resources/views/index.blade.php @@ -46,10 +46,11 @@ @endauth
- @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
@@ -84,14 +85,32 @@

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

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

- View +
+ View + @auth + @if($listing->user_id && (int) $listing->user_id !== (int) auth()->id()) + @if($conversationId) + + Sohbete Git + + @else +
+ @csrf + +
+ @endif + @endif + @endauth +
@empty
Bu filtreye uygun ilan bulunamadı.
- @endforeach + @endforelse
{{ $listings->links() }}
diff --git a/Modules/Listing/resources/views/show.blade.php b/Modules/Listing/resources/views/show.blade.php index 4d95cda9f..ae10809eb 100644 --- a/Modules/Listing/resources/views/show.blade.php +++ b/Modules/Listing/resources/views/show.blade.php @@ -52,6 +52,18 @@ {{ $isSellerFavorited ? 'Satıcı Favorilerde' : 'Satıcıyı Takip Et' }} + @if($existingConversationId) + + Sohbete Git + + @else +
+ @csrf + +
+ @endif @endif @else @@ -65,6 +77,19 @@

Description

{{ $displayDescription }}

+ @if(($presentableCustomFields ?? []) !== []) +
+

İlan Özellikleri

+
+ @foreach($presentableCustomFields as $field) +
+

{{ $field['label'] }}

+

{{ $field['value'] }}

+
+ @endforeach +
+
+ @endif

Contact Seller

@if($listing->user) diff --git a/Modules/Location/routes/web.php b/Modules/Location/routes/web.php index 182dfe53c..948ee2d43 100644 --- a/Modules/Location/routes/web.php +++ b/Modules/Location/routes/web.php @@ -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) { diff --git a/Modules/Partner/Filament/Resources/ListingResource.php b/Modules/Partner/Filament/Resources/ListingResource.php index fdbbaca8e..e3d506970 100644 --- a/Modules/Partner/Filament/Resources/ListingResource.php +++ b/Modules/Partner/Filament/Resources/ListingResource.php @@ -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') diff --git a/Modules/Partner/Filament/Resources/ListingResource/Pages/ListListings.php b/Modules/Partner/Filament/Resources/ListingResource/Pages/ListListings.php index 8b4ce2225..6df6cae6d 100644 --- a/Modules/Partner/Filament/Resources/ListingResource/Pages/ListListings.php +++ b/Modules/Partner/Filament/Resources/ListingResource/Pages/ListListings.php @@ -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)), ]; diff --git a/Modules/Partner/Filament/Resources/ListingResource/Pages/QuickCreateListing.php b/Modules/Partner/Filament/Resources/ListingResource/Pages/QuickCreateListing.php index 90afd75a3..c7cced8e1 100644 --- a/Modules/Partner/Filament/Resources/ListingResource/Pages/QuickCreateListing.php +++ b/Modules/Partner/Filament/Resources/ListingResource/Pages/QuickCreateListing.php @@ -1,21 +1,36 @@ + */ + public array $countries = []; + + /** + * @var array + */ + public array $cities = []; + + /** + * @var array}> + */ + public array $listingCustomFields = []; + + /** + * @var array + */ + 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 + */ + 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 + */ + 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 + */ + 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 + */ + 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 + */ + 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) diff --git a/Modules/Partner/Providers/PartnerPanelProvider.php b/Modules/Partner/Providers/PartnerPanelProvider.php index 139049d5b..4b5413ae0 100644 --- a/Modules/Partner/Providers/PartnerPanelProvider.php +++ b/Modules/Partner/Providers/PartnerPanelProvider.php @@ -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') diff --git a/app/Http/Controllers/ConversationController.php b/app/Http/Controllers/ConversationController.php new file mode 100644 index 000000000..d5c3f20c1 --- /dev/null +++ b/app/Http/Controllers/ConversationController.php @@ -0,0 +1,116 @@ +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; + } +} diff --git a/app/Http/Controllers/FavoriteController.php b/app/Http/Controllers/FavoriteController.php index 846505bd0..7d02a50c5 100644 --- a/app/Http/Controllers/FavoriteController.php +++ b/app/Http/Controllers/FavoriteController.php @@ -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, ]); } diff --git a/app/Models/Conversation.php b/app/Models/Conversation.php new file mode 100644 index 000000000..305754ce1 --- /dev/null +++ b/app/Models/Conversation.php @@ -0,0 +1,58 @@ + '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); + }); + } +} diff --git a/app/Models/ConversationMessage.php b/app/Models/ConversationMessage.php new file mode 100644 index 000000000..b26c99405 --- /dev/null +++ b/app/Models/ConversationMessage.php @@ -0,0 +1,32 @@ + 'datetime', + ]; + + public function conversation() + { + return $this->belongsTo(Conversation::class); + } + + public function sender() + { + return $this->belongsTo(User::class, 'sender_id'); + } +} diff --git a/app/Models/User.php b/app/Models/User.php index c100c0a96..f7c48d3f3 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -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'); diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 861d4d2d9..c2bcdc67d 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -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 diff --git a/config/theme.php b/config/theme.php index 8022600bd..0064114fd 100644 --- a/config/theme.php +++ b/config/theme.php @@ -1,8 +1,8 @@ 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, +); diff --git a/database/migrations/2026_03_03_230000_create_conversations_tables.php b/database/migrations/2026_03_03_230000_create_conversations_tables.php new file mode 100644 index 000000000..49a13af3f --- /dev/null +++ b/database/migrations/2026_03_03_230000_create_conversations_tables.php @@ -0,0 +1,42 @@ +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'); + } +}; diff --git a/public/themes/minimal b/public/themes/minimal new file mode 120000 index 000000000..e10dee1d1 --- /dev/null +++ b/public/themes/minimal @@ -0,0 +1 @@ +/Users/alp/Documents/projects/oc2/vendor/tallcms/cms/resources/themes/minimal/public \ No newline at end of file diff --git a/resources/views/favorites/index.blade.php b/resources/views/favorites/index.blade.php index c503fb290..aea427044 100644 --- a/resources/views/favorites/index.blade.php +++ b/resources/views/favorites/index.blade.php @@ -19,19 +19,28 @@
@if($activeTab === 'listings') + @php + $listingTabQuery = array_filter([ + 'tab' => 'listings', + 'status' => $statusFilter, + 'category' => $selectedCategoryId, + 'message_filter' => $messageFilter, + ], fn ($value) => !is_null($value) && $value !== ''); + @endphp

Favori Listem

+ + @if($selectedCategoryId) + + @endif + + +
+ @endif + @else + {{ $isOwnListing ? 'Kendi ilanın' : 'Satıcı bilgisi yok' }} + @endif +
@csrf @@ -91,7 +127,7 @@ @empty - + Henüz favori ilan bulunmuyor. @@ -107,6 +143,176 @@ @if($favoriteListings?->hasPages())
{{ $favoriteListings->links() }}
@endif + +
+
+
+
+
+

Gelen Kutusu

+ + + +
+
+

Hızlı Filtreler

+ +
+
+ @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 + +
+
+ @if($conversationImage) + {{ $conversationListing?->title }} + @else +
İlan
+ @endif +
+
+
+

{{ $partner?->name ?? 'Kullanıcı' }}

+

{{ $conversation->last_message_at?->format('d.m.Y') }}

+
+

{{ $conversationListing?->title ?? 'İlan silinmiş' }}

+

+ {{ $lastMessage !== '' ? $lastMessage : 'Henüz mesaj yok' }} +

+
+ @if($conversation->unread_count > 0) + + {{ $conversation->unread_count }} + + @endif +
+
+ @empty +
+ Henüz bir sohbetin yok. +
+ @endforelse +
+
+ +
+ @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 +
+
+ {{ strtoupper(substr((string) ($activePartner?->name ?? 'K'), 0, 1)) }} +
+
+

{{ $activePartner?->name ?? 'Kullanıcı' }}

+

{{ $activeListing?->title ?? 'İlan silinmiş' }}

+
+ @if($activePriceLabel) +
{{ $activePriceLabel }}
+ @endif +
+ +
+ @forelse($selectedConversation->messages as $message) + @php $isMine = (int) $message->sender_id === (int) auth()->id(); @endphp +
+
+
+ {{ $message->body }} +
+

+ {{ $message->created_at?->format('H:i') }} +

+
+
+ @empty +
+
+

Henüz mesaj yok.

+

Aşağıdaki hazır metinlerden birini seçebilir veya yeni mesaj yazabilirsin.

+
+
+ @endforelse +
+ +
+
+ @foreach($quickMessages as $quickMessage) + + @csrf + + @if($selectedCategoryId) + + @endif + + + + + @endforeach +
+
+ @csrf + + @if($selectedCategoryId) + + @endif + + + +
+ @error('message') +

{{ $message }}

+ @enderror +
+ @else +
+
+

Mesajlaşma için bir sohbet seç.

+

İlan detayından veya favori ilan satırındaki "Mesaj Gönder" butonundan yeni sohbet başlatabilirsin.

+
+
+ @endif +
+
+
+
@endif @if($activeTab === 'searches') diff --git a/resources/views/filament/partner/listings/quick-create.blade.php b/resources/views/filament/partner/listings/quick-create.blade.php index 511eaede2..0cdb519b6 100644 --- a/resources/views/filament/partner/listings/quick-create.blade.php +++ b/resources/views/filament/partner/listings/quick-create.blade.php @@ -1,88 +1,838 @@ -
-
-
{{ $currentStep === 1 ? 'Fotoğraf' : 'Kategori Seçimi' }}
-
-