mirror of
https://github.com/openclassify/openclassify.git
synced 2026-04-14 03:02:08 -05:00
beta
This commit is contained in:
parent
7116b755f9
commit
a5a8f9d853
277
.agents/skills/laravel-permission-development/SKILL.md
Normal file
277
.agents/skills/laravel-permission-development/SKILL.md
Normal file
@ -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.
|
||||
7
.chatgpt/CUSTOM_INSTRUCTIONS.md
Normal file
7
.chatgpt/CUSTOM_INSTRUCTIONS.md
Normal file
@ -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.
|
||||
7
.codex/CUSTOM_INSTRUCTIONS.md
Normal file
7
.codex/CUSTOM_INSTRUCTIONS.md
Normal file
@ -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.
|
||||
7
.gemini/CUSTOM_INSTRUCTIONS.md
Normal file
7
.gemini/CUSTOM_INSTRUCTIONS.md
Normal file
@ -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.
|
||||
11
.gemini/settings.json
Normal file
11
.gemini/settings.json
Normal file
@ -0,0 +1,11 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"laravel-boost": {
|
||||
"command": "php",
|
||||
"args": [
|
||||
"artisan",
|
||||
"boost:mcp"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
47
.github/workflows/tests.yml
vendored
47
.github/workflows/tests.yml
vendored
@ -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
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@ -25,3 +25,6 @@ Thumbs.db
|
||||
/public/js/
|
||||
/public/fonts/
|
||||
/public/css/
|
||||
composer.lock
|
||||
.codex/config.toml
|
||||
/public/vendor/
|
||||
|
||||
19
CHANGELOG.md
19
CHANGELOG.md
@ -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
|
||||
174
Modules/Admin/Filament/Pages/ManageGeneralSettings.php
Normal file
174
Modules/Admin/Filament/Pages/ManageGeneralSettings.php
Normal file
@ -0,0 +1,174 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Admin\Filament\Pages;
|
||||
|
||||
use App\Settings\GeneralSettings;
|
||||
use BackedEnum;
|
||||
use Filament\Forms\Components\FileUpload;
|
||||
use Filament\Forms\Components\Select;
|
||||
use Filament\Forms\Components\TagsInput;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Forms\Components\Textarea;
|
||||
use Filament\Forms\Components\Toggle;
|
||||
use Filament\Pages\SettingsPage;
|
||||
use Filament\Schemas\Schema;
|
||||
use UnitEnum;
|
||||
use Ysfkaya\FilamentPhoneInput\Forms\PhoneInput;
|
||||
|
||||
class ManageGeneralSettings extends SettingsPage
|
||||
{
|
||||
protected static string $settings = GeneralSettings::class;
|
||||
|
||||
protected static ?string $title = 'General Settings';
|
||||
|
||||
protected static ?string $navigationLabel = 'General Settings';
|
||||
|
||||
protected static string | BackedEnum | null $navigationIcon = 'heroicon-o-cog-6-tooth';
|
||||
|
||||
protected static string | UnitEnum | null $navigationGroup = 'Settings';
|
||||
|
||||
protected static ?int $navigationSort = 1;
|
||||
|
||||
public function form(Schema $schema): Schema
|
||||
{
|
||||
return $schema
|
||||
->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'];
|
||||
}
|
||||
}
|
||||
@ -1,28 +1,31 @@
|
||||
<?php
|
||||
namespace Modules\Admin\Filament\Resources;
|
||||
|
||||
use BackedEnum;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Actions\DeleteAction;
|
||||
use Filament\Actions\EditAction;
|
||||
use Filament\Forms\Components\Select;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Forms\Components\Toggle;
|
||||
use Filament\Forms\Form;
|
||||
use Filament\Resources\Resource;
|
||||
use Filament\Tables\Actions\DeleteAction;
|
||||
use Filament\Tables\Actions\EditAction;
|
||||
use Filament\Schemas\Schema;
|
||||
use Filament\Tables\Columns\IconColumn;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Table;
|
||||
use Modules\Admin\Filament\Resources\CategoryResource\Pages;
|
||||
use Modules\Category\Models\Category;
|
||||
use UnitEnum;
|
||||
|
||||
class CategoryResource extends Resource
|
||||
{
|
||||
protected static ?string $model = Category::class;
|
||||
protected static ?string $navigationIcon = 'heroicon-o-tag';
|
||||
protected static ?string $navigationGroup = 'Catalog';
|
||||
protected static string | BackedEnum | null $navigationIcon = 'heroicon-o-tag';
|
||||
protected static string | UnitEnum | null $navigationGroup = 'Catalog';
|
||||
|
||||
public static function form(Form $form): Form
|
||||
public static function form(Schema $schema): Schema
|
||||
{
|
||||
return $form->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'),
|
||||
];
|
||||
}
|
||||
|
||||
@ -0,0 +1,10 @@
|
||||
<?php
|
||||
namespace Modules\Admin\Filament\Resources\CategoryResource\Pages;
|
||||
|
||||
use Modules\Admin\Filament\Resources\CategoryResource;
|
||||
use pxlrbt\FilamentActivityLog\Pages\ListActivities;
|
||||
|
||||
class ListCategoryActivities extends ListActivities
|
||||
{
|
||||
protected static string $resource = CategoryResource::class;
|
||||
}
|
||||
@ -1,60 +1,103 @@
|
||||
<?php
|
||||
namespace Modules\Admin\Filament\Resources;
|
||||
|
||||
use A909M\FilamentStateFusion\Forms\Components\StateFusionSelect;
|
||||
use A909M\FilamentStateFusion\Tables\Columns\StateFusionSelectColumn;
|
||||
use A909M\FilamentStateFusion\Tables\Filters\StateFusionSelectFilter;
|
||||
use App\Settings\GeneralSettings;
|
||||
use BackedEnum;
|
||||
use Cheesegrits\FilamentGoogleMaps\Fields\Map;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Actions\DeleteAction;
|
||||
use Filament\Actions\EditAction;
|
||||
use Filament\Forms\Components\Select;
|
||||
use Filament\Forms\Components\SpatieMediaLibraryFileUpload;
|
||||
use Filament\Forms\Components\Textarea;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Forms\Components\Toggle;
|
||||
use Filament\Forms\Form;
|
||||
use Filament\Resources\Resource;
|
||||
use Filament\Tables\Actions\DeleteAction;
|
||||
use Filament\Tables\Actions\EditAction;
|
||||
use Filament\Schemas\Schema;
|
||||
use Filament\Tables\Columns\IconColumn;
|
||||
use Filament\Tables\Columns\SpatieMediaLibraryImageColumn;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Filters\SelectFilter;
|
||||
use Filament\Tables\Table;
|
||||
use Modules\Admin\Filament\Resources\ListingResource\Pages;
|
||||
use Modules\Category\Models\Category;
|
||||
use Modules\Listing\Models\Listing;
|
||||
use Throwable;
|
||||
use UnitEnum;
|
||||
use Ysfkaya\FilamentPhoneInput\Forms\PhoneInput;
|
||||
|
||||
class ListingResource extends Resource
|
||||
{
|
||||
protected static ?string $model = Listing::class;
|
||||
protected static ?string $navigationIcon = 'heroicon-o-clipboard-document-list';
|
||||
protected static ?string $navigationGroup = 'Catalog';
|
||||
protected static string | BackedEnum | null $navigationIcon = 'heroicon-o-clipboard-document-list';
|
||||
protected static string | UnitEnum | null $navigationGroup = 'Catalog';
|
||||
|
||||
public static function form(Form $form): Form
|
||||
public static function form(Schema $schema): Schema
|
||||
{
|
||||
return $form->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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,10 @@
|
||||
<?php
|
||||
namespace Modules\Admin\Filament\Resources\ListingResource\Pages;
|
||||
|
||||
use Modules\Admin\Filament\Resources\ListingResource;
|
||||
use pxlrbt\FilamentActivityLog\Pages\ListActivities;
|
||||
|
||||
class ListListingActivities extends ListActivities
|
||||
{
|
||||
protected static string $resource = ListingResource::class;
|
||||
}
|
||||
@ -1,28 +1,31 @@
|
||||
<?php
|
||||
namespace Modules\Admin\Filament\Resources;
|
||||
|
||||
use BackedEnum;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Actions\DeleteAction;
|
||||
use Filament\Actions\EditAction;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Forms\Components\Toggle;
|
||||
use Filament\Forms\Form;
|
||||
use Filament\Resources\Resource;
|
||||
use Filament\Tables\Actions\DeleteAction;
|
||||
use Filament\Tables\Actions\EditAction;
|
||||
use Filament\Schemas\Schema;
|
||||
use Filament\Tables\Columns\IconColumn;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Table;
|
||||
use Modules\Admin\Filament\Resources\LocationResource\Pages;
|
||||
use Modules\Location\Models\Country;
|
||||
use UnitEnum;
|
||||
|
||||
class LocationResource extends Resource
|
||||
{
|
||||
protected static ?string $model = Country::class;
|
||||
protected static ?string $navigationIcon = 'heroicon-o-globe-alt';
|
||||
protected static ?string $navigationGroup = 'Settings';
|
||||
protected static string | BackedEnum | null $navigationIcon = 'heroicon-o-globe-alt';
|
||||
protected static string | UnitEnum | null $navigationGroup = 'Settings';
|
||||
protected static ?string $label = 'Country';
|
||||
|
||||
public static function form(Form $form): Form
|
||||
public static function form(Schema $schema): Schema
|
||||
{
|
||||
return $form->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'),
|
||||
];
|
||||
}
|
||||
|
||||
@ -0,0 +1,10 @@
|
||||
<?php
|
||||
namespace Modules\Admin\Filament\Resources\LocationResource\Pages;
|
||||
|
||||
use Modules\Admin\Filament\Resources\LocationResource;
|
||||
use pxlrbt\FilamentActivityLog\Pages\ListActivities;
|
||||
|
||||
class ListLocationActivities extends ListActivities
|
||||
{
|
||||
protected static string $resource = LocationResource::class;
|
||||
}
|
||||
@ -1,29 +1,37 @@
|
||||
<?php
|
||||
namespace Modules\Admin\Filament\Resources;
|
||||
|
||||
use A909M\FilamentStateFusion\Forms\Components\StateFusionSelect;
|
||||
use A909M\FilamentStateFusion\Tables\Columns\StateFusionSelectColumn;
|
||||
use A909M\FilamentStateFusion\Tables\Filters\StateFusionSelectFilter;
|
||||
use App\Models\User;
|
||||
use BackedEnum;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Actions\DeleteAction;
|
||||
use Filament\Actions\EditAction;
|
||||
use Filament\Forms\Components\Select;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Forms\Form;
|
||||
use Filament\Resources\Resource;
|
||||
use Filament\Tables\Actions\DeleteAction;
|
||||
use Filament\Tables\Actions\EditAction;
|
||||
use Filament\Schemas\Schema;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Table;
|
||||
use Modules\Admin\Filament\Resources\UserResource\Pages;
|
||||
use STS\FilamentImpersonate\Actions\Impersonate;
|
||||
use UnitEnum;
|
||||
|
||||
class UserResource extends Resource
|
||||
{
|
||||
protected static ?string $model = User::class;
|
||||
protected static ?string $navigationIcon = 'heroicon-o-users';
|
||||
protected static ?string $navigationGroup = 'User Management';
|
||||
protected static string | BackedEnum | null $navigationIcon = 'heroicon-o-users';
|
||||
protected static string | UnitEnum | null $navigationGroup = 'User Management';
|
||||
|
||||
public static function form(Form $form): Form
|
||||
public static function form(Schema $schema): Schema
|
||||
{
|
||||
return $form->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'),
|
||||
];
|
||||
}
|
||||
|
||||
@ -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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,10 @@
|
||||
<?php
|
||||
namespace Modules\Admin\Filament\Resources\UserResource\Pages;
|
||||
|
||||
use Modules\Admin\Filament\Resources\UserResource;
|
||||
use pxlrbt\FilamentActivityLog\Pages\ListActivities;
|
||||
|
||||
class ListUserActivities extends ListActivities
|
||||
{
|
||||
protected static string $resource = UserResource::class;
|
||||
}
|
||||
@ -1,6 +1,8 @@
|
||||
<?php
|
||||
namespace Modules\Admin\Providers;
|
||||
|
||||
use A909M\FilamentStateFusion\FilamentStateFusionPlugin;
|
||||
use DutchCodingCompany\FilamentDeveloperLogins\FilamentDeveloperLoginsPlugin;
|
||||
use Filament\Http\Middleware\Authenticate;
|
||||
use Filament\Http\Middleware\AuthenticateSession;
|
||||
use Filament\Http\Middleware\DisableBladeIconComponents;
|
||||
@ -15,6 +17,9 @@ use Illuminate\Foundation\Http\Middleware\VerifyCsrfToken;
|
||||
use Illuminate\Routing\Middleware\SubstituteBindings;
|
||||
use Illuminate\Session\Middleware\StartSession;
|
||||
use Illuminate\View\Middleware\ShareErrorsFromSession;
|
||||
use Jeffgreco13\FilamentBreezy\BreezyCore;
|
||||
use MWGuerra\FileManager\FileManagerPlugin;
|
||||
use MWGuerra\FileManager\Filament\Pages\FileManager;
|
||||
use Modules\Admin\Filament\Resources\CategoryResource;
|
||||
use Modules\Admin\Filament\Resources\ListingResource;
|
||||
use Modules\Admin\Filament\Resources\LocationResource;
|
||||
@ -26,12 +31,33 @@ class AdminPanelProvider extends PanelProvider
|
||||
{
|
||||
return $panel
|
||||
->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,
|
||||
|
||||
@ -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');
|
||||
|
||||
@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('listings', function (Blueprint $table): void {
|
||||
if (! Schema::hasColumn('listings', 'latitude')) {
|
||||
$table->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');
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
@ -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'];
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,18 +1,25 @@
|
||||
<?php
|
||||
namespace Modules\Listing\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Casts\Attribute;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Modules\Listing\States\ListingStatus;
|
||||
use Spatie\Activitylog\LogOptions;
|
||||
use Spatie\Activitylog\Traits\LogsActivity;
|
||||
use Spatie\MediaLibrary\HasMedia;
|
||||
use Spatie\MediaLibrary\InteractsWithMedia;
|
||||
use Spatie\ModelStates\HasStates;
|
||||
|
||||
class Listing extends Model
|
||||
class Listing extends Model implements HasMedia
|
||||
{
|
||||
use HasFactory;
|
||||
use HasFactory, HasStates, InteractsWithMedia, LogsActivity;
|
||||
|
||||
protected $fillable = [
|
||||
'title', 'description', 'price', 'currency', 'category_id',
|
||||
'user_id', 'status', 'images', 'slug',
|
||||
'contact_phone', 'contact_email', 'is_featured', 'expires_at',
|
||||
'city', 'country',
|
||||
'city', 'country', 'latitude', 'longitude', 'location',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
@ -20,8 +27,21 @@ class Listing extends Model
|
||||
'is_featured' => '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,
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
33
Modules/Listing/States/ActiveListingStatus.php
Normal file
33
Modules/Listing/States/ActiveListingStatus.php
Normal file
@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Listing\States;
|
||||
|
||||
use Filament\Support\Contracts\HasColor;
|
||||
use Filament\Support\Contracts\HasDescription;
|
||||
use Filament\Support\Contracts\HasIcon;
|
||||
use Filament\Support\Contracts\HasLabel;
|
||||
|
||||
class ActiveListingStatus extends ListingStatus implements HasColor, HasDescription, HasIcon, HasLabel
|
||||
{
|
||||
protected static string $name = 'active';
|
||||
|
||||
public function getLabel(): ?string
|
||||
{
|
||||
return 'Active';
|
||||
}
|
||||
|
||||
public function getColor(): string | array | null
|
||||
{
|
||||
return 'success';
|
||||
}
|
||||
|
||||
public function getIcon(): ?string
|
||||
{
|
||||
return 'heroicon-o-check-circle';
|
||||
}
|
||||
|
||||
public function getDescription(): ?string
|
||||
{
|
||||
return 'Listing is visible to buyers.';
|
||||
}
|
||||
}
|
||||
33
Modules/Listing/States/ExpiredListingStatus.php
Normal file
33
Modules/Listing/States/ExpiredListingStatus.php
Normal file
@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Listing\States;
|
||||
|
||||
use Filament\Support\Contracts\HasColor;
|
||||
use Filament\Support\Contracts\HasDescription;
|
||||
use Filament\Support\Contracts\HasIcon;
|
||||
use Filament\Support\Contracts\HasLabel;
|
||||
|
||||
class ExpiredListingStatus extends ListingStatus implements HasColor, HasDescription, HasIcon, HasLabel
|
||||
{
|
||||
protected static string $name = 'expired';
|
||||
|
||||
public function getLabel(): ?string
|
||||
{
|
||||
return 'Expired';
|
||||
}
|
||||
|
||||
public function getColor(): string | array | null
|
||||
{
|
||||
return 'danger';
|
||||
}
|
||||
|
||||
public function getIcon(): ?string
|
||||
{
|
||||
return 'heroicon-o-x-circle';
|
||||
}
|
||||
|
||||
public function getDescription(): ?string
|
||||
{
|
||||
return 'Listing is no longer active due to expiry.';
|
||||
}
|
||||
}
|
||||
21
Modules/Listing/States/ListingStatus.php
Normal file
21
Modules/Listing/States/ListingStatus.php
Normal file
@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Listing\States;
|
||||
|
||||
use A909M\FilamentStateFusion\Concerns\StateFusionInfo;
|
||||
use A909M\FilamentStateFusion\Contracts\HasFilamentStateFusion;
|
||||
use Spatie\ModelStates\State;
|
||||
use Spatie\ModelStates\StateConfig;
|
||||
|
||||
abstract class ListingStatus extends State implements HasFilamentStateFusion
|
||||
{
|
||||
use StateFusionInfo;
|
||||
|
||||
public static function config(): StateConfig
|
||||
{
|
||||
return parent::config()
|
||||
->default(ActiveListingStatus::class)
|
||||
->allowAllTransitions()
|
||||
->ignoreSameState();
|
||||
}
|
||||
}
|
||||
33
Modules/Listing/States/PendingListingStatus.php
Normal file
33
Modules/Listing/States/PendingListingStatus.php
Normal file
@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Listing\States;
|
||||
|
||||
use Filament\Support\Contracts\HasColor;
|
||||
use Filament\Support\Contracts\HasDescription;
|
||||
use Filament\Support\Contracts\HasIcon;
|
||||
use Filament\Support\Contracts\HasLabel;
|
||||
|
||||
class PendingListingStatus extends ListingStatus implements HasColor, HasDescription, HasIcon, HasLabel
|
||||
{
|
||||
protected static string $name = 'pending';
|
||||
|
||||
public function getLabel(): ?string
|
||||
{
|
||||
return 'Pending';
|
||||
}
|
||||
|
||||
public function getColor(): string | array | null
|
||||
{
|
||||
return 'warning';
|
||||
}
|
||||
|
||||
public function getIcon(): ?string
|
||||
{
|
||||
return 'heroicon-o-clock';
|
||||
}
|
||||
|
||||
public function getDescription(): ?string
|
||||
{
|
||||
return 'Listing is waiting for review.';
|
||||
}
|
||||
}
|
||||
33
Modules/Listing/States/SoldListingStatus.php
Normal file
33
Modules/Listing/States/SoldListingStatus.php
Normal file
@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Listing\States;
|
||||
|
||||
use Filament\Support\Contracts\HasColor;
|
||||
use Filament\Support\Contracts\HasDescription;
|
||||
use Filament\Support\Contracts\HasIcon;
|
||||
use Filament\Support\Contracts\HasLabel;
|
||||
|
||||
class SoldListingStatus extends ListingStatus implements HasColor, HasDescription, HasIcon, HasLabel
|
||||
{
|
||||
protected static string $name = 'sold';
|
||||
|
||||
public function getLabel(): ?string
|
||||
{
|
||||
return 'Sold';
|
||||
}
|
||||
|
||||
public function getColor(): string | array | null
|
||||
{
|
||||
return 'gray';
|
||||
}
|
||||
|
||||
public function getIcon(): ?string
|
||||
{
|
||||
return 'heroicon-o-currency-dollar';
|
||||
}
|
||||
|
||||
public function getDescription(): ?string
|
||||
{
|
||||
return 'Listing is completed and no longer available.';
|
||||
}
|
||||
}
|
||||
@ -9,7 +9,9 @@ class ListingSeeder extends Seeder
|
||||
{
|
||||
public function run(): void
|
||||
{
|
||||
$user = \App\Models\User::where('email', 'partner@openclassify.com')->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,
|
||||
])
|
||||
|
||||
@ -17,14 +17,37 @@
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Price</label>
|
||||
<input type="number" name="price" value="{{ old('price') }}" step="0.01" class="w-full border rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||
@error('price')<p class="text-red-500 text-sm mt-1">{{ $message }}</p>@enderror
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Currency</label>
|
||||
<select name="currency" class="w-full border rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||
@php($defaultCurrency = old('currency', $currencies[0] ?? 'USD'))
|
||||
@foreach(($currencies ?? ['USD']) as $currency)
|
||||
<option value="{{ $currency }}" @selected($defaultCurrency === $currency)>{{ $currency }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
@error('currency')<p class="text-red-500 text-sm mt-1">{{ $message }}</p>@enderror
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">City</label>
|
||||
<input type="text" name="city" value="{{ old('city') }}" class="w-full border rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||
@error('city')<p class="text-red-500 text-sm mt-1">{{ $message }}</p>@enderror
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Country</label>
|
||||
<input type="text" name="country" value="{{ old('country') }}" class="w-full border rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||
@error('country')<p class="text-red-500 text-sm mt-1">{{ $message }}</p>@enderror
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Contact Email</label>
|
||||
<input type="email" name="contact_email" value="{{ old('contact_email') }}" class="w-full border rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||
@error('contact_email')<p class="text-red-500 text-sm mt-1">{{ $message }}</p>@enderror
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Contact Phone</label>
|
||||
<input type="text" name="contact_phone" value="{{ old('contact_phone') }}" class="w-full border rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||
@error('contact_phone')<p class="text-red-500 text-sm mt-1">{{ $message }}</p>@enderror
|
||||
</div>
|
||||
<button type="submit" class="w-full bg-blue-600 text-white py-3 rounded-lg hover:bg-blue-700 transition font-medium">Post Listing</button>
|
||||
</form>
|
||||
|
||||
@ -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
|
||||
<div class="container mx-auto px-4 py-8">
|
||||
<div class="max-w-4xl mx-auto">
|
||||
<div class="bg-white rounded-lg shadow-md overflow-hidden">
|
||||
@ -8,16 +24,24 @@
|
||||
</div>
|
||||
<div class="p-6">
|
||||
<div class="flex justify-between items-start">
|
||||
<h1 class="text-2xl font-bold text-gray-900">{{ $listing->title }}</h1>
|
||||
<h1 class="text-2xl font-bold text-gray-900">{{ $displayTitle }}</h1>
|
||||
<span class="text-3xl font-bold text-green-600">
|
||||
@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
|
||||
</span>
|
||||
</div>
|
||||
<p class="text-gray-500 mt-2">{{ $listing->city }}, {{ $listing->country }}</p>
|
||||
<p class="text-gray-500 text-sm">Posted {{ $listing->created_at->diffForHumans() }}</p>
|
||||
<p class="text-gray-500 mt-2">{{ $location !== '' ? $location : 'Location not specified' }}</p>
|
||||
<p class="text-gray-500 text-sm">Posted {{ $listing->created_at?->diffForHumans() ?? 'recently' }}</p>
|
||||
<div class="mt-4 border-t pt-4">
|
||||
<h2 class="font-semibold text-lg mb-2">Description</h2>
|
||||
<p class="text-gray-700">{{ $listing->description }}</p>
|
||||
<p class="text-gray-700">{{ $displayDescription }}</p>
|
||||
</div>
|
||||
<div class="mt-6 bg-gray-50 rounded-lg p-4">
|
||||
<h2 class="font-semibold text-lg mb-3">Contact Seller</h2>
|
||||
@ -27,6 +51,9 @@
|
||||
@if($listing->contact_email)
|
||||
<p class="text-gray-700"><span class="font-medium">Email:</span> {{ $listing->contact_email }}</p>
|
||||
@endif
|
||||
@if(!$listing->contact_phone && !$listing->contact_email)
|
||||
<p class="text-gray-700">No contact details provided.</p>
|
||||
@endif
|
||||
</div>
|
||||
<div class="mt-6">
|
||||
<a href="{{ route('listings.index') }}" class="text-blue-600 hover:underline">← Back to listings</a>
|
||||
|
||||
@ -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');
|
||||
});
|
||||
|
||||
@ -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); }
|
||||
}
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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); }
|
||||
}
|
||||
|
||||
@ -1,52 +1,98 @@
|
||||
<?php
|
||||
namespace Modules\Partner\Filament\Resources;
|
||||
|
||||
use A909M\FilamentStateFusion\Forms\Components\StateFusionSelect;
|
||||
use A909M\FilamentStateFusion\Tables\Columns\StateFusionSelectColumn;
|
||||
use A909M\FilamentStateFusion\Tables\Filters\StateFusionSelectFilter;
|
||||
use App\Settings\GeneralSettings;
|
||||
use BackedEnum;
|
||||
use Cheesegrits\FilamentGoogleMaps\Fields\Map;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Actions\DeleteAction;
|
||||
use Filament\Actions\EditAction;
|
||||
use Filament\Facades\Filament;
|
||||
use Filament\Forms\Components\Select;
|
||||
use Filament\Forms\Components\SpatieMediaLibraryFileUpload;
|
||||
use Filament\Forms\Components\Textarea;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Forms\Form;
|
||||
use Filament\Resources\Resource;
|
||||
use Filament\Tables\Actions\DeleteAction;
|
||||
use Filament\Tables\Actions\EditAction;
|
||||
use Filament\Schemas\Schema;
|
||||
use Filament\Tables\Columns\SpatieMediaLibraryImageColumn;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Modules\Category\Models\Category;
|
||||
use Modules\Listing\Models\Listing;
|
||||
use Modules\Partner\Filament\Resources\ListingResource\Pages;
|
||||
use Throwable;
|
||||
use Ysfkaya\FilamentPhoneInput\Forms\PhoneInput;
|
||||
|
||||
class ListingResource extends Resource
|
||||
{
|
||||
protected static ?string $model = Listing::class;
|
||||
protected static ?string $navigationIcon = 'heroicon-o-clipboard-document-list';
|
||||
protected static string | BackedEnum | null $navigationIcon = 'heroicon-o-clipboard-document-list';
|
||||
|
||||
public static function form(Form $form): Form
|
||||
public static function form(Schema $schema): Schema
|
||||
{
|
||||
return $form->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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,10 @@
|
||||
<?php
|
||||
namespace Modules\Partner\Filament\Resources\ListingResource\Pages;
|
||||
|
||||
use Modules\Partner\Filament\Resources\ListingResource;
|
||||
use pxlrbt\FilamentActivityLog\Pages\ListActivities;
|
||||
|
||||
class ListListingActivities extends ListActivities
|
||||
{
|
||||
protected static string $resource = ListingResource::class;
|
||||
}
|
||||
@ -1,6 +1,7 @@
|
||||
<?php
|
||||
namespace Modules\Partner\Providers;
|
||||
|
||||
use A909M\FilamentStateFusion\FilamentStateFusionPlugin;
|
||||
use App\Models\User;
|
||||
use Filament\Http\Middleware\Authenticate;
|
||||
use Filament\Http\Middleware\AuthenticateSession;
|
||||
@ -16,6 +17,7 @@ use Illuminate\Foundation\Http\Middleware\VerifyCsrfToken;
|
||||
use Illuminate\Routing\Middleware\SubstituteBindings;
|
||||
use Illuminate\Session\Middleware\StartSession;
|
||||
use Illuminate\View\Middleware\ShareErrorsFromSession;
|
||||
use Jeffgreco13\FilamentBreezy\BreezyCore;
|
||||
|
||||
class PartnerPanelProvider extends PanelProvider
|
||||
{
|
||||
@ -30,6 +32,18 @@ class PartnerPanelProvider extends PanelProvider
|
||||
->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,
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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 |
|
||||
|
||||
@ -1,6 +1,8 @@
|
||||
<?php
|
||||
namespace App\Models;
|
||||
|
||||
use App\States\UserStatus;
|
||||
use Filament\Models\Contracts\HasAvatar;
|
||||
use Filament\Models\Contracts\FilamentUser;
|
||||
use Filament\Models\Contracts\HasTenants;
|
||||
use Filament\Panel;
|
||||
@ -9,13 +11,19 @@ use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||
use Illuminate\Notifications\Notifiable;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Jeffgreco13\FilamentBreezy\Traits\TwoFactorAuthenticatable;
|
||||
use Laravel\Sanctum\HasApiTokens;
|
||||
use Spatie\Activitylog\LogOptions;
|
||||
use Spatie\Activitylog\Traits\LogsActivity;
|
||||
use Spatie\ModelStates\HasStates;
|
||||
use Spatie\Permission\Traits\HasRoles;
|
||||
|
||||
class User extends Authenticatable implements FilamentUser, HasTenants
|
||||
class User extends Authenticatable implements FilamentUser, HasTenants, HasAvatar
|
||||
{
|
||||
use HasFactory, HasRoles, Notifiable;
|
||||
use HasApiTokens, HasFactory, HasRoles, LogsActivity, Notifiable, HasStates, TwoFactorAuthenticatable;
|
||||
|
||||
protected $fillable = ['name', 'email', 'password'];
|
||||
protected $fillable = ['name', 'email', 'password', 'avatar_url', 'status'];
|
||||
protected $hidden = ['password', 'remember_token'];
|
||||
|
||||
protected function casts(): array
|
||||
@ -23,11 +31,25 @@ class User extends Authenticatable implements FilamentUser, HasTenants
|
||||
return [
|
||||
'email_verified_at' => '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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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'];
|
||||
}
|
||||
}
|
||||
|
||||
55
app/Settings/GeneralSettings.php
Normal file
55
app/Settings/GeneralSettings.php
Normal file
@ -0,0 +1,55 @@
|
||||
<?php
|
||||
|
||||
namespace App\Settings;
|
||||
|
||||
use Spatie\LaravelSettings\Settings;
|
||||
|
||||
class GeneralSettings extends Settings
|
||||
{
|
||||
public string $site_name;
|
||||
|
||||
public string $site_description;
|
||||
|
||||
public ?string $site_logo;
|
||||
|
||||
public string $default_language;
|
||||
|
||||
public array $currencies;
|
||||
|
||||
public string $sender_email;
|
||||
|
||||
public string $sender_name;
|
||||
|
||||
public ?string $linkedin_url;
|
||||
|
||||
public ?string $instagram_url;
|
||||
|
||||
public ?string $whatsapp;
|
||||
|
||||
public bool $enable_google_maps;
|
||||
|
||||
public ?string $google_maps_api_key;
|
||||
|
||||
public bool $enable_google_login;
|
||||
|
||||
public ?string $google_client_id;
|
||||
|
||||
public ?string $google_client_secret;
|
||||
|
||||
public bool $enable_facebook_login;
|
||||
|
||||
public ?string $facebook_client_id;
|
||||
|
||||
public ?string $facebook_client_secret;
|
||||
|
||||
public bool $enable_apple_login;
|
||||
|
||||
public ?string $apple_client_id;
|
||||
|
||||
public ?string $apple_client_secret;
|
||||
|
||||
public static function group(): string
|
||||
{
|
||||
return 'general';
|
||||
}
|
||||
}
|
||||
33
app/States/ActiveUserStatus.php
Normal file
33
app/States/ActiveUserStatus.php
Normal file
@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
namespace App\States;
|
||||
|
||||
use Filament\Support\Contracts\HasColor;
|
||||
use Filament\Support\Contracts\HasDescription;
|
||||
use Filament\Support\Contracts\HasIcon;
|
||||
use Filament\Support\Contracts\HasLabel;
|
||||
|
||||
class ActiveUserStatus extends UserStatus implements HasColor, HasDescription, HasIcon, HasLabel
|
||||
{
|
||||
protected static string $name = 'active';
|
||||
|
||||
public function getLabel(): ?string
|
||||
{
|
||||
return 'Active';
|
||||
}
|
||||
|
||||
public function getColor(): string | array | null
|
||||
{
|
||||
return 'success';
|
||||
}
|
||||
|
||||
public function getIcon(): ?string
|
||||
{
|
||||
return 'heroicon-o-check-circle';
|
||||
}
|
||||
|
||||
public function getDescription(): ?string
|
||||
{
|
||||
return 'User can access all assigned panels.';
|
||||
}
|
||||
}
|
||||
33
app/States/BannedUserStatus.php
Normal file
33
app/States/BannedUserStatus.php
Normal file
@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
namespace App\States;
|
||||
|
||||
use Filament\Support\Contracts\HasColor;
|
||||
use Filament\Support\Contracts\HasDescription;
|
||||
use Filament\Support\Contracts\HasIcon;
|
||||
use Filament\Support\Contracts\HasLabel;
|
||||
|
||||
class BannedUserStatus extends UserStatus implements HasColor, HasDescription, HasIcon, HasLabel
|
||||
{
|
||||
protected static string $name = 'banned';
|
||||
|
||||
public function getLabel(): ?string
|
||||
{
|
||||
return 'Banned';
|
||||
}
|
||||
|
||||
public function getColor(): string | array | null
|
||||
{
|
||||
return 'danger';
|
||||
}
|
||||
|
||||
public function getIcon(): ?string
|
||||
{
|
||||
return 'heroicon-o-no-symbol';
|
||||
}
|
||||
|
||||
public function getDescription(): ?string
|
||||
{
|
||||
return 'User is blocked from panel access.';
|
||||
}
|
||||
}
|
||||
33
app/States/SuspendedUserStatus.php
Normal file
33
app/States/SuspendedUserStatus.php
Normal file
@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
namespace App\States;
|
||||
|
||||
use Filament\Support\Contracts\HasColor;
|
||||
use Filament\Support\Contracts\HasDescription;
|
||||
use Filament\Support\Contracts\HasIcon;
|
||||
use Filament\Support\Contracts\HasLabel;
|
||||
|
||||
class SuspendedUserStatus extends UserStatus implements HasColor, HasDescription, HasIcon, HasLabel
|
||||
{
|
||||
protected static string $name = 'suspended';
|
||||
|
||||
public function getLabel(): ?string
|
||||
{
|
||||
return 'Suspended';
|
||||
}
|
||||
|
||||
public function getColor(): string | array | null
|
||||
{
|
||||
return 'warning';
|
||||
}
|
||||
|
||||
public function getIcon(): ?string
|
||||
{
|
||||
return 'heroicon-o-pause-circle';
|
||||
}
|
||||
|
||||
public function getDescription(): ?string
|
||||
{
|
||||
return 'User access is temporarily limited.';
|
||||
}
|
||||
}
|
||||
21
app/States/UserStatus.php
Normal file
21
app/States/UserStatus.php
Normal file
@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
namespace App\States;
|
||||
|
||||
use A909M\FilamentStateFusion\Concerns\StateFusionInfo;
|
||||
use A909M\FilamentStateFusion\Contracts\HasFilamentStateFusion;
|
||||
use Spatie\ModelStates\State;
|
||||
use Spatie\ModelStates\StateConfig;
|
||||
|
||||
abstract class UserStatus extends State implements HasFilamentStateFusion
|
||||
{
|
||||
use StateFusionInfo;
|
||||
|
||||
public static function config(): StateConfig
|
||||
{
|
||||
return parent::config()
|
||||
->default(ActiveUserStatus::class)
|
||||
->allowAllTransitions()
|
||||
->ignoreSameState();
|
||||
}
|
||||
}
|
||||
18
boost.json
Normal file
18
boost.json
Normal file
@ -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"
|
||||
]
|
||||
}
|
||||
@ -2,4 +2,6 @@
|
||||
|
||||
return [
|
||||
App\Providers\AppServiceProvider::class,
|
||||
Modules\Admin\Providers\AdminPanelProvider::class,
|
||||
Modules\Partner\Providers\PartnerPanelProvider::class,
|
||||
];
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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'),
|
||||
|
||||
341
config/filemanager.php
Normal file
341
config/filemanager.php
Normal file
@ -0,0 +1,341 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| File Manager Mode
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| The file manager supports two modes:
|
||||
|
|
||||
| - 'database': Files and folders are tracked in a database table.
|
||||
| Metadata, hierarchy, and relationships are stored in the database.
|
||||
| File contents are stored on the configured disk. Best for applications
|
||||
| that need to attach metadata, tags, or relationships to files.
|
||||
|
|
||||
| - 'storage': Files and folders are read directly from a storage disk.
|
||||
| No database is used. The file manager shows the actual file system
|
||||
| structure. Renaming and moving actually rename/move files on the disk.
|
||||
| Best for managing cloud storage (S3, etc.) or local file systems.
|
||||
|
|
||||
*/
|
||||
'mode' => '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,
|
||||
],
|
||||
],
|
||||
];
|
||||
@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up()
|
||||
{
|
||||
Schema::create('settings', function (Blueprint $table): void {
|
||||
$table->id();
|
||||
|
||||
$table->string('group');
|
||||
$table->string('name');
|
||||
$table->boolean('locked')->default(false);
|
||||
$table->json('payload');
|
||||
|
||||
$table->timestamps();
|
||||
|
||||
$table->unique(['group', 'name']);
|
||||
});
|
||||
}
|
||||
};
|
||||
32
database/migrations/2026_03_03_085614_create_media_table.php
Normal file
32
database/migrations/2026_03_03_085614_create_media_table.php
Normal file
@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('media', function (Blueprint $table) {
|
||||
$table->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();
|
||||
});
|
||||
}
|
||||
};
|
||||
@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up()
|
||||
{
|
||||
Schema::create('breezy_sessions', function (Blueprint $table) {
|
||||
$table->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');
|
||||
}
|
||||
};
|
||||
@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('personal_access_tokens', function (Blueprint $table) {
|
||||
$table->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');
|
||||
}
|
||||
};
|
||||
@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration {
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('breezy_sessions', function (Blueprint $table) {
|
||||
$table->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();
|
||||
});
|
||||
});
|
||||
}
|
||||
};
|
||||
@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration {
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('passkeys', function (Blueprint $table) {
|
||||
$table->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');
|
||||
}
|
||||
};
|
||||
@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
|
||||
class CreateActivityLogTable extends Migration
|
||||
{
|
||||
public function up()
|
||||
{
|
||||
Schema::connection(config('activitylog.database_connection'))->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'));
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
|
||||
class AddEventColumnToActivityLogTable extends Migration
|
||||
{
|
||||
public function up()
|
||||
{
|
||||
Schema::connection(config('activitylog.database_connection'))->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');
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
|
||||
class AddBatchUuidColumnToActivityLogTable extends Migration
|
||||
{
|
||||
public function up()
|
||||
{
|
||||
Schema::connection(config('activitylog.database_connection'))->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');
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('users', function (Blueprint $table): void {
|
||||
if (! Schema::hasColumn('users', 'avatar_url')) {
|
||||
$table->string('avatar_url')->nullable()->after('password');
|
||||
}
|
||||
|
||||
if (! Schema::hasColumn('users', 'status')) {
|
||||
$table->string('status')->default('active')->after('email_verified_at');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('users', function (Blueprint $table): void {
|
||||
if (Schema::hasColumn('users', 'avatar_url')) {
|
||||
$table->dropColumn('avatar_url');
|
||||
}
|
||||
|
||||
if (Schema::hasColumn('users', 'status')) {
|
||||
$table->dropColumn('status');
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
@ -1,29 +1,31 @@
|
||||
<?php
|
||||
namespace Database\Seeders;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Seeder;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Spatie\Permission\Models\Role;
|
||||
|
||||
class DatabaseSeeder extends Seeder
|
||||
{
|
||||
public function run(): void
|
||||
{
|
||||
$admin = \App\Models\User::factory()->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([
|
||||
|
||||
@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
use Spatie\LaravelSettings\Migrations\SettingsMigration;
|
||||
|
||||
return new class extends SettingsMigration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
$this->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);
|
||||
}
|
||||
};
|
||||
@ -0,0 +1,12 @@
|
||||
<?php
|
||||
|
||||
use Spatie\LaravelSettings\Migrations\SettingsMigration;
|
||||
|
||||
return new class extends SettingsMigration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
$this->migrator->add('general.enable_google_maps', false);
|
||||
$this->migrator->add('general.google_maps_api_key', null);
|
||||
}
|
||||
};
|
||||
35
phpunit.xml
35
phpunit.xml
@ -1,35 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
|
||||
bootstrap="vendor/autoload.php"
|
||||
colors="true"
|
||||
>
|
||||
<testsuites>
|
||||
<testsuite name="Unit">
|
||||
<directory>tests/Unit</directory>
|
||||
</testsuite>
|
||||
<testsuite name="Feature">
|
||||
<directory>tests/Feature</directory>
|
||||
</testsuite>
|
||||
</testsuites>
|
||||
<source>
|
||||
<include>
|
||||
<directory>app</directory>
|
||||
</include>
|
||||
</source>
|
||||
<php>
|
||||
<env name="APP_ENV" value="testing"/>
|
||||
<env name="APP_MAINTENANCE_DRIVER" value="file"/>
|
||||
<env name="BCRYPT_ROUNDS" value="4"/>
|
||||
<env name="BROADCAST_CONNECTION" value="null"/>
|
||||
<env name="CACHE_STORE" value="array"/>
|
||||
<env name="DB_CONNECTION" value="sqlite"/>
|
||||
<env name="DB_DATABASE" value=":memory:"/>
|
||||
<env name="MAIL_MAILER" value="array"/>
|
||||
<env name="QUEUE_CONNECTION" value="sync"/>
|
||||
<env name="SESSION_DRIVER" value="array"/>
|
||||
<env name="PULSE_ENABLED" value="false"/>
|
||||
<env name="TELESCOPE_ENABLED" value="false"/>
|
||||
<env name="NIGHTWATCH_ENABLED" value="false"/>
|
||||
</php>
|
||||
</phpunit>
|
||||
35
resources/views/errors/403.blade.php
Normal file
35
resources/views/errors/403.blade.php
Normal file
@ -0,0 +1,35 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="csrf-token" content="{{ csrf_token() }}">
|
||||
<title>403 Forbidden</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
</head>
|
||||
<body class="min-h-screen bg-gray-100 flex items-center justify-center p-6">
|
||||
<div class="max-w-md w-full bg-white rounded-xl shadow p-6 text-center">
|
||||
<h1 class="text-2xl font-bold text-gray-900">403</h1>
|
||||
<p class="mt-2 text-gray-700">Bu sayfaya erişim izniniz yok.</p>
|
||||
|
||||
<div class="mt-6 flex items-center justify-center gap-3">
|
||||
<a href="{{ route('home') }}" class="px-4 py-2 rounded-lg border border-gray-300 text-gray-700 hover:bg-gray-50">
|
||||
Ana Sayfa
|
||||
</a>
|
||||
|
||||
@auth
|
||||
<form method="POST" action="{{ route('logout') }}">
|
||||
@csrf
|
||||
<button type="submit" class="px-4 py-2 rounded-lg bg-red-600 text-white hover:bg-red-700">
|
||||
Çıkış Yap
|
||||
</button>
|
||||
</form>
|
||||
@else
|
||||
<a href="{{ route('login') }}" class="px-4 py-2 rounded-lg bg-blue-600 text-white hover:bg-blue-700">
|
||||
Giriş Yap
|
||||
</a>
|
||||
@endauth
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@ -86,7 +86,7 @@
|
||||
<h2 class="text-3xl font-bold mb-4">{{ __('messages.sell_something') }}</h2>
|
||||
<p class="text-blue-200 mb-6">Post your first listing for free!</p>
|
||||
@auth
|
||||
<a href="{{ route('listings.create') }}" class="bg-orange-500 text-white px-8 py-3 rounded-xl hover:bg-orange-600 transition font-semibold text-lg">Post a Free Ad</a>
|
||||
<a href="{{ route('filament.partner.resources.listings.create', ['tenant' => auth()->id()]) }}" class="bg-orange-500 text-white px-8 py-3 rounded-xl hover:bg-orange-600 transition font-semibold text-lg">Post a Free Ad</a>
|
||||
@else
|
||||
<a href="{{ route('register') }}" class="bg-white text-blue-600 px-8 py-3 rounded-xl hover:bg-gray-100 transition font-semibold text-lg">Get Started Free</a>
|
||||
@endauth
|
||||
|
||||
@ -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
|
||||
<!DOCTYPE html>
|
||||
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}" dir="{{ in_array(app()->getLocale(), ['ar']) ? 'rtl' : 'ltr' }}">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="csrf-token" content="{{ csrf_token() }}">
|
||||
<title>{{ config('app.name', 'OpenClassify') }} @hasSection('title') - @yield('title') @endif</title>
|
||||
<title>{{ $siteName }} @hasSection('title') - @yield('title') @endif</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||
<style>body { font-family: 'Inter', sans-serif; } [dir="rtl"] { text-align: right; }</style>
|
||||
</head>
|
||||
@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
|
||||
<body class="bg-gray-50">
|
||||
<nav class="bg-white shadow-sm border-b sticky top-0 z-50">
|
||||
<div class="container mx-auto px-4">
|
||||
<div class="flex items-center justify-between h-16">
|
||||
<a href="{{ route('home') }}" class="text-2xl font-bold text-blue-600">OpenClassify</a>
|
||||
<a href="{{ route('home') }}" class="text-2xl font-bold text-blue-600 flex items-center gap-2">
|
||||
@if($siteLogoUrl)
|
||||
<img src="{{ $siteLogoUrl }}" alt="{{ $siteName }}" class="h-9 w-auto rounded">
|
||||
@endif
|
||||
<span>{{ $siteName }}</span>
|
||||
</a>
|
||||
<div class="hidden md:flex items-center space-x-6">
|
||||
<a href="{{ route('home') }}" class="text-gray-600 hover:text-blue-600 transition">{{ __('messages.home') }}</a>
|
||||
<a href="{{ route('categories.index') }}" class="text-gray-600 hover:text-blue-600 transition">{{ __('messages.categories') }}</a>
|
||||
@ -33,7 +53,7 @@
|
||||
</div>
|
||||
</div>
|
||||
@auth
|
||||
<a href="{{ route('listings.create') }}" class="bg-orange-500 text-white px-4 py-2 rounded-lg hover:bg-orange-600 transition font-medium text-sm">+ Post Ad</a>
|
||||
<a href="{{ $partnerCreateRoute }}" class="bg-orange-500 text-white px-4 py-2 rounded-lg hover:bg-orange-600 transition font-medium text-sm">+ Post Ad</a>
|
||||
<div class="relative group">
|
||||
<button class="text-gray-600 hover:text-blue-600">{{ auth()->user()->name }}</button>
|
||||
<div class="absolute right-0 mt-1 bg-white shadow-lg rounded-lg border hidden group-hover:block z-50 w-40">
|
||||
@ -64,8 +84,8 @@
|
||||
<div class="container mx-auto px-4">
|
||||
<div class="grid grid-cols-1 md:grid-cols-4 gap-8">
|
||||
<div>
|
||||
<h3 class="text-white font-bold text-lg mb-4">OpenClassify</h3>
|
||||
<p class="text-sm">The marketplace for buying and selling everything.</p>
|
||||
<h3 class="text-white font-bold text-lg mb-4">{{ $siteName }}</h3>
|
||||
<p class="text-sm">{{ $siteDescription }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<h4 class="text-white font-medium mb-4">Quick Links</h4>
|
||||
@ -83,7 +103,22 @@
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<h4 class="text-white font-medium mb-4">Languages</h4>
|
||||
<h4 class="text-white font-medium mb-4">Connect</h4>
|
||||
<ul class="space-y-2 text-sm mb-4">
|
||||
@if($linkedinUrl)
|
||||
<li><a href="{{ $linkedinUrl }}" target="_blank" rel="noopener" class="hover:text-white transition">LinkedIn</a></li>
|
||||
@endif
|
||||
@if($instagramUrl)
|
||||
<li><a href="{{ $instagramUrl }}" target="_blank" rel="noopener" class="hover:text-white transition">Instagram</a></li>
|
||||
@endif
|
||||
@if($whatsappUrl)
|
||||
<li><a href="{{ $whatsappUrl }}" target="_blank" rel="noopener" class="hover:text-white transition">WhatsApp</a></li>
|
||||
@endif
|
||||
@if(!$linkedinUrl && !$instagramUrl && !$whatsappUrl)
|
||||
<li>No social links added yet.</li>
|
||||
@endif
|
||||
</ul>
|
||||
<h4 class="text-white font-medium mb-3">Languages</h4>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
@foreach(config('app.available_locales', ['en']) as $locale)
|
||||
<a href="{{ route('lang.switch', $locale) }}" class="text-xs {{ app()->getLocale() === $locale ? 'text-white' : 'hover:text-white' }} transition">{{ strtoupper($locale) }}</a>
|
||||
@ -92,9 +127,10 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="border-t border-gray-700 mt-8 pt-8 text-center text-sm">
|
||||
<p>© {{ date('Y') }} OpenClassify. All rights reserved.</p>
|
||||
<p>© {{ date('Y') }} {{ $siteName }}. All rights reserved.</p>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
<x-impersonate::banner />
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
@extends('layouts.app')
|
||||
@section('title', 'Dashboard')
|
||||
@section('content')
|
||||
@php($partnerCreateRoute = route('filament.partner.resources.listings.create', ['tenant' => auth()->id()]))
|
||||
<div class="container mx-auto px-4 py-8">
|
||||
<h1 class="text-3xl font-bold mb-8">My Dashboard</h1>
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
|
||||
@ -14,7 +15,7 @@
|
||||
</div>
|
||||
<div class="bg-white rounded-xl shadow-sm p-6 border border-gray-100">
|
||||
<div class="text-gray-500 text-sm font-medium">Quick Actions</div>
|
||||
<a href="{{ route('listings.create') }}" class="mt-2 block text-center bg-orange-500 text-white py-2 rounded-lg hover:bg-orange-600 transition text-sm">+ Post New Listing</a>
|
||||
<a href="{{ $partnerCreateRoute }}" class="mt-2 block text-center bg-orange-500 text-white py-2 rounded-lg hover:bg-orange-600 transition text-sm">+ Post New Listing</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-white rounded-xl shadow-sm overflow-hidden">
|
||||
@ -48,7 +49,7 @@
|
||||
</td>
|
||||
</tr>
|
||||
@empty
|
||||
<tr><td colspan="5" class="px-6 py-8 text-center text-gray-500">No listings yet. <a href="{{ route('listings.create') }}" class="text-blue-600 hover:underline">Post your first listing!</a></td></tr>
|
||||
<tr><td colspan="5" class="px-6 py-8 text-center text-gray-500">No listings yet. <a href="{{ $partnerCreateRoute }}" class="text-blue-600 hover:underline">Post your first listing!</a></td></tr>
|
||||
@endforelse
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
@ -1,10 +1,11 @@
|
||||
@extends('layouts.app')
|
||||
@section('title', 'My Listings')
|
||||
@section('content')
|
||||
@php($partnerCreateRoute = route('filament.partner.resources.listings.create', ['tenant' => auth()->id()]))
|
||||
<div class="container mx-auto px-4 py-8">
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<h1 class="text-2xl font-bold">My Listings</h1>
|
||||
<a href="{{ route('listings.create') }}" class="bg-orange-500 text-white px-4 py-2 rounded-lg hover:bg-orange-600 transition">+ New Listing</a>
|
||||
<a href="{{ $partnerCreateRoute }}" class="bg-orange-500 text-white px-4 py-2 rounded-lg hover:bg-orange-600 transition">+ New Listing</a>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
@forelse($listings as $listing)
|
||||
@ -22,7 +23,7 @@
|
||||
@empty
|
||||
<div class="col-span-3 text-center py-12 text-gray-500">
|
||||
<p>No listings yet.</p>
|
||||
<a href="{{ route('listings.create') }}" class="mt-3 inline-block bg-blue-600 text-white px-6 py-2 rounded-lg hover:bg-blue-700 transition">Post Your First Listing</a>
|
||||
<a href="{{ $partnerCreateRoute }}" class="mt-3 inline-block bg-blue-600 text-white px-6 py-2 rounded-lg hover:bg-blue-700 transition">Post Your First Listing</a>
|
||||
</div>
|
||||
@endforelse
|
||||
</div>
|
||||
|
||||
@ -1,54 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Auth;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
class AuthenticationTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_login_screen_can_be_rendered(): void
|
||||
{
|
||||
$response = $this->get('/login');
|
||||
|
||||
$response->assertStatus(200);
|
||||
}
|
||||
|
||||
public function test_users_can_authenticate_using_the_login_screen(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
|
||||
$response = $this->post('/login', [
|
||||
'email' => $user->email,
|
||||
'password' => 'password',
|
||||
]);
|
||||
|
||||
$this->assertAuthenticated();
|
||||
$response->assertRedirect(route('dashboard', absolute: false));
|
||||
}
|
||||
|
||||
public function test_users_can_not_authenticate_with_invalid_password(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
|
||||
$this->post('/login', [
|
||||
'email' => $user->email,
|
||||
'password' => 'wrong-password',
|
||||
]);
|
||||
|
||||
$this->assertGuest();
|
||||
}
|
||||
|
||||
public function test_users_can_logout(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
|
||||
$response = $this->actingAs($user)->post('/logout');
|
||||
|
||||
$this->assertGuest();
|
||||
$response->assertRedirect('/');
|
||||
}
|
||||
}
|
||||
@ -1,58 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Auth;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Auth\Events\Verified;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Event;
|
||||
use Illuminate\Support\Facades\URL;
|
||||
use Tests\TestCase;
|
||||
|
||||
class EmailVerificationTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_email_verification_screen_can_be_rendered(): void
|
||||
{
|
||||
$user = User::factory()->unverified()->create();
|
||||
|
||||
$response = $this->actingAs($user)->get('/verify-email');
|
||||
|
||||
$response->assertStatus(200);
|
||||
}
|
||||
|
||||
public function test_email_can_be_verified(): void
|
||||
{
|
||||
$user = User::factory()->unverified()->create();
|
||||
|
||||
Event::fake();
|
||||
|
||||
$verificationUrl = URL::temporarySignedRoute(
|
||||
'verification.verify',
|
||||
now()->addMinutes(60),
|
||||
['id' => $user->id, 'hash' => sha1($user->email)]
|
||||
);
|
||||
|
||||
$response = $this->actingAs($user)->get($verificationUrl);
|
||||
|
||||
Event::assertDispatched(Verified::class);
|
||||
$this->assertTrue($user->fresh()->hasVerifiedEmail());
|
||||
$response->assertRedirect(route('dashboard', absolute: false).'?verified=1');
|
||||
}
|
||||
|
||||
public function test_email_is_not_verified_with_invalid_hash(): void
|
||||
{
|
||||
$user = User::factory()->unverified()->create();
|
||||
|
||||
$verificationUrl = URL::temporarySignedRoute(
|
||||
'verification.verify',
|
||||
now()->addMinutes(60),
|
||||
['id' => $user->id, 'hash' => sha1('wrong-email')]
|
||||
);
|
||||
|
||||
$this->actingAs($user)->get($verificationUrl);
|
||||
|
||||
$this->assertFalse($user->fresh()->hasVerifiedEmail());
|
||||
}
|
||||
}
|
||||
@ -1,44 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Auth;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
class PasswordConfirmationTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_confirm_password_screen_can_be_rendered(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
|
||||
$response = $this->actingAs($user)->get('/confirm-password');
|
||||
|
||||
$response->assertStatus(200);
|
||||
}
|
||||
|
||||
public function test_password_can_be_confirmed(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
|
||||
$response = $this->actingAs($user)->post('/confirm-password', [
|
||||
'password' => 'password',
|
||||
]);
|
||||
|
||||
$response->assertRedirect();
|
||||
$response->assertSessionHasNoErrors();
|
||||
}
|
||||
|
||||
public function test_password_is_not_confirmed_with_invalid_password(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
|
||||
$response = $this->actingAs($user)->post('/confirm-password', [
|
||||
'password' => 'wrong-password',
|
||||
]);
|
||||
|
||||
$response->assertSessionHasErrors();
|
||||
}
|
||||
}
|
||||
@ -1,73 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Auth;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Auth\Notifications\ResetPassword;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Notification;
|
||||
use Tests\TestCase;
|
||||
|
||||
class PasswordResetTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_reset_password_link_screen_can_be_rendered(): void
|
||||
{
|
||||
$response = $this->get('/forgot-password');
|
||||
|
||||
$response->assertStatus(200);
|
||||
}
|
||||
|
||||
public function test_reset_password_link_can_be_requested(): void
|
||||
{
|
||||
Notification::fake();
|
||||
|
||||
$user = User::factory()->create();
|
||||
|
||||
$this->post('/forgot-password', ['email' => $user->email]);
|
||||
|
||||
Notification::assertSentTo($user, ResetPassword::class);
|
||||
}
|
||||
|
||||
public function test_reset_password_screen_can_be_rendered(): void
|
||||
{
|
||||
Notification::fake();
|
||||
|
||||
$user = User::factory()->create();
|
||||
|
||||
$this->post('/forgot-password', ['email' => $user->email]);
|
||||
|
||||
Notification::assertSentTo($user, ResetPassword::class, function ($notification) {
|
||||
$response = $this->get('/reset-password/'.$notification->token);
|
||||
|
||||
$response->assertStatus(200);
|
||||
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
public function test_password_can_be_reset_with_valid_token(): void
|
||||
{
|
||||
Notification::fake();
|
||||
|
||||
$user = User::factory()->create();
|
||||
|
||||
$this->post('/forgot-password', ['email' => $user->email]);
|
||||
|
||||
Notification::assertSentTo($user, ResetPassword::class, function ($notification) use ($user) {
|
||||
$response = $this->post('/reset-password', [
|
||||
'token' => $notification->token,
|
||||
'email' => $user->email,
|
||||
'password' => 'password',
|
||||
'password_confirmation' => 'password',
|
||||
]);
|
||||
|
||||
$response
|
||||
->assertSessionHasNoErrors()
|
||||
->assertRedirect(route('login'));
|
||||
|
||||
return true;
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -1,51 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Auth;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Tests\TestCase;
|
||||
|
||||
class PasswordUpdateTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_password_can_be_updated(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
|
||||
$response = $this
|
||||
->actingAs($user)
|
||||
->from('/profile')
|
||||
->put('/password', [
|
||||
'current_password' => 'password',
|
||||
'password' => 'new-password',
|
||||
'password_confirmation' => 'new-password',
|
||||
]);
|
||||
|
||||
$response
|
||||
->assertSessionHasNoErrors()
|
||||
->assertRedirect('/profile');
|
||||
|
||||
$this->assertTrue(Hash::check('new-password', $user->refresh()->password));
|
||||
}
|
||||
|
||||
public function test_correct_password_must_be_provided_to_update_password(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
|
||||
$response = $this
|
||||
->actingAs($user)
|
||||
->from('/profile')
|
||||
->put('/password', [
|
||||
'current_password' => 'wrong-password',
|
||||
'password' => 'new-password',
|
||||
'password_confirmation' => 'new-password',
|
||||
]);
|
||||
|
||||
$response
|
||||
->assertSessionHasErrorsIn('updatePassword', 'current_password')
|
||||
->assertRedirect('/profile');
|
||||
}
|
||||
}
|
||||
@ -1,31 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Auth;
|
||||
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
class RegistrationTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_registration_screen_can_be_rendered(): void
|
||||
{
|
||||
$response = $this->get('/register');
|
||||
|
||||
$response->assertStatus(200);
|
||||
}
|
||||
|
||||
public function test_new_users_can_register(): void
|
||||
{
|
||||
$response = $this->post('/register', [
|
||||
'name' => 'Test User',
|
||||
'email' => 'test@example.com',
|
||||
'password' => 'password',
|
||||
'password_confirmation' => 'password',
|
||||
]);
|
||||
|
||||
$this->assertAuthenticated();
|
||||
$response->assertRedirect(route('dashboard', absolute: false));
|
||||
}
|
||||
}
|
||||
@ -1,21 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
class ExampleTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
/**
|
||||
* A basic test example.
|
||||
*/
|
||||
public function test_the_application_returns_a_successful_response(): void
|
||||
{
|
||||
$response = $this->get('/');
|
||||
|
||||
$response->assertStatus(200);
|
||||
}
|
||||
}
|
||||
@ -1,99 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
class ProfileTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_profile_page_is_displayed(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
|
||||
$response = $this
|
||||
->actingAs($user)
|
||||
->get('/profile');
|
||||
|
||||
$response->assertOk();
|
||||
}
|
||||
|
||||
public function test_profile_information_can_be_updated(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
|
||||
$response = $this
|
||||
->actingAs($user)
|
||||
->patch('/profile', [
|
||||
'name' => 'Test User',
|
||||
'email' => 'test@example.com',
|
||||
]);
|
||||
|
||||
$response
|
||||
->assertSessionHasNoErrors()
|
||||
->assertRedirect('/profile');
|
||||
|
||||
$user->refresh();
|
||||
|
||||
$this->assertSame('Test User', $user->name);
|
||||
$this->assertSame('test@example.com', $user->email);
|
||||
$this->assertNull($user->email_verified_at);
|
||||
}
|
||||
|
||||
public function test_email_verification_status_is_unchanged_when_the_email_address_is_unchanged(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
|
||||
$response = $this
|
||||
->actingAs($user)
|
||||
->patch('/profile', [
|
||||
'name' => 'Test User',
|
||||
'email' => $user->email,
|
||||
]);
|
||||
|
||||
$response
|
||||
->assertSessionHasNoErrors()
|
||||
->assertRedirect('/profile');
|
||||
|
||||
$this->assertNotNull($user->refresh()->email_verified_at);
|
||||
}
|
||||
|
||||
public function test_user_can_delete_their_account(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
|
||||
$response = $this
|
||||
->actingAs($user)
|
||||
->delete('/profile', [
|
||||
'password' => 'password',
|
||||
]);
|
||||
|
||||
$response
|
||||
->assertSessionHasNoErrors()
|
||||
->assertRedirect('/');
|
||||
|
||||
$this->assertGuest();
|
||||
$this->assertNull($user->fresh());
|
||||
}
|
||||
|
||||
public function test_correct_password_must_be_provided_to_delete_account(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
|
||||
$response = $this
|
||||
->actingAs($user)
|
||||
->from('/profile')
|
||||
->delete('/profile', [
|
||||
'password' => 'wrong-password',
|
||||
]);
|
||||
|
||||
$response
|
||||
->assertSessionHasErrorsIn('userDeletion', 'password')
|
||||
->assertRedirect('/profile');
|
||||
|
||||
$this->assertNotNull($user->fresh());
|
||||
}
|
||||
}
|
||||
@ -1,14 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace Tests;
|
||||
|
||||
use Illuminate\Foundation\Testing\TestCase as BaseTestCase;
|
||||
|
||||
abstract class TestCase extends BaseTestCase
|
||||
{
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
$this->withoutVite();
|
||||
}
|
||||
}
|
||||
@ -1,16 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class ExampleTest extends TestCase
|
||||
{
|
||||
/**
|
||||
* A basic test example.
|
||||
*/
|
||||
public function test_that_true_is_true(): void
|
||||
{
|
||||
$this->assertTrue(true);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user