From a5a8f9d853a5fab384ebce726f8f51ce6dc8b623 Mon Sep 17 00:00:00 2001 From: fatihalp Date: Tue, 3 Mar 2026 12:49:08 +0300 Subject: [PATCH] beta --- .../laravel-permission-development/SKILL.md | 277 ++++++++++++++ .chatgpt/CUSTOM_INSTRUCTIONS.md | 7 + .codex/CUSTOM_INSTRUCTIONS.md | 7 + .gemini/CUSTOM_INSTRUCTIONS.md | 7 + .gemini/settings.json | 11 + .github/workflows/tests.yml | 47 --- .gitignore | 3 + CHANGELOG.md | 19 - .../Filament/Pages/ManageGeneralSettings.php | 174 +++++++++ .../Filament/Resources/CategoryResource.php | 26 +- .../Pages/ListCategoryActivities.php | 10 + .../Filament/Resources/ListingResource.php | 103 +++++- .../Pages/ListListingActivities.php | 10 + .../Filament/Resources/LocationResource.php | 26 +- .../Pages/ListLocationActivities.php | 10 + .../Admin/Filament/Resources/UserResource.php | 35 +- .../Resources/UserResource/Pages/EditUser.php | 9 +- .../UserResource/Pages/ListUserActivities.php | 10 + .../Admin/Providers/AdminPanelProvider.php | 26 ++ Modules/Category/Models/Category.php | 12 + ...0200_add_coordinates_to_listings_table.php | 34 ++ .../Http/Controllers/ListingController.php | 28 +- Modules/Listing/Models/Listing.php | 54 ++- .../Listing/States/ActiveListingStatus.php | 33 ++ .../Listing/States/ExpiredListingStatus.php | 33 ++ Modules/Listing/States/ListingStatus.php | 21 ++ .../Listing/States/PendingListingStatus.php | 33 ++ Modules/Listing/States/SoldListingStatus.php | 33 ++ .../database/seeders/ListingSeeder.php | 6 +- .../Listing/resources/views/create.blade.php | 23 ++ .../Listing/resources/views/show.blade.php | 37 +- Modules/Listing/routes/web.php | 4 +- Modules/Location/Models/City.php | 12 + Modules/Location/Models/Country.php | 12 + Modules/Location/Models/District.php | 12 + .../Filament/Resources/ListingResource.php | 98 ++++- .../Pages/ListListingActivities.php | 10 + .../Providers/PartnerPanelProvider.php | 14 + Modules/Profile/Models/Profile.php | 12 + README.md | 8 + app/Models/User.php | 45 ++- app/Providers/AppServiceProvider.php | 180 ++++++++- app/Settings/GeneralSettings.php | 55 +++ app/States/ActiveUserStatus.php | 33 ++ app/States/BannedUserStatus.php | 33 ++ app/States/SuspendedUserStatus.php | 33 ++ app/States/UserStatus.php | 21 ++ boost.json | 18 + bootstrap/providers.php | 2 + composer.json | 18 +- config/app.php | 2 + config/filemanager.php | 341 ++++++++++++++++++ ...022_12_14_083707_create_settings_table.php | 24 ++ .../2026_03_03_085614_create_media_table.php | 32 ++ ...03_092653_create_breezy_sessions_table.php | 31 ++ ...53_create_personal_access_tokens_table.php | 33 ++ ..._03_092654_alter_breezy_sessions_table.php | 31 ++ ...026_03_03_092655_create_passkeys_table.php | 26 ++ ...03_03_093635_create_activity_log_table.php | 27 ++ ...add_event_column_to_activity_log_table.php | 22 ++ ...atch_uuid_column_to_activity_log_table.php | 22 ++ ..._add_profile_and_status_to_users_table.php | 34 ++ database/seeders/DatabaseSeeder.php | 30 +- ...6_03_03_120000_create_general_settings.php | 20 + ..._03_03_140100_add_google_maps_settings.php | 12 + phpunit.xml | 35 -- resources/views/errors/403.blade.php | 35 ++ resources/views/home.blade.php | 2 +- resources/views/layouts/app.blade.php | 50 ++- resources/views/partner/dashboard.blade.php | 5 +- .../views/partner/listings/index.blade.php | 5 +- tests/Feature/Auth/AuthenticationTest.php | 54 --- tests/Feature/Auth/EmailVerificationTest.php | 58 --- .../Feature/Auth/PasswordConfirmationTest.php | 44 --- tests/Feature/Auth/PasswordResetTest.php | 73 ---- tests/Feature/Auth/PasswordUpdateTest.php | 51 --- tests/Feature/Auth/RegistrationTest.php | 31 -- tests/Feature/ExampleTest.php | 21 -- tests/Feature/ProfileTest.php | 99 ----- tests/TestCase.php | 14 - tests/Unit/ExampleTest.php | 16 - 81 files changed, 2401 insertions(+), 663 deletions(-) create mode 100644 .agents/skills/laravel-permission-development/SKILL.md create mode 100644 .chatgpt/CUSTOM_INSTRUCTIONS.md create mode 100644 .codex/CUSTOM_INSTRUCTIONS.md create mode 100644 .gemini/CUSTOM_INSTRUCTIONS.md create mode 100644 .gemini/settings.json delete mode 100644 .github/workflows/tests.yml delete mode 100644 CHANGELOG.md create mode 100644 Modules/Admin/Filament/Pages/ManageGeneralSettings.php create mode 100644 Modules/Admin/Filament/Resources/CategoryResource/Pages/ListCategoryActivities.php create mode 100644 Modules/Admin/Filament/Resources/ListingResource/Pages/ListListingActivities.php create mode 100644 Modules/Admin/Filament/Resources/LocationResource/Pages/ListLocationActivities.php create mode 100644 Modules/Admin/Filament/Resources/UserResource/Pages/ListUserActivities.php create mode 100644 Modules/Listing/Database/migrations/2026_03_03_140200_add_coordinates_to_listings_table.php create mode 100644 Modules/Listing/States/ActiveListingStatus.php create mode 100644 Modules/Listing/States/ExpiredListingStatus.php create mode 100644 Modules/Listing/States/ListingStatus.php create mode 100644 Modules/Listing/States/PendingListingStatus.php create mode 100644 Modules/Listing/States/SoldListingStatus.php create mode 100644 Modules/Partner/Filament/Resources/ListingResource/Pages/ListListingActivities.php create mode 100644 app/Settings/GeneralSettings.php create mode 100644 app/States/ActiveUserStatus.php create mode 100644 app/States/BannedUserStatus.php create mode 100644 app/States/SuspendedUserStatus.php create mode 100644 app/States/UserStatus.php create mode 100644 boost.json create mode 100644 config/filemanager.php create mode 100644 database/migrations/2022_12_14_083707_create_settings_table.php create mode 100644 database/migrations/2026_03_03_085614_create_media_table.php create mode 100644 database/migrations/2026_03_03_092653_create_breezy_sessions_table.php create mode 100644 database/migrations/2026_03_03_092653_create_personal_access_tokens_table.php create mode 100644 database/migrations/2026_03_03_092654_alter_breezy_sessions_table.php create mode 100644 database/migrations/2026_03_03_092655_create_passkeys_table.php create mode 100644 database/migrations/2026_03_03_093635_create_activity_log_table.php create mode 100644 database/migrations/2026_03_03_093636_add_event_column_to_activity_log_table.php create mode 100644 database/migrations/2026_03_03_093637_add_batch_uuid_column_to_activity_log_table.php create mode 100644 database/migrations/2026_03_03_130000_add_profile_and_status_to_users_table.php create mode 100644 database/settings/2026_03_03_120000_create_general_settings.php create mode 100644 database/settings/2026_03_03_140100_add_google_maps_settings.php delete mode 100644 phpunit.xml create mode 100644 resources/views/errors/403.blade.php delete mode 100644 tests/Feature/Auth/AuthenticationTest.php delete mode 100644 tests/Feature/Auth/EmailVerificationTest.php delete mode 100644 tests/Feature/Auth/PasswordConfirmationTest.php delete mode 100644 tests/Feature/Auth/PasswordResetTest.php delete mode 100644 tests/Feature/Auth/PasswordUpdateTest.php delete mode 100644 tests/Feature/Auth/RegistrationTest.php delete mode 100644 tests/Feature/ExampleTest.php delete mode 100644 tests/Feature/ProfileTest.php delete mode 100644 tests/TestCase.php delete mode 100644 tests/Unit/ExampleTest.php 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 @@
+ @error('price')

{{ $message }}

@enderror +
+
+ + + @error('currency')

{{ $message }}

@enderror +
+
+ + + @error('city')

{{ $message }}

@enderror +
+
+ + + @error('country')

{{ $message }}

@enderror
+ @error('contact_email')

{{ $message }}

@enderror
+ @error('contact_phone')

{{ $message }}

@enderror
diff --git a/Modules/Listing/resources/views/show.blade.php b/Modules/Listing/resources/views/show.blade.php index dbb19b76b..7f8dac469 100644 --- a/Modules/Listing/resources/views/show.blade.php +++ b/Modules/Listing/resources/views/show.blade.php @@ -1,5 +1,21 @@ @extends('layouts.app') @section('content') +@php + $title = trim((string) ($listing->title ?? '')); + $displayTitle = ($title !== '' && preg_match('/[\pL\pN]/u', $title)) ? $title : 'Untitled listing'; + + $city = trim((string) ($listing->city ?? '')); + $country = trim((string) ($listing->country ?? '')); + $location = implode(', ', array_filter([$city, $country], fn ($value) => $value !== '')); + + $description = trim((string) ($listing->description ?? '')); + $displayDescription = ($description !== '' && preg_match('/[\pL\pN]/u', $description)) + ? $description + : 'No description provided.'; + + $hasPrice = !is_null($listing->price); + $priceValue = $hasPrice ? (float) $listing->price : null; +@endphp
@@ -8,16 +24,24 @@
-

{{ $listing->title }}

+

{{ $displayTitle }}

- @if($listing->price) {{ number_format($listing->price, 0) }} {{ $listing->currency }} @else Free @endif + @if($hasPrice) + @if($priceValue > 0) + {{ number_format($priceValue, 0) }} {{ $listing->currency ?? 'USD' }} + @else + Free + @endif + @else + Price on request + @endif
-

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

-

Posted {{ $listing->created_at->diffForHumans() }}

+

{{ $location !== '' ? $location : 'Location not specified' }}

+

Posted {{ $listing->created_at?->diffForHumans() ?? 'recently' }}

Description

-

{{ $listing->description }}

+

{{ $displayDescription }}

Contact Seller

@@ -27,6 +51,9 @@ @if($listing->contact_email)

Email: {{ $listing->contact_email }}

@endif + @if(!$listing->contact_phone && !$listing->contact_email) +

No contact details provided.

+ @endif
← Back to listings diff --git a/Modules/Listing/routes/web.php b/Modules/Listing/routes/web.php index 45132f09b..380e6491d 100644 --- a/Modules/Listing/routes/web.php +++ b/Modules/Listing/routes/web.php @@ -2,9 +2,9 @@ use Illuminate\Support\Facades\Route; use Modules\Listing\Http\Controllers\ListingController; -Route::prefix('listings')->name('listings.')->group(function () { +Route::middleware('web')->prefix('listings')->name('listings.')->group(function () { Route::get('/', [ListingController::class, 'index'])->name('index'); - Route::get('/create', [ListingController::class, 'create'])->name('create')->middleware('auth'); + Route::get('/create', [ListingController::class, 'create'])->name('create'); Route::post('/', [ListingController::class, 'store'])->name('store')->middleware('auth'); Route::get('/{listing}', [ListingController::class, 'show'])->name('show'); }); diff --git a/Modules/Location/Models/City.php b/Modules/Location/Models/City.php index 8f36292f3..8cff54c82 100644 --- a/Modules/Location/Models/City.php +++ b/Modules/Location/Models/City.php @@ -2,12 +2,24 @@ namespace Modules\Location\Models; use Illuminate\Database\Eloquent\Model; +use Spatie\Activitylog\LogOptions; +use Spatie\Activitylog\Traits\LogsActivity; class City extends Model { + use LogsActivity; + protected $fillable = ['name', 'country_id', 'is_active']; protected $casts = ['is_active' => 'boolean']; + public function getActivitylogOptions(): LogOptions + { + return LogOptions::defaults() + ->logFillable() + ->logOnlyDirty() + ->dontSubmitEmptyLogs(); + } + public function country() { return $this->belongsTo(Country::class); } public function districts() { return $this->hasMany(District::class); } } diff --git a/Modules/Location/Models/Country.php b/Modules/Location/Models/Country.php index 0c791b9d4..6ccb3754c 100644 --- a/Modules/Location/Models/Country.php +++ b/Modules/Location/Models/Country.php @@ -2,12 +2,24 @@ namespace Modules\Location\Models; use Illuminate\Database\Eloquent\Model; +use Spatie\Activitylog\LogOptions; +use Spatie\Activitylog\Traits\LogsActivity; class Country extends Model { + use LogsActivity; + protected $fillable = ['name', 'code', 'phone_code', 'flag', 'is_active']; protected $casts = ['is_active' => 'boolean']; + public function getActivitylogOptions(): LogOptions + { + return LogOptions::defaults() + ->logFillable() + ->logOnlyDirty() + ->dontSubmitEmptyLogs(); + } + public function cities() { return $this->hasMany(City::class); diff --git a/Modules/Location/Models/District.php b/Modules/Location/Models/District.php index c5f734f8d..32866899f 100644 --- a/Modules/Location/Models/District.php +++ b/Modules/Location/Models/District.php @@ -2,11 +2,23 @@ namespace Modules\Location\Models; use Illuminate\Database\Eloquent\Model; +use Spatie\Activitylog\LogOptions; +use Spatie\Activitylog\Traits\LogsActivity; class District extends Model { + use LogsActivity; + protected $fillable = ['name', 'city_id', 'is_active']; protected $casts = ['is_active' => 'boolean']; + public function getActivitylogOptions(): LogOptions + { + return LogOptions::defaults() + ->logFillable() + ->logOnlyDirty() + ->dontSubmitEmptyLogs(); + } + public function city() { return $this->belongsTo(City::class); } } diff --git a/Modules/Partner/Filament/Resources/ListingResource.php b/Modules/Partner/Filament/Resources/ListingResource.php index 6d59ca596..6ad878f1d 100644 --- a/Modules/Partner/Filament/Resources/ListingResource.php +++ b/Modules/Partner/Filament/Resources/ListingResource.php @@ -1,52 +1,98 @@ 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(), - 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), 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('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'), TextColumn::make('city'), 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 (Listing $record): string => static::getUrl('activities', ['record' => $record])), + DeleteAction::make(), + ]); } public static function getEloquentQuery(): Builder @@ -59,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/Partner/Filament/Resources/ListingResource/Pages/ListListingActivities.php b/Modules/Partner/Filament/Resources/ListingResource/Pages/ListListingActivities.php new file mode 100644 index 000000000..ec484363d --- /dev/null +++ b/Modules/Partner/Filament/Resources/ListingResource/Pages/ListListingActivities.php @@ -0,0 +1,10 @@ +discoverResources(in: module_path('Partner', 'Filament/Resources'), for: 'Modules\\Partner\\Filament\\Resources') ->discoverPages(in: module_path('Partner', 'Filament/Pages'), for: 'Modules\\Partner\\Filament\\Pages') ->discoverWidgets(in: module_path('Partner', 'Filament/Widgets'), for: 'Modules\\Partner\\Filament\\Widgets') + ->plugins([ + FilamentStateFusionPlugin::make(), + BreezyCore::make() + ->myProfile( + shouldRegisterNavigation: true, + navigationGroup: 'Account', + hasAvatars: true, + userMenuLabel: 'My Profile', + ) + ->enableTwoFactorAuthentication() + ->enableSanctumTokens(), + ]) ->pages([Dashboard::class]) ->middleware([ EncryptCookies::class, diff --git a/Modules/Profile/Models/Profile.php b/Modules/Profile/Models/Profile.php index 58242c344..8283751cc 100644 --- a/Modules/Profile/Models/Profile.php +++ b/Modules/Profile/Models/Profile.php @@ -2,12 +2,24 @@ namespace Modules\Profile\Models; use Illuminate\Database\Eloquent\Model; +use Spatie\Activitylog\LogOptions; +use Spatie\Activitylog\Traits\LogsActivity; 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 + { + return LogOptions::defaults() + ->logFillable() + ->logOnlyDirty() + ->dontSubmitEmptyLogs(); + } + public function user() { return $this->belongsTo(\App\Models\User::class); diff --git a/README.md b/README.md index 49738b991..0192e54eb 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,14 @@ A modern classified ads platform built with Laravel 12, FilamentPHP v5, and Lara - 🐳 **Docker Ready** — One-command production and development setup - ☁️ **GitHub Codespaces** — Zero-config cloud development +## AI Custom Instructions + +Project-level custom instruction set files are available at: + +- `.chatgpt/CUSTOM_INSTRUCTIONS.md` (ChatGPT) +- `.codex/CUSTOM_INSTRUCTIONS.md` (Codex) +- `.gemini/CUSTOM_INSTRUCTIONS.md` (Google Gemini / Antigravity) + ## Tech Stack | Layer | Technology | diff --git a/app/Models/User.php b/app/Models/User.php index a97a62b19..42b5d0f48 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -1,6 +1,8 @@ 'datetime', 'password' => 'hashed', + 'status' => UserStatus::class, ]; } + public function getActivitylogOptions(): LogOptions + { + return LogOptions::defaults() + ->logFillable() + ->logExcept(['password']) + ->logOnlyDirty() + ->dontSubmitEmptyLogs(); + } + public function canAccessPanel(Panel $panel): bool { + if ((string) $this->status !== 'active') { + return false; + } + return match ($panel->getId()) { 'admin' => $this->hasRole('admin'), 'partner' => true, @@ -49,4 +71,21 @@ class User extends Authenticatable implements FilamentUser, HasTenants { return $this->hasMany(\Modules\Listing\Models\Listing::class); } + + public function canImpersonate(): bool + { + return $this->hasRole('admin'); + } + + public function canBeImpersonated(): bool + { + return ! $this->hasRole('admin'); + } + + public function getFilamentAvatarUrl(): ?string + { + return filled($this->avatar_url) + ? Storage::disk('public')->url($this->avatar_url) + : null; + } } diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 452e6b65b..23320f1f6 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -2,23 +2,189 @@ namespace App\Providers; +use App\Settings\GeneralSettings; +use BezhanSalleh\LanguageSwitch\LanguageSwitch; use Illuminate\Support\ServiceProvider; +use Illuminate\Support\Facades\Event; +use Illuminate\Support\Facades\Schema; +use Illuminate\Support\Facades\Storage; +use Illuminate\Support\Facades\View; +use SocialiteProviders\Manager\SocialiteWasCalled; +use Throwable; class AppServiceProvider extends ServiceProvider { - /** - * Register any application services. - */ public function register(): void { // } - /** - * Bootstrap any application services. - */ public function boot(): void { - // + $fallbackName = config('app.name', 'OpenClassify'); + $fallbackLocale = config('app.locale', 'en'); + $fallbackCurrencies = $this->normalizeCurrencies(config('app.currencies', ['USD'])); + $fallbackDescription = 'The marketplace for buying and selling everything.'; + $fallbackGoogleMapsApiKey = env('GOOGLE_MAPS_API_KEY'); + $fallbackGoogleClientId = env('GOOGLE_CLIENT_ID'); + $fallbackGoogleClientSecret = env('GOOGLE_CLIENT_SECRET'); + $fallbackFacebookClientId = env('FACEBOOK_CLIENT_ID'); + $fallbackFacebookClientSecret = env('FACEBOOK_CLIENT_SECRET'); + $fallbackAppleClientId = env('APPLE_CLIENT_ID'); + $fallbackAppleClientSecret = env('APPLE_CLIENT_SECRET'); + + $generalSettings = [ + 'site_name' => $fallbackName, + 'site_description' => $fallbackDescription, + 'site_logo_url' => null, + 'default_language' => $fallbackLocale, + 'currencies' => $fallbackCurrencies, + 'sender_email' => config('mail.from.address', 'hello@example.com'), + 'sender_name' => config('mail.from.name', $fallbackName), + 'linkedin_url' => null, + 'instagram_url' => null, + 'whatsapp' => null, + 'google_maps_enabled' => false, + 'google_maps_api_key' => $fallbackGoogleMapsApiKey, + 'google_login_enabled' => (bool) env('ENABLE_GOOGLE_LOGIN', false), + 'google_client_id' => $fallbackGoogleClientId, + 'google_client_secret' => $fallbackGoogleClientSecret, + 'facebook_login_enabled' => (bool) env('ENABLE_FACEBOOK_LOGIN', false), + 'facebook_client_id' => $fallbackFacebookClientId, + 'facebook_client_secret' => $fallbackFacebookClientSecret, + 'apple_login_enabled' => (bool) env('ENABLE_APPLE_LOGIN', false), + 'apple_client_id' => $fallbackAppleClientId, + 'apple_client_secret' => $fallbackAppleClientSecret, + ]; + + $hasSettingsTable = false; + + try { + $hasSettingsTable = Schema::hasTable('settings'); + } catch (Throwable) { + $hasSettingsTable = false; + } + + if ($hasSettingsTable) { + try { + $settings = app(GeneralSettings::class); + $currencies = $this->normalizeCurrencies($settings->currencies ?? $fallbackCurrencies); + $availableLocales = config('app.available_locales', ['en']); + $defaultLanguage = in_array($settings->default_language, $availableLocales, true) + ? $settings->default_language + : $fallbackLocale; + $googleMapsApiKey = trim((string) ($settings->google_maps_api_key ?: $fallbackGoogleMapsApiKey)); + $googleMapsApiKey = $googleMapsApiKey !== '' ? $googleMapsApiKey : null; + $googleClientId = trim((string) ($settings->google_client_id ?: $fallbackGoogleClientId)); + $googleClientSecret = trim((string) ($settings->google_client_secret ?: $fallbackGoogleClientSecret)); + $facebookClientId = trim((string) ($settings->facebook_client_id ?: $fallbackFacebookClientId)); + $facebookClientSecret = trim((string) ($settings->facebook_client_secret ?: $fallbackFacebookClientSecret)); + $appleClientId = trim((string) ($settings->apple_client_id ?: $fallbackAppleClientId)); + $appleClientSecret = trim((string) ($settings->apple_client_secret ?: $fallbackAppleClientSecret)); + + $generalSettings = [ + 'site_name' => trim((string) ($settings->site_name ?: $fallbackName)), + 'site_description' => trim((string) ($settings->site_description ?: $fallbackDescription)), + 'site_logo_url' => filled($settings->site_logo) + ? Storage::disk('public')->url($settings->site_logo) + : null, + 'default_language' => $defaultLanguage, + 'currencies' => $currencies, + 'sender_email' => trim((string) ($settings->sender_email ?: config('mail.from.address'))), + 'sender_name' => trim((string) ($settings->sender_name ?: $fallbackName)), + 'linkedin_url' => $settings->linkedin_url ?: null, + 'instagram_url' => $settings->instagram_url ?: null, + 'whatsapp' => $settings->whatsapp ?: null, + 'google_maps_enabled' => (bool) ($settings->enable_google_maps ?? false), + 'google_maps_api_key' => $googleMapsApiKey, + 'google_login_enabled' => (bool) ($settings->enable_google_login ?? false), + 'google_client_id' => $googleClientId !== '' ? $googleClientId : null, + 'google_client_secret' => $googleClientSecret !== '' ? $googleClientSecret : null, + 'facebook_login_enabled' => (bool) ($settings->enable_facebook_login ?? false), + 'facebook_client_id' => $facebookClientId !== '' ? $facebookClientId : null, + 'facebook_client_secret' => $facebookClientSecret !== '' ? $facebookClientSecret : null, + 'apple_login_enabled' => (bool) ($settings->enable_apple_login ?? false), + 'apple_client_id' => $appleClientId !== '' ? $appleClientId : null, + 'apple_client_secret' => $appleClientSecret !== '' ? $appleClientSecret : null, + ]; + + config([ + 'app.name' => $generalSettings['site_name'], + 'app.locale' => $generalSettings['default_language'], + 'app.currencies' => $generalSettings['currencies'], + 'mail.from.address' => $generalSettings['sender_email'], + 'mail.from.name' => $generalSettings['sender_name'], + ]); + } catch (Throwable) { + config(['app.currencies' => $fallbackCurrencies]); + } + } else { + config(['app.currencies' => $fallbackCurrencies]); + } + + $mapsKey = $generalSettings['google_maps_enabled'] + ? $generalSettings['google_maps_api_key'] + : null; + + config([ + 'filament-google-maps.key' => $mapsKey, + 'filament-google-maps.keys.web_key' => $mapsKey, + 'filament-google-maps.keys.server_key' => $mapsKey, + 'services.google.client_id' => $generalSettings['google_client_id'], + 'services.google.client_secret' => $generalSettings['google_client_secret'], + 'services.google.redirect' => url('/oauth/callback/google'), + 'services.google.enabled' => (bool) $generalSettings['google_login_enabled'], + 'services.facebook.client_id' => $generalSettings['facebook_client_id'], + 'services.facebook.client_secret' => $generalSettings['facebook_client_secret'], + 'services.facebook.redirect' => url('/oauth/callback/facebook'), + 'services.facebook.enabled' => (bool) $generalSettings['facebook_login_enabled'], + 'services.apple.client_id' => $generalSettings['apple_client_id'], + 'services.apple.client_secret' => $generalSettings['apple_client_secret'], + 'services.apple.redirect' => url('/oauth/callback/apple'), + 'services.apple.stateless' => true, + 'services.apple.enabled' => (bool) $generalSettings['apple_login_enabled'], + ]); + + Event::listen(function (SocialiteWasCalled $event): void { + $event->extendSocialite('apple', \SocialiteProviders\Apple\Provider::class); + }); + + $availableLocales = config('app.available_locales', ['en']); + $localeLabels = [ + 'en' => 'English', + 'tr' => 'Türkçe', + 'ar' => 'العربية', + 'zh' => '中文', + 'es' => 'Español', + 'fr' => 'Français', + 'de' => 'Deutsch', + 'pt' => 'Português', + 'ru' => 'Русский', + 'ja' => '日本語', + ]; + + LanguageSwitch::configureUsing(function (LanguageSwitch $switch) use ($availableLocales, $localeLabels): void { + $switch + ->locales($availableLocales) + ->labels(collect($availableLocales)->mapWithKeys( + fn (string $locale) => [$locale => $localeLabels[$locale] ?? strtoupper($locale)] + )->all()) + ->visible(insidePanels: count($availableLocales) > 1, outsidePanels: false); + }); + + View::share('generalSettings', $generalSettings); + } + + private function normalizeCurrencies(array $currencies): array + { + $normalized = collect($currencies) + ->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/app/Settings/GeneralSettings.php b/app/Settings/GeneralSettings.php new file mode 100644 index 000000000..dbec6ab4d --- /dev/null +++ b/app/Settings/GeneralSettings.php @@ -0,0 +1,55 @@ +default(ActiveUserStatus::class) + ->allowAllTransitions() + ->ignoreSameState(); + } +} diff --git a/boost.json b/boost.json new file mode 100644 index 000000000..c411c4399 --- /dev/null +++ b/boost.json @@ -0,0 +1,18 @@ +{ + "agents": [ + "codex", + "gemini", + "copilot" + ], + "herd_mcp": false, + "mcp": true, + "nightwatch_mcp": false, + "packages": [ + "filament/filament", + "spatie/laravel-permission" + ], + "sail": false, + "skills": [ + "laravel-permission-development" + ] +} diff --git a/bootstrap/providers.php b/bootstrap/providers.php index 38b258d18..63cd44717 100644 --- a/bootstrap/providers.php +++ b/bootstrap/providers.php @@ -2,4 +2,6 @@ return [ App\Providers\AppServiceProvider::class, + Modules\Admin\Providers\AdminPanelProvider::class, + Modules\Partner\Providers\PartnerPanelProvider::class, ]; diff --git a/composer.json b/composer.json index 0995bdd42..d13cde7ed 100644 --- a/composer.json +++ b/composer.json @@ -7,14 +7,30 @@ "license": "MIT", "require": { "php": "^8.2", + "a909m/filament-statefusion": "^2.3", + "bezhansalleh/filament-language-switch": "^4.1", + "cheesegrits/filament-google-maps": "^5.0", + "dutchcodingcompany/filament-developer-logins": "^2.1", + "dutchcodingcompany/filament-socialite": "^3.1", "filament/filament": "^5.0", + "filament/spatie-laravel-media-library-plugin": "^5.3", + "filament/spatie-laravel-settings-plugin": "^5.3", + "jeffgreco13/filament-breezy": "^3.2", "laravel/framework": "^12.0", + "laravel/sanctum": "^4.3", "laravel/tinker": "^2.10.1", + "mwguerra/filemanager": "^2.0", "nwidart/laravel-modules": "^11.0", - "spatie/laravel-permission": "^7.2" + "pxlrbt/filament-activity-log": "^2.1", + "socialiteproviders/apple": "^5.9", + "spatie/laravel-permission": "^7.2", + "spatie/laravel-settings": "^3.7", + "stechstudio/filament-impersonate": "^5.1", + "ysfkaya/filament-phone-input": "^4.1" }, "require-dev": { "fakerphp/faker": "^1.23", + "laravel/boost": "^2.2", "laravel/pail": "^1.2.2", "laravel/pint": "^1.24", "laravel/sail": "^1.41", diff --git a/config/app.php b/config/app.php index 9153e524f..aba3546ce 100644 --- a/config/app.php +++ b/config/app.php @@ -82,6 +82,8 @@ return [ 'available_locales' => ['en', 'tr', 'ar', 'zh', 'es', 'fr', 'de', 'pt', 'ru', 'ja'], + 'currencies' => ['USD'], + 'fallback_locale' => env('APP_FALLBACK_LOCALE', 'en'), 'faker_locale' => env('APP_FAKER_LOCALE', 'en_US'), diff --git a/config/filemanager.php b/config/filemanager.php new file mode 100644 index 000000000..a56923945 --- /dev/null +++ b/config/filemanager.php @@ -0,0 +1,341 @@ + 'database', // 'database' or 'storage' + + /* + |-------------------------------------------------------------------------- + | Storage Mode Settings + |-------------------------------------------------------------------------- + | + | These settings only apply when mode is set to 'storage'. + | + | - disk: The Laravel filesystem disk to use (e.g., 'local', 's3', 'public') + | - root: The root path within the disk (empty string for disk root) + | - show_hidden: Whether to show hidden files (starting with .) + | + */ + 'storage_mode' => [ + 'disk' => env('FILEMANAGER_DISK', env('FILESYSTEM_DISK', 'public')), + 'root' => env('FILEMANAGER_ROOT', ''), + 'show_hidden' => env('FILEMANAGER_SHOW_HIDDEN', false), + // For S3/MinIO: URL expiration time in minutes for signed URLs + 'url_expiration' => env('FILEMANAGER_URL_EXPIRATION', 60), + ], + + /* + |-------------------------------------------------------------------------- + | File Streaming Settings + |-------------------------------------------------------------------------- + | + | Configure how files are served for preview and download. + | + | The file manager uses different URL strategies based on the disk: + | - S3-compatible disks: Uses temporaryUrl() for pre-signed URLs + | - Public disk: Uses direct Storage::url() (works via symlink) + | - Local/other disks: Uses signed routes to a streaming controller + | + */ + 'streaming' => [ + // URL generation strategy: + // - 'auto': Automatically detect best strategy per disk (recommended) + // - 'signed_route': Always use signed routes to streaming controller + // - 'direct': Always use Storage::url() (only works for public disk) + 'url_strategy' => env('FILEMANAGER_URL_STRATEGY', 'auto'), + + // URL expiration in minutes (for signed URLs and S3 temporary URLs) + 'url_expiration' => env('FILEMANAGER_URL_EXPIRATION', 60), + + // Route prefix for streaming endpoints + 'route_prefix' => env('FILEMANAGER_ROUTE_PREFIX', 'filemanager'), + + // Middleware applied to streaming routes + 'middleware' => ['web'], + + // Disks that should always use signed routes (even if public) + // Useful if you want extra security for certain disks + 'force_signed_disks' => [], + + // Disks that are publicly accessible via URL (override auto-detection) + // Files on these disks can be accessed directly without streaming + 'public_disks' => ['public'], + + // Disks that don't require authentication for streaming access + // Use with caution - files on these disks can be accessed without login + // Note: Signed URLs are still required, this just skips the auth check + 'public_access_disks' => [], + ], + + /* + |-------------------------------------------------------------------------- + | File System Item Model (Database Mode) + |-------------------------------------------------------------------------- + | + | This is the model that represents files and folders in your application. + | Only used when mode is 'database'. + | It must implement the MWGuerra\FileManager\Contracts\FileSystemItemInterface. + | + | The package provides a default model. You can extend it or create your own: + | + | Option 1: Use the package model directly (default) + | 'model' => \MWGuerra\FileManager\Models\FileSystemItem::class, + | + | Option 2: Extend the package model in your app + | 'model' => \App\Models\FileSystemItem::class, + | // where App\Models\FileSystemItem extends MWGuerra\FileManager\Models\FileSystemItem + | + | Option 3: Create your own model implementing FileSystemItemInterface + | 'model' => \App\Models\CustomFileModel::class, + | + */ + 'model' => \MWGuerra\FileManager\Models\FileSystemItem::class, + + /* + |-------------------------------------------------------------------------- + | File Manager Page (Database Mode) + |-------------------------------------------------------------------------- + | + | Configure the File Manager page which uses database mode to track + | files with metadata, hierarchy, and relationships. + | + */ + 'file_manager' => [ + 'enabled' => true, + 'navigation' => [ + 'icon' => 'heroicon-o-folder', + 'label' => 'File Manager', + 'sort' => 1, + 'group' => 'FileManager', + ], + ], + + /* + |-------------------------------------------------------------------------- + | File System Page (Storage Mode) + |-------------------------------------------------------------------------- + | + | Configure the File System page which shows files directly from the + | storage disk without using the database. + | + */ + 'file_system' => [ + 'enabled' => true, + 'navigation' => [ + 'icon' => 'heroicon-o-server-stack', + 'label' => 'File System', + 'sort' => 2, + 'group' => 'FileManager', + ], + ], + + /* + |-------------------------------------------------------------------------- + | Schema Example Page + |-------------------------------------------------------------------------- + | + | Enable or disable the Schema Example page which demonstrates + | how to embed the file manager components into Filament forms. + | + */ + 'schema_example' => [ + 'enabled' => true, + ], + + /* + |-------------------------------------------------------------------------- + | Upload Settings + |-------------------------------------------------------------------------- + | + | Configure upload settings for the file manager. + | + | Note: You may also need to adjust PHP settings in php.ini: + | - upload_max_filesize (default: 2M) + | - post_max_size (default: 8M) + | - max_execution_time (default: 30) + | + | For Livewire temporary uploads, also check config/livewire.php: + | - temporary_file_upload.rules (default: max:12288 = 12MB) + | + */ + 'upload' => [ + 'disk' => env('FILEMANAGER_DISK', env('FILESYSTEM_DISK', 'public')), + 'directory' => env('FILEMANAGER_UPLOAD_DIR', 'uploads'), + 'max_file_size' => 100 * 1024, // 100 MB in kilobytes + 'allowed_mimes' => [ + // Videos + 'video/mp4', 'video/webm', 'video/ogg', 'video/quicktime', 'video/x-msvideo', + // Images (SVG excluded by default - can contain scripts) + 'image/jpeg', 'image/png', 'image/gif', 'image/webp', + // Documents + 'application/pdf', + 'application/msword', + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + 'application/vnd.ms-excel', + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + 'application/vnd.ms-powerpoint', + 'application/vnd.openxmlformats-officedocument.presentationml.presentation', + 'text/plain', + // Audio + 'audio/mpeg', 'audio/wav', 'audio/ogg', 'audio/webm', 'audio/flac', + // Archives + 'application/zip', 'application/x-rar-compressed', 'application/x-7z-compressed', + ], + ], + + /* + |-------------------------------------------------------------------------- + | Security Settings + |-------------------------------------------------------------------------- + | + | Configure security settings to prevent malicious file uploads and access. + | + */ + 'security' => [ + // Dangerous extensions that should NEVER be uploaded (executable files) + 'blocked_extensions' => [ + // Server-side scripts + 'php', 'php3', 'php4', 'php5', 'php7', 'php8', 'phtml', 'phar', + 'pl', 'py', 'pyc', 'pyo', 'rb', 'sh', 'bash', 'zsh', 'cgi', + 'asp', 'aspx', 'jsp', 'jspx', 'cfm', 'cfc', + // Executables + 'exe', 'msi', 'dll', 'com', 'bat', 'cmd', 'vbs', 'vbe', + 'js', 'jse', 'ws', 'wsf', 'wsc', 'wsh', 'ps1', 'psm1', + // Other dangerous + 'htaccess', 'htpasswd', 'ini', 'log', 'sql', 'env', + 'pem', 'key', 'crt', 'cer', + ], + + // Files that can contain embedded scripts (XSS risk when served inline) + 'sanitize_extensions' => ['svg', 'html', 'htm', 'xml'], + + // Validate MIME type matches extension (prevents spoofing) + 'validate_mime' => true, + + // Rename files to prevent execution (adds random prefix) + 'rename_uploads' => false, + + // Strip potentially dangerous characters from filenames + 'sanitize_filenames' => true, + + // Maximum filename length + 'max_filename_length' => 255, + + // Patterns blocked in filenames (regex) + 'blocked_filename_patterns' => [ + '/\.{2,}/', // Multiple dots (path traversal) + '/^\./', // Hidden files + '/[\x00-\x1f]/', // Control characters + '/[<>:"|?*]/', // Windows reserved characters + ], + ], + + /* + |-------------------------------------------------------------------------- + | Authorization Settings + |-------------------------------------------------------------------------- + | + | Configure authorization for file manager operations. + | + | When enabled, the package will check permissions before allowing operations. + | You can specify permission names that will be checked via the user's can() method. + | + | To customize authorization logic, extend FileSystemItemPolicy and register + | your custom policy in your application's AuthServiceProvider. + | + */ + 'authorization' => [ + // Enable/disable authorization checks (set to false during development) + 'enabled' => env('FILEMANAGER_AUTH_ENABLED', true), + + // Permission names to check (uses user->can() method) + // Set to null to skip permission check and just require authentication + 'permissions' => [ + 'view_any' => null, // Access file manager page + 'view' => null, // View/preview files + 'create' => null, // Upload files, create folders + 'update' => null, // Rename, move items + 'delete' => null, // Delete items + 'delete_any' => null, // Bulk delete + 'download' => null, // Download files + ], + + // The policy class to use (can be overridden with custom implementation) + 'policy' => \MWGuerra\FileManager\Policies\FileSystemItemPolicy::class, + ], + + /* + |-------------------------------------------------------------------------- + | Panel Sidebar Settings + |-------------------------------------------------------------------------- + | + | Configure the file manager folder tree that can be rendered in the + | Filament panel sidebar using render hooks. + | + | - enabled: Enable/disable the sidebar folder tree + | - root_label: Label for the root folder (e.g., "Root", "/", "Home") + | - heading: Heading text shown above the folder tree + | - show_in_file_manager: Show the sidebar within the file manager page + | + */ + 'sidebar' => [ + 'enabled' => true, + 'root_label' => env('FILEMANAGER_SIDEBAR_ROOT_LABEL', 'Root'), + 'heading' => env('FILEMANAGER_SIDEBAR_HEADING', 'Folders'), + 'show_in_file_manager' => true, + ], + + /* + |-------------------------------------------------------------------------- + | File Types + |-------------------------------------------------------------------------- + | + | Configure which file types are enabled and register custom file types. + | + | Built-in types can be disabled by setting their value to false. + | Custom types can be added by listing their fully-qualified class names. + | + | Each custom type class must implement FileTypeContract or extend + | AbstractFileType from MWGuerra\FileManager\FileTypes. + | + | Example of registering custom types: + | + | 'custom' => [ + | \App\FileTypes\ThreeDModelFileType::class, + | \App\FileTypes\EbookFileType::class, + | ], + | + */ + 'file_types' => [ + // Built-in types (set to false to disable) + 'video' => true, + 'image' => true, + 'audio' => true, + 'pdf' => true, + 'text' => true, + 'document' => true, + 'archive' => true, + + // Custom file types (fully-qualified class names) + 'custom' => [ + // \App\FileTypes\ThreeDModelFileType::class, + ], + ], +]; diff --git a/database/migrations/2022_12_14_083707_create_settings_table.php b/database/migrations/2022_12_14_083707_create_settings_table.php new file mode 100644 index 000000000..9b14b8613 --- /dev/null +++ b/database/migrations/2022_12_14_083707_create_settings_table.php @@ -0,0 +1,24 @@ +id(); + + $table->string('group'); + $table->string('name'); + $table->boolean('locked')->default(false); + $table->json('payload'); + + $table->timestamps(); + + $table->unique(['group', 'name']); + }); + } +}; diff --git a/database/migrations/2026_03_03_085614_create_media_table.php b/database/migrations/2026_03_03_085614_create_media_table.php new file mode 100644 index 000000000..47a4be987 --- /dev/null +++ b/database/migrations/2026_03_03_085614_create_media_table.php @@ -0,0 +1,32 @@ +id(); + + $table->morphs('model'); + $table->uuid()->nullable()->unique(); + $table->string('collection_name'); + $table->string('name'); + $table->string('file_name'); + $table->string('mime_type')->nullable(); + $table->string('disk'); + $table->string('conversions_disk')->nullable(); + $table->unsignedBigInteger('size'); + $table->json('manipulations'); + $table->json('custom_properties'); + $table->json('generated_conversions'); + $table->json('responsive_images'); + $table->unsignedInteger('order_column')->nullable()->index(); + + $table->nullableTimestamps(); + }); + } +}; diff --git a/database/migrations/2026_03_03_092653_create_breezy_sessions_table.php b/database/migrations/2026_03_03_092653_create_breezy_sessions_table.php new file mode 100644 index 000000000..586ff6650 --- /dev/null +++ b/database/migrations/2026_03_03_092653_create_breezy_sessions_table.php @@ -0,0 +1,31 @@ +id(); + $table->morphs('authenticatable'); + $table->string('panel_id')->nullable(); + $table->string('guard')->nullable(); + $table->string('ip_address', 45)->nullable(); + $table->text('user_agent')->nullable(); + $table->timestamp('expires_at')->nullable(); + $table->text('two_factor_secret')->nullable(); + $table->text('two_factor_recovery_codes')->nullable(); + $table->timestamp('two_factor_confirmed_at')->nullable(); + $table->timestamps(); + }); + + } + + public function down() + { + Schema::dropIfExists('breezy_sessions'); + } +}; diff --git a/database/migrations/2026_03_03_092653_create_personal_access_tokens_table.php b/database/migrations/2026_03_03_092653_create_personal_access_tokens_table.php new file mode 100644 index 000000000..40ff706ee --- /dev/null +++ b/database/migrations/2026_03_03_092653_create_personal_access_tokens_table.php @@ -0,0 +1,33 @@ +id(); + $table->morphs('tokenable'); + $table->text('name'); + $table->string('token', 64)->unique(); + $table->text('abilities')->nullable(); + $table->timestamp('last_used_at')->nullable(); + $table->timestamp('expires_at')->nullable()->index(); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('personal_access_tokens'); + } +}; diff --git a/database/migrations/2026_03_03_092654_alter_breezy_sessions_table.php b/database/migrations/2026_03_03_092654_alter_breezy_sessions_table.php new file mode 100644 index 000000000..f433b19cc --- /dev/null +++ b/database/migrations/2026_03_03_092654_alter_breezy_sessions_table.php @@ -0,0 +1,31 @@ +dropColumn([ + 'guard', + 'ip_address', + 'user_agent', + 'expires_at', + ]); + }); + } + + public function down(): void + { + Schema::table('breezy_sessions', function (Blueprint $table) { + $table->after('panel_id', function (BluePrint $table) { + $table->string('guard')->nullable(); + $table->string('ip_address', 45)->nullable(); + $table->text('user_agent')->nullable(); + $table->timestamp('expires_at')->nullable(); + }); + }); + } +}; diff --git a/database/migrations/2026_03_03_092655_create_passkeys_table.php b/database/migrations/2026_03_03_092655_create_passkeys_table.php new file mode 100644 index 000000000..8dc4560ea --- /dev/null +++ b/database/migrations/2026_03_03_092655_create_passkeys_table.php @@ -0,0 +1,26 @@ +id(); + $table->morphs('authenticatable'); + $table->string('panel_id')->nullable(); + $table->text('name'); + $table->text('credential_id'); + $table->json('data'); + $table->timestamp('last_used_at')->nullable(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('passkeys'); + } +}; diff --git a/database/migrations/2026_03_03_093635_create_activity_log_table.php b/database/migrations/2026_03_03_093635_create_activity_log_table.php new file mode 100644 index 000000000..7c05bc892 --- /dev/null +++ b/database/migrations/2026_03_03_093635_create_activity_log_table.php @@ -0,0 +1,27 @@ +create(config('activitylog.table_name'), function (Blueprint $table) { + $table->bigIncrements('id'); + $table->string('log_name')->nullable(); + $table->text('description'); + $table->nullableMorphs('subject', 'subject'); + $table->nullableMorphs('causer', 'causer'); + $table->json('properties')->nullable(); + $table->timestamps(); + $table->index('log_name'); + }); + } + + public function down() + { + Schema::connection(config('activitylog.database_connection'))->dropIfExists(config('activitylog.table_name')); + } +} diff --git a/database/migrations/2026_03_03_093636_add_event_column_to_activity_log_table.php b/database/migrations/2026_03_03_093636_add_event_column_to_activity_log_table.php new file mode 100644 index 000000000..7b797fd5e --- /dev/null +++ b/database/migrations/2026_03_03_093636_add_event_column_to_activity_log_table.php @@ -0,0 +1,22 @@ +table(config('activitylog.table_name'), function (Blueprint $table) { + $table->string('event')->nullable()->after('subject_type'); + }); + } + + public function down() + { + Schema::connection(config('activitylog.database_connection'))->table(config('activitylog.table_name'), function (Blueprint $table) { + $table->dropColumn('event'); + }); + } +} diff --git a/database/migrations/2026_03_03_093637_add_batch_uuid_column_to_activity_log_table.php b/database/migrations/2026_03_03_093637_add_batch_uuid_column_to_activity_log_table.php new file mode 100644 index 000000000..8f7db6654 --- /dev/null +++ b/database/migrations/2026_03_03_093637_add_batch_uuid_column_to_activity_log_table.php @@ -0,0 +1,22 @@ +table(config('activitylog.table_name'), function (Blueprint $table) { + $table->uuid('batch_uuid')->nullable()->after('properties'); + }); + } + + public function down() + { + Schema::connection(config('activitylog.database_connection'))->table(config('activitylog.table_name'), function (Blueprint $table) { + $table->dropColumn('batch_uuid'); + }); + } +} diff --git a/database/migrations/2026_03_03_130000_add_profile_and_status_to_users_table.php b/database/migrations/2026_03_03_130000_add_profile_and_status_to_users_table.php new file mode 100644 index 000000000..c60e70b02 --- /dev/null +++ b/database/migrations/2026_03_03_130000_add_profile_and_status_to_users_table.php @@ -0,0 +1,34 @@ +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'); + } + }); + } +}; diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index 37fe8e369..1510d4648 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -1,29 +1,31 @@ create([ - 'name' => 'Admin User', - 'email' => 'admin@openclassify.com', - 'password' => Hash::make('password'), - ]); + $admin = User::updateOrCreate( + ['email' => 'a@a.com'], + ['name' => 'Admin', 'password' => Hash::make('236330'), 'status' => 'active'] + ); - $partner = \App\Models\User::factory()->create([ - 'name' => 'Partner User', - 'email' => 'partner@openclassify.com', - 'password' => Hash::make('password'), - ]); + $partner = User::updateOrCreate( + ['email' => 'b@b.com'], + ['name' => 'Partner', 'password' => Hash::make('36330'), 'status' => 'active'] + ); - if (class_exists(\Spatie\Permission\Models\Role::class)) { - $adminRole = \Spatie\Permission\Models\Role::firstOrCreate(['name' => 'admin', 'guard_name' => 'web']); - \Spatie\Permission\Models\Role::firstOrCreate(['name' => 'partner', 'guard_name' => 'web']); - $admin->assignRole($adminRole); + if (class_exists(Role::class)) { + $adminRole = Role::firstOrCreate(['name' => 'admin', 'guard_name' => 'web']); + $partnerRole = Role::firstOrCreate(['name' => 'partner', 'guard_name' => 'web']); + + $admin->syncRoles([$adminRole->name]); + $partner->syncRoles([$partnerRole->name]); } $this->call([ diff --git a/database/settings/2026_03_03_120000_create_general_settings.php b/database/settings/2026_03_03_120000_create_general_settings.php new file mode 100644 index 000000000..1bd266e6d --- /dev/null +++ b/database/settings/2026_03_03_120000_create_general_settings.php @@ -0,0 +1,20 @@ +migrator->add('general.site_name', 'OpenClassify'); + $this->migrator->add('general.site_description', 'The marketplace for buying and selling everything.'); + $this->migrator->add('general.site_logo', null); + $this->migrator->add('general.default_language', 'en'); + $this->migrator->add('general.currencies', ['USD']); + $this->migrator->add('general.sender_email', 'hello@example.com'); + $this->migrator->add('general.sender_name', 'OpenClassify'); + $this->migrator->add('general.linkedin_url', null); + $this->migrator->add('general.instagram_url', null); + $this->migrator->add('general.whatsapp', null); + } +}; diff --git a/database/settings/2026_03_03_140100_add_google_maps_settings.php b/database/settings/2026_03_03_140100_add_google_maps_settings.php new file mode 100644 index 000000000..a34c46782 --- /dev/null +++ b/database/settings/2026_03_03_140100_add_google_maps_settings.php @@ -0,0 +1,12 @@ +migrator->add('general.enable_google_maps', false); + $this->migrator->add('general.google_maps_api_key', null); + } +}; diff --git a/phpunit.xml b/phpunit.xml deleted file mode 100644 index d70324153..000000000 --- a/phpunit.xml +++ /dev/null @@ -1,35 +0,0 @@ - - - - - tests/Unit - - - tests/Feature - - - - - app - - - - - - - - - - - - - - - - - - diff --git a/resources/views/errors/403.blade.php b/resources/views/errors/403.blade.php new file mode 100644 index 000000000..5a47fa522 --- /dev/null +++ b/resources/views/errors/403.blade.php @@ -0,0 +1,35 @@ + + + + + + + 403 Forbidden + + + +
+

403

+

Bu sayfaya erişim izniniz yok.

+ +
+ + Ana Sayfa + + + @auth +
+ @csrf + +
+ @else + + Giriş Yap + + @endauth +
+
+ + diff --git a/resources/views/home.blade.php b/resources/views/home.blade.php index 54596cb56..c5862321e 100644 --- a/resources/views/home.blade.php +++ b/resources/views/home.blade.php @@ -86,7 +86,7 @@

{{ __('messages.sell_something') }}

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 - {{ config('app.name', 'OpenClassify') }} @hasSection('title') - @yield('title') @endif + {{ $siteName }} @hasSection('title') - @yield('title') @endif +@php + $partnerCreateRoute = auth()->check() && \Illuminate\Support\Facades\Route::has('filament.partner.resources.listings.create') + ? route('filament.partner.resources.listings.create', ['tenant' => auth()->id()]) + : route('listings.create'); +@endphp