Merge pull request #1391 from openclassify/copilot/develop-improved-listing-site

Greenfield refactor: Laravel 12 + FilamentPHP v5 admin/partner panels, Docker, Codespaces
This commit is contained in:
Fatih Alp 2026-03-03 11:20:41 +03:00 committed by GitHub
commit c632e56376
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
85 changed files with 12957 additions and 157 deletions

View File

@ -0,0 +1,33 @@
{
"name": "OpenClassify Dev",
"dockerComposeFile": ["../docker-compose.dev.yml"],
"service": "app",
"workspaceFolder": "/var/www/html",
"forwardPorts": [8000, 5173, 8025],
"portsAttributes": {
"8000": {"label": "Application", "onAutoForward": "openPreview"},
"5173": {"label": "Vite HMR"},
"8025": {"label": "Mailpit"}
},
"postCreateCommand": "composer install && npm install && cp -n .env.example .env && php artisan key:generate --force",
"postStartCommand": "php artisan migrate --force && php artisan db:seed --force",
"customizations": {
"vscode": {
"extensions": [
"bmewburn.vscode-intelephense-client",
"bradlc.vscode-tailwindcss",
"esbenp.prettier-vscode",
"onecentlin.laravel-blade",
"amiralizadeh9480.laravel-extra-intellisense"
],
"settings": {
"php.validate.executablePath": "/usr/local/bin/php",
"editor.formatOnSave": true,
"editor.defaultFormatter": "esbenp.prettier-vscode",
"[php]": {
"editor.defaultFormatter": "bmewburn.vscode-intelephense-client"
}
}
}
}
}

View File

@ -1,17 +1,14 @@
APP_NAME=Laravel
APP_NAME=OpenClassify
APP_ENV=local
APP_KEY=
APP_DEBUG=true
APP_URL=http://localhost
APP_URL=http://localhost:8000
APP_LOCALE=en
APP_FALLBACK_LOCALE=en
APP_FAKER_LOCALE=en_US
APP_MAINTENANCE_DRIVER=file
# APP_MAINTENANCE_STORE=database
# PHP_CLI_SERVER_WORKERS=4
BCRYPT_ROUNDS=12
@ -23,9 +20,9 @@ LOG_LEVEL=debug
DB_CONNECTION=sqlite
# DB_HOST=127.0.0.1
# DB_PORT=3306
# DB_DATABASE=laravel
# DB_USERNAME=root
# DB_PASSWORD=
# DB_DATABASE=openclassify
# DB_USERNAME=openclassify
# DB_PASSWORD=secret
SESSION_DRIVER=database
SESSION_LIFETIME=120
@ -38,9 +35,6 @@ FILESYSTEM_DISK=local
QUEUE_CONNECTION=database
CACHE_STORE=database
# CACHE_PREFIX=
MEMCACHED_HOST=127.0.0.1
REDIS_CLIENT=phpredis
REDIS_HOST=127.0.0.1
@ -53,13 +47,7 @@ MAIL_HOST=127.0.0.1
MAIL_PORT=2525
MAIL_USERNAME=null
MAIL_PASSWORD=null
MAIL_FROM_ADDRESS="hello@example.com"
MAIL_FROM_ADDRESS="hello@openclassify.com"
MAIL_FROM_NAME="${APP_NAME}"
AWS_ACCESS_KEY_ID=
AWS_SECRET_ACCESS_KEY=
AWS_DEFAULT_REGION=us-east-1
AWS_BUCKET=
AWS_USE_PATH_STYLE_ENDPOINT=false
VITE_APP_NAME="${APP_NAME}"

View File

@ -25,7 +25,7 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@v6
uses: actions/checkout@v4
- name: Setup PHP
uses: shivammathur/setup-php@v2

41
Dockerfile Normal file
View File

@ -0,0 +1,41 @@
FROM php:8.3-fpm-alpine
RUN apk add --no-cache \
nginx \
nodejs \
npm \
git \
curl \
zip \
unzip \
libpng-dev \
libjpeg-turbo-dev \
freetype-dev \
oniguruma-dev \
libxml2-dev \
&& docker-php-ext-configure gd --with-freetype --with-jpeg \
&& docker-php-ext-install pdo pdo_mysql mbstring exif pcntl bcmath gd opcache
COPY --from=composer:latest /usr/bin/composer /usr/bin/composer
WORKDIR /var/www/html
COPY composer.json composer.lock ./
RUN composer install --no-dev --no-scripts --no-autoloader --prefer-dist
COPY . .
RUN composer dump-autoload --optimize \
&& npm ci \
&& npm run build
COPY docker/nginx.conf /etc/nginx/nginx.conf
COPY docker/start.sh /start.sh
RUN chmod +x /start.sh
RUN chown -R www-data:www-data storage bootstrap/cache \
&& chmod -R 775 storage bootstrap/cache
EXPOSE 80
CMD ["/start.sh"]

29
Dockerfile.dev Normal file
View File

@ -0,0 +1,29 @@
FROM php:8.3-fpm-alpine
RUN apk add --no-cache \
nginx \
nodejs \
npm \
git \
curl \
zip \
unzip \
libpng-dev \
libjpeg-turbo-dev \
freetype-dev \
oniguruma-dev \
libxml2-dev \
&& docker-php-ext-configure gd --with-freetype --with-jpeg \
&& docker-php-ext-install pdo pdo_mysql mbstring exif pcntl bcmath gd
COPY --from=composer:latest /usr/bin/composer /usr/bin/composer
WORKDIR /var/www/html
COPY docker/nginx.conf /etc/nginx/nginx.conf
COPY docker/start-dev.sh /start.sh
RUN chmod +x /start.sh
EXPOSE 80 5173
CMD ["/start.sh"]

View File

@ -0,0 +1,56 @@
<?php
namespace Modules\Admin\Filament\Resources;
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\Tables\Columns\IconColumn;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table;
use Modules\Admin\Filament\Resources\CategoryResource\Pages;
use Modules\Category\Models\Category;
class CategoryResource extends Resource
{
protected static ?string $model = Category::class;
protected static ?string $navigationIcon = 'heroicon-o-tag';
protected static ?string $navigationGroup = 'Catalog';
public static function form(Form $form): Form
{
return $form->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),
TextInput::make('icon')->maxLength(100),
Select::make('parent_id')->label('Parent Category')->options(fn () => Category::whereNull('parent_id')->pluck('name', 'id'))->nullable()->searchable(),
TextInput::make('sort_order')->numeric()->default(0),
Toggle::make('is_active')->default(true),
]);
}
public static function table(Table $table): Table
{
return $table->columns([
TextColumn::make('id')->sortable(),
TextColumn::make('name')->searchable()->sortable(),
TextColumn::make('parent.name')->label('Parent')->default('-'),
TextColumn::make('listings_count')->counts('listings')->label('Listings'),
IconColumn::make('is_active')->boolean(),
TextColumn::make('sort_order')->sortable(),
])->actions([EditAction::make(), DeleteAction::make()]);
}
public static function getPages(): array
{
return [
'index' => Pages\ListCategories::route('/'),
'create' => Pages\CreateCategory::route('/create'),
'edit' => Pages\EditCategory::route('/{record}/edit'),
];
}
}

View File

@ -0,0 +1,10 @@
<?php
namespace Modules\Admin\Filament\Resources\CategoryResource\Pages;
use Filament\Resources\Pages\CreateRecord;
use Modules\Admin\Filament\Resources\CategoryResource;
class CreateCategory extends CreateRecord
{
protected static string $resource = CategoryResource::class;
}

View File

@ -0,0 +1,12 @@
<?php
namespace Modules\Admin\Filament\Resources\CategoryResource\Pages;
use Filament\Actions\DeleteAction;
use Filament\Resources\Pages\EditRecord;
use Modules\Admin\Filament\Resources\CategoryResource;
class EditCategory extends EditRecord
{
protected static string $resource = CategoryResource::class;
protected function getHeaderActions(): array { return [DeleteAction::make()]; }
}

View File

@ -0,0 +1,12 @@
<?php
namespace Modules\Admin\Filament\Resources\CategoryResource\Pages;
use Filament\Actions\CreateAction;
use Filament\Resources\Pages\ListRecords;
use Modules\Admin\Filament\Resources\CategoryResource;
class ListCategories extends ListRecords
{
protected static string $resource = CategoryResource::class;
protected function getHeaderActions(): array { return [CreateAction::make()]; }
}

View File

@ -0,0 +1,68 @@
<?php
namespace Modules\Admin\Filament\Resources;
use Filament\Forms\Components\Select;
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\Tables\Columns\IconColumn;
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;
class ListingResource extends Resource
{
protected static ?string $model = Listing::class;
protected static ?string $navigationIcon = 'heroicon-o-clipboard-document-list';
protected static ?string $navigationGroup = 'Catalog';
public static function form(Form $form): Form
{
return $form->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('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),
TextInput::make('contact_email')->email()->maxLength(255),
Toggle::make('is_featured')->default(false),
TextInput::make('city')->maxLength(100),
TextInput::make('country')->maxLength(100),
]);
}
public static function table(Table $table): Table
{
return $table->columns([
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' }),
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()]);
}
public static function getPages(): array
{
return [
'index' => Pages\ListListings::route('/'),
'create' => Pages\CreateListing::route('/create'),
'edit' => Pages\EditListing::route('/{record}/edit'),
];
}
}

View File

@ -0,0 +1,10 @@
<?php
namespace Modules\Admin\Filament\Resources\ListingResource\Pages;
use Filament\Resources\Pages\CreateRecord;
use Modules\Admin\Filament\Resources\ListingResource;
class CreateListing extends CreateRecord
{
protected static string $resource = ListingResource::class;
}

View File

@ -0,0 +1,12 @@
<?php
namespace Modules\Admin\Filament\Resources\ListingResource\Pages;
use Filament\Actions\DeleteAction;
use Filament\Resources\Pages\EditRecord;
use Modules\Admin\Filament\Resources\ListingResource;
class EditListing extends EditRecord
{
protected static string $resource = ListingResource::class;
protected function getHeaderActions(): array { return [DeleteAction::make()]; }
}

View File

@ -0,0 +1,12 @@
<?php
namespace Modules\Admin\Filament\Resources\ListingResource\Pages;
use Filament\Actions\CreateAction;
use Filament\Resources\Pages\ListRecords;
use Modules\Admin\Filament\Resources\ListingResource;
class ListListings extends ListRecords
{
protected static string $resource = ListingResource::class;
protected function getHeaderActions(): array { return [CreateAction::make()]; }
}

View File

@ -0,0 +1,52 @@
<?php
namespace Modules\Admin\Filament\Resources;
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\Tables\Columns\IconColumn;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table;
use Modules\Admin\Filament\Resources\LocationResource\Pages;
use Modules\Location\Models\Country;
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 $label = 'Country';
public static function form(Form $form): Form
{
return $form->schema([
TextInput::make('name')->required()->maxLength(100),
TextInput::make('code')->required()->maxLength(2)->unique(ignoreRecord: true),
TextInput::make('phone_code')->maxLength(10),
Toggle::make('is_active')->default(true),
]);
}
public static function table(Table $table): Table
{
return $table->columns([
TextColumn::make('id')->sortable(),
TextColumn::make('name')->searchable()->sortable(),
TextColumn::make('code'),
TextColumn::make('phone_code'),
IconColumn::make('is_active')->boolean(),
])->actions([EditAction::make(), DeleteAction::make()]);
}
public static function getPages(): array
{
return [
'index' => Pages\ListLocations::route('/'),
'create' => Pages\CreateLocation::route('/create'),
'edit' => Pages\EditLocation::route('/{record}/edit'),
];
}
}

View File

@ -0,0 +1,10 @@
<?php
namespace Modules\Admin\Filament\Resources\LocationResource\Pages;
use Filament\Resources\Pages\CreateRecord;
use Modules\Admin\Filament\Resources\LocationResource;
class CreateLocation extends CreateRecord
{
protected static string $resource = LocationResource::class;
}

View File

@ -0,0 +1,12 @@
<?php
namespace Modules\Admin\Filament\Resources\LocationResource\Pages;
use Filament\Actions\DeleteAction;
use Filament\Resources\Pages\EditRecord;
use Modules\Admin\Filament\Resources\LocationResource;
class EditLocation extends EditRecord
{
protected static string $resource = LocationResource::class;
protected function getHeaderActions(): array { return [DeleteAction::make()]; }
}

View File

@ -0,0 +1,12 @@
<?php
namespace Modules\Admin\Filament\Resources\LocationResource\Pages;
use Filament\Actions\CreateAction;
use Filament\Resources\Pages\ListRecords;
use Modules\Admin\Filament\Resources\LocationResource;
class ListLocations extends ListRecords
{
protected static string $resource = LocationResource::class;
protected function getHeaderActions(): array { return [CreateAction::make()]; }
}

View File

@ -0,0 +1,50 @@
<?php
namespace Modules\Admin\Filament\Resources;
use App\Models\User;
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\Tables\Columns\TextColumn;
use Filament\Tables\Table;
use Modules\Admin\Filament\Resources\UserResource\Pages;
class UserResource extends Resource
{
protected static ?string $model = User::class;
protected static ?string $navigationIcon = 'heroicon-o-users';
protected static ?string $navigationGroup = 'User Management';
public static function form(Form $form): Form
{
return $form->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)),
Select::make('roles')->multiple()->relationship('roles', 'name')->preload(),
]);
}
public static function table(Table $table): Table
{
return $table->columns([
TextColumn::make('id')->sortable(),
TextColumn::make('name')->searchable()->sortable(),
TextColumn::make('email')->searchable()->sortable(),
TextColumn::make('roles.name')->badge()->label('Roles'),
TextColumn::make('created_at')->dateTime()->sortable(),
])->actions([EditAction::make(), DeleteAction::make()]);
}
public static function getPages(): array
{
return [
'index' => Pages\ListUsers::route('/'),
'create' => Pages\CreateUser::route('/create'),
'edit' => Pages\EditUser::route('/{record}/edit'),
];
}
}

View File

@ -0,0 +1,10 @@
<?php
namespace Modules\Admin\Filament\Resources\UserResource\Pages;
use Filament\Resources\Pages\CreateRecord;
use Modules\Admin\Filament\Resources\UserResource;
class CreateUser extends CreateRecord
{
protected static string $resource = UserResource::class;
}

View File

@ -0,0 +1,12 @@
<?php
namespace Modules\Admin\Filament\Resources\UserResource\Pages;
use Filament\Actions\DeleteAction;
use Filament\Resources\Pages\EditRecord;
use Modules\Admin\Filament\Resources\UserResource;
class EditUser extends EditRecord
{
protected static string $resource = UserResource::class;
protected function getHeaderActions(): array { return [DeleteAction::make()]; }
}

View File

@ -0,0 +1,12 @@
<?php
namespace Modules\Admin\Filament\Resources\UserResource\Pages;
use Filament\Actions\CreateAction;
use Filament\Resources\Pages\ListRecords;
use Modules\Admin\Filament\Resources\UserResource;
class ListUsers extends ListRecords
{
protected static string $resource = UserResource::class;
protected function getHeaderActions(): array { return [CreateAction::make()]; }
}

View File

@ -0,0 +1,49 @@
<?php
namespace Modules\Admin\Providers;
use Filament\Http\Middleware\Authenticate;
use Filament\Http\Middleware\AuthenticateSession;
use Filament\Http\Middleware\DisableBladeIconComponents;
use Filament\Http\Middleware\DispatchServingFilamentEvent;
use Filament\Pages\Dashboard;
use Filament\Panel;
use Filament\PanelProvider;
use Filament\Support\Colors\Color;
use Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse;
use Illuminate\Cookie\Middleware\EncryptCookies;
use Illuminate\Foundation\Http\Middleware\VerifyCsrfToken;
use Illuminate\Routing\Middleware\SubstituteBindings;
use Illuminate\Session\Middleware\StartSession;
use Illuminate\View\Middleware\ShareErrorsFromSession;
use Modules\Admin\Filament\Resources\CategoryResource;
use Modules\Admin\Filament\Resources\ListingResource;
use Modules\Admin\Filament\Resources\LocationResource;
use Modules\Admin\Filament\Resources\UserResource;
class AdminPanelProvider extends PanelProvider
{
public function panel(Panel $panel): Panel
{
return $panel
->id('admin')
->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')
->pages([Dashboard::class])
->middleware([
EncryptCookies::class,
AddQueuedCookiesToResponse::class,
StartSession::class,
AuthenticateSession::class,
ShareErrorsFromSession::class,
VerifyCsrfToken::class,
SubstituteBindings::class,
DisableBladeIconComponents::class,
DispatchServingFilamentEvent::class,
])
->authMiddleware([Authenticate::class]);
}
}

View File

@ -0,0 +1,17 @@
<?php
namespace Modules\Admin\Providers;
use Illuminate\Support\ServiceProvider;
class AdminServiceProvider extends ServiceProvider
{
public function boot(): void
{
$this->loadMigrationsFrom(module_path('Admin', 'database/migrations'));
}
public function register(): void
{
$this->app->register(AdminPanelProvider::class);
}
}

13
Modules/Admin/module.json Normal file
View File

@ -0,0 +1,13 @@
{
"name": "Admin",
"alias": "admin",
"description": "Admin panel using FilamentPHP",
"keywords": [],
"priority": 0,
"providers": [
"Modules\\Admin\\Providers\\AdminServiceProvider"
],
"aliases": {},
"files": [],
"requires": []
}

View File

@ -3,32 +3,33 @@ namespace Modules\Category\Database\Seeders;
use Illuminate\Database\Seeder;
use Modules\Category\Models\Category;
use Illuminate\Support\Str;
class CategorySeeder extends Seeder
{
public function run(): void
{
$categories = [
['name' => 'Electronics', 'icon' => '📱', 'children' => ['Mobile Phones', 'Laptops & Computers', 'Tablets', 'Cameras', 'Audio']],
['name' => 'Vehicles', 'icon' => '🚗', 'children' => ['Cars', 'Motorcycles', 'Trucks', 'Boats']],
['name' => 'Real Estate', 'icon' => '🏠', 'children' => ['Apartments for Rent', 'Houses for Sale', 'Commercial', 'Land']],
['name' => 'Furniture', 'icon' => '🛋️', 'children' => ['Sofas', 'Beds', 'Tables', 'Wardrobes']],
['name' => 'Fashion', 'icon' => '👗', 'children' => ['Women', 'Men', 'Kids', 'Accessories']],
['name' => 'Jobs', 'icon' => '💼', 'children' => ['IT & Technology', 'Marketing', 'Sales', 'Education']],
['name' => 'Services', 'icon' => '🔧', 'children' => ['Home Repair', 'Tutoring', 'Design', 'Cleaning']],
['name' => 'Sports & Hobbies', 'icon' => '⚽', 'children' => ['Sports Equipment', 'Musical Instruments', 'Books', 'Games']],
['name' => 'Electronics', 'slug' => 'electronics', 'icon' => 'laptop', 'children' => ['Phones', 'Computers', 'Tablets', 'TVs']],
['name' => 'Vehicles', 'slug' => 'vehicles', 'icon' => 'car', 'children' => ['Cars', 'Motorcycles', 'Trucks', 'Boats']],
['name' => 'Real Estate', 'slug' => 'real-estate', 'icon' => 'home', 'children' => ['For Sale', 'For Rent', 'Commercial']],
['name' => 'Fashion', 'slug' => 'fashion', 'icon' => 'shirt', 'children' => ['Men', 'Women', 'Kids', 'Shoes']],
['name' => 'Home & Garden', 'slug' => 'home-garden', 'icon' => 'sofa', 'children' => ['Furniture', 'Garden', 'Appliances']],
['name' => 'Sports', 'slug' => 'sports', 'icon' => 'football', 'children' => ['Outdoor', 'Fitness', 'Team Sports']],
['name' => 'Jobs', 'slug' => 'jobs', 'icon' => 'briefcase', 'children' => ['Full Time', 'Part Time', 'Freelance']],
['name' => 'Services', 'slug' => 'services', 'icon' => 'wrench', 'children' => ['Cleaning', 'Repair', 'Education']],
];
foreach ($categories as $catData) {
foreach ($categories as $index => $data) {
$parent = Category::firstOrCreate(
['slug' => Str::slug($catData['name'])],
['name' => $catData['name'], 'icon' => $catData['icon'] ?? null, 'level' => 0, 'is_active' => true]
['slug' => $data['slug']],
['name' => $data['name'], 'slug' => $data['slug'], 'icon' => $data['icon'], 'level' => 0, 'sort_order' => $index, 'is_active' => true]
);
foreach ($catData['children'] as $childName) {
foreach ($data['children'] as $i => $childName) {
$childSlug = $data['slug'] . '-' . \Illuminate\Support\Str::slug($childName);
Category::firstOrCreate(
['slug' => Str::slug($childName)],
['name' => $childName, 'parent_id' => $parent->id, 'level' => 1, 'is_active' => true]
['slug' => $childSlug],
['name' => $childName, 'slug' => $childSlug, 'parent_id' => $parent->id, 'level' => 1, 'sort_order' => $i, 'is_active' => true]
);
}
}

View File

@ -2,29 +2,47 @@
namespace Modules\Listing\Database\Seeders;
use Illuminate\Database\Seeder;
use Modules\Category\Models\Category;
use Modules\Listing\Models\Listing;
use Illuminate\Support\Str;
class ListingSeeder extends Seeder
{
public function run(): void
{
$user = \App\Models\User::where('email', 'partner@openclassify.com')->first();
$categories = Category::where('level', 0)->get();
if (!$user || $categories->isEmpty()) return;
$listings = [
['title' => 'iPhone 14 Pro - Like New', 'price' => 750, 'category_id' => 1, 'status' => 'active'],
['title' => 'Samsung Galaxy S23', 'price' => 550, 'category_id' => 1, 'status' => 'active'],
['title' => 'MacBook Pro 2023', 'price' => 1800, 'category_id' => 2, 'status' => 'active'],
['title' => '2019 Toyota Corolla', 'price' => 15000, 'category_id' => 3, 'status' => 'active'],
['title' => 'Apartment for Rent - 2BR', 'price' => 1200, 'category_id' => 4, 'status' => 'active'],
['title' => 'Sofa Set - Excellent Condition', 'price' => 350, 'category_id' => 5, 'status' => 'active'],
['title' => 'iPhone 14 Pro - Excellent Condition', 'price' => 799, 'city' => 'Istanbul', 'country' => 'Turkey'],
['title' => 'MacBook Pro 2023', 'price' => 1499, 'city' => 'Ankara', 'country' => 'Turkey'],
['title' => '2020 Toyota Corolla', 'price' => 18000, 'city' => 'New York', 'country' => 'United States'],
['title' => '3-Bedroom Apartment for Sale', 'price' => 250000, 'city' => 'Istanbul', 'country' => 'Turkey'],
['title' => 'Nike Running Shoes Size 42', 'price' => 89, 'city' => 'Berlin', 'country' => 'Germany'],
['title' => 'IKEA Dining Table', 'price' => 150, 'city' => 'London', 'country' => 'United Kingdom'],
['title' => 'Yoga Mat - Brand New', 'price' => 35, 'city' => 'Paris', 'country' => 'France'],
['title' => 'Web Developer for Hire', 'price' => 0, 'city' => 'Remote', 'country' => 'Turkey'],
['title' => 'Samsung 55" 4K TV', 'price' => 599, 'city' => 'Madrid', 'country' => 'Spain'],
['title' => 'Honda CBR500R Motorcycle 2021', 'price' => 6500, 'city' => 'Tokyo', 'country' => 'Japan'],
];
foreach ($listings as $data) {
$data['slug'] = Str::slug($data['title']) . '-' . Str::random(6);
$data['description'] = 'Great item in excellent condition. Contact for more details.';
$data['contact_email'] = 'seller@example.com';
$data['city'] = 'Istanbul';
$data['country'] = 'Turkey';
Listing::firstOrCreate(['title' => $data['title']], $data);
foreach ($listings as $i => $listing) {
$category = $categories->get($i % $categories->count());
Listing::firstOrCreate(
['slug' => \Illuminate\Support\Str::slug($listing['title']) . '-' . ($i + 1)],
array_merge($listing, [
'slug' => \Illuminate\Support\Str::slug($listing['title']) . '-' . ($i + 1),
'description' => 'This is a sample listing description for ' . $listing['title'],
'currency' => $listing['price'] > 0 ? 'USD' : 'USD',
'category_id' => $category?->id,
'user_id' => $user->id,
'status' => 'active',
'contact_email' => 'partner@openclassify.com',
'contact_phone' => '+1234567890',
'is_featured' => $i < 3,
])
);
}
}
}

View File

@ -2,36 +2,43 @@
namespace Modules\Location\Database\Seeders;
use Illuminate\Database\Seeder;
use Modules\Location\Models\Country;
use Modules\Location\Models\City;
use Modules\Location\Models\District;
use Modules\Location\Models\Country;
class LocationSeeder extends Seeder
{
public function run(): void
{
$locations = [
['name' => 'Turkey', 'code' => 'TR', 'phone_code' => '+90', 'flag' => '🇹🇷',
'cities' => ['Istanbul' => ['Beyoglu', 'Kadikoy', 'Besiktas'], 'Ankara' => ['Cankaya', 'Kecioren'], 'Izmir' => ['Konak', 'Karsiyaka']]],
['name' => 'United States', 'code' => 'US', 'phone_code' => '+1', 'flag' => '🇺🇸',
'cities' => ['New York' => ['Manhattan', 'Brooklyn'], 'Los Angeles' => ['Hollywood', 'Venice'], 'Chicago' => ['Downtown', 'Midtown']]],
['name' => 'United Kingdom', 'code' => 'GB', 'phone_code' => '+44', 'flag' => '🇬🇧',
'cities' => ['London' => ['Westminster', 'Shoreditch'], 'Manchester' => ['City Centre'], 'Birmingham' => ['Jewellery Quarter']]],
['name' => 'Germany', 'code' => 'DE', 'phone_code' => '+49', 'flag' => '🇩🇪',
'cities' => ['Berlin' => ['Mitte', 'Prenzlauer Berg'], 'Munich' => ['Schwabing', 'Maxvorstadt']]],
['name' => 'France', 'code' => 'FR', 'phone_code' => '+33', 'flag' => '🇫🇷',
'cities' => ['Paris' => ['Marais', 'Montmartre'], 'Lyon' => ['Presquile']]],
$countries = [
['name' => 'Turkey', 'code' => 'TR', 'phone_code' => '+90'],
['name' => 'United States', 'code' => 'US', 'phone_code' => '+1'],
['name' => 'Germany', 'code' => 'DE', 'phone_code' => '+49'],
['name' => 'France', 'code' => 'FR', 'phone_code' => '+33'],
['name' => 'United Kingdom', 'code' => 'GB', 'phone_code' => '+44'],
['name' => 'Spain', 'code' => 'ES', 'phone_code' => '+34'],
['name' => 'Italy', 'code' => 'IT', 'phone_code' => '+39'],
['name' => 'Russia', 'code' => 'RU', 'phone_code' => '+7'],
['name' => 'China', 'code' => 'CN', 'phone_code' => '+86'],
['name' => 'Japan', 'code' => 'JP', 'phone_code' => '+81'],
];
foreach ($locations as $countryData) {
$cities = $countryData['cities'];
unset($countryData['cities']);
$country = Country::firstOrCreate(['code' => $countryData['code']], $countryData);
foreach ($cities as $cityName => $districts) {
$city = City::firstOrCreate(['name' => $cityName, 'country_id' => $country->id]);
foreach ($districts as $districtName) {
District::firstOrCreate(['name' => $districtName, 'city_id' => $city->id]);
}
foreach ($countries as $country) {
Country::firstOrCreate(['code' => $country['code']], array_merge($country, ['is_active' => true]));
}
$tr = Country::where('code', 'TR')->first();
if ($tr) {
$cities = ['Istanbul', 'Ankara', 'Izmir', 'Bursa', 'Antalya'];
foreach ($cities as $city) {
City::firstOrCreate(['name' => $city, 'country_id' => $tr->id]);
}
}
$us = Country::where('code', 'US')->first();
if ($us) {
$cities = ['New York', 'Los Angeles', 'Chicago', 'Houston', 'Phoenix'];
foreach ($cities as $city) {
City::firstOrCreate(['name' => $city, 'country_id' => $us->id]);
}
}
}

View File

@ -0,0 +1,65 @@
<?php
namespace Modules\Partner\Filament\Resources;
use Filament\Facades\Filament;
use Filament\Forms\Components\Select;
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\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;
class ListingResource extends Resource
{
protected static ?string $model = Listing::class;
protected static ?string $navigationIcon = 'heroicon-o-clipboard-document-list';
public static function form(Form $form): Form
{
return $form->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('category_id')->label('Category')->options(fn () => Category::where('is_active', true)->pluck('name', 'id'))->searchable()->nullable(),
TextInput::make('contact_phone')->tel()->maxLength(50),
TextInput::make('contact_email')->email()->maxLength(255),
TextInput::make('city')->maxLength(100),
TextInput::make('country')->maxLength(100),
]);
}
public static function table(Table $table): Table
{
return $table->columns([
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' }),
TextColumn::make('city'),
TextColumn::make('created_at')->dateTime()->sortable(),
])->actions([EditAction::make(), DeleteAction::make()]);
}
public static function getEloquentQuery(): Builder
{
return parent::getEloquentQuery()->where('user_id', Filament::auth()->id());
}
public static function getPages(): array
{
return [
'index' => Pages\ListListings::route('/'),
'create' => Pages\CreateListing::route('/create'),
'edit' => Pages\EditListing::route('/{record}/edit'),
];
}
}

View File

@ -0,0 +1,17 @@
<?php
namespace Modules\Partner\Filament\Resources\ListingResource\Pages;
use Filament\Resources\Pages\CreateRecord;
use Modules\Partner\Filament\Resources\ListingResource;
class CreateListing extends CreateRecord
{
protected static string $resource = ListingResource::class;
protected function mutateFormDataBeforeCreate(array $data): array
{
$data['user_id'] = \Filament\Facades\Filament::auth()->id();
$data['status'] = 'pending';
return $data;
}
}

View File

@ -0,0 +1,12 @@
<?php
namespace Modules\Partner\Filament\Resources\ListingResource\Pages;
use Filament\Actions\DeleteAction;
use Filament\Resources\Pages\EditRecord;
use Modules\Partner\Filament\Resources\ListingResource;
class EditListing extends EditRecord
{
protected static string $resource = ListingResource::class;
protected function getHeaderActions(): array { return [DeleteAction::make()]; }
}

View File

@ -0,0 +1,12 @@
<?php
namespace Modules\Partner\Filament\Resources\ListingResource\Pages;
use Filament\Actions\CreateAction;
use Filament\Resources\Pages\ListRecords;
use Modules\Partner\Filament\Resources\ListingResource;
class ListListings extends ListRecords
{
protected static string $resource = ListingResource::class;
protected function getHeaderActions(): array { return [CreateAction::make()]; }
}

View File

@ -0,0 +1,47 @@
<?php
namespace Modules\Partner\Providers;
use App\Models\User;
use Filament\Http\Middleware\Authenticate;
use Filament\Http\Middleware\AuthenticateSession;
use Filament\Http\Middleware\DisableBladeIconComponents;
use Filament\Http\Middleware\DispatchServingFilamentEvent;
use Filament\Pages\Dashboard;
use Filament\Panel;
use Filament\PanelProvider;
use Filament\Support\Colors\Color;
use Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse;
use Illuminate\Cookie\Middleware\EncryptCookies;
use Illuminate\Foundation\Http\Middleware\VerifyCsrfToken;
use Illuminate\Routing\Middleware\SubstituteBindings;
use Illuminate\Session\Middleware\StartSession;
use Illuminate\View\Middleware\ShareErrorsFromSession;
class PartnerPanelProvider extends PanelProvider
{
public function panel(Panel $panel): Panel
{
return $panel
->id('partner')
->path('partner')
->login()
->colors(['primary' => Color::Emerald])
->tenant(User::class, slugAttribute: 'id')
->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')
->pages([Dashboard::class])
->middleware([
EncryptCookies::class,
AddQueuedCookiesToResponse::class,
StartSession::class,
AuthenticateSession::class,
ShareErrorsFromSession::class,
VerifyCsrfToken::class,
SubstituteBindings::class,
DisableBladeIconComponents::class,
DispatchServingFilamentEvent::class,
])
->authMiddleware([Authenticate::class]);
}
}

View File

@ -0,0 +1,14 @@
<?php
namespace Modules\Partner\Providers;
use Illuminate\Support\ServiceProvider;
class PartnerServiceProvider extends ServiceProvider
{
public function boot(): void {}
public function register(): void
{
$this->app->register(PartnerPanelProvider::class);
}
}

View File

@ -0,0 +1,13 @@
{
"name": "Partner",
"alias": "partner",
"description": "Partner panel for users to manage their own listings",
"keywords": [],
"priority": 0,
"providers": [
"Modules\\Partner\\Providers\\PartnerServiceProvider"
],
"aliases": {},
"files": [],
"requires": []
}

265
README.md
View File

@ -1,59 +1,244 @@
<p align="center"><a href="https://laravel.com" target="_blank"><img src="https://raw.githubusercontent.com/laravel/art/master/logo-lockup/5%20SVG/2%20CMYK/1%20Full%20Color/laravel-logolockup-cmyk-red.svg" width="400" alt="Laravel Logo"></a></p>
# OpenClassify
<p align="center">
<a href="https://github.com/laravel/framework/actions"><img src="https://github.com/laravel/framework/workflows/tests/badge.svg" alt="Build Status"></a>
<a href="https://packagist.org/packages/laravel/framework"><img src="https://img.shields.io/packagist/dt/laravel/framework" alt="Total Downloads"></a>
<a href="https://packagist.org/packages/laravel/framework"><img src="https://img.shields.io/packagist/v/laravel/framework" alt="Latest Stable Version"></a>
<a href="https://packagist.org/packages/laravel/framework"><img src="https://img.shields.io/packagist/l/laravel/framework" alt="License"></a>
</p>
A modern classified ads platform built with Laravel 12, FilamentPHP v5, and Laravel Modules — similar to Letgo and Sahibinden.
## About Laravel
## Features
Laravel is a web application framework with expressive, elegant syntax. We believe development must be an enjoyable and creative experience to be truly fulfilling. Laravel takes the pain out of development by easing common tasks used in many web projects, such as:
- 🛍️ **Classified Listings** — Browse, search, and post ads across categories
- 🗂️ **Categories** — Hierarchical categories with icons
- 📍 **Locations** — Country and city management
- 👤 **User Profiles** — Manage your listings and account
- 🔐 **Admin Panel** — Full control via FilamentPHP v5 at `/admin`
- 🤝 **Partner Panel** — Users manage their own listings at `/partner/{id}` (tenant isolation)
- 🌍 **10 Languages** — English, Turkish, Arabic, German, French, Spanish, Portuguese, Russian, Chinese, Japanese
- 🐳 **Docker Ready** — One-command production and development setup
- ☁️ **GitHub Codespaces** — Zero-config cloud development
- [Simple, fast routing engine](https://laravel.com/docs/routing).
- [Powerful dependency injection container](https://laravel.com/docs/container).
- Multiple back-ends for [session](https://laravel.com/docs/session) and [cache](https://laravel.com/docs/cache) storage.
- Expressive, intuitive [database ORM](https://laravel.com/docs/eloquent).
- Database agnostic [schema migrations](https://laravel.com/docs/migrations).
- [Robust background job processing](https://laravel.com/docs/queues).
- [Real-time event broadcasting](https://laravel.com/docs/broadcasting).
## Tech Stack
Laravel is accessible, powerful, and provides tools required for large, robust applications.
| Layer | Technology |
|-------|-----------|
| Framework | Laravel 12 |
| Admin UI | FilamentPHP v5 |
| Modules | nWidart/laravel-modules v11 |
| Auth/Roles | Spatie Laravel Permission |
| Frontend | Blade + TailwindCSS + Vite |
| Database | MySQL / SQLite |
| Cache/Queue | Redis |
## Learning Laravel
## Quick Start (Docker)
Laravel has the most extensive and thorough [documentation](https://laravel.com/docs) and video tutorial library of all modern web application frameworks, making it a breeze to get started with the framework. You can also check out [Laravel Learn](https://laravel.com/learn), where you will be guided through building a modern Laravel application.
```bash
# Clone the repository
git clone https://github.com/openclassify/openclassify.git
cd openclassify
If you don't feel like reading, [Laracasts](https://laracasts.com) can help. Laracasts contains thousands of video tutorials on a range of topics including Laravel, modern PHP, unit testing, and JavaScript. Boost your skills by digging into our comprehensive video library.
# Copy environment file
cp .env.example .env
## Laravel Sponsors
# Start with Docker Compose (production-like)
docker compose up -d
We would like to extend our thanks to the following sponsors for funding Laravel development. If you are interested in becoming a sponsor, please visit the [Laravel Partners program](https://partners.laravel.com).
# The application will be available at http://localhost:8000
```
### Premium Partners
### Default Credentials
- **[Vehikl](https://vehikl.com)**
- **[Tighten Co.](https://tighten.co)**
- **[Kirschbaum Development Group](https://kirschbaumdevelopment.com)**
- **[64 Robots](https://64robots.com)**
- **[Curotec](https://www.curotec.com/services/technologies/laravel)**
- **[DevSquad](https://devsquad.com/hire-laravel-developers)**
- **[Redberry](https://redberry.international/laravel-development)**
- **[Active Logic](https://activelogic.com)**
| Role | Email | Password |
|------|-------|----------|
| Admin | admin@openclassify.com | password |
| Partner | partner@openclassify.com | password |
**Admin Panel:** http://localhost:8000/admin
**Partner Panel:** http://localhost:8000/partner
---
## Development Setup
### Option 1: GitHub Codespaces (Zero Config)
1. Click **Code → Codespaces → New codespace** on GitHub
2. Wait for the environment to build (~2 minutes)
3. The app starts automatically at port 8000
### Option 2: Docker Development
```bash
# Start development environment with hot reload
docker compose -f docker-compose.dev.yml up -d
# View logs
docker compose -f docker-compose.dev.yml logs -f app
```
### Option 3: Local (PHP + Node)
**Requirements:** PHP 8.2+, Composer, Node 18+, SQLite or MySQL
```bash
# Install dependencies
composer install
npm install
# Setup environment
cp .env.example .env
php artisan key:generate
# Database (SQLite for quick start)
touch database/database.sqlite
php artisan migrate
php artisan db:seed
# Start all services (server + queue + vite)
composer run dev
```
---
## Architecture
### Module Structure
```
Modules/
├── Admin/ # FilamentPHP Admin Panel
│ ├── Filament/
│ │ └── Resources/ # CRUD resources (User, Category, Listing, Location)
│ └── Providers/
│ ├── AdminServiceProvider.php
│ └── AdminPanelProvider.php
├── Partner/ # FilamentPHP Tenant Panel
│ ├── Filament/
│ │ └── Resources/ # Tenant-scoped Listing resource
│ └── Providers/
│ ├── PartnerServiceProvider.php
│ └── PartnerPanelProvider.php
├── Category/ # Category management
│ ├── Models/Category.php
│ ├── Http/Controllers/
│ ├── database/migrations/
│ └── database/seeders/
├── Listing/ # Listing management
│ ├── Models/Listing.php
│ ├── Http/Controllers/
│ ├── database/migrations/
│ └── database/seeders/
├── Location/ # Countries & Cities
│ ├── Models/{Country,City,District}.php
│ ├── database/migrations/
│ └── database/seeders/
└── Profile/ # User profile pages
├── Models/Profile.php
├── Http/Controllers/
└── database/migrations/
```
### Panels
| Panel | URL | Access |
|-------|-----|--------|
| Admin | `/admin` | Users with `admin` role |
| Partner | `/partner/{id}` | All authenticated users (tenant-scoped) |
### Roles (Spatie Permission)
| Role | Access |
|------|--------|
| `admin` | Full admin panel access |
| `partner` | Partner panel only (manages own listings) |
---
## Creating a New Module
```bash
php artisan module:make ModuleName
```
Then add to `modules_statuses.json`:
```json
{
"ModuleName": true
}
```
---
## Adding a Filament Resource to Admin Panel
Resources are auto-discovered from `Modules/Admin/Filament/Resources/`.
---
## Language Support
Languages are in `lang/{locale}/messages.php`. To add a new language:
1. Create `lang/{locale}/messages.php`
2. Switch language via: `GET /lang/{locale}`
Supported locales: `en`, `tr`, `ar`, `de`, `fr`, `es`, `pt`, `ru`, `zh`, `ja`
---
## Running Tests
```bash
php artisan test
```
---
## Production Deployment
### Environment Variables
```env
APP_ENV=production
APP_DEBUG=false
APP_URL=https://yourdomain.com
DB_CONNECTION=mysql
DB_HOST=your-db-host
DB_DATABASE=openclassify
DB_USERNAME=openclassify
DB_PASSWORD=your-secure-password
REDIS_HOST=your-redis-host
CACHE_STORE=redis
SESSION_DRIVER=redis
QUEUE_CONNECTION=redis
```
### Post-Deploy Commands
```bash
php artisan migrate --force
php artisan db:seed --force # Only on first deploy
php artisan config:cache
php artisan route:cache
php artisan view:cache
php artisan storage:link
```
---
## Contributing
Thank you for considering contributing to the Laravel framework! The contribution guide can be found in the [Laravel documentation](https://laravel.com/docs/contributions).
1. Fork the repository
2. Create a feature branch: `git checkout -b feature/your-feature`
3. Commit your changes: `git commit -m 'Add your feature'`
4. Push to the branch: `git push origin feature/your-feature`
5. Open a Pull Request
## Code of Conduct
In order to ensure that the Laravel community is welcoming to all, please review and abide by the [Code of Conduct](https://laravel.com/docs/contributions#code-of-conduct).
## Security Vulnerabilities
If you discover a security vulnerability within Laravel, please send an e-mail to Taylor Otwell via [taylor@laravel.com](mailto:taylor@laravel.com). All security vulnerabilities will be promptly addressed.
---
## License
The Laravel framework is open-sourced software licensed under the [MIT license](https://opensource.org/licenses/MIT).
MIT License. See [LICENSE](LICENSE) for details.

View File

@ -1,43 +1,23 @@
<?php
namespace App\Models;
// use Illuminate\Contracts\Auth\MustVerifyEmail;
use Filament\Models\Contracts\FilamentUser;
use Filament\Models\Contracts\HasTenants;
use Filament\Panel;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Illuminate\Support\Collection;
use Spatie\Permission\Traits\HasRoles;
class User extends Authenticatable
class User extends Authenticatable implements FilamentUser, HasTenants
{
/** @use HasFactory<\Database\Factories\UserFactory> */
use HasFactory, Notifiable;
use HasFactory, HasRoles, Notifiable;
/**
* The attributes that are mass assignable.
*
* @var list<string>
*/
protected $fillable = [
'name',
'email',
'password',
];
protected $fillable = ['name', 'email', 'password'];
protected $hidden = ['password', 'remember_token'];
/**
* The attributes that should be hidden for serialization.
*
* @var list<string>
*/
protected $hidden = [
'password',
'remember_token',
];
/**
* Get the attributes that should be cast.
*
* @return array<string, string>
*/
protected function casts(): array
{
return [
@ -45,4 +25,28 @@ class User extends Authenticatable
'password' => 'hashed',
];
}
public function canAccessPanel(Panel $panel): bool
{
return match ($panel->getId()) {
'admin' => $this->hasRole('admin'),
'partner' => true,
default => false,
};
}
public function getTenants(Panel $panel): Collection
{
return collect([$this]);
}
public function canAccessTenant(Model $tenant): bool
{
return $tenant->getKey() === $this->getKey();
}
public function listings()
{
return $this->hasMany(\Modules\Listing\Models\Listing::class);
}
}

View File

@ -7,13 +7,14 @@
"license": "MIT",
"require": {
"php": "^8.2",
"filament/filament": "^5.0",
"laravel/framework": "^12.0",
"laravel/tinker": "^2.10.1",
"nwidart/laravel-modules": "^11.0"
"nwidart/laravel-modules": "^11.0",
"spatie/laravel-permission": "^7.2"
},
"require-dev": {
"fakerphp/faker": "^1.23",
"laravel/breeze": "*",
"laravel/pail": "^1.2.2",
"laravel/pint": "^1.24",
"laravel/sail": "^1.41",
@ -53,7 +54,8 @@
],
"post-autoload-dump": [
"Illuminate\\Foundation\\ComposerScripts::postAutoloadDump",
"@php artisan package:discover --ansi"
"@php artisan package:discover --ansi",
"@php artisan filament:upgrade"
],
"post-update-cmd": [
"@php artisan vendor:publish --tag=laravel-assets --ansi --force"

10816
composer.lock generated Normal file

File diff suppressed because it is too large Load Diff

202
config/permission.php Normal file
View File

@ -0,0 +1,202 @@
<?php
return [
'models' => [
/*
* When using the "HasPermissions" trait from this package, we need to know which
* Eloquent model should be used to retrieve your permissions. Of course, it
* is often just the "Permission" model but you may use whatever you like.
*
* The model you want to use as a Permission model needs to implement the
* `Spatie\Permission\Contracts\Permission` contract.
*/
'permission' => Spatie\Permission\Models\Permission::class,
/*
* When using the "HasRoles" trait from this package, we need to know which
* Eloquent model should be used to retrieve your roles. Of course, it
* is often just the "Role" model but you may use whatever you like.
*
* The model you want to use as a Role model needs to implement the
* `Spatie\Permission\Contracts\Role` contract.
*/
'role' => Spatie\Permission\Models\Role::class,
],
'table_names' => [
/*
* When using the "HasRoles" trait from this package, we need to know which
* table should be used to retrieve your roles. We have chosen a basic
* default value but you may easily change it to any table you like.
*/
'roles' => 'roles',
/*
* When using the "HasPermissions" trait from this package, we need to know which
* table should be used to retrieve your permissions. We have chosen a basic
* default value but you may easily change it to any table you like.
*/
'permissions' => 'permissions',
/*
* When using the "HasPermissions" trait from this package, we need to know which
* table should be used to retrieve your models permissions. We have chosen a
* basic default value but you may easily change it to any table you like.
*/
'model_has_permissions' => 'model_has_permissions',
/*
* When using the "HasRoles" trait from this package, we need to know which
* table should be used to retrieve your models roles. We have chosen a
* basic default value but you may easily change it to any table you like.
*/
'model_has_roles' => 'model_has_roles',
/*
* When using the "HasRoles" trait from this package, we need to know which
* table should be used to retrieve your roles permissions. We have chosen a
* basic default value but you may easily change it to any table you like.
*/
'role_has_permissions' => 'role_has_permissions',
],
'column_names' => [
/*
* Change this if you want to name the related pivots other than defaults
*/
'role_pivot_key' => null, // default 'role_id',
'permission_pivot_key' => null, // default 'permission_id',
/*
* Change this if you want to name the related model primary key other than
* `model_id`.
*
* For example, this would be nice if your primary keys are all UUIDs. In
* that case, name this `model_uuid`.
*/
'model_morph_key' => 'model_id',
/*
* Change this if you want to use the teams feature and your related model's
* foreign key is other than `team_id`.
*/
'team_foreign_key' => 'team_id',
],
/*
* When set to true, the method for checking permissions will be registered on the gate.
* Set this to false if you want to implement custom logic for checking permissions.
*/
'register_permission_check_method' => true,
/*
* When set to true, Laravel\Octane\Events\OperationTerminated event listener will be registered
* this will refresh permissions on every TickTerminated, TaskTerminated and RequestTerminated
* NOTE: This should not be needed in most cases, but an Octane/Vapor combination benefited from it.
*/
'register_octane_reset_listener' => false,
/*
* Events will fire when a role or permission is assigned/unassigned:
* \Spatie\Permission\Events\RoleAttached
* \Spatie\Permission\Events\RoleDetached
* \Spatie\Permission\Events\PermissionAttached
* \Spatie\Permission\Events\PermissionDetached
*
* To enable, set to true, and then create listeners to watch these events.
*/
'events_enabled' => false,
/*
* Teams Feature.
* When set to true the package implements teams using the 'team_foreign_key'.
* If you want the migrations to register the 'team_foreign_key', you must
* set this to true before doing the migration.
* If you already did the migration then you must make a new migration to also
* add 'team_foreign_key' to 'roles', 'model_has_roles', and 'model_has_permissions'
* (view the latest version of this package's migration file)
*/
'teams' => false,
/*
* The class to use to resolve the permissions team id
*/
'team_resolver' => \Spatie\Permission\DefaultTeamResolver::class,
/*
* Passport Client Credentials Grant
* When set to true the package will use Passports Client to check permissions
*/
'use_passport_client_credentials' => false,
/*
* When set to true, the required permission names are added to exception messages.
* This could be considered an information leak in some contexts, so the default
* setting is false here for optimum safety.
*/
'display_permission_in_exception' => false,
/*
* When set to true, the required role names are added to exception messages.
* This could be considered an information leak in some contexts, so the default
* setting is false here for optimum safety.
*/
'display_role_in_exception' => false,
/*
* By default wildcard permission lookups are disabled.
* See documentation to understand supported syntax.
*/
'enable_wildcard_permission' => false,
/*
* The class to use for interpreting wildcard permissions.
* If you need to modify delimiters, override the class and specify its name here.
*/
// 'wildcard_permission' => Spatie\Permission\WildcardPermission::class,
/* Cache-specific settings */
'cache' => [
/*
* By default all permissions are cached for 24 hours to speed up performance.
* When permissions or roles are updated the cache is flushed automatically.
*/
'expiration_time' => \DateInterval::createFromDateString('24 hours'),
/*
* The cache key used to store all permissions.
*/
'key' => 'spatie.permission.cache',
/*
* You may optionally indicate a specific cache driver to use for permission and
* role caching using any of the `store` drivers listed in the cache.php config
* file. Using 'default' here means to use the `default` set in cache.php.
*/
'store' => 'default',
],
];

View File

@ -0,0 +1,137 @@
<?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
{
$teams = config('permission.teams');
$tableNames = config('permission.table_names');
$columnNames = config('permission.column_names');
$pivotRole = $columnNames['role_pivot_key'] ?? 'role_id';
$pivotPermission = $columnNames['permission_pivot_key'] ?? 'permission_id';
throw_if(empty($tableNames), 'Error: config/permission.php not loaded. Run [php artisan config:clear] and try again.');
throw_if($teams && empty($columnNames['team_foreign_key'] ?? null), 'Error: team_foreign_key on config/permission.php not loaded. Run [php artisan config:clear] and try again.');
/**
* See `docs/prerequisites.md` for suggested lengths on 'name' and 'guard_name' if "1071 Specified key was too long" errors are encountered.
*/
Schema::create($tableNames['permissions'], static function (Blueprint $table) {
$table->id(); // permission id
$table->string('name');
$table->string('guard_name');
$table->timestamps();
$table->unique(['name', 'guard_name']);
});
/**
* See `docs/prerequisites.md` for suggested lengths on 'name' and 'guard_name' if "1071 Specified key was too long" errors are encountered.
*/
Schema::create($tableNames['roles'], static function (Blueprint $table) use ($teams, $columnNames) {
$table->id(); // role id
if ($teams || config('permission.testing')) { // permission.testing is a fix for sqlite testing
$table->unsignedBigInteger($columnNames['team_foreign_key'])->nullable();
$table->index($columnNames['team_foreign_key'], 'roles_team_foreign_key_index');
}
$table->string('name');
$table->string('guard_name');
$table->timestamps();
if ($teams || config('permission.testing')) {
$table->unique([$columnNames['team_foreign_key'], 'name', 'guard_name']);
} else {
$table->unique(['name', 'guard_name']);
}
});
Schema::create($tableNames['model_has_permissions'], static function (Blueprint $table) use ($tableNames, $columnNames, $pivotPermission, $teams) {
$table->unsignedBigInteger($pivotPermission);
$table->string('model_type');
$table->unsignedBigInteger($columnNames['model_morph_key']);
$table->index([$columnNames['model_morph_key'], 'model_type'], 'model_has_permissions_model_id_model_type_index');
$table->foreign($pivotPermission)
->references('id') // permission id
->on($tableNames['permissions'])
->cascadeOnDelete();
if ($teams) {
$table->unsignedBigInteger($columnNames['team_foreign_key']);
$table->index($columnNames['team_foreign_key'], 'model_has_permissions_team_foreign_key_index');
$table->primary([$columnNames['team_foreign_key'], $pivotPermission, $columnNames['model_morph_key'], 'model_type'],
'model_has_permissions_permission_model_type_primary');
} else {
$table->primary([$pivotPermission, $columnNames['model_morph_key'], 'model_type'],
'model_has_permissions_permission_model_type_primary');
}
});
Schema::create($tableNames['model_has_roles'], static function (Blueprint $table) use ($tableNames, $columnNames, $pivotRole, $teams) {
$table->unsignedBigInteger($pivotRole);
$table->string('model_type');
$table->unsignedBigInteger($columnNames['model_morph_key']);
$table->index([$columnNames['model_morph_key'], 'model_type'], 'model_has_roles_model_id_model_type_index');
$table->foreign($pivotRole)
->references('id') // role id
->on($tableNames['roles'])
->cascadeOnDelete();
if ($teams) {
$table->unsignedBigInteger($columnNames['team_foreign_key']);
$table->index($columnNames['team_foreign_key'], 'model_has_roles_team_foreign_key_index');
$table->primary([$columnNames['team_foreign_key'], $pivotRole, $columnNames['model_morph_key'], 'model_type'],
'model_has_roles_role_model_type_primary');
} else {
$table->primary([$pivotRole, $columnNames['model_morph_key'], 'model_type'],
'model_has_roles_role_model_type_primary');
}
});
Schema::create($tableNames['role_has_permissions'], static function (Blueprint $table) use ($tableNames, $pivotRole, $pivotPermission) {
$table->unsignedBigInteger($pivotPermission);
$table->unsignedBigInteger($pivotRole);
$table->foreign($pivotPermission)
->references('id') // permission id
->on($tableNames['permissions'])
->cascadeOnDelete();
$table->foreign($pivotRole)
->references('id') // role id
->on($tableNames['roles'])
->cascadeOnDelete();
$table->primary([$pivotPermission, $pivotRole], 'role_has_permissions_permission_id_role_id_primary');
});
app('cache')
->store(config('permission.cache.store') != 'default' ? config('permission.cache.store') : null)
->forget(config('permission.cache.key'));
}
/**
* Reverse the migrations.
*/
public function down(): void
{
$tableNames = config('permission.table_names');
throw_if(empty($tableNames), 'Error: config/permission.php not found and defaults could not be merged. Please publish the package configuration before proceeding, or drop the tables manually.');
Schema::dropIfExists($tableNames['role_has_permissions']);
Schema::dropIfExists($tableNames['model_has_roles']);
Schema::dropIfExists($tableNames['model_has_permissions']);
Schema::dropIfExists($tableNames['roles']);
Schema::dropIfExists($tableNames['permissions']);
}
};

View File

@ -1,25 +1,35 @@
<?php
namespace Database\Seeders;
use App\Models\User;
use Illuminate\Database\Console\Seeds\WithoutModelEvents;
use Illuminate\Database\Seeder;
use Illuminate\Support\Facades\Hash;
class DatabaseSeeder extends Seeder
{
use WithoutModelEvents;
/**
* Seed the application's database.
*/
public function run(): void
{
// User::factory(10)->create();
$admin = \App\Models\User::factory()->create([
'name' => 'Admin User',
'email' => 'admin@openclassify.com',
'password' => Hash::make('password'),
]);
User::factory()->create([
'name' => 'Test User',
'email' => 'test@example.com',
$partner = \App\Models\User::factory()->create([
'name' => 'Partner User',
'email' => 'partner@openclassify.com',
'password' => Hash::make('password'),
]);
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);
}
$this->call([
\Modules\Location\Database\Seeders\LocationSeeder::class,
\Modules\Category\Database\Seeders\CategorySeeder::class,
\Modules\Listing\Database\Seeders\ListingSeeder::class,
]);
}
}

64
docker-compose.dev.yml Normal file
View File

@ -0,0 +1,64 @@
services:
app:
build:
context: .
dockerfile: Dockerfile.dev
ports:
- "8000:80"
- "5173:5173"
environment:
APP_ENV: local
APP_DEBUG: "true"
DB_CONNECTION: mysql
DB_HOST: db
DB_PORT: 3306
DB_DATABASE: openclassify
DB_USERNAME: openclassify
DB_PASSWORD: secret
REDIS_HOST: redis
CACHE_STORE: redis
SESSION_DRIVER: redis
QUEUE_CONNECTION: redis
volumes:
- .:/var/www/html
- /var/www/html/vendor
- /var/www/html/node_modules
depends_on:
db:
condition: service_healthy
redis:
condition: service_started
db:
image: mysql:8.0
environment:
MYSQL_DATABASE: openclassify
MYSQL_USER: openclassify
MYSQL_PASSWORD: secret
MYSQL_ROOT_PASSWORD: rootsecret
ports:
- "3306:3306"
volumes:
- db_data_dev:/var/lib/mysql
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
interval: 10s
timeout: 5s
retries: 5
redis:
image: redis:7-alpine
ports:
- "6379:6379"
volumes:
- redis_data_dev:/data
mailpit:
image: axllent/mailpit:latest
ports:
- "8025:8025"
- "1025:1025"
volumes:
db_data_dev:
redis_data_dev:

52
docker-compose.yml Normal file
View File

@ -0,0 +1,52 @@
services:
app:
build:
context: .
dockerfile: Dockerfile
ports:
- "8000:80"
environment:
APP_ENV: production
APP_DEBUG: "false"
DB_CONNECTION: mysql
DB_HOST: db
DB_PORT: 3306
DB_DATABASE: openclassify
DB_USERNAME: openclassify
DB_PASSWORD: secret
REDIS_HOST: redis
CACHE_STORE: redis
SESSION_DRIVER: redis
QUEUE_CONNECTION: redis
volumes:
- storage_data:/var/www/html/storage/app
depends_on:
db:
condition: service_healthy
redis:
condition: service_started
db:
image: mysql:8.0
environment:
MYSQL_DATABASE: openclassify
MYSQL_USER: openclassify
MYSQL_PASSWORD: secret
MYSQL_ROOT_PASSWORD: rootsecret
volumes:
- db_data:/var/lib/mysql
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
interval: 10s
timeout: 5s
retries: 5
redis:
image: redis:7-alpine
volumes:
- redis_data:/data
volumes:
db_data:
redis_data:
storage_data:

37
docker/nginx.conf Normal file
View File

@ -0,0 +1,37 @@
user www-data;
worker_processes auto;
pid /run/nginx.pid;
events {
worker_connections 1024;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
sendfile on;
keepalive_timeout 65;
client_max_body_size 20M;
server {
listen 80;
server_name _;
root /var/www/html/public;
index index.php;
location / {
try_files $uri $uri/ /index.php?$query_string;
}
location ~ \.php$ {
fastcgi_pass 127.0.0.1:9000;
fastcgi_index index.php;
fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
include fastcgi_params;
}
location ~ /\.(?!well-known).* {
deny all;
}
}
}

20
docker/start-dev.sh Normal file
View File

@ -0,0 +1,20 @@
#!/bin/sh
set -e
if [ ! -f /var/www/html/.env ]; then
cp /var/www/html/.env.example /var/www/html/.env
fi
cd /var/www/html
composer install
npm install
php artisan key:generate --force
php artisan migrate --force
php artisan db:seed --force
php artisan storage:link
php-fpm -D
npm run dev &
nginx -g 'daemon off;'

17
docker/start.sh Normal file
View File

@ -0,0 +1,17 @@
#!/bin/sh
set -e
if [ ! -f /var/www/html/.env ]; then
cp /var/www/html/.env.example /var/www/html/.env
php artisan key:generate --force
fi
php artisan migrate --force
php artisan db:seed --force
php artisan config:cache
php artisan route:cache
php artisan view:cache
php artisan storage:link
php-fpm -D
nginx -g 'daemon off;'

View File

@ -2,5 +2,7 @@
"Category": true,
"Listing": true,
"Location": true,
"Profile": true
"Profile": true,
"Admin": true,
"Partner": true
}

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1 @@
@font-face{font-family:Inter Variable;font-style:normal;font-display:swap;font-weight:100 900;src:url("./inter-cyrillic-ext-wght-normal-IYF56FF6.woff2") format("woff2-variations");unicode-range:U+0460-052F,U+1C80-1C8A,U+20B4,U+2DE0-2DFF,U+A640-A69F,U+FE2E-FE2F}@font-face{font-family:Inter Variable;font-style:normal;font-display:swap;font-weight:100 900;src:url("./inter-cyrillic-wght-normal-JEOLYBOO.woff2") format("woff2-variations");unicode-range:U+0301,U+0400-045F,U+0490-0491,U+04B0-04B1,U+2116}@font-face{font-family:Inter Variable;font-style:normal;font-display:swap;font-weight:100 900;src:url("./inter-greek-ext-wght-normal-EOVOK2B5.woff2") format("woff2-variations");unicode-range:U+1F00-1FFF}@font-face{font-family:Inter Variable;font-style:normal;font-display:swap;font-weight:100 900;src:url("./inter-greek-wght-normal-IRE366VL.woff2") format("woff2-variations");unicode-range:U+0370-0377,U+037A-037F,U+0384-038A,U+038C,U+038E-03A1,U+03A3-03FF}@font-face{font-family:Inter Variable;font-style:normal;font-display:swap;font-weight:100 900;src:url("./inter-vietnamese-wght-normal-CE5GGD3W.woff2") format("woff2-variations");unicode-range:U+0102-0103,U+0110-0111,U+0128-0129,U+0168-0169,U+01A0-01A1,U+01AF-01B0,U+0300-0301,U+0303-0304,U+0308-0309,U+0323,U+0329,U+1EA0-1EF9,U+20AB}@font-face{font-family:Inter Variable;font-style:normal;font-display:swap;font-weight:100 900;src:url("./inter-latin-ext-wght-normal-HA22NDSG.woff2") format("woff2-variations");unicode-range:U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF}@font-face{font-family:Inter Variable;font-style:normal;font-display:swap;font-weight:100 900;src:url("./inter-latin-wght-normal-NRMW37G5.woff2") format("woff2-variations");unicode-range:U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD}

View File

@ -0,0 +1 @@
(()=>{var n=({livewireId:e})=>({actionNestingIndex:null,init(){window.addEventListener("sync-action-modals",t=>{t.detail.id===e&&this.syncActionModals(t.detail.newActionNestingIndex,t.detail.shouldOverlayParentActions??!1)})},syncActionModals(t,i=!1){if(this.actionNestingIndex===t){this.actionNestingIndex!==null&&this.$nextTick(()=>this.openModal());return}let s=this.actionNestingIndex!==null&&t!==null&&t>this.actionNestingIndex;if(this.actionNestingIndex!==null&&!(i&&s)&&this.closeModal(),this.actionNestingIndex=t,this.actionNestingIndex!==null){if(!this.$el.querySelector(`#${this.generateModalId(t)}`)){this.$nextTick(()=>this.openModal());return}this.openModal()}},generateModalId(t){return`fi-${e}-action-`+t},openModal(){let t=this.generateModalId(this.actionNestingIndex);document.dispatchEvent(new CustomEvent("open-modal",{bubbles:!0,composed:!0,detail:{id:t}}))},closeModal(){let t=this.generateModalId(this.actionNestingIndex);document.dispatchEvent(new CustomEvent("close-modal-quietly",{bubbles:!0,composed:!0,detail:{id:t}}))}});document.addEventListener("alpine:init",()=>{window.Alpine.data("filamentActionModals",n)});})();

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1 @@
function c({livewireId:s}){return{areAllCheckboxesChecked:!1,checkboxListOptions:[],search:"",unsubscribeLivewireHook:null,visibleCheckboxListOptions:[],init(){this.checkboxListOptions=Array.from(this.$root.querySelectorAll(".fi-fo-checkbox-list-option")),this.updateVisibleCheckboxListOptions(),this.$nextTick(()=>{this.checkIfAllCheckboxesAreChecked()}),this.unsubscribeLivewireHook=Livewire.interceptMessage(({message:e,onSuccess:t})=>{t(()=>{this.$nextTick(()=>{e.component.id===s&&(this.checkboxListOptions=Array.from(this.$root.querySelectorAll(".fi-fo-checkbox-list-option")),this.updateVisibleCheckboxListOptions(),this.checkIfAllCheckboxesAreChecked())})})}),this.$watch("search",()=>{this.updateVisibleCheckboxListOptions(),this.checkIfAllCheckboxesAreChecked()})},checkIfAllCheckboxesAreChecked(){this.areAllCheckboxesChecked=this.visibleCheckboxListOptions.length===this.visibleCheckboxListOptions.filter(e=>e.querySelector("input[type=checkbox]:checked, input[type=checkbox]:disabled")).length},toggleAllCheckboxes(){this.checkIfAllCheckboxesAreChecked();let e=!this.areAllCheckboxesChecked;this.visibleCheckboxListOptions.forEach(t=>{let i=t.querySelector("input[type=checkbox]");i.disabled||i.checked!==e&&(i.checked=e,i.dispatchEvent(new Event("change")))}),this.areAllCheckboxesChecked=e},updateVisibleCheckboxListOptions(){this.visibleCheckboxListOptions=this.checkboxListOptions.filter(e=>["",null,void 0].includes(this.search)||e.querySelector(".fi-fo-checkbox-list-option-label")?.innerText.toLowerCase().includes(this.search.toLowerCase())?!0:e.querySelector(".fi-fo-checkbox-list-option-description")?.innerText.toLowerCase().includes(this.search.toLowerCase()))},destroy(){this.unsubscribeLivewireHook?.()}}}export{c as default};

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1 @@
function a({state:r}){return{state:r,rows:[],init(){this.updateRows(),this.rows.length<=0?this.rows.push({key:"",value:""}):this.updateState(),this.$watch("state",(e,t)=>{if(!Array.isArray(e))return;let s=i=>i===null?0:Array.isArray(i)?i.length:typeof i!="object"?0:Object.keys(i).length;s(e)===0&&s(t)===0||this.updateRows()})},addRow(){this.rows.push({key:"",value:""}),this.updateState()},deleteRow(e){this.rows.splice(e,1),this.rows.length<=0&&this.addRow(),this.updateState()},reorderRows(e){let t=Alpine.raw(this.rows);this.rows=[];let s=t.splice(e.oldIndex,1)[0];t.splice(e.newIndex,0,s),this.$nextTick(()=>{this.rows=t,this.updateState()})},updateRows(){let t=Alpine.raw(this.state).map(({key:s,value:i})=>({key:s,value:i}));this.rows.forEach(s=>{(s.key===""||s.key===null)&&t.push({key:"",value:s.value})}),this.rows=t},updateState(){let e=[];this.rows.forEach(t=>{t.key===""||t.key===null||e.push({key:t.key,value:t.value})}),JSON.stringify(this.state)!==JSON.stringify(e)&&(this.state=e)}}}export{a as default};

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1 @@
function s({state:n,splitKeys:a}){return{newTag:"",state:n,createTag(){if(this.newTag=this.newTag.trim(),this.newTag!==""){if(this.state.includes(this.newTag)){this.newTag="";return}this.state.push(this.newTag),this.newTag=""}},deleteTag(t){this.state=this.state.filter(e=>e!==t)},reorderTags(t){let e=this.state.splice(t.oldIndex,1)[0];this.state.splice(t.newIndex,0,e),this.state=[...this.state]},input:{"x-on:blur":"createTag()","x-model":"newTag","x-on:keydown"(t){["Enter",...a].includes(t.key)&&(t.preventDefault(),t.stopPropagation(),this.createTag())},"x-on:paste"(){this.$nextTick(()=>{if(a.length===0){this.createTag();return}let t=a.map(e=>e.replace(/[/\-\\^$*+?.()|[\]{}]/g,"\\$&")).join("|");this.newTag.split(new RegExp(t,"g")).forEach(e=>{this.newTag=e,this.createTag()})})}}}}export{s as default};

View File

@ -0,0 +1 @@
function n({initialHeight:e,shouldAutosize:i,state:h}){return{state:h,wrapperEl:null,init(){this.wrapperEl=this.$el.parentNode,this.setInitialHeight(),i?this.$watch("state",()=>{this.resize()}):this.setUpResizeObserver()},setInitialHeight(){this.$el.scrollHeight<=0||(this.wrapperEl.style.height=e+"rem")},resize(){if(this.$el.scrollHeight<=0)return;let t=this.$el.style.height;this.$el.style.height="0px";let r=this.$el.scrollHeight;this.$el.style.height=t;let l=parseFloat(e)*parseFloat(getComputedStyle(document.documentElement).fontSize),s=Math.max(r,l)+"px";this.wrapperEl.style.height!==s&&(this.wrapperEl.style.height=s)},setUpResizeObserver(){new ResizeObserver(()=>{this.wrapperEl.style.height=this.$el.style.height}).observe(this.$el)}}}export{n as default};

View File

@ -0,0 +1 @@
(()=>{function c(s,t=()=>{}){let i=!1;return function(){i?t.apply(this,arguments):(i=!0,s.apply(this,arguments))}}var d=s=>{s.data("notificationComponent",({notification:t})=>({isShown:!1,computedStyle:null,transitionDuration:null,transitionEasing:null,unsubscribeLivewireHook:null,init(){this.computedStyle=window.getComputedStyle(this.$el),this.transitionDuration=parseFloat(this.computedStyle.transitionDuration)*1e3,this.transitionEasing=this.computedStyle.transitionTimingFunction,this.configureTransitions(),this.configureAnimations(),t.duration&&t.duration!=="persistent"&&setTimeout(()=>{if(!this.$el.matches(":hover")){this.close();return}this.$el.addEventListener("mouseleave",()=>this.close())},t.duration),this.isShown=!0},configureTransitions(){let i=this.computedStyle.display,e=()=>{s.mutateDom(()=>{this.$el.style.setProperty("display",i),this.$el.style.setProperty("visibility","visible")}),this.$el._x_isShown=!0},o=()=>{s.mutateDom(()=>{this.$el._x_isShown?this.$el.style.setProperty("visibility","hidden"):this.$el.style.setProperty("display","none")})},r=c(n=>n?e():o(),n=>{this.$el._x_toggleAndCascadeWithTransitions(this.$el,n,e,o)});s.effect(()=>r(this.isShown))},configureAnimations(){let i;this.unsubscribeLivewireHook=Livewire.interceptMessage(({onFinish:e,onSuccess:o})=>{requestAnimationFrame(()=>{let r=()=>this.$el.getBoundingClientRect().top,n=r();e(()=>{i=()=>{this.isShown&&this.$el.animate([{transform:`translateY(${n-r()}px)`},{transform:"translateY(0px)"}],{duration:this.transitionDuration,easing:this.transitionEasing})},this.$el.getAnimations().forEach(l=>l.finish())}),o(({payload:l})=>{l?.snapshot?.data?.isFilamentNotificationsComponent&&typeof i=="function"&&i()})})})},close(){this.isShown=!1,setTimeout(()=>window.dispatchEvent(new CustomEvent("notificationClosed",{detail:{id:t.id}})),this.transitionDuration)},markAsRead(){window.dispatchEvent(new CustomEvent("markedNotificationAsRead",{detail:{id:t.id}}))},markAsUnread(){window.dispatchEvent(new CustomEvent("markedNotificationAsUnread",{detail:{id:t.id}}))},destroy(){this.unsubscribeLivewireHook?.()}}))};var h=class{constructor(){return this.id(crypto.randomUUID()),this}id(t){return this.id=t,this}title(t){return this.title=t,this}body(t){return this.body=t,this}actions(t){return this.actions=t,this}status(t){return this.status=t,this}color(t){return this.color=t,this}icon(t){return this.icon=t,this}iconColor(t){return this.iconColor=t,this}duration(t){return this.duration=t,this}seconds(t){return this.duration(t*1e3),this}persistent(){return this.duration("persistent"),this}danger(){return this.status("danger"),this}info(){return this.status("info"),this}success(){return this.status("success"),this}warning(){return this.status("warning"),this}view(t){return this.view=t,this}viewData(t){return this.viewData=t,this}send(){return window.dispatchEvent(new CustomEvent("notificationSent",{detail:{notification:this}})),this}},a=class{constructor(t){return this.name(t),this}name(t){return this.name=t,this}color(t){return this.color=t,this}dispatch(t,i){return this.event(t),this.eventData(i),this}dispatchSelf(t,i){return this.dispatch(t,i),this.dispatchDirection="self",this}dispatchTo(t,i,e){return this.dispatch(i,e),this.dispatchDirection="to",this.dispatchToComponent=t,this}emit(t,i){return this.dispatch(t,i),this}emitSelf(t,i){return this.dispatchSelf(t,i),this}emitTo(t,i,e){return this.dispatchTo(t,i,e),this}dispatchDirection(t){return this.dispatchDirection=t,this}dispatchToComponent(t){return this.dispatchToComponent=t,this}event(t){return this.event=t,this}eventData(t){return this.eventData=t,this}extraAttributes(t){return this.extraAttributes=t,this}icon(t){return this.icon=t,this}iconPosition(t){return this.iconPosition=t,this}outlined(t=!0){return this.isOutlined=t,this}disabled(t=!0){return this.isDisabled=t,this}label(t){return this.label=t,this}close(t=!0){return this.shouldClose=t,this}openUrlInNewTab(t=!0){return this.shouldOpenUrlInNewTab=t,this}size(t){return this.size=t,this}url(t){return this.url=t,this}view(t){return this.view=t,this}button(){return this.view("filament::components.button.index"),this}grouped(){return this.view("filament::components.dropdown.list.item"),this}iconButton(){return this.view("filament::components.icon-button"),this}link(){return this.view("filament::components.link"),this}},u=class{constructor(t){return this.actions(t),this}actions(t){return this.actions=t.map(i=>i.grouped()),this}color(t){return this.color=t,this}icon(t){return this.icon=t,this}iconPosition(t){return this.iconPosition=t,this}label(t){return this.label=t,this}tooltip(t){return this.tooltip=t,this}};window.FilamentNotificationAction=a;window.FilamentNotificationActionGroup=u;window.FilamentNotification=h;document.addEventListener("alpine:init",()=>{window.Alpine.plugin(d)});})();

View File

@ -0,0 +1 @@
var i=()=>({isSticky:!1,width:0,resizeObserver:null,boundUpdateWidth:null,init(){let e=this.$el.parentElement;e&&(this.updateWidth(),this.resizeObserver=new ResizeObserver(()=>this.updateWidth()),this.resizeObserver.observe(e),this.boundUpdateWidth=this.updateWidth.bind(this),window.addEventListener("resize",this.boundUpdateWidth))},enableSticky(){this.isSticky=this.$el.getBoundingClientRect().top>0},disableSticky(){this.isSticky=!1},updateWidth(){let e=this.$el.parentElement;if(!e)return;let t=getComputedStyle(this.$root.querySelector(".fi-ac"));this.width=e.offsetWidth+parseInt(t.marginInlineStart,10)*-1+parseInt(t.marginInlineEnd,10)*-1},destroy(){this.resizeObserver&&(this.resizeObserver.disconnect(),this.resizeObserver=null),this.boundUpdateWidth&&(window.removeEventListener("resize",this.boundUpdateWidth),this.boundUpdateWidth=null)}});export{i as default};

View File

@ -0,0 +1 @@
function v({activeTab:w,isScrollable:f,isTabPersistedInQueryString:m,livewireId:g,tab:T,tabQueryStringKey:r}){return{boundResizeHandler:null,isScrollable:f,resizeDebounceTimer:null,tab:T,unsubscribeLivewireHook:null,withinDropdownIndex:null,withinDropdownMounted:!1,init(){let t=this.getTabs(),e=new URLSearchParams(window.location.search);m&&e.has(r)&&t.includes(e.get(r))&&(this.tab=e.get(r)),(!this.tab||!t.includes(this.tab))&&(this.tab=t[w-1]),this.$watch("tab",()=>{this.updateQueryString(),this.autofocusFields()}),this.autofocusFields(!0),this.unsubscribeLivewireHook=Livewire.interceptMessage(({message:i,onSuccess:a})=>{a(()=>{this.$nextTick(()=>{if(i.component.id!==g)return;let l=this.getTabs();l.includes(this.tab)||(this.tab=l[w-1]??this.tab)})})}),f||(this.boundResizeHandler=this.debouncedUpdateTabsWithinDropdown.bind(this),window.addEventListener("resize",this.boundResizeHandler),this.updateTabsWithinDropdown())},calculateAvailableWidth(t){let e=window.getComputedStyle(t);return Math.floor(t.clientWidth)-Math.ceil(parseFloat(e.paddingLeft))*2},calculateContainerGap(t){let e=window.getComputedStyle(t);return Math.ceil(parseFloat(e.columnGap))},calculateDropdownIconWidth(t){let e=t.querySelector(".fi-icon");return Math.ceil(e.clientWidth)},calculateTabItemGap(t){let e=window.getComputedStyle(t);return Math.ceil(parseFloat(e.columnGap)||8)},calculateTabItemPadding(t){let e=window.getComputedStyle(t);return Math.ceil(parseFloat(e.paddingLeft))+Math.ceil(parseFloat(e.paddingRight))},findOverflowIndex(t,e,i,a,l,h){let u=t.map(n=>Math.ceil(n.clientWidth)),b=t.map(n=>{let c=n.querySelector(".fi-tabs-item-label"),s=n.querySelector(".fi-badge"),o=Math.ceil(c.clientWidth),d=s?Math.ceil(s.clientWidth):0;return{label:o,badge:d,total:o+(d>0?a+d:0)}});for(let n=0;n<t.length;n++){let c=u.slice(0,n+1).reduce((p,y)=>p+y,0),s=n*i,o=b.slice(n+1),d=o.length>0,D=d?Math.max(...o.map(p=>p.total)):0,W=d?l+D+a+h+i:0;if(c+s+W>e)return n}return-1},get isDropdownButtonVisible(){return this.withinDropdownMounted?this.withinDropdownIndex===null?!1:this.getTabs().findIndex(e=>e===this.tab)<this.withinDropdownIndex:!0},getTabs(){return this.$refs.tabsData?JSON.parse(this.$refs.tabsData.value):[]},updateQueryString(){if(!m)return;let t=new URL(window.location.href);t.searchParams.set(r,this.tab),history.replaceState(null,document.title,t.toString())},autofocusFields(t=!1){this.$nextTick(()=>{if(t&&document.activeElement&&document.activeElement!==document.body&&this.$el.compareDocumentPosition(document.activeElement)&Node.DOCUMENT_POSITION_PRECEDING)return;let e=this.$el.querySelectorAll(".fi-sc-tabs-tab.fi-active [autofocus]");for(let i of e)if(i.focus(),document.activeElement===i)break})},debouncedUpdateTabsWithinDropdown(){clearTimeout(this.resizeDebounceTimer),this.resizeDebounceTimer=setTimeout(()=>this.updateTabsWithinDropdown(),150)},async updateTabsWithinDropdown(){this.withinDropdownIndex=null,this.withinDropdownMounted=!1,await this.$nextTick();let t=this.$el.querySelector(".fi-tabs"),e=t.querySelector(".fi-tabs-item:last-child"),i=Array.from(t.children).slice(0,-1),a=i.map(s=>s.style.display);i.forEach(s=>s.style.display=""),t.offsetHeight;let l=this.calculateAvailableWidth(t),h=this.calculateContainerGap(t),u=this.calculateDropdownIconWidth(e),b=this.calculateTabItemGap(i[0]),n=this.calculateTabItemPadding(i[0]),c=this.findOverflowIndex(i,l,h,b,n,u);i.forEach((s,o)=>s.style.display=a[o]),c!==-1&&(this.withinDropdownIndex=c),this.withinDropdownMounted=!0},destroy(){this.unsubscribeLivewireHook?.(),this.boundResizeHandler&&window.removeEventListener("resize",this.boundResizeHandler),clearTimeout(this.resizeDebounceTimer)}}}export{v as default};

View File

@ -0,0 +1 @@
function p({isSkippable:i,isStepPersistedInQueryString:n,key:r,startStep:o,stepQueryStringKey:h}){return{step:null,init(){this.step=this.getSteps().at(o-1),this.$watch("step",()=>{this.updateQueryString(),this.autofocusFields()}),this.autofocusFields(!0)},async requestNextStep(){await this.$wire.callSchemaComponentMethod(r,"nextStep",{currentStepIndex:this.getStepIndex(this.step)})},goToNextStep(){let t=this.getStepIndex(this.step)+1;t>=this.getSteps().length||(this.step=this.getSteps()[t],this.scroll())},goToPreviousStep(){let t=this.getStepIndex(this.step)-1;t<0||(this.step=this.getSteps()[t],this.scroll())},goToStep(t){let e=this.getStepIndex(t);e<=-1||!i&&e>this.getStepIndex(this.step)||(this.step=t,this.scroll())},scroll(){this.$nextTick(()=>{this.$refs.header?.children[this.getStepIndex(this.step)].scrollIntoView({behavior:"smooth",block:"start"})})},autofocusFields(t=!1){this.$nextTick(()=>{if(t&&document.activeElement&&document.activeElement!==document.body&&this.$el.compareDocumentPosition(document.activeElement)&Node.DOCUMENT_POSITION_PRECEDING)return;let e=this.$refs[`step-${this.step}`]?.querySelectorAll("[autofocus]")??[];for(let s of e)if(s.focus(),document.activeElement===s)break})},getStepIndex(t){let e=this.getSteps().findIndex(s=>s===t);return e===-1?0:e},getSteps(){return JSON.parse(this.$refs.stepsData.value)},isFirstStep(){return this.getStepIndex(this.step)<=0},isLastStep(){return this.getStepIndex(this.step)+1>=this.getSteps().length},isStepAccessible(t){return i||this.getStepIndex(this.step)>this.getStepIndex(t)},updateQueryString(){if(!n)return;let t=new URL(window.location.href);t.searchParams.set(h,this.step),history.replaceState(null,document.title,t.toString())}}}export{p as default};

View File

@ -0,0 +1 @@
(()=>{var o=()=>({isSticky:!1,width:0,resizeObserver:null,boundUpdateWidth:null,init(){let i=this.$el.parentElement;i&&(this.updateWidth(),this.resizeObserver=new ResizeObserver(()=>this.updateWidth()),this.resizeObserver.observe(i),this.boundUpdateWidth=this.updateWidth.bind(this),window.addEventListener("resize",this.boundUpdateWidth))},enableSticky(){this.isSticky=this.$el.getBoundingClientRect().top>0},disableSticky(){this.isSticky=!1},updateWidth(){let i=this.$el.parentElement;if(!i)return;let e=getComputedStyle(this.$root.querySelector(".fi-ac"));this.width=i.offsetWidth+parseInt(e.marginInlineStart,10)*-1+parseInt(e.marginInlineEnd,10)*-1},destroy(){this.resizeObserver&&(this.resizeObserver.disconnect(),this.resizeObserver=null),this.boundUpdateWidth&&(window.removeEventListener("resize",this.boundUpdateWidth),this.boundUpdateWidth=null)}});var a=function(i,e,n){let t=i;if(e.startsWith("/")&&(n=!0,e=e.slice(1)),n)return e;for(;e.startsWith("../");)t=t.includes(".")?t.slice(0,t.lastIndexOf(".")):null,e=e.slice(3);return["",null,void 0].includes(t)?e:["",null,void 0].includes(e)?t:`${t}.${e}`},d=i=>{let e=Alpine.findClosest(i,n=>n.__livewire);if(!e)throw"Could not find Livewire component in DOM tree.";return e.__livewire};document.addEventListener("alpine:init",()=>{window.Alpine.data("filamentSchema",({livewireId:i})=>({handleFormValidationError(e){e.detail.livewireId===i&&this.$nextTick(()=>{let n=this.$el.querySelector("[data-validation-error]");if(!n)return;let t=n;for(;t;)t.dispatchEvent(new CustomEvent("expand")),t=t.parentNode;setTimeout(()=>n.closest("[data-field-wrapper]").scrollIntoView({behavior:"smooth",block:"start",inline:"start"}),200)})},isStateChanged(e,n){if(e===void 0)return!1;try{return JSON.stringify(e)!==JSON.stringify(n)}catch{return e!==n}}})),window.Alpine.data("filamentSchemaComponent",({path:i,containerPath:e,$wire:n})=>({$statePath:i,$get:(t,r)=>n.$get(a(e,t,r)),$set:(t,r,s,l=!1)=>n.$set(a(e,t,s),r,l),get $state(){return n.$get(i)}})),window.Alpine.data("filamentActionsSchemaComponent",o),Livewire.interceptMessage(({message:i,onSuccess:e})=>{e(({payload:n})=>{n.effects?.dispatches?.forEach(t=>{if(!t.params?.awaitSchemaComponent)return;let r=Array.from(i.component.el.querySelectorAll(`[wire\\:partial="schema-component::${t.params.awaitSchemaComponent}"]`)).filter(s=>d(s)===i.component);if(r.length!==1){if(r.length>1)throw`Multiple schema components found with key [${t.params.awaitSchemaComponent}].`;window.addEventListener(`schema-component-${component.id}-${t.params.awaitSchemaComponent}-loaded`,()=>{window.dispatchEvent(new CustomEvent(t.name,{detail:t.params}))},{once:!0})}})})})});})();

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1 @@
function a({name:r,recordKey:s,state:n}){return{error:void 0,isLoading:!1,state:n,unsubscribeLivewireHook:null,init(){this.unsubscribeLivewireHook=Livewire.interceptMessage(({message:e,onSuccess:t})=>{t(()=>{this.$nextTick(()=>{if(this.isLoading||e.component.id!==this.$root.closest("[wire\\:id]")?.attributes["wire:id"].value)return;let i=this.getServerState();i===void 0||Alpine.raw(this.state)===i||(this.state=i)})})}),this.$watch("state",async()=>{let e=this.getServerState();if(e===void 0||Alpine.raw(this.state)===e)return;this.isLoading=!0;let t=await this.$wire.updateTableColumnState(r,s,this.state);this.error=t?.error??void 0,!this.error&&this.$refs.serverState&&(this.$refs.serverState.value=this.state?"1":"0"),this.isLoading=!1})},getServerState(){if(this.$refs.serverState)return[1,"1"].includes(this.$refs.serverState.value)},destroy(){this.unsubscribeLivewireHook?.()}}}export{a as default};

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1 @@
function a({name:i,recordKey:s,state:n}){return{error:void 0,isLoading:!1,state:n,unsubscribeLivewireHook:null,init(){this.unsubscribeLivewireHook=Livewire.interceptMessage(({message:e,onSuccess:t})=>{t(()=>{this.$nextTick(()=>{if(this.isLoading||e.component.id!==this.$root.closest("[wire\\:id]")?.attributes["wire:id"].value)return;let r=this.getServerState();r===void 0||this.getNormalizedState()===r||(this.state=r)})})}),this.$watch("state",async()=>{let e=this.getServerState();if(e===void 0||this.getNormalizedState()===e)return;this.isLoading=!0;let t=await this.$wire.updateTableColumnState(i,s,this.state);this.error=t?.error??void 0,!this.error&&this.$refs.serverState&&(this.$refs.serverState.value=this.getNormalizedState()),this.isLoading=!1})},getServerState(){if(this.$refs.serverState)return[null,void 0].includes(this.$refs.serverState.value)?"":this.$refs.serverState.value.replaceAll('\\"','"')},getNormalizedState(){let e=Alpine.raw(this.state);return[null,void 0].includes(e)?"":e},destroy(){this.unsubscribeLivewireHook?.()}}}export{a as default};

View File

@ -0,0 +1 @@
function a({name:r,recordKey:s,state:n}){return{error:void 0,isLoading:!1,state:n,unsubscribeLivewireHook:null,init(){this.unsubscribeLivewireHook=Livewire.interceptMessage(({message:e,onSuccess:t})=>{t(()=>{this.$nextTick(()=>{if(this.isLoading||e.component.id!==this.$root.closest("[wire\\:id]")?.attributes["wire:id"].value)return;let i=this.getServerState();i===void 0||Alpine.raw(this.state)===i||(this.state=i)})})}),this.$watch("state",async()=>{let e=this.getServerState();if(e===void 0||Alpine.raw(this.state)===e)return;this.isLoading=!0;let t=await this.$wire.updateTableColumnState(r,s,this.state);this.error=t?.error??void 0,!this.error&&this.$refs.serverState&&(this.$refs.serverState.value=this.state?"1":"0"),this.isLoading=!1})},getServerState(){if(this.$refs.serverState)return[1,"1"].includes(this.$refs.serverState.value)},destroy(){this.unsubscribeLivewireHook?.()}}}export{a as default};

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -6,5 +6,9 @@ use Illuminate\Foundation\Testing\TestCase as BaseTestCase;
abstract class TestCase extends BaseTestCase
{
//
protected function setUp(): void
{
parent::setUp();
$this->withoutVite();
}
}