diff --git a/.agents/skills/laravel-permission-development/SKILL.md b/.agents/skills/laravel-permission-development/SKILL.md new file mode 100644 index 000000000..aa7f2b125 --- /dev/null +++ b/.agents/skills/laravel-permission-development/SKILL.md @@ -0,0 +1,277 @@ +--- +name: laravel-permission-development +description: Build and work with Spatie Laravel Permission features, including roles, permissions, middleware, policies, teams, and Blade directives. +--- + +# Laravel Permission Development + +## When to use this skill + +Use this skill when working with authorization, roles, permissions, access control, middleware guards, or Blade permission directives using spatie/laravel-permission. + +## Core Concepts + +- **Users have Roles, Roles have Permissions, Apps check Permissions** (not Roles). +- Direct permissions on users are an anti-pattern; assign permissions to roles instead. +- Use `$user->can('permission-name')` for all authorization checks (supports Super Admin via Gate). +- The `HasRoles` trait (which includes `HasPermissions`) is added to User models. + +## Setup + +Add the `HasRoles` trait to your User model: + +```php +use Spatie\Permission\Traits\HasRoles; + +class User extends Authenticatable +{ + use HasRoles; +} +``` + +## Creating Roles and Permissions + +```php +use Spatie\Permission\Models\Role; +use Spatie\Permission\Models\Permission; + +$role = Role::create(['name' => 'writer']); +$permission = Permission::create(['name' => 'edit articles']); + +// findOrCreate is idempotent (safe for seeders) +$role = Role::findOrCreate('writer', 'web'); +$permission = Permission::findOrCreate('edit articles', 'web'); +``` + +## Assigning Roles and Permissions + +```php +// Assign roles to users +$user->assignRole('writer'); +$user->assignRole('writer', 'admin'); +$user->assignRole(['writer', 'admin']); +$user->syncRoles(['writer', 'admin']); // replaces all +$user->removeRole('writer'); + +// Assign permissions to roles (preferred) +$role->givePermissionTo('edit articles'); +$role->givePermissionTo(['edit articles', 'delete articles']); +$role->syncPermissions(['edit articles', 'delete articles']); +$role->revokePermissionTo('edit articles'); + +// Reverse assignment +$permission->assignRole('writer'); +$permission->syncRoles(['writer', 'editor']); +$permission->removeRole('writer'); +``` + +## Checking Roles and Permissions + +```php +// Permission checks (preferred - supports Super Admin via Gate) +$user->can('edit articles'); +$user->canAny(['edit articles', 'delete articles']); + +// Direct package methods (bypass Gate, no Super Admin support) +$user->hasPermissionTo('edit articles'); +$user->hasAnyPermission(['edit articles', 'publish articles']); +$user->hasAllPermissions(['edit articles', 'publish articles']); +$user->hasDirectPermission('edit articles'); + +// Role checks +$user->hasRole('writer'); +$user->hasAnyRole(['writer', 'editor']); +$user->hasAllRoles(['writer', 'editor']); +$user->hasExactRoles(['writer', 'editor']); + +// Get assigned roles and permissions +$user->getRoleNames(); // Collection of role name strings +$user->getPermissionNames(); // Collection of permission name strings +$user->getDirectPermissions(); // Direct permissions only +$user->getPermissionsViaRoles(); // Inherited via roles +$user->getAllPermissions(); // Both direct and inherited +``` + +## Query Scopes + +```php +$users = User::role('writer')->get(); +$users = User::withoutRole('writer')->get(); +$users = User::permission('edit articles')->get(); +$users = User::withoutPermission('edit articles')->get(); +``` + +## Middleware + +Register middleware aliases in `bootstrap/app.php`: + +```php +->withMiddleware(function (Middleware $middleware) { + $middleware->alias([ + 'role' => \Spatie\Permission\Middleware\RoleMiddleware::class, + 'permission' => \Spatie\Permission\Middleware\PermissionMiddleware::class, + 'role_or_permission' => \Spatie\Permission\Middleware\RoleOrPermissionMiddleware::class, + ]); +}) +``` + +Use in routes (pipe `|` for OR logic): + +```php +Route::middleware(['permission:edit articles'])->group(function () { ... }); +Route::middleware(['role:manager|writer'])->group(function () { ... }); +Route::middleware(['role_or_permission:manager|edit articles'])->group(function () { ... }); + +// With specific guard +Route::middleware(['role:manager,api'])->group(function () { ... }); +``` + +For single permissions, Laravel's built-in `can` middleware also works: + +```php +Route::middleware(['can:edit articles'])->group(function () { ... }); +``` + +## Blade Directives + +Prefer `@can` (permission-based) over `@role` (role-based): + +```blade +@can('edit articles') + {{-- User can edit articles (supports Super Admin) --}} +@endcan + +@canany(['edit articles', 'delete articles']) + {{-- User can do at least one --}} +@endcanany + +@role('admin') + {{-- Only use for super-admin type checks --}} +@endrole + +@hasanyrole('writer|admin') + {{-- Has writer or admin --}} +@endhasanyrole +``` + +## Super Admin + +Use `Gate::before` in `AppServiceProvider::boot()`: + +```php +use Illuminate\Support\Facades\Gate; + +public function boot(): void +{ + Gate::before(function ($user, $ability) { + return $user->hasRole('Super Admin') ? true : null; + }); +} +``` + +This makes `$user->can()` and `@can` always return true for Super Admins. Must return `null` (not `false`) to allow normal checks for other users. + +## Policies + +Use `$user->can()` inside policy methods to check permissions: + +```php +class PostPolicy +{ + public function update(User $user, Post $post): bool + { + if ($user->can('edit all posts')) { + return true; + } + + return $user->can('edit own posts') && $user->id === $post->user_id; + } +} +``` + +## Enums + +```php +enum RolesEnum: string +{ + case WRITER = 'writer'; + case EDITOR = 'editor'; +} + +enum PermissionsEnum: string +{ + case EDIT_POSTS = 'edit posts'; + case DELETE_POSTS = 'delete posts'; +} + +// Creation requires ->value +Permission::findOrCreate(PermissionsEnum::EDIT_POSTS->value, 'web'); + +// Most methods accept enums directly +$user->assignRole(RolesEnum::WRITER); +$user->hasRole(RolesEnum::WRITER); +$role->givePermissionTo(PermissionsEnum::EDIT_POSTS); +$user->hasPermissionTo(PermissionsEnum::EDIT_POSTS); +``` + +## Seeding + +Always flush the permission cache when seeding: + +```php +class RolesAndPermissionsSeeder extends Seeder +{ + public function run(): void + { + // Reset cache + app()[\Spatie\Permission\PermissionRegistrar::class]->forgetCachedPermissions(); + + // Create permissions + Permission::findOrCreate('edit articles', 'web'); + Permission::findOrCreate('delete articles', 'web'); + + // Create roles and assign permissions + Role::findOrCreate('writer', 'web') + ->givePermissionTo(['edit articles']); + + Role::findOrCreate('admin', 'web') + ->givePermissionTo(Permission::all()); + } +} +``` + +## Teams (Multi-Tenancy) + +Enable in `config/permission.php` before running migrations: + +```php +'teams' => true, +``` + +Set the active team in middleware: + +```php +setPermissionsTeamId($teamId); +``` + +When switching teams, unset cached relations: + +```php +$user->unsetRelation('roles')->unsetRelation('permissions'); +``` + +## Events + +Enable in `config/permission.php`: + +```php +'events_enabled' => true, +``` + +Available events: `RoleAttachedEvent`, `RoleDetachedEvent`, `PermissionAttachedEvent`, `PermissionDetachedEvent` in the `Spatie\Permission\Events` namespace. + +## Performance + +- Permissions are cached automatically. The cache is flushed when roles/permissions change via package methods. +- After direct DB operations, flush manually: `app()[\Spatie\Permission\PermissionRegistrar::class]->forgetCachedPermissions()` +- For bulk seeding, use `Permission::insert()` for speed, but flush the cache afterward. \ No newline at end of file diff --git a/.chatgpt/CUSTOM_INSTRUCTIONS.md b/.chatgpt/CUSTOM_INSTRUCTIONS.md new file mode 100644 index 000000000..031b84595 --- /dev/null +++ b/.chatgpt/CUSTOM_INSTRUCTIONS.md @@ -0,0 +1,7 @@ +Act as a Senior Laravel & FilamentPHP Architect. Refactor the attached code as a greenfield project adhering to the following strict constraints: +1. Architecture: Enforce strict SOLID principles, prioritize brevity, and completely ignore backward compatibility. +2. Cleanup: Remove all legacy code, comments, tests, and PHPDocs. +3. Refactoring: Move all database logic into Models and extract repetitive Filament code into dedicated Helper classes. Identify and fix any existing logical errors. +4. Database: Consolidate migrations into a single file per table or topic (e.g., users, cache, jobs) to reduce the overall number of migration files. +5. Modularity: Use the `laravel-modules` package to encapsulate all features, routing, and Filament resources strictly inside their respective modules. +6. Frontend: Optimize and reduce the CSS footprint while maintaining the exact same visual output. diff --git a/.codex/CUSTOM_INSTRUCTIONS.md b/.codex/CUSTOM_INSTRUCTIONS.md new file mode 100644 index 000000000..031b84595 --- /dev/null +++ b/.codex/CUSTOM_INSTRUCTIONS.md @@ -0,0 +1,7 @@ +Act as a Senior Laravel & FilamentPHP Architect. Refactor the attached code as a greenfield project adhering to the following strict constraints: +1. Architecture: Enforce strict SOLID principles, prioritize brevity, and completely ignore backward compatibility. +2. Cleanup: Remove all legacy code, comments, tests, and PHPDocs. +3. Refactoring: Move all database logic into Models and extract repetitive Filament code into dedicated Helper classes. Identify and fix any existing logical errors. +4. Database: Consolidate migrations into a single file per table or topic (e.g., users, cache, jobs) to reduce the overall number of migration files. +5. Modularity: Use the `laravel-modules` package to encapsulate all features, routing, and Filament resources strictly inside their respective modules. +6. Frontend: Optimize and reduce the CSS footprint while maintaining the exact same visual output. diff --git a/.gemini/CUSTOM_INSTRUCTIONS.md b/.gemini/CUSTOM_INSTRUCTIONS.md new file mode 100644 index 000000000..031b84595 --- /dev/null +++ b/.gemini/CUSTOM_INSTRUCTIONS.md @@ -0,0 +1,7 @@ +Act as a Senior Laravel & FilamentPHP Architect. Refactor the attached code as a greenfield project adhering to the following strict constraints: +1. Architecture: Enforce strict SOLID principles, prioritize brevity, and completely ignore backward compatibility. +2. Cleanup: Remove all legacy code, comments, tests, and PHPDocs. +3. Refactoring: Move all database logic into Models and extract repetitive Filament code into dedicated Helper classes. Identify and fix any existing logical errors. +4. Database: Consolidate migrations into a single file per table or topic (e.g., users, cache, jobs) to reduce the overall number of migration files. +5. Modularity: Use the `laravel-modules` package to encapsulate all features, routing, and Filament resources strictly inside their respective modules. +6. Frontend: Optimize and reduce the CSS footprint while maintaining the exact same visual output. diff --git a/.gemini/settings.json b/.gemini/settings.json new file mode 100644 index 000000000..8c6715a15 --- /dev/null +++ b/.gemini/settings.json @@ -0,0 +1,11 @@ +{ + "mcpServers": { + "laravel-boost": { + "command": "php", + "args": [ + "artisan", + "boost:mcp" + ] + } + } +} \ No newline at end of file diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml deleted file mode 100644 index e43d40dd0..000000000 --- a/.github/workflows/tests.yml +++ /dev/null @@ -1,47 +0,0 @@ -name: Tests - -on: - push: - branches: - - master - - '*.x' - pull_request: - schedule: - - cron: '0 0 * * *' - -permissions: - contents: read - -jobs: - tests: - runs-on: ubuntu-latest - - strategy: - fail-fast: true - matrix: - php: [8.2, 8.3, 8.4] - - name: PHP ${{ matrix.php }} - - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Setup PHP - uses: shivammathur/setup-php@v2 - with: - php-version: ${{ matrix.php }} - extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, sqlite, pdo_sqlite - coverage: none - - - name: Install Composer dependencies - run: composer install --prefer-dist --no-interaction --no-progress - - - name: Copy environment file - run: cp .env.example .env - - - name: Generate app key - run: php artisan key:generate - - - name: Execute tests - run: php artisan test diff --git a/.gitignore b/.gitignore index 1476c926f..f862d9be3 100644 --- a/.gitignore +++ b/.gitignore @@ -25,3 +25,6 @@ Thumbs.db /public/js/ /public/fonts/ /public/css/ +composer.lock +.codex/config.toml +/public/vendor/ diff --git a/CHANGELOG.md b/CHANGELOG.md deleted file mode 100644 index 90e8a12dc..000000000 --- a/CHANGELOG.md +++ /dev/null @@ -1,19 +0,0 @@ -# OpenClassify Changelog - -All notable changes to OpenClassify will be documented in this file. - -## [1.0.0] - 2025-01-01 - -### Added -- Initial release of OpenClassify — a Laravel 12 classified ads platform (inspired by Letgo/OLX) -- **Category Module**: Hierarchical categories with icons and up to 10 levels of nesting; seeded with 8 top-level categories and 33 subcategories -- **Listing Module**: Classified ads with title, description, price, currency, location, featured flag, and contact info -- **Location Module**: Country/City/District/Neighborhood hierarchy with seed data for 5 countries -- **Profile Module**: User profile management with bio, phone, location, and website -- Home page with hero search bar, stats bar, category grid, featured listings, and recent listings -- Partner dashboard showing user's own listings with activity stats -- Language switcher with support for 10 locales: English, Turkish, Arabic, Chinese, Spanish, French, German, Portuguese, Russian, Japanese -- RTL layout support for Arabic -- SQLite database with full migration support -- Authentication via Laravel Breeze (login, register, password reset, email verification) -- Responsive UI using Tailwind CSS diff --git a/Modules/Admin/Filament/Pages/ManageGeneralSettings.php b/Modules/Admin/Filament/Pages/ManageGeneralSettings.php new file mode 100644 index 000000000..cbba4a139 --- /dev/null +++ b/Modules/Admin/Filament/Pages/ManageGeneralSettings.php @@ -0,0 +1,174 @@ +components([ + TextInput::make('site_name') + ->label('Site Name') + ->required() + ->maxLength(255), + Textarea::make('site_description') + ->label('Site Description') + ->rows(3) + ->maxLength(500), + FileUpload::make('site_logo') + ->label('Site Logo') + ->image() + ->disk('public') + ->directory('settings') + ->visibility('public'), + TextInput::make('sender_name') + ->label('Sender Name') + ->required() + ->maxLength(120), + TextInput::make('sender_email') + ->label('Sender Email') + ->email() + ->required() + ->maxLength(255), + Select::make('default_language') + ->label('Default Language') + ->options($this->localeOptions()) + ->required() + ->searchable(), + TagsInput::make('currencies') + ->label('Currencies') + ->placeholder('USD') + ->helperText('Add 3-letter currency codes like USD, EUR, TRY.') + ->required() + ->rules(['array', 'min:1']) + ->afterStateHydrated(fn (TagsInput $component, $state) => $component->state($this->normalizeCurrencies($state))) + ->dehydrateStateUsing(fn ($state) => $this->normalizeCurrencies($state)), + TextInput::make('linkedin_url') + ->label('LinkedIn URL') + ->url() + ->nullable() + ->maxLength(255), + TextInput::make('instagram_url') + ->label('Instagram URL') + ->url() + ->nullable() + ->maxLength(255), + PhoneInput::make('whatsapp') + ->label('WhatsApp') + ->defaultCountry('TR') + ->nullable() + ->formatAsYouType() + ->helperText('Use international format, e.g. +905551112233.'), + Toggle::make('enable_google_maps') + ->label('Enable Google Maps') + ->default(false), + TextInput::make('google_maps_api_key') + ->label('Google Maps API Key') + ->password() + ->revealable() + ->nullable() + ->maxLength(255) + ->helperText('Required to enable map fields in listing forms.'), + Toggle::make('enable_google_login') + ->label('Enable Google Login') + ->default(false), + TextInput::make('google_client_id') + ->label('Google Client ID') + ->nullable() + ->maxLength(255), + TextInput::make('google_client_secret') + ->label('Google Client Secret') + ->password() + ->revealable() + ->nullable() + ->maxLength(255), + Toggle::make('enable_facebook_login') + ->label('Enable Facebook Login') + ->default(false), + TextInput::make('facebook_client_id') + ->label('Facebook Client ID') + ->nullable() + ->maxLength(255), + TextInput::make('facebook_client_secret') + ->label('Facebook Client Secret') + ->password() + ->revealable() + ->nullable() + ->maxLength(255), + Toggle::make('enable_apple_login') + ->label('Enable Apple Login') + ->default(false), + TextInput::make('apple_client_id') + ->label('Apple Client ID') + ->nullable() + ->maxLength(255), + TextInput::make('apple_client_secret') + ->label('Apple Client Secret') + ->password() + ->revealable() + ->nullable() + ->maxLength(255), + ]); + } + + private function localeOptions(): array + { + $labels = [ + 'en' => 'English', + 'tr' => 'Türkçe', + 'ar' => 'العربية', + 'zh' => '中文', + 'es' => 'Español', + 'fr' => 'Français', + 'de' => 'Deutsch', + 'pt' => 'Português', + 'ru' => 'Русский', + 'ja' => '日本語', + ]; + + return collect(config('app.available_locales', ['en'])) + ->mapWithKeys(fn (string $locale) => [$locale => $labels[$locale] ?? strtoupper($locale)]) + ->all(); + } + + private function normalizeCurrencies(null | array | string $state): array + { + $source = is_array($state) ? $state : (filled($state) ? [$state] : []); + + $normalized = collect($source) + ->filter(fn ($currency) => is_string($currency) && trim($currency) !== '') + ->map(fn (string $currency) => strtoupper(substr(trim($currency), 0, 3))) + ->filter(fn (string $currency) => strlen($currency) === 3) + ->unique() + ->values() + ->all(); + + return $normalized !== [] ? $normalized : ['USD']; + } +} diff --git a/Modules/Admin/Filament/Resources/CategoryResource.php b/Modules/Admin/Filament/Resources/CategoryResource.php index fdc1f06b8..1344c6241 100644 --- a/Modules/Admin/Filament/Resources/CategoryResource.php +++ b/Modules/Admin/Filament/Resources/CategoryResource.php @@ -1,28 +1,31 @@ schema([ + return $schema->schema([ TextInput::make('name')->required()->maxLength(255)->live(onBlur: true)->afterStateUpdated(fn ($state, $set) => $set('slug', \Illuminate\Support\Str::slug($state))), TextInput::make('slug')->required()->maxLength(255)->unique(ignoreRecord: true), TextInput::make('description')->maxLength(500), @@ -42,7 +45,13 @@ class CategoryResource extends Resource TextColumn::make('listings_count')->counts('listings')->label('Listings'), IconColumn::make('is_active')->boolean(), TextColumn::make('sort_order')->sortable(), - ])->actions([EditAction::make(), DeleteAction::make()]); + ])->actions([ + EditAction::make(), + Action::make('activities') + ->icon('heroicon-o-clock') + ->url(fn (Category $record): string => static::getUrl('activities', ['record' => $record])), + DeleteAction::make(), + ]); } public static function getPages(): array @@ -50,6 +59,7 @@ class CategoryResource extends Resource return [ 'index' => Pages\ListCategories::route('/'), 'create' => Pages\CreateCategory::route('/create'), + 'activities' => Pages\ListCategoryActivities::route('/{record}/activities'), 'edit' => Pages\EditCategory::route('/{record}/edit'), ]; } diff --git a/Modules/Admin/Filament/Resources/CategoryResource/Pages/ListCategoryActivities.php b/Modules/Admin/Filament/Resources/CategoryResource/Pages/ListCategoryActivities.php new file mode 100644 index 000000000..2dac0f1fd --- /dev/null +++ b/Modules/Admin/Filament/Resources/CategoryResource/Pages/ListCategoryActivities.php @@ -0,0 +1,10 @@ +schema([ + return $schema->schema([ TextInput::make('title')->required()->maxLength(255)->live(onBlur: true)->afterStateUpdated(fn ($state, $set) => $set('slug', \Illuminate\Support\Str::slug($state) . '-' . \Illuminate\Support\Str::random(4))), TextInput::make('slug')->required()->maxLength(255)->unique(ignoreRecord: true), Textarea::make('description')->rows(4), TextInput::make('price')->numeric()->prefix('$'), - TextInput::make('currency')->default('USD')->maxLength(3), + Select::make('currency') + ->options(fn () => self::currencyOptions()) + ->default(fn () => self::defaultCurrency()) + ->required(), Select::make('category_id')->label('Category')->options(fn () => Category::where('is_active', true)->pluck('name', 'id'))->searchable()->nullable(), - Select::make('status')->options(['active' => 'Active', 'pending' => 'Pending', 'sold' => 'Sold', 'expired' => 'Expired'])->default('active')->required(), - TextInput::make('contact_phone')->tel()->maxLength(50), + StateFusionSelect::make('status')->required(), + PhoneInput::make('contact_phone')->defaultCountry('TR')->nullable(), TextInput::make('contact_email')->email()->maxLength(255), Toggle::make('is_featured')->default(false), TextInput::make('city')->maxLength(100), TextInput::make('country')->maxLength(100), + Map::make('location') + ->label('Location') + ->visible(fn (): bool => self::googleMapsEnabled()) + ->draggable() + ->clickable() + ->autocomplete('city') + ->autocompleteReverse(true) + ->reverseGeocode([ + 'city' => '%L', + 'country' => '%C', + ]) + ->defaultLocation([41.0082, 28.9784]) + ->defaultZoom(10) + ->height('320px') + ->columnSpanFull(), + SpatieMediaLibraryFileUpload::make('images') + ->collection('listing-images') + ->multiple() + ->image() + ->reorderable(), ]); } public static function table(Table $table): Table { return $table->columns([ + SpatieMediaLibraryImageColumn::make('images') + ->collection('listing-images') + ->circular(), TextColumn::make('id')->sortable(), TextColumn::make('title')->searchable()->sortable()->limit(40), TextColumn::make('category.name')->label('Category'), TextColumn::make('price')->money('USD')->sortable(), - TextColumn::make('status')->badge()->color(fn ($state) => match ($state) { 'active' => 'success', 'sold' => 'gray', 'pending' => 'warning', default => 'danger' }), + StateFusionSelectColumn::make('status'), IconColumn::make('is_featured')->boolean()->label('Featured'), TextColumn::make('city'), TextColumn::make('created_at')->dateTime()->sortable(), ])->filters([ - SelectFilter::make('status')->options(['active' => 'Active', 'pending' => 'Pending', 'sold' => 'Sold', 'expired' => 'Expired']), - ])->actions([EditAction::make(), DeleteAction::make()]); + StateFusionSelectFilter::make('status'), + ])->actions([ + EditAction::make(), + Action::make('activities') + ->icon('heroicon-o-clock') + ->url(fn (Listing $record): string => static::getUrl('activities', ['record' => $record])), + DeleteAction::make(), + ]); } public static function getPages(): array @@ -62,7 +105,39 @@ class ListingResource extends Resource return [ 'index' => Pages\ListListings::route('/'), 'create' => Pages\CreateListing::route('/create'), + 'activities' => Pages\ListListingActivities::route('/{record}/activities'), 'edit' => Pages\EditListing::route('/{record}/edit'), ]; } + + private static function currencyOptions(): array + { + $codes = collect(config('app.currencies', ['USD'])) + ->filter(fn ($code) => is_string($code) && trim($code) !== '') + ->map(fn (string $code) => strtoupper(substr(trim($code), 0, 3))) + ->filter(fn (string $code) => strlen($code) === 3) + ->unique() + ->values() + ->all(); + + if ($codes === []) { + $codes = ['USD']; + } + + return collect($codes)->mapWithKeys(fn (string $code) => [$code => $code])->all(); + } + + private static function defaultCurrency(): string + { + return array_key_first(self::currencyOptions()) ?? 'USD'; + } + + private static function googleMapsEnabled(): bool + { + try { + return (bool) app(GeneralSettings::class)->enable_google_maps; + } catch (Throwable) { + return false; + } + } } diff --git a/Modules/Admin/Filament/Resources/ListingResource/Pages/ListListingActivities.php b/Modules/Admin/Filament/Resources/ListingResource/Pages/ListListingActivities.php new file mode 100644 index 000000000..baecba0aa --- /dev/null +++ b/Modules/Admin/Filament/Resources/ListingResource/Pages/ListListingActivities.php @@ -0,0 +1,10 @@ +schema([ + return $schema->schema([ TextInput::make('name')->required()->maxLength(100), TextInput::make('code')->required()->maxLength(2)->unique(ignoreRecord: true), TextInput::make('phone_code')->maxLength(10), @@ -38,7 +41,13 @@ class LocationResource extends Resource TextColumn::make('code'), TextColumn::make('phone_code'), IconColumn::make('is_active')->boolean(), - ])->actions([EditAction::make(), DeleteAction::make()]); + ])->actions([ + EditAction::make(), + Action::make('activities') + ->icon('heroicon-o-clock') + ->url(fn (Country $record): string => static::getUrl('activities', ['record' => $record])), + DeleteAction::make(), + ]); } public static function getPages(): array @@ -46,6 +55,7 @@ class LocationResource extends Resource return [ 'index' => Pages\ListLocations::route('/'), 'create' => Pages\CreateLocation::route('/create'), + 'activities' => Pages\ListLocationActivities::route('/{record}/activities'), 'edit' => Pages\EditLocation::route('/{record}/edit'), ]; } diff --git a/Modules/Admin/Filament/Resources/LocationResource/Pages/ListLocationActivities.php b/Modules/Admin/Filament/Resources/LocationResource/Pages/ListLocationActivities.php new file mode 100644 index 000000000..b47e17118 --- /dev/null +++ b/Modules/Admin/Filament/Resources/LocationResource/Pages/ListLocationActivities.php @@ -0,0 +1,10 @@ +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(), ]); } @@ -35,8 +43,18 @@ class UserResource extends Resource TextColumn::make('name')->searchable()->sortable(), TextColumn::make('email')->searchable()->sortable(), TextColumn::make('roles.name')->badge()->label('Roles'), + StateFusionSelectColumn::make('status'), TextColumn::make('created_at')->dateTime()->sortable(), - ])->actions([EditAction::make(), DeleteAction::make()]); + ])->filters([ + StateFusionSelectFilter::make('status'), + ])->actions([ + EditAction::make(), + Action::make('activities') + ->icon('heroicon-o-clock') + ->url(fn (User $record): string => static::getUrl('activities', ['record' => $record])), + Impersonate::make(), + DeleteAction::make(), + ]); } public static function getPages(): array @@ -44,6 +62,7 @@ class UserResource extends Resource return [ 'index' => Pages\ListUsers::route('/'), 'create' => Pages\CreateUser::route('/create'), + 'activities' => Pages\ListUserActivities::route('/{record}/activities'), 'edit' => Pages\EditUser::route('/{record}/edit'), ]; } diff --git a/Modules/Admin/Filament/Resources/UserResource/Pages/EditUser.php b/Modules/Admin/Filament/Resources/UserResource/Pages/EditUser.php index 72ce911df..055814570 100644 --- a/Modules/Admin/Filament/Resources/UserResource/Pages/EditUser.php +++ b/Modules/Admin/Filament/Resources/UserResource/Pages/EditUser.php @@ -4,9 +4,16 @@ namespace Modules\Admin\Filament\Resources\UserResource\Pages; use Filament\Actions\DeleteAction; use Filament\Resources\Pages\EditRecord; use Modules\Admin\Filament\Resources\UserResource; +use STS\FilamentImpersonate\Actions\Impersonate; class EditUser extends EditRecord { protected static string $resource = UserResource::class; - protected function getHeaderActions(): array { return [DeleteAction::make()]; } + protected function getHeaderActions(): array + { + return [ + Impersonate::make()->record($this->getRecord()), + DeleteAction::make(), + ]; + } } diff --git a/Modules/Admin/Filament/Resources/UserResource/Pages/ListUserActivities.php b/Modules/Admin/Filament/Resources/UserResource/Pages/ListUserActivities.php new file mode 100644 index 000000000..f97ef6202 --- /dev/null +++ b/Modules/Admin/Filament/Resources/UserResource/Pages/ListUserActivities.php @@ -0,0 +1,10 @@ +id('admin') + ->default() ->path('admin') ->login() ->colors(['primary' => Color::Blue]) ->discoverResources(in: module_path('Admin', 'Filament/Resources'), for: 'Modules\\Admin\\Filament\\Resources') ->discoverPages(in: module_path('Admin', 'Filament/Pages'), for: 'Modules\\Admin\\Filament\\Pages') ->discoverWidgets(in: module_path('Admin', 'Filament/Widgets'), for: 'Modules\\Admin\\Filament\\Widgets') + ->plugins([ + FilamentStateFusionPlugin::make(), + BreezyCore::make() + ->myProfile( + shouldRegisterNavigation: true, + navigationGroup: 'Settings', + hasAvatars: true, + userMenuLabel: 'My Profile', + ) + ->enableTwoFactorAuthentication() + ->enableSanctumTokens(), + FileManagerPlugin::make()->only([ + FileManager::class, + ]), + FilamentDeveloperLoginsPlugin::make() + ->enabled(fn (): bool => app()->environment('local')) + ->users([ + 'Admin' => 'a@a.com', + ]), + ]) ->pages([Dashboard::class]) ->middleware([ EncryptCookies::class, diff --git a/Modules/Category/Models/Category.php b/Modules/Category/Models/Category.php index 64e89f48e..2e7d30738 100644 --- a/Modules/Category/Models/Category.php +++ b/Modules/Category/Models/Category.php @@ -4,12 +4,24 @@ namespace Modules\Category\Models; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Spatie\Activitylog\LogOptions; +use Spatie\Activitylog\Traits\LogsActivity; class Category extends Model { + use LogsActivity; + protected $fillable = ['name', 'slug', 'description', 'icon', 'parent_id', 'level', 'sort_order', 'is_active']; protected $casts = ['is_active' => 'boolean']; + public function getActivitylogOptions(): LogOptions + { + return LogOptions::defaults() + ->logFillable() + ->logOnlyDirty() + ->dontSubmitEmptyLogs(); + } + public function parent(): BelongsTo { return $this->belongsTo(Category::class, 'parent_id'); diff --git a/Modules/Listing/Database/migrations/2026_03_03_140200_add_coordinates_to_listings_table.php b/Modules/Listing/Database/migrations/2026_03_03_140200_add_coordinates_to_listings_table.php new file mode 100644 index 000000000..64d521c89 --- /dev/null +++ b/Modules/Listing/Database/migrations/2026_03_03_140200_add_coordinates_to_listings_table.php @@ -0,0 +1,34 @@ +decimal('latitude', 10, 7)->nullable()->after('country'); + } + + if (! Schema::hasColumn('listings', 'longitude')) { + $table->decimal('longitude', 10, 7)->nullable()->after('latitude'); + } + }); + } + + public function down(): void + { + Schema::table('listings', function (Blueprint $table): void { + if (Schema::hasColumn('listings', 'longitude')) { + $table->dropColumn('longitude'); + } + + if (Schema::hasColumn('listings', 'latitude')) { + $table->dropColumn('latitude'); + } + }); + } +}; diff --git a/Modules/Listing/Http/Controllers/ListingController.php b/Modules/Listing/Http/Controllers/ListingController.php index bd47dc6fb..92f13a10a 100644 --- a/Modules/Listing/Http/Controllers/ListingController.php +++ b/Modules/Listing/Http/Controllers/ListingController.php @@ -3,6 +3,7 @@ namespace Modules\Listing\Http\Controllers; use Illuminate\Http\Request; use App\Http\Controllers\Controller; +use Illuminate\Validation\Rule; use Modules\Listing\Models\Listing; class ListingController extends Controller @@ -23,22 +24,43 @@ class ListingController extends Controller public function create() { - return view('listing::create'); + return view('listing::create', [ + 'currencies' => $this->currencyCodes(), + ]); } public function store(Request $request) { + $currencies = $this->currencyCodes(); + $data = $request->validate([ - 'title' => 'required|string|max:255', + 'title' => 'required|string|min:3|max:255', 'description' => 'nullable|string', - 'price' => 'nullable|numeric', + 'price' => 'nullable|numeric|min:0', + 'currency' => ['nullable', 'string', 'size:3', Rule::in($currencies)], + 'city' => 'nullable|string|max:120', + 'country' => 'nullable|string|max:120', 'category_id' => 'nullable|integer', 'contact_email' => 'nullable|email', 'contact_phone' => 'nullable|string', ]); $data['user_id'] = auth()->id(); + $data['currency'] = strtoupper($data['currency'] ?? $currencies[0]); $data['slug'] = \Illuminate\Support\Str::slug($data['title']) . '-' . \Illuminate\Support\Str::random(6); $listing = Listing::create($data); return redirect()->route('listings.show', $listing)->with('success', 'Listing created!'); } + + private function currencyCodes(): array + { + $codes = collect(config('app.currencies', ['USD'])) + ->filter(fn ($code) => is_string($code) && trim($code) !== '') + ->map(fn (string $code) => strtoupper(substr(trim($code), 0, 3))) + ->filter(fn (string $code) => strlen($code) === 3) + ->unique() + ->values() + ->all(); + + return $codes !== [] ? $codes : ['USD']; + } } diff --git a/Modules/Listing/Models/Listing.php b/Modules/Listing/Models/Listing.php index 4ad82c152..5ba630bef 100644 --- a/Modules/Listing/Models/Listing.php +++ b/Modules/Listing/Models/Listing.php @@ -1,18 +1,25 @@ 'boolean', 'expires_at' => 'datetime', 'price' => 'decimal:2', + 'latitude' => 'decimal:7', + 'longitude' => 'decimal:7', + 'status' => ListingStatus::class, ]; + protected $appends = ['location']; + + public function getActivitylogOptions(): LogOptions + { + return LogOptions::defaults() + ->logFillable() + ->logOnlyDirty() + ->dontSubmitEmptyLogs(); + } + public function category() { return $this->belongsTo(\Modules\Category\Models\Category::class); @@ -31,4 +51,32 @@ class Listing extends Model { return $this->belongsTo(\App\Models\User::class); } + + public function registerMediaCollections(): void + { + $this->addMediaCollection('listing-images'); + } + + protected function location(): Attribute + { + return Attribute::make( + get: function (mixed $value, array $attributes): ?array { + $latitude = $attributes['latitude'] ?? null; + $longitude = $attributes['longitude'] ?? null; + + if ($latitude === null || $longitude === null) { + return null; + } + + return [ + 'lat' => (float) $latitude, + 'lng' => (float) $longitude, + ]; + }, + set: fn (?array $value): array => [ + 'latitude' => is_array($value) ? ($value['lat'] ?? null) : null, + 'longitude' => is_array($value) ? ($value['lng'] ?? null) : null, + ], + ); + } } diff --git a/Modules/Listing/States/ActiveListingStatus.php b/Modules/Listing/States/ActiveListingStatus.php new file mode 100644 index 000000000..5f41e2ac3 --- /dev/null +++ b/Modules/Listing/States/ActiveListingStatus.php @@ -0,0 +1,33 @@ +default(ActiveListingStatus::class) + ->allowAllTransitions() + ->ignoreSameState(); + } +} diff --git a/Modules/Listing/States/PendingListingStatus.php b/Modules/Listing/States/PendingListingStatus.php new file mode 100644 index 000000000..38126a7e5 --- /dev/null +++ b/Modules/Listing/States/PendingListingStatus.php @@ -0,0 +1,33 @@ +first(); + $user = \App\Models\User::where('email', 'b@b.com') + ->orWhere('email', 'partner@openclassify.com') + ->first(); $categories = Category::where('level', 0)->get(); if (!$user || $categories->isEmpty()) return; @@ -38,7 +40,7 @@ class ListingSeeder extends Seeder 'category_id' => $category?->id, 'user_id' => $user->id, 'status' => 'active', - 'contact_email' => 'partner@openclassify.com', + 'contact_email' => $user->email, 'contact_phone' => '+1234567890', 'is_featured' => $i < 3, ]) diff --git a/Modules/Listing/resources/views/create.blade.php b/Modules/Listing/resources/views/create.blade.php index 54289ca7b..acd39775b 100644 --- a/Modules/Listing/resources/views/create.blade.php +++ b/Modules/Listing/resources/views/create.blade.php @@ -17,14 +17,37 @@
{{ $message }}
@enderror +{{ $message }}
@enderror +{{ $message }}
@enderror +{{ $message }}
@enderror{{ $message }}
@enderror{{ $message }}
@enderror{{ $listing->city }}, {{ $listing->country }}
-Posted {{ $listing->created_at->diffForHumans() }}
+{{ $location !== '' ? $location : 'Location not specified' }}
+Posted {{ $listing->created_at?->diffForHumans() ?? 'recently' }}
{{ $listing->description }}
+{{ $displayDescription }}
Email: {{ $listing->contact_email }}
@endif + @if(!$listing->contact_phone && !$listing->contact_email) +No contact details provided.
+ @endifBu sayfaya erişim izniniz yok.
+ +Post your first listing for free!
@auth - Post a Free Ad + Post a Free Ad @else Get Started Free @endauth diff --git a/resources/views/layouts/app.blade.php b/resources/views/layouts/app.blade.php index cf7b4796d..e41f388de 100644 --- a/resources/views/layouts/app.blade.php +++ b/resources/views/layouts/app.blade.php @@ -1,19 +1,39 @@ +@php + $siteName = $generalSettings['site_name'] ?? config('app.name', 'OpenClassify'); + $siteDescription = $generalSettings['site_description'] ?? 'The marketplace for buying and selling everything.'; + $siteLogoUrl = $generalSettings['site_logo_url'] ?? null; + $linkedinUrl = $generalSettings['linkedin_url'] ?? null; + $instagramUrl = $generalSettings['instagram_url'] ?? null; + $whatsappNumber = $generalSettings['whatsapp'] ?? null; + $whatsappDigits = preg_replace('/\D+/', '', (string) $whatsappNumber); + $whatsappUrl = $whatsappDigits !== '' ? 'https://wa.me/' . $whatsappDigits : null; +@endphp -