feat: Implement user profile management and favorites module

- Added routes for user profile management including edit, update, and delete functionalities.
- Created ProfileController to handle profile-related requests.
- Introduced Profile model to manage user profile data.
- Developed user status states (Active, Banned, Suspended) with appropriate labels and descriptions.
- Implemented favorite listings and sellers functionality in the User model.
- Created views for profile editing, updating password, and deleting account.
- Added migration for user and profile tables along with necessary fields.
- Registered User module with service provider and routes.
This commit is contained in:
fatihalp 2026-03-05 01:23:42 +03:00
parent 72fbabb60b
commit 7e9d77c0a8
65 changed files with 817 additions and 936 deletions

View File

@ -1,21 +1,19 @@
<?php
namespace Modules\Admin\Filament\Resources;
use A909M\FilamentStateFusion\Forms\Components\StateFusionSelect;
use A909M\FilamentStateFusion\Tables\Columns\StateFusionSelectColumn;
use A909M\FilamentStateFusion\Tables\Filters\StateFusionSelectFilter;
use App\Models\User;
use Modules\User\App\Models\User;
use BackedEnum;
use Filament\Actions\Action;
use Filament\Actions\DeleteAction;
use Filament\Actions\EditAction;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
use Filament\Resources\Resource;
use Filament\Schemas\Schema;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table;
use Modules\Admin\Filament\Resources\UserResource\Pages;
use Modules\User\App\Support\Filament\UserFormFields;
use STS\FilamentImpersonate\Actions\Impersonate;
use UnitEnum;
@ -28,11 +26,11 @@ class UserResource extends Resource
public static function form(Schema $schema): Schema
{
return $schema->schema([
TextInput::make('name')->required()->maxLength(255),
TextInput::make('email')->email()->required()->maxLength(255)->unique(ignoreRecord: true),
TextInput::make('password')->password()->required(fn ($livewire) => $livewire instanceof Pages\CreateUser)->dehydrateStateUsing(fn ($state) => filled($state) ? bcrypt($state) : null)->dehydrated(fn ($state) => filled($state)),
StateFusionSelect::make('status')->required(),
Select::make('roles')->multiple()->relationship('roles', 'name')->preload(),
UserFormFields::name(),
UserFormFields::email(),
UserFormFields::password(fn ($livewire) => $livewire instanceof Pages\CreateUser),
UserFormFields::status(),
UserFormFields::roles(),
]);
}

View File

@ -0,0 +1,120 @@
<?php
namespace Modules\Conversation\App\Http\Controllers;
use App\Http\Controllers\Controller;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
use Modules\Conversation\App\Models\Conversation;
use Modules\Conversation\App\Support\QuickMessageCatalog;
use Modules\Listing\Models\Listing;
class ConversationController extends Controller
{
public function inbox(Request $request): View
{
$userId = (int) $request->user()->getKey();
$messageFilter = $this->resolveMessageFilter($request);
$conversations = Conversation::inboxForUser($userId, $messageFilter);
$selectedConversation = Conversation::resolveSelected($conversations, $request->integer('conversation'));
if ($selectedConversation) {
$selectedConversation->loadThread();
$selectedConversation->markAsReadFor($userId);
$conversations = $conversations->map(function (Conversation $conversation) use ($selectedConversation): Conversation {
if ((int) $conversation->getKey() === (int) $selectedConversation->getKey()) {
$conversation->unread_count = 0;
}
return $conversation;
});
}
return view('conversation::inbox', [
'conversations' => $conversations,
'selectedConversation' => $selectedConversation,
'messageFilter' => $messageFilter,
'quickMessages' => QuickMessageCatalog::all(),
]);
}
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::openForListingBuyer($listing, (int) $user->getKey());
$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('panel.inbox.index', array_merge($this->inboxFilters($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'],
]);
$messageBody = trim($payload['message']);
if ($messageBody === '') {
return back()->with('error', 'Mesaj boş olamaz.');
}
$message = $conversation->messages()->create([
'sender_id' => $userId,
'body' => $messageBody,
]);
$conversation->forceFill(['last_message_at' => $message->created_at])->save();
return redirect()
->route('panel.inbox.index', array_merge($this->inboxFilters($request), ['conversation' => $conversation->getKey()]))
->with('success', 'Mesaj gönderildi.');
}
private function inboxFilters(Request $request): array
{
$messageFilter = $this->resolveMessageFilter($request);
return $messageFilter === 'all' ? [] : ['message_filter' => $messageFilter];
}
private function resolveMessageFilter(Request $request): string
{
$messageFilter = (string) $request->string('message_filter', 'all');
return in_array($messageFilter, ['all', 'unread', 'important'], true) ? $messageFilter : 'all';
}
}

View File

@ -0,0 +1,168 @@
<?php
namespace Modules\Conversation\App\Models;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection as EloquentCollection;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Modules\Listing\Models\Listing;
use Modules\User\App\Models\User;
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()
->select([
'conversation_messages.id',
'conversation_messages.conversation_id',
'conversation_messages.sender_id',
'conversation_messages.body',
'conversation_messages.created_at',
]);
}
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);
});
}
public function scopeApplyMessageFilter(Builder $query, int $userId, string $messageFilter): Builder
{
if (! in_array($messageFilter, ['unread', 'important'], true)) {
return $query;
}
return $query->whereHas('messages', function (Builder $messageQuery) use ($userId): void {
$messageQuery->where('sender_id', '!=', $userId)->whereNull('read_at');
});
}
public function scopeWithInboxData(Builder $query, int $userId): Builder
{
return $query
->with([
'listing:id,title,price,currency,user_id',
'buyer:id,name',
'seller:id,name',
'lastMessage',
'lastMessage.sender:id,name',
])
->withCount([
'messages as unread_count' => fn (Builder $messageQuery) => $messageQuery
->where('sender_id', '!=', $userId)
->whereNull('read_at'),
])
->orderByDesc('last_message_at')
->orderByDesc('updated_at');
}
public static function inboxForUser(int $userId, string $messageFilter = 'all'): EloquentCollection
{
return static::query()
->forUser($userId)
->applyMessageFilter($userId, $messageFilter)
->withInboxData($userId)
->get();
}
public static function resolveSelected(EloquentCollection $conversations, ?int $conversationId): ?self
{
$selectedConversationId = $conversationId;
if (($selectedConversationId ?? 0) <= 0 && $conversations->isNotEmpty()) {
$selectedConversationId = (int) $conversations->first()->getKey();
}
if (($selectedConversationId ?? 0) <= 0) {
return null;
}
$selected = $conversations->firstWhere('id', $selectedConversationId);
if (! $selected instanceof self) {
return null;
}
return $selected;
}
public function loadThread(): void
{
$this->load([
'listing:id,title,price,currency,user_id',
'messages' => fn (Builder $query) => $query->with('sender:id,name')->orderBy('created_at'),
]);
}
public function markAsReadFor(int $userId): void
{
ConversationMessage::query()
->where('conversation_id', $this->getKey())
->where('sender_id', '!=', $userId)
->whereNull('read_at')
->update([
'read_at' => now(),
'updated_at' => now(),
]);
}
public static function openForListingBuyer(Listing $listing, int $buyerId): self
{
$conversation = static::query()->firstOrCreate(
[
'listing_id' => $listing->getKey(),
'buyer_id' => $buyerId,
],
[
'seller_id' => $listing->user_id,
],
);
if ((int) $conversation->seller_id !== (int) $listing->user_id) {
$conversation->forceFill(['seller_id' => $listing->user_id])->save();
}
return $conversation;
}
public static function buyerListingConversationId(int $listingId, int $buyerId): ?int
{
$value = static::query()
->where('listing_id', $listingId)
->where('buyer_id', $buyerId)
->value('id');
return is_null($value) ? null : (int) $value;
}
}

View File

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

View File

@ -0,0 +1,19 @@
<?php
namespace Modules\Conversation\App\Providers;
use Illuminate\Support\ServiceProvider;
class ConversationServiceProvider extends ServiceProvider
{
public function boot(): void
{
$this->loadMigrationsFrom(module_path('Conversation', 'database/migrations'));
$this->loadRoutesFrom(module_path('Conversation', 'routes/web.php'));
$this->loadViewsFrom(module_path('Conversation', 'resources/views'), 'conversation');
}
public function register(): void
{
}
}

View File

@ -0,0 +1,16 @@
<?php
namespace Modules\Conversation\App\Support;
class QuickMessageCatalog
{
public static function all(): array
{
return [
'Merhaba',
'İlan hâlâ satışta mı?',
'Son fiyat nedir?',
'Teşekkürler',
];
}
}

View File

@ -0,0 +1,46 @@
<?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
{
if (! Schema::hasTable('conversations')) {
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']);
});
}
if (! Schema::hasTable('conversation_messages')) {
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');
}
};

View File

@ -0,0 +1,11 @@
{
"name": "Conversation",
"alias": "conversation",
"description": "",
"keywords": [],
"priority": 0,
"providers": [
"Modules\\Conversation\\App\\Providers\\ConversationServiceProvider"
],
"files": []
}

View File

@ -0,0 +1,13 @@
<?php
use Illuminate\Support\Facades\Route;
use Modules\Conversation\App\Http\Controllers\ConversationController;
Route::middleware('auth')->prefix('panel')->name('panel.')->group(function () {
Route::get('/gelen-kutusu', [ConversationController::class, 'inbox'])->name('inbox.index');
});
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');
});

View File

@ -1,43 +1,42 @@
<?php
namespace App\Http\Controllers;
namespace Modules\Favorite\App\Http\Controllers;
use App\Models\Conversation;
use App\Models\ConversationMessage;
use App\Models\FavoriteSearch;
use App\Models\User;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use Modules\Category\Models\Category;
use Modules\Conversation\App\Models\Conversation;
use Modules\Conversation\App\Support\QuickMessageCatalog;
use Modules\Favorite\App\Models\FavoriteSearch;
use Modules\Listing\Models\Listing;
use Modules\User\App\Models\User;
class FavoriteController extends Controller
{
public function index(Request $request)
{
$activeTab = (string) $request->string('tab', 'listings');
if (! in_array($activeTab, ['listings', 'searches', 'sellers'], true)) {
$activeTab = 'listings';
}
$statusFilter = (string) $request->string('status', 'all');
if (! in_array($statusFilter, ['all', 'active'], true)) {
$statusFilter = 'all';
}
$selectedCategoryId = $request->integer('category');
if ($selectedCategoryId <= 0) {
$selectedCategoryId = null;
}
$messageFilter = (string) $request->string('message_filter', 'all');
if (! in_array($messageFilter, ['all', 'unread', 'important'], true)) {
$messageFilter = 'all';
}
$selectedCategoryId = $request->integer('category');
if ($selectedCategoryId <= 0) {
$selectedCategoryId = null;
}
$user = $request->user();
$categories = Category::query()
->where('is_active', true)
->orderBy('name')
@ -49,12 +48,6 @@ class FavoriteController extends Controller
$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()
@ -67,58 +60,18 @@ class FavoriteController extends Controller
->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',
'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();
$conversations = Conversation::inboxForUser($userId, $messageFilter);
$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);
$selectedConversation = Conversation::resolveSelected($conversations, $request->integer('conversation'));
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(),
]);
$selectedConversation->loadThread();
$selectedConversation->markAsReadFor($userId);
$conversations = $conversations->map(function (Conversation $conversation) use ($selectedConversation): Conversation {
if ((int) $conversation->getKey() === (int) $selectedConversation->getKey()) {
@ -129,7 +82,6 @@ class FavoriteController extends Controller
});
}
}
}
if ($activeTab === 'searches') {
$favoriteSearches = $user->favoriteSearches()
@ -149,7 +101,7 @@ class FavoriteController extends Controller
->withQueryString();
}
return view('favorites.index', [
return view('favorite::index', [
'activeTab' => $activeTab,
'statusFilter' => $statusFilter,
'selectedCategoryId' => $selectedCategoryId,
@ -161,24 +113,15 @@ class FavoriteController extends Controller
'conversations' => $conversations,
'selectedConversation' => $selectedConversation,
'buyerConversationListingMap' => $buyerConversationListingMap,
'quickMessages' => $quickMessages,
'quickMessages' => QuickMessageCatalog::all(),
]);
}
public function toggleListing(Request $request, Listing $listing)
{
$user = $request->user();
$isFavorite = $user->favoriteListings()->whereKey($listing->getKey())->exists();
$isNowFavorite = $request->user()->toggleFavoriteListing($listing);
if ($isFavorite) {
$user->favoriteListings()->detach($listing->getKey());
return back()->with('success', 'İlan favorilerden kaldırıldı.');
}
$user->favoriteListings()->syncWithoutDetaching([$listing->getKey()]);
return back()->with('success', 'İlan favorilere eklendi.');
return back()->with('success', $isNowFavorite ? 'İlan favorilere eklendi.' : 'İlan favorilerden kaldırıldı.');
}
public function toggleSeller(Request $request, User $seller)
@ -189,17 +132,9 @@ class FavoriteController extends Controller
return back()->with('error', 'Kendi hesabını favorilere ekleyemezsin.');
}
$isFavorite = $user->favoriteSellers()->whereKey($seller->getKey())->exists();
$isNowFavorite = $user->toggleFavoriteSeller($seller);
if ($isFavorite) {
$user->favoriteSellers()->detach($seller->getKey());
return back()->with('success', 'Satıcı favorilerden kaldırıldı.');
}
$user->favoriteSellers()->syncWithoutDetaching([$seller->getKey()]);
return back()->with('success', 'Satıcı favorilere eklendi.');
return back()->with('success', $isNowFavorite ? 'Satıcı favorilere eklendi.' : 'Satıcı favorilerden kaldırıldı.');
}
public function storeSearch(Request $request)
@ -225,15 +160,7 @@ class FavoriteController extends Controller
$categoryName = Category::query()->whereKey($filters['category'])->value('name');
}
$labelParts = [];
if (! empty($filters['search'])) {
$labelParts[] = '"'.$filters['search'].'"';
}
if ($categoryName) {
$labelParts[] = $categoryName;
}
$label = $labelParts !== [] ? implode(' · ', $labelParts) : 'Filtreli arama';
$label = FavoriteSearch::labelFor($filters, is_string($categoryName) ? $categoryName : null);
$favoriteSearch = $request->user()->favoriteSearches()->firstOrCreate(
['signature' => $signature],

View File

@ -1,23 +1,16 @@
<?php
namespace App\Models;
namespace Modules\Favorite\App\Models;
use Illuminate\Database\Eloquent\Model;
use Modules\Category\Models\Category;
use Modules\User\App\Models\User;
class FavoriteSearch extends Model
{
protected $fillable = [
'user_id',
'label',
'search_term',
'category_id',
'filters',
'signature',
];
protected $fillable = ['user_id', 'label', 'search_term', 'category_id', 'filters', 'signature'];
protected $casts = [
'filters' => 'array',
];
protected $casts = ['filters' => 'array'];
public function user()
{
@ -26,7 +19,7 @@ class FavoriteSearch extends Model
public function category()
{
return $this->belongsTo(\Modules\Category\Models\Category::class);
return $this->belongsTo(Category::class);
}
public static function normalizeFilters(array $filters): array
@ -45,4 +38,19 @@ class FavoriteSearch extends Model
return hash('sha256', is_string($payload) ? $payload : '');
}
public static function labelFor(array $filters, ?string $categoryName = null): string
{
$labelParts = [];
if (! empty($filters['search'])) {
$labelParts[] = '"'.$filters['search'].'"';
}
if (filled($categoryName)) {
$labelParts[] = $categoryName;
}
return $labelParts !== [] ? implode(' · ', $labelParts) : 'Filtreli arama';
}
}

View File

@ -0,0 +1,19 @@
<?php
namespace Modules\Favorite\App\Providers;
use Illuminate\Support\ServiceProvider;
class FavoriteServiceProvider extends ServiceProvider
{
public function boot(): void
{
$this->loadMigrationsFrom(module_path('Favorite', 'database/migrations'));
$this->loadRoutesFrom(module_path('Favorite', 'routes/web.php'));
$this->loadViewsFrom(module_path('Favorite', 'resources/views'), 'favorite');
}
public function register(): void
{
}
}

View File

@ -0,0 +1,55 @@
<?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
{
if (! Schema::hasTable('favorite_listings')) {
Schema::create('favorite_listings', function (Blueprint $table): void {
$table->id();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->foreignId('listing_id')->constrained('listings')->cascadeOnDelete();
$table->timestamps();
$table->unique(['user_id', 'listing_id']);
});
}
if (! Schema::hasTable('favorite_sellers')) {
Schema::create('favorite_sellers', function (Blueprint $table): void {
$table->id();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->foreignId('seller_id')->constrained('users')->cascadeOnDelete();
$table->timestamps();
$table->unique(['user_id', 'seller_id']);
});
}
if (! Schema::hasTable('favorite_searches')) {
Schema::create('favorite_searches', function (Blueprint $table): void {
$table->id();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->string('label')->nullable();
$table->string('search_term')->nullable();
$table->foreignId('category_id')->nullable()->constrained('categories')->nullOnDelete();
$table->json('filters')->nullable();
$table->string('signature', 64);
$table->timestamps();
$table->unique(['user_id', 'signature']);
});
}
}
public function down(): void
{
Schema::dropIfExists('favorite_searches');
Schema::dropIfExists('favorite_sellers');
Schema::dropIfExists('favorite_listings');
}
};

View File

@ -0,0 +1,11 @@
{
"name": "Favorite",
"alias": "favorite",
"description": "",
"keywords": [],
"priority": 0,
"providers": [
"Modules\\Favorite\\App\\Providers\\FavoriteServiceProvider"
],
"files": []
}

View File

@ -0,0 +1,12 @@
<?php
use Illuminate\Support\Facades\Route;
use Modules\Favorite\App\Http\Controllers\FavoriteController;
Route::middleware('auth')->prefix('favorites')->name('favorites.')->group(function () {
Route::get('/', [FavoriteController::class, 'index'])->name('index');
Route::post('/listings/{listing}/toggle', [FavoriteController::class, 'toggleListing'])->name('listings.toggle');
Route::post('/sellers/{seller}/toggle', [FavoriteController::class, 'toggleSeller'])->name('sellers.toggle');
Route::post('/searches', [FavoriteController::class, 'storeSearch'])->name('searches.store');
Route::delete('/searches/{favoriteSearch}', [FavoriteController::class, 'destroySearch'])->name('searches.destroy');
});

View File

@ -2,7 +2,7 @@
namespace Modules\Listing\Database\Seeders;
use App\Models\User;
use Modules\User\App\Models\User;
use Illuminate\Database\Seeder;
use Illuminate\Support\Collection;
use Illuminate\Support\Str;
@ -11,9 +11,6 @@ use Modules\Listing\Models\Listing;
class ListingSeeder extends Seeder
{
/**
* @var array<int, array{title: string, description: string, price: int, city: string, country: string, image: string}>
*/
private const LISTINGS = [
[
'title' => 'iPhone 14 Pro 256 GB, temiz kullanılmış',
@ -106,9 +103,6 @@ class ListingSeeder extends Seeder
->first();
}
/**
* @param array{title: string, description: string, price: int, city: string, country: string, image: string} $data
*/
private function upsertListing(int $index, array $data, Collection $categories, User $user): Listing
{
$slug = Str::slug($data['title']) . '-' . ($index + 1);
@ -167,7 +161,11 @@ class ListingSeeder extends Seeder
$media = $mediaItems->first();
if (! $media || (string) $media->file_name !== $targetFileName) {
if (
! $media
|| (string) $media->file_name !== $targetFileName
|| (string) $media->disk !== 'public'
) {
return false;
}

View File

@ -2,8 +2,8 @@
namespace Modules\Listing\Http\Controllers;
use App\Http\Controllers\Controller;
use App\Models\Conversation;
use App\Models\FavoriteSearch;
use Modules\Conversation\App\Models\Conversation;
use Modules\Favorite\App\Models\FavoriteSearch;
use Illuminate\Support\Carbon;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Schema;

View File

@ -55,18 +55,18 @@ class Listing extends Model implements HasMedia
public function user()
{
return $this->belongsTo(\App\Models\User::class);
return $this->belongsTo(\Modules\User\App\Models\User::class);
}
public function favoritedByUsers()
{
return $this->belongsToMany(\App\Models\User::class, 'favorite_listings')
return $this->belongsToMany(\Modules\User\App\Models\User::class, 'favorite_listings')
->withTimestamps();
}
public function conversations()
{
return $this->hasMany(\App\Models\Conversation::class);
return $this->hasMany(\Modules\Conversation\App\Models\Conversation::class);
}
public function scopePublicFeed(Builder $query): Builder

View File

@ -20,7 +20,7 @@ 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 Modules\User\App\Models\Profile;
use Throwable;
class QuickCreateListing extends Page

View File

@ -2,7 +2,7 @@
namespace Modules\Partner\Providers;
use A909M\FilamentStateFusion\FilamentStateFusionPlugin;
use App\Models\User;
use Modules\User\App\Models\User;
use DutchCodingCompany\FilamentDeveloperLogins\FilamentDeveloperLoginsPlugin;
use DutchCodingCompany\FilamentSocialite\FilamentSocialitePlugin;
use Filament\Http\Middleware\Authenticate;

View File

@ -1,34 +0,0 @@
<?php
namespace Modules\Profile\Http\Controllers;
use Illuminate\Http\Request;
use App\Http\Controllers\Controller;
use Modules\Profile\Models\Profile;
class ProfileController extends Controller
{
public function show()
{
$profile = Profile::firstOrCreate(['user_id' => auth()->id()]);
return view('profile::show', compact('profile'));
}
public function edit()
{
$profile = Profile::firstOrCreate(['user_id' => auth()->id()]);
return view('profile::edit', compact('profile'));
}
public function update(Request $request)
{
$data = $request->validate([
'bio' => 'nullable|string|max:500',
'phone' => 'nullable|string|max:20',
'city' => 'nullable|string|max:100',
'country' => 'nullable|string|max:100',
'website' => 'nullable|url',
]);
Profile::updateOrCreate(['user_id' => auth()->id()], $data);
return redirect()->route('profile.show')->with('success', 'Profile updated!');
}
}

View File

@ -1,18 +0,0 @@
<?php
namespace Modules\Profile\Providers;
use Illuminate\Support\ServiceProvider;
class ProfileServiceProvider extends ServiceProvider
{
protected string $moduleName = 'Profile';
public function boot(): void
{
$this->loadMigrationsFrom(module_path($this->moduleName, 'database/migrations'));
$this->loadRoutesFrom(module_path($this->moduleName, 'routes/web.php'));
$this->loadViewsFrom(module_path($this->moduleName, 'resources/views'), 'profile');
}
public function register(): void {}
}

View File

@ -1,28 +0,0 @@
<?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('profiles', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->unique()->constrained('users')->cascadeOnDelete();
$table->string('avatar')->nullable();
$table->text('bio')->nullable();
$table->string('phone')->nullable();
$table->string('city')->nullable();
$table->string('country')->nullable();
$table->string('website')->nullable();
$table->boolean('is_verified')->default(false);
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('profiles');
}
};

View File

@ -1,12 +0,0 @@
{
"name": "Profile",
"alias": "profile",
"description": "User profile management",
"keywords": [],
"priority": 0,
"providers": [
"Modules\\Profile\\Providers\\ProfileServiceProvider"
],
"aliases": {},
"files": []
}

View File

@ -1,34 +0,0 @@
@extends('app::layouts.app')
@section('content')
<div class="container mx-auto px-4 py-8">
<div class="max-w-2xl mx-auto">
<h1 class="text-2xl font-bold mb-6">Edit Profile</h1>
<form method="POST" action="{{ route('profile.update') }}" class="bg-white rounded-lg shadow-md p-6 space-y-4">
@csrf @method('PUT')
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Bio</label>
<textarea name="bio" rows="3" class="w-full border rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500">{{ old('bio', $profile->bio) }}</textarea>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Phone</label>
<input type="text" name="phone" value="{{ old('phone', $profile->phone) }}" class="w-full border rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500">
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">City</label>
<input type="text" name="city" value="{{ old('city', $profile->city) }}" class="w-full border rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Country</label>
<input type="text" name="country" value="{{ old('country', $profile->country) }}" class="w-full border rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500">
</div>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Website</label>
<input type="url" name="website" value="{{ old('website', $profile->website) }}" class="w-full border rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500">
</div>
<button type="submit" class="w-full bg-blue-600 text-white py-3 rounded-lg hover:bg-blue-700 transition font-medium">Save Profile</button>
</form>
</div>
</div>
@endsection

View File

@ -1,25 +0,0 @@
@extends('app::layouts.app')
@section('content')
<div class="container mx-auto px-4 py-8">
<div class="max-w-2xl mx-auto bg-white rounded-lg shadow-md p-6">
<div class="flex items-center space-x-4 mb-6">
<div class="w-16 h-16 bg-blue-100 rounded-full flex items-center justify-center">
<span class="text-2xl font-bold text-blue-600">{{ substr(auth()->user()->name, 0, 1) }}</span>
</div>
<div>
<h1 class="text-2xl font-bold">{{ auth()->user()->name }}</h1>
<p class="text-gray-500">{{ auth()->user()->email }}</p>
</div>
</div>
@if($profile->bio)<p class="text-gray-700 mb-4">{{ $profile->bio }}</p>@endif
<div class="space-y-2 text-gray-600">
@if($profile->phone)<p>📞 {{ $profile->phone }}</p>@endif
@if($profile->city)<p>📍 {{ $profile->city }}@if($profile->country), {{ $profile->country }}@endif</p>@endif
@if($profile->website)<p>🌐 <a href="{{ $profile->website }}" class="text-blue-600 hover:underline">{{ $profile->website }}</a></p>@endif
</div>
<div class="mt-6">
<a href="{{ route('profile.edit') }}" class="bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 transition">Edit Profile</a>
</div>
</div>
</div>
@endsection

View File

@ -1,9 +0,0 @@
<?php
use Illuminate\Support\Facades\Route;
use Modules\Profile\Http\Controllers\ProfileController;
Route::middleware('auth')->prefix('profile')->name('profile.')->group(function () {
Route::get('/', [ProfileController::class, 'show'])->name('show');
Route::get('/edit', [ProfileController::class, 'edit'])->name('edit');
Route::put('/extended', [ProfileController::class, 'update'])->name('update');
});

View File

@ -1,23 +1,33 @@
<?php
namespace App\Http\Controllers\Auth;
namespace Modules\User\App\Http\Controllers;
use App\Http\Controllers\Controller;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Validation\Rule;
use Illuminate\View\View;
class ProfileController extends Controller
{
/**
* Update the user's profile information.
*/
public function edit(Request $request): View
{
return view('user::profile.edit', ['user' => $request->user()]);
}
public function update(Request $request): RedirectResponse
{
$validated = $request->validateWithBag('updateProfile', [
'name' => ['required', 'string', 'max:255'],
'email' => ['required', 'string', 'lowercase', 'email', 'max:255', Rule::unique('users')->ignore($request->user()->id)],
'email' => [
'required',
'string',
'lowercase',
'email',
'max:255',
Rule::unique('users')->ignore($request->user()->id),
],
]);
$request->user()->fill($validated);
@ -28,12 +38,9 @@ class ProfileController extends Controller
$request->user()->save();
return redirect('/profile')->with('status', 'profile-updated');
return redirect()->route('profile.edit')->with('status', 'profile-updated');
}
/**
* Delete the user's account.
*/
public function destroy(Request $request): RedirectResponse
{
$request->validateWithBag('userDeletion', [
@ -43,7 +50,6 @@ class ProfileController extends Controller
$user = $request->user();
Auth::logout();
$user->delete();
$request->session()->invalidate();

View File

@ -1,5 +1,6 @@
<?php
namespace Modules\Profile\Models;
namespace Modules\User\App\Models;
use Illuminate\Database\Eloquent\Model;
use Spatie\Activitylog\LogOptions;
@ -10,6 +11,7 @@ class Profile extends Model
use LogsActivity;
protected $fillable = ['user_id', 'avatar', 'bio', 'phone', 'city', 'country', 'website', 'is_verified'];
protected $casts = ['is_verified' => 'boolean'];
public function getActivitylogOptions(): LogOptions
@ -22,6 +24,6 @@ class Profile extends Model
public function user()
{
return $this->belongsTo(\App\Models\User::class);
return $this->belongsTo(User::class);
}
}

View File

@ -1,11 +1,12 @@
<?php
namespace App\Models;
use App\States\UserStatus;
use Filament\Models\Contracts\HasAvatar;
namespace Modules\User\App\Models;
use Filament\Models\Contracts\FilamentUser;
use Filament\Models\Contracts\HasAvatar;
use Filament\Models\Contracts\HasTenants;
use Filament\Panel;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Foundation\Auth\User as Authenticatable;
@ -14,6 +15,11 @@ use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Storage;
use Jeffgreco13\FilamentBreezy\Traits\TwoFactorAuthenticatable;
use Laravel\Sanctum\HasApiTokens;
use Modules\Conversation\App\Models\Conversation;
use Modules\Conversation\App\Models\ConversationMessage;
use Modules\Favorite\App\Models\FavoriteSearch;
use Modules\Listing\Models\Listing;
use Modules\User\App\States\UserStatus;
use Spatie\Activitylog\LogOptions;
use Spatie\Activitylog\Traits\LogsActivity;
use Spatie\ModelStates\HasStates;
@ -21,9 +27,16 @@ use Spatie\Permission\Traits\HasRoles;
class User extends Authenticatable implements FilamentUser, HasTenants, HasAvatar
{
use HasApiTokens, HasFactory, HasRoles, LogsActivity, Notifiable, HasStates, TwoFactorAuthenticatable;
use HasApiTokens;
use HasFactory;
use HasRoles;
use LogsActivity;
use Notifiable;
use HasStates;
use TwoFactorAuthenticatable;
protected $fillable = ['name', 'email', 'password', 'avatar_url', 'status'];
protected $hidden = ['password', 'remember_token'];
protected function casts(): array
@ -35,6 +48,11 @@ class User extends Authenticatable implements FilamentUser, HasTenants, HasAvata
];
}
protected static function newFactory(): Factory
{
return \Database\Factories\UserFactory::new();
}
public function getActivitylogOptions(): LogOptions
{
return LogOptions::defaults()
@ -64,19 +82,22 @@ class User extends Authenticatable implements FilamentUser, HasTenants, HasAvata
public function listings()
{
return $this->hasMany(\Modules\Listing\Models\Listing::class);
return $this->hasMany(Listing::class);
}
public function profile()
{
return $this->hasOne(Profile::class);
}
public function favoriteListings()
{
return $this->belongsToMany(\Modules\Listing\Models\Listing::class, 'favorite_listings')
->withTimestamps();
return $this->belongsToMany(Listing::class, 'favorite_listings')->withTimestamps();
}
public function favoriteSellers()
{
return $this->belongsToMany(self::class, 'favorite_sellers', 'user_id', 'seller_id')
->withTimestamps();
return $this->belongsToMany(self::class, 'favorite_sellers', 'user_id', 'seller_id')->withTimestamps();
}
public function favoriteSearches()
@ -111,8 +132,40 @@ class User extends Authenticatable implements FilamentUser, HasTenants, HasAvata
public function getFilamentAvatarUrl(): ?string
{
return filled($this->avatar_url)
? Storage::disk('public')->url($this->avatar_url)
: null;
return filled($this->avatar_url) ? Storage::disk('public')->url($this->avatar_url) : null;
}
public function toggleFavoriteListing(Listing $listing): bool
{
$isFavorite = $this->favoriteListings()->whereKey($listing->getKey())->exists();
if ($isFavorite) {
$this->favoriteListings()->detach($listing->getKey());
return false;
}
$this->favoriteListings()->syncWithoutDetaching([$listing->getKey()]);
return true;
}
public function toggleFavoriteSeller(self $seller): bool
{
if ((int) $seller->getKey() === (int) $this->getKey()) {
return false;
}
$isFavorite = $this->favoriteSellers()->whereKey($seller->getKey())->exists();
if ($isFavorite) {
$this->favoriteSellers()->detach($seller->getKey());
return false;
}
$this->favoriteSellers()->syncWithoutDetaching([$seller->getKey()]);
return true;
}
}

View File

@ -0,0 +1,19 @@
<?php
namespace Modules\User\App\Providers;
use Illuminate\Support\ServiceProvider;
class UserServiceProvider extends ServiceProvider
{
public function boot(): void
{
$this->loadMigrationsFrom(module_path('User', 'database/migrations'));
$this->loadRoutesFrom(module_path('User', 'routes/web.php'));
$this->loadViewsFrom(module_path('User', 'resources/views'), 'user');
}
public function register(): void
{
}
}

View File

@ -1,6 +1,6 @@
<?php
namespace App\States;
namespace Modules\User\App\States;
use Filament\Support\Contracts\HasColor;
use Filament\Support\Contracts\HasDescription;

View File

@ -1,6 +1,6 @@
<?php
namespace App\States;
namespace Modules\User\App\States;
use Filament\Support\Contracts\HasColor;
use Filament\Support\Contracts\HasDescription;

View File

@ -1,6 +1,6 @@
<?php
namespace App\States;
namespace Modules\User\App\States;
use Filament\Support\Contracts\HasColor;
use Filament\Support\Contracts\HasDescription;

View File

@ -1,6 +1,6 @@
<?php
namespace App\States;
namespace Modules\User\App\States;
use A909M\FilamentStateFusion\Concerns\StateFusionInfo;
use A909M\FilamentStateFusion\Contracts\HasFilamentStateFusion;

View File

@ -0,0 +1,39 @@
<?php
namespace Modules\User\App\Support\Filament;
use A909M\FilamentStateFusion\Forms\Components\StateFusionSelect;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
class UserFormFields
{
public static function name(): TextInput
{
return TextInput::make('name')->required()->maxLength(255);
}
public static function email(): TextInput
{
return TextInput::make('email')->email()->required()->maxLength(255)->unique(ignoreRecord: true);
}
public static function password(callable $requiredCallback): TextInput
{
return TextInput::make('password')
->password()
->required($requiredCallback)
->dehydrateStateUsing(fn ($state) => filled($state) ? bcrypt($state) : null)
->dehydrated(fn ($state) => filled($state));
}
public static function status(): StateFusionSelect
{
return StateFusionSelect::make('status')->required();
}
public static function roles(): Select
{
return Select::make('roles')->multiple()->relationship('roles', 'name')->preload();
}
}

View File

@ -0,0 +1,67 @@
<?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
{
if (! Schema::hasTable('users')) {
Schema::create('users', function (Blueprint $table): void {
$table->id();
$table->string('name');
$table->string('email')->unique();
$table->timestamp('email_verified_at')->nullable();
$table->string('status')->default('active');
$table->string('password');
$table->string('avatar_url')->nullable();
$table->rememberToken();
$table->timestamps();
});
}
if (! Schema::hasTable('profiles')) {
Schema::create('profiles', function (Blueprint $table): void {
$table->id();
$table->foreignId('user_id')->unique()->constrained('users')->cascadeOnDelete();
$table->string('avatar')->nullable();
$table->text('bio')->nullable();
$table->string('phone')->nullable();
$table->string('city')->nullable();
$table->string('country')->nullable();
$table->string('website')->nullable();
$table->boolean('is_verified')->default(false);
$table->timestamps();
});
}
if (! Schema::hasTable('password_reset_tokens')) {
Schema::create('password_reset_tokens', function (Blueprint $table): void {
$table->string('email')->primary();
$table->string('token');
$table->timestamp('created_at')->nullable();
});
}
if (! Schema::hasTable('sessions')) {
Schema::create('sessions', function (Blueprint $table): void {
$table->string('id')->primary();
$table->foreignId('user_id')->nullable()->index();
$table->string('ip_address', 45)->nullable();
$table->text('user_agent')->nullable();
$table->longText('payload');
$table->integer('last_activity')->index();
});
}
}
public function down(): void
{
Schema::dropIfExists('sessions');
Schema::dropIfExists('password_reset_tokens');
Schema::dropIfExists('profiles');
Schema::dropIfExists('users');
}
};

11
Modules/User/module.json Normal file
View File

@ -0,0 +1,11 @@
{
"name": "User",
"alias": "user",
"description": "",
"keywords": [],
"priority": 0,
"providers": [
"Modules\\User\\App\\Providers\\UserServiceProvider"
],
"files": []
}

View File

@ -9,19 +9,19 @@
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8 space-y-6">
<div class="p-4 sm:p-8 bg-white shadow sm:rounded-lg">
<div class="max-w-xl">
@include('profile.partials.update-profile-information-form')
@include('user::profile.partials.update-profile-information-form')
</div>
</div>
<div class="p-4 sm:p-8 bg-white shadow sm:rounded-lg">
<div class="max-w-xl">
@include('profile.partials.update-password-form')
@include('user::profile.partials.update-password-form')
</div>
</div>
<div class="p-4 sm:p-8 bg-white shadow sm:rounded-lg">
<div class="max-w-xl">
@include('profile.partials.delete-user-form')
@include('user::profile.partials.delete-user-form')
</div>
</div>
</div>

View File

@ -0,0 +1,10 @@
<?php
use Illuminate\Support\Facades\Route;
use Modules\User\App\Http\Controllers\ProfileController;
Route::middleware('auth')->prefix('profile')->name('profile.')->group(function () {
Route::get('/', [ProfileController::class, 'edit'])->name('edit');
Route::patch('/', [ProfileController::class, 'update'])->name('update');
Route::delete('/', [ProfileController::class, 'destroy'])->name('destroy');
});

View File

@ -3,7 +3,7 @@
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use App\Models\User;
use Modules\User\App\Models\User;
use Illuminate\Auth\Events\PasswordReset;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
@ -15,19 +15,11 @@ use Illuminate\View\View;
class NewPasswordController extends Controller
{
/**
* Display the password reset view.
*/
public function create(Request $request): View
{
return view('auth.reset-password', ['request' => $request]);
}
/**
* Handle an incoming new password request.
*
* @throws \Illuminate\Validation\ValidationException
*/
public function store(Request $request): RedirectResponse
{
$request->validate([
@ -36,9 +28,6 @@ class NewPasswordController extends Controller
'password' => ['required', 'confirmed', Rules\Password::defaults()],
]);
// Here we will attempt to reset the user's password. If it is successful we
// will update the password on an actual user model and persist it to the
// database. Otherwise we will parse the error and return the response.
$status = Password::reset(
$request->only('email', 'password', 'password_confirmation', 'token'),
function (User $user) use ($request) {
@ -51,9 +40,6 @@ class NewPasswordController extends Controller
}
);
// If the password was successfully reset, we will redirect the user back to
// the application's home authenticated view. If there is an error we can
// redirect them back to where they came from with their error message.
return $status == Password::PASSWORD_RESET
? redirect()->route('login')->with('status', __($status))
: back()->withInput($request->only('email'))

View File

@ -3,7 +3,7 @@
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use App\Models\User;
use Modules\User\App\Models\User;
use Illuminate\Auth\Events\Registered;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
@ -14,19 +14,11 @@ use Illuminate\View\View;
class RegisteredUserController extends Controller
{
/**
* Display the registration view.
*/
public function create(): View
{
return view('auth.register');
}
/**
* Handle an incoming registration request.
*
* @throws \Illuminate\Validation\ValidationException
*/
public function store(Request $request): RedirectResponse
{
$request->validate([

View File

@ -3,7 +3,7 @@
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use App\Models\User;
use Modules\User\App\Models\User;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
@ -15,9 +15,6 @@ use Throwable;
class SocialAuthController extends Controller
{
/**
* @var array<int, string>
*/
private array $allowedProviders = ['google', 'facebook', 'apple'];
public function redirect(string $provider): RedirectResponse

View File

@ -1,104 +0,0 @@
<?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('panel.inbox.index', array_merge(
$this->inboxFilters($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('panel.inbox.index', array_merge(
$this->inboxFilters($request),
['conversation' => $conversation->getKey()],
))
->with('success', 'Mesaj gönderildi.');
}
private function inboxFilters(Request $request): array
{
$filters = [];
$messageFilter = (string) $request->string('message_filter');
if (in_array($messageFilter, ['all', 'unread', 'important'], true)) {
$filters['message_filter'] = $messageFilter;
}
return $filters;
}
}

View File

@ -4,7 +4,7 @@ namespace App\Http\Controllers;
use Illuminate\Http\Request;
use Modules\Listing\Models\Listing;
use Modules\Category\Models\Category;
use App\Models\User;
use Modules\User\App\Models\User;
class HomeController extends Controller
{

View File

@ -2,8 +2,6 @@
namespace App\Http\Controllers;
use App\Models\Conversation;
use App\Models\ConversationMessage;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
@ -59,88 +57,6 @@ class PanelController extends Controller
]);
}
public function inbox(Request $request): View
{
$userId = (int) $request->user()->getKey();
$messageFilter = (string) $request->string('message_filter', 'all');
if (! in_array($messageFilter, ['all', 'unread', 'important'], true)) {
$messageFilter = 'all';
}
$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',
])
->withCount([
'messages as unread_count' => fn ($query) => $query
->where('sender_id', '!=', $userId)
->whereNull('read_at'),
])
->orderByDesc('last_message_at')
->orderByDesc('updated_at')
->get();
$selectedConversation = null;
$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;
});
}
}
return view('panel.inbox', [
'conversations' => $conversations,
'selectedConversation' => $selectedConversation,
'messageFilter' => $messageFilter,
'quickMessages' => [
'Merhaba',
'İlan hâlâ satışta mı?',
'Son fiyat nedir?',
'Teşekkürler',
],
]);
}
public function destroyListing(Request $request, Listing $listing): RedirectResponse
{
$this->guardListingOwner($request, $listing);

View File

@ -1,60 +0,0 @@
<?php
namespace App\Http\Controllers;
use App\Http\Requests\ProfileUpdateRequest;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Redirect;
use Illuminate\View\View;
class ProfileController extends Controller
{
/**
* Display the user's profile form.
*/
public function edit(Request $request): View
{
return view('profile.edit', [
'user' => $request->user(),
]);
}
/**
* Update the user's profile information.
*/
public function update(ProfileUpdateRequest $request): RedirectResponse
{
$request->user()->fill($request->validated());
if ($request->user()->isDirty('email')) {
$request->user()->email_verified_at = null;
}
$request->user()->save();
return Redirect::route('profile.edit')->with('status', 'profile-updated');
}
/**
* Delete the user's account.
*/
public function destroy(Request $request): RedirectResponse
{
$request->validateWithBag('userDeletion', [
'password' => ['required', 'current_password'],
]);
$user = $request->user();
Auth::logout();
$user->delete();
$request->session()->invalidate();
$request->session()->regenerateToken();
return Redirect::to('/');
}
}

View File

@ -1,30 +0,0 @@
<?php
namespace App\Http\Requests;
use App\Models\User;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class ProfileUpdateRequest extends FormRequest
{
/**
* Get the validation rules that apply to the request.
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'name' => ['required', 'string', 'max:255'],
'email' => [
'required',
'string',
'lowercase',
'email',
'max:255',
Rule::unique(User::class)->ignore($this->user()->id),
],
];
}
}

View File

@ -16,7 +16,7 @@ use Modules\Listing\Support\ListingCustomFieldSchemaBuilder;
use Modules\Listing\Support\ListingPanelHelper;
use Modules\Location\Models\City;
use Modules\Location\Models\Country;
use Modules\Profile\Models\Profile;
use Modules\User\App\Models\Profile;
use Throwable;
class PanelQuickListingForm extends Component

View File

@ -1,76 +0,0 @@
<?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()
->select([
'conversation_messages.id',
'conversation_messages.conversation_id',
'conversation_messages.sender_id',
'conversation_messages.body',
'conversation_messages.created_at',
]);
}
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);
});
}
public static function buyerListingConversationId(int $listingId, int $buyerId): ?int
{
$value = static::query()
->where('listing_id', $listingId)
->where('buyer_id', $buyerId)
->value('id');
return is_null($value) ? null : (int) $value;
}
}

View File

@ -62,7 +62,7 @@ return [
'providers' => [
'users' => [
'driver' => 'eloquent',
'model' => env('AUTH_MODEL', App\Models\User::class),
'model' => env('AUTH_MODEL', Modules\User\App\Models\User::class),
],
// 'users' => [

View File

@ -5,22 +5,14 @@ namespace Database\Factories;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str;
use Modules\User\App\Models\User;
/**
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\User>
*/
class UserFactory extends Factory
{
/**
* The current password being used by the factory.
*/
protected $model = User::class;
protected static ?string $password;
/**
* Define the model's default state.
*
* @return array<string, mixed>
*/
public function definition(): array
{
return [
@ -32,9 +24,6 @@ class UserFactory extends Factory
];
}
/**
* Indicate that the model's email address should be unverified.
*/
public function unverified(): static
{
return $this->state(fn (array $attributes) => [

View File

@ -1,49 +0,0 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('users', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->string('email')->unique();
$table->timestamp('email_verified_at')->nullable();
$table->string('password');
$table->rememberToken();
$table->timestamps();
});
Schema::create('password_reset_tokens', function (Blueprint $table) {
$table->string('email')->primary();
$table->string('token');
$table->timestamp('created_at')->nullable();
});
Schema::create('sessions', function (Blueprint $table) {
$table->string('id')->primary();
$table->foreignId('user_id')->nullable()->index();
$table->string('ip_address', 45)->nullable();
$table->text('user_agent')->nullable();
$table->longText('payload');
$table->integer('last_activity')->index();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('users');
Schema::dropIfExists('password_reset_tokens');
Schema::dropIfExists('sessions');
}
};

View File

@ -1,34 +0,0 @@
<?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::table('users', function (Blueprint $table): void {
if (! Schema::hasColumn('users', 'avatar_url')) {
$table->string('avatar_url')->nullable()->after('password');
}
if (! Schema::hasColumn('users', 'status')) {
$table->string('status')->default('active')->after('email_verified_at');
}
});
}
public function down(): void
{
Schema::table('users', function (Blueprint $table): void {
if (Schema::hasColumn('users', 'avatar_url')) {
$table->dropColumn('avatar_url');
}
if (Schema::hasColumn('users', 'status')) {
$table->dropColumn('status');
}
});
}
};

View File

@ -1,49 +0,0 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('favorite_listings', function (Blueprint $table): void {
$table->id();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->foreignId('listing_id')->constrained('listings')->cascadeOnDelete();
$table->timestamps();
$table->unique(['user_id', 'listing_id']);
});
Schema::create('favorite_sellers', function (Blueprint $table): void {
$table->id();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->foreignId('seller_id')->constrained('users')->cascadeOnDelete();
$table->timestamps();
$table->unique(['user_id', 'seller_id']);
});
Schema::create('favorite_searches', function (Blueprint $table): void {
$table->id();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->string('label')->nullable();
$table->string('search_term')->nullable();
$table->foreignId('category_id')->nullable()->constrained('categories')->nullOnDelete();
$table->json('filters')->nullable();
$table->string('signature', 64);
$table->timestamps();
$table->unique(['user_id', 'signature']);
});
}
public function down(): void
{
Schema::dropIfExists('favorite_searches');
Schema::dropIfExists('favorite_sellers');
Schema::dropIfExists('favorite_listings');
}
};

View File

@ -1,42 +0,0 @@
<?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');
}
};

View File

@ -1,7 +1,7 @@
<?php
namespace Database\Seeders;
use App\Models\User;
use Modules\User\App\Models\User;
use Illuminate\Database\Seeder;
use Illuminate\Support\Facades\Hash;
use Spatie\Permission\Models\Role;

View File

@ -2,8 +2,10 @@
"Category": true,
"Listing": true,
"Location": true,
"Profile": true,
"Admin": true,
"Partner": false,
"Theme": true
"Theme": true,
"Conversation": true,
"Favorite": true,
"User": true
}

View File

@ -52,9 +52,6 @@ Route::middleware('auth')->group(function () {
Route::put('password', [PasswordController::class, 'update'])->name('password.update');
Route::patch('profile', [\App\Http\Controllers\Auth\ProfileController::class, 'update'])->name('profile.update');
Route::delete('profile', [\App\Http\Controllers\Auth\ProfileController::class, 'destroy'])->name('profile.destroy');
Route::post('logout', [AuthenticatedSessionController::class, 'destroy'])
->name('logout');
});

View File

@ -1,7 +1,5 @@
<?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;
use App\Http\Controllers\PanelController;
@ -18,7 +16,6 @@ Route::middleware('auth')->prefix('panel')->name('panel.')->group(function () {
Route::get('/', [PanelController::class, 'index'])->name('index');
Route::get('/ilanlarim', [PanelController::class, 'listings'])->name('listings.index');
Route::get('/ilan-ver', [PanelController::class, 'create'])->name('listings.create');
Route::get('/gelen-kutusu', [PanelController::class, 'inbox'])->name('inbox.index');
Route::post('/ilanlarim/{listing}/kaldir', [PanelController::class, 'destroyListing'])->name('listings.destroy');
Route::post('/ilanlarim/{listing}/satildi', [PanelController::class, 'markListingAsSold'])->name('listings.mark-sold');
Route::post('/ilanlarim/{listing}/yeniden-yayinla', [PanelController::class, 'republishListing'])->name('listings.republish');
@ -27,17 +24,4 @@ Route::middleware('auth')->prefix('panel')->name('panel.')->group(function () {
Route::get('/partner/{any?}', fn () => redirect()->route('panel.listings.index'))
->where('any', '.*');
Route::middleware('auth')->prefix('favorites')->name('favorites.')->group(function () {
Route::get('/', [FavoriteController::class, 'index'])->name('index');
Route::post('/listings/{listing}/toggle', [FavoriteController::class, 'toggleListing'])->name('listings.toggle');
Route::post('/sellers/{seller}/toggle', [FavoriteController::class, 'toggleSeller'])->name('sellers.toggle');
Route::post('/searches', [FavoriteController::class, 'storeSearch'])->name('searches.store');
Route::delete('/searches/{favoriteSearch}', [FavoriteController::class, 'destroySearch'])->name('searches.destroy');
});
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';