diff --git a/.env.example b/.env.example index 4cda70dbc51..5ee1c5ab2a2 100644 --- a/.env.example +++ b/.env.example @@ -3,9 +3,9 @@ APP_ENV=local APP_KEY= APP_DEBUG=true APP_URL=http://localhost:8000 -OC_THEME=otoplus -OC_THEME_LISTING=otoplus -OC_THEME_CATEGORY=otoplus +OC_THEME=default +OC_THEME_LISTING=default +OC_THEME_CATEGORY=default APP_LOCALE=en APP_FALLBACK_LOCALE=en @@ -20,12 +20,12 @@ LOG_STACK=single LOG_DEPRECATIONS_CHANNEL=null LOG_LEVEL=debug -DB_CONNECTION=sqlite -# DB_HOST=127.0.0.1 -# DB_PORT=3306 -# DB_DATABASE=openclassify -# DB_USERNAME=openclassify -# DB_PASSWORD=secret +DB_CONNECTION=pgsql +DB_HOST=127.0.0.1 +DB_PORT=5432 +DB_DATABASE=openclassify +DB_USERNAME=openclassify +DB_PASSWORD=secret SESSION_DRIVER=database SESSION_LIFETIME=120 diff --git a/AGENTS.md b/AGENTS.md index 626e2f1e265..12b011d0b09 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,422 +1,11 @@ -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. - -=== - - -=== foundation rules === - -# Laravel Boost Guidelines - -The Laravel Boost guidelines are specifically curated by Laravel maintainers for this application. These guidelines should be followed closely to ensure the best experience when building Laravel applications. - -## Foundational Context - -This application is a Laravel application and its main Laravel ecosystems package & versions are below. You are an expert with them all. Ensure you abide by these specific packages & versions. - -- php - 8.4.18 -- filament/filament (FILAMENT) - v5 -- laravel/ai (AI) - v0 -- laravel/framework (LARAVEL) - v12 -- laravel/prompts (PROMPTS) - v0 -- laravel/reverb (REVERB) - v1 -- laravel/sanctum (SANCTUM) - v4 -- laravel/socialite (SOCIALITE) - v5 -- livewire/livewire (LIVEWIRE) - v4 -- laravel/boost (BOOST) - v2 -- laravel/mcp (MCP) - v0 -- laravel/pail (PAIL) - v1 -- laravel/pint (PINT) - v1 -- laravel/sail (SAIL) - v1 -- phpunit/phpunit (PHPUNIT) - v11 -- alpinejs (ALPINEJS) - v3 -- laravel-echo (ECHO) - v2 -- tailwindcss (TAILWINDCSS) - v3 - -## Skills Activation - -This project has domain-specific skills available. You MUST activate the relevant skill whenever you work in that domain—don't wait until you're stuck. - -- `tailwindcss-development` — Styles applications using Tailwind CSS v3 utilities. Activates when adding styles, restyling components, working with gradients, spacing, layout, flex, grid, responsive design, dark mode, colors, typography, or borders; or when the user mentions CSS, styling, classes, Tailwind, restyle, hero section, cards, buttons, or any visual/UI changes. -- `developing-with-ai-sdk` — Builds AI agents, generates text and chat responses, produces images, synthesizes audio, transcribes speech, generates vector embeddings, reranks documents, and manages files and vector stores using the Laravel AI SDK (laravel/ai). Supports structured output, streaming, tools, conversation memory, middleware, queueing, broadcasting, and provider failover. Use when building, editing, updating, debugging, or testing any AI functionality, including agents, LLMs, chatbots, text generation, image generation, audio, transcription, embeddings, RAG, similarity search, vector stores, prompting, structured output, or any AI provider (OpenAI, Anthropic, Gemini, Cohere, Groq, xAI, ElevenLabs, Jina, OpenRouter). - -## Conventions - -- You must follow all existing code conventions used in this application. When creating or editing a file, check sibling files for the correct structure, approach, and naming. -- Use descriptive names for variables and methods. For example, `isRegisteredForDiscounts`, not `discount()`. -- Check for existing components to reuse before writing a new one. - -## Verification Scripts - -- Do not create verification scripts or tinker when tests cover that functionality and prove they work. Unit and feature tests are more important. - -## Application Structure & Architecture - -- Stick to existing directory structure; don't create new base folders without approval. -- Do not change the application's dependencies without approval. - -## Frontend Bundling - -- If the user doesn't see a frontend change reflected in the UI, it could mean they need to run `npm run build`, `npm run dev`, or `composer run dev`. Ask them. - -## Documentation Files - -- You must only create documentation files if explicitly requested by the user. - -## Replies - -- Be concise in your explanations - focus on what's important rather than explaining obvious details. - -=== boost rules === - -# Laravel Boost - -- Laravel Boost is an MCP server that comes with powerful tools designed specifically for this application. Use them. - -## Artisan - -- Use the `list-artisan-commands` tool when you need to call an Artisan command to double-check the available parameters. - -## URLs - -- Whenever you share a project URL with the user, you should use the `get-absolute-url` tool to ensure you're using the correct scheme, domain/IP, and port. - -## Tinker / Debugging - -- You should use the `tinker` tool when you need to execute PHP to debug code or query Eloquent models directly. -- Use the `database-query` tool when you only need to read from the database. -- Use the `database-schema` tool to inspect table structure before writing migrations or models. - -## Reading Browser Logs With the `browser-logs` Tool - -- You can read browser logs, errors, and exceptions using the `browser-logs` tool from Boost. -- Only recent browser logs will be useful - ignore old logs. - -## Searching Documentation (Critically Important) - -- Boost comes with a powerful `search-docs` tool you should use before trying other approaches when working with Laravel or Laravel ecosystem packages. This tool automatically passes a list of installed packages and their versions to the remote Boost API, so it returns only version-specific documentation for the user's circumstance. You should pass an array of packages to filter on if you know you need docs for particular packages. -- Search the documentation before making code changes to ensure we are taking the correct approach. -- Use multiple, broad, simple, topic-based queries at once. For example: `['rate limiting', 'routing rate limiting', 'routing']`. The most relevant results will be returned first. -- Do not add package names to queries; package information is already shared. For example, use `test resource table`, not `filament 4 test resource table`. - -### Available Search Syntax - -1. Simple Word Searches with auto-stemming - query=authentication - finds 'authenticate' and 'auth'. -2. Multiple Words (AND Logic) - query=rate limit - finds knowledge containing both "rate" AND "limit". -3. Quoted Phrases (Exact Position) - query="infinite scroll" - words must be adjacent and in that order. -4. Mixed Queries - query=middleware "rate limit" - "middleware" AND exact phrase "rate limit". -5. Multiple Queries - queries=["authentication", "middleware"] - ANY of these terms. - -=== php rules === - -# PHP - -- Always use curly braces for control structures, even for single-line bodies. - -## Constructors - -- Use PHP 8 constructor property promotion in `__construct()`. - - `public function __construct(public GitHub $github) { }` -- Do not allow empty `__construct()` methods with zero parameters unless the constructor is private. - -## Type Declarations - -- Always use explicit return type declarations for methods and functions. -- Use appropriate PHP type hints for method parameters. - - -```php -protected function isAccessible(User $user, ?string $path = null): bool -{ - ... -} -``` - -## Enums - -- Typically, keys in an Enum should be TitleCase. For example: `FavoritePerson`, `BestLake`, `Monthly`. - -## Comments - -- Prefer PHPDoc blocks over inline comments. Never use comments within the code itself unless the logic is exceptionally complex. - -## PHPDoc Blocks - -- Add useful array shape type definitions when appropriate. - -=== laravel/core rules === - -# Do Things the Laravel Way - -- Use `php artisan make:` commands to create new files (i.e. migrations, controllers, models, etc.). You can list available Artisan commands using the `list-artisan-commands` tool. -- If you're creating a generic PHP class, use `php artisan make:class`. -- Pass `--no-interaction` to all Artisan commands to ensure they work without user input. You should also pass the correct `--options` to ensure correct behavior. - -## Database - -- Always use proper Eloquent relationship methods with return type hints. Prefer relationship methods over raw queries or manual joins. -- Use Eloquent models and relationships before suggesting raw database queries. -- Avoid `DB::`; prefer `Model::query()`. Generate code that leverages Laravel's ORM capabilities rather than bypassing them. -- Generate code that prevents N+1 query problems by using eager loading. -- Use Laravel's query builder for very complex database operations. - -### Model Creation - -- When creating new models, create useful factories and seeders for them too. Ask the user if they need any other things, using `list-artisan-commands` to check the available options to `php artisan make:model`. - -### APIs & Eloquent Resources - -- For APIs, default to using Eloquent API Resources and API versioning unless existing API routes do not, then you should follow existing application convention. - -## Controllers & Validation - -- Always create Form Request classes for validation rather than inline validation in controllers. Include both validation rules and custom error messages. -- Check sibling Form Requests to see if the application uses array or string based validation rules. - -## Authentication & Authorization - -- Use Laravel's built-in authentication and authorization features (gates, policies, Sanctum, etc.). - -## URL Generation - -- When generating links to other pages, prefer named routes and the `route()` function. - -## Queues - -- Use queued jobs for time-consuming operations with the `ShouldQueue` interface. - -## Configuration - -- Use environment variables only in configuration files - never use the `env()` function directly outside of config files. Always use `config('app.name')`, not `env('APP_NAME')`. - -## Testing - -- When creating models for tests, use the factories for the models. Check if the factory has custom states that can be used before manually setting up the model. -- Faker: Use methods such as `$this->faker->word()` or `fake()->randomDigit()`. Follow existing conventions whether to use `$this->faker` or `fake()`. -- When creating tests, make use of `php artisan make:test [options] {name}` to create a feature test, and pass `--unit` to create a unit test. Most tests should be feature tests. - -## Vite Error - -- If you receive an "Illuminate\Foundation\ViteException: Unable to locate file in Vite manifest" error, you can run `npm run build` or ask the user to run `npm run dev` or `composer run dev`. - -=== laravel/v12 rules === - -# Laravel 12 - -- CRITICAL: ALWAYS use `search-docs` tool for version-specific Laravel documentation and updated code examples. -- Since Laravel 11, Laravel has a new streamlined file structure which this project uses. - -## Laravel 12 Structure - -- In Laravel 12, middleware are no longer registered in `app/Http/Kernel.php`. -- Middleware are configured declaratively in `bootstrap/app.php` using `Application::configure()->withMiddleware()`. -- `bootstrap/app.php` is the file to register middleware, exceptions, and routing files. -- `bootstrap/providers.php` contains application specific service providers. -- The `app\Console\Kernel.php` file no longer exists; use `bootstrap/app.php` or `routes/console.php` for console configuration. -- Console commands in `app/Console/Commands/` are automatically available and do not require manual registration. - -## Database - -- When modifying a column, the migration must include all of the attributes that were previously defined on the column. Otherwise, they will be dropped and lost. -- Laravel 12 allows limiting eagerly loaded records natively, without external packages: `$query->latest()->limit(10);`. - -### Models - -- Casts can and likely should be set in a `casts()` method on a model rather than the `$casts` property. Follow existing conventions from other models. - -=== pint/core rules === - -# Laravel Pint Code Formatter - -- If you have modified any PHP files, you must run `vendor/bin/pint --dirty --format agent` before finalizing changes to ensure your code matches the project's expected style. -- Do not run `vendor/bin/pint --test --format agent`, simply run `vendor/bin/pint --format agent` to fix any formatting issues. - -=== phpunit/core rules === - -# PHPUnit - -- This application uses PHPUnit for testing. All tests must be written as PHPUnit classes. Use `php artisan make:test --phpunit {name}` to create a new test. -- If you see a test using "Pest", convert it to PHPUnit. -- Every time a test has been updated, run that singular test. -- When the tests relating to your feature are passing, ask the user if they would like to also run the entire test suite to make sure everything is still passing. -- Tests should cover all happy paths, failure paths, and edge cases. -- You must not remove any tests or test files from the tests directory without approval. These are not temporary or helper files; these are core to the application. - -## Running Tests - -- Run the minimal number of tests, using an appropriate filter, before finalizing. -- To run all tests: `php artisan test --compact`. -- To run all tests in a file: `php artisan test --compact tests/Feature/ExampleTest.php`. -- To filter on a particular test name: `php artisan test --compact --filter=testName` (recommended after making a change to a related file). - -=== tailwindcss/core rules === - -# Tailwind CSS - -- Always use existing Tailwind conventions; check project patterns before adding new ones. -- IMPORTANT: Always use `search-docs` tool for version-specific Tailwind CSS documentation and updated code examples. Never rely on training data. -- IMPORTANT: Activate `tailwindcss-development` every time you're working with a Tailwind CSS or styling-related task. - -=== filament/filament rules === - -## Filament - -- Filament is used by this application. Follow the existing conventions for how and where it is implemented. -- Filament is a Server-Driven UI (SDUI) framework for Laravel that lets you define user interfaces in PHP using structured configuration objects. Built on Livewire, Alpine.js, and Tailwind CSS. -- Use the `search-docs` tool for official documentation on Artisan commands, code examples, testing, relationships, and idiomatic practices. If `search-docs` is unavailable, refer to https://filamentphp.com/docs. - -### Artisan - -- Always use Filament-specific Artisan commands to create files. Find available commands with the `list-artisan-commands` tool, or run `php artisan --help`. -- Always inspect required options before running a command, and always pass `--no-interaction`. - -### Patterns - -Always use static `make()` methods to initialize components. Most configuration methods accept a `Closure` for dynamic values. - -Use `Get $get` to read other form field values for conditional logic: - - -use Filament\Forms\Components\Select; -use Filament\Forms\Components\TextInput; -use Filament\Schemas\Components\Utilities\Get; - -Select::make('type') - ->options(CompanyType::class) - ->required() - ->live(), - -TextInput::make('company_name') - ->required() - ->visible(fn (Get $get): bool => $get('type') === 'business'), - - - -Use `state()` with a `Closure` to compute derived column values: - - -use Filament\Tables\Columns\TextColumn; - -TextColumn::make('full_name') - ->state(fn (User $record): string => "{$record->first_name} {$record->last_name}"), - - - -Actions encapsulate a button with an optional modal form and logic: - - -use Filament\Actions\Action; -use Filament\Forms\Components\TextInput; - -Action::make('updateEmail') - ->schema([ - TextInput::make('email') - ->email() - ->required(), - ]) - ->action(fn (array $data, User $record) => $record->update($data)) - - - -### Testing - -Always authenticate before testing panel functionality. Filament uses Livewire, so use `Livewire::test()` or `livewire()` (available when `pestphp/pest-plugin-livewire` is in `composer.json`): - - -use function Pest\Livewire\livewire; - -livewire(ListUsers::class) - ->assertCanSeeTableRecords($users) - ->searchTable($users->first()->name) - ->assertCanSeeTableRecords($users->take(1)) - ->assertCanNotSeeTableRecords($users->skip(1)); - - - - -use function Pest\Laravel\assertDatabaseHas; -use function Pest\Livewire\livewire; - -livewire(CreateUser::class) - ->fillForm([ - 'name' => 'Test', - 'email' => 'test@example.com', - ]) - ->call('create') - ->assertNotified() - ->assertRedirect(); - -assertDatabaseHas(User::class, [ - 'name' => 'Test', - 'email' => 'test@example.com', -]); - - - - -use function Pest\Livewire\livewire; - -livewire(CreateUser::class) - ->fillForm([ - 'name' => null, - 'email' => 'invalid-email', - ]) - ->call('create') - ->assertHasFormErrors([ - 'name' => 'required', - 'email' => 'email', - ]) - ->assertNotNotified(); - - - - -use Filament\Actions\DeleteAction; -use function Pest\Livewire\livewire; - -livewire(EditUser::class, ['record' => $user->id]) - ->callAction(DeleteAction::class) - ->assertNotified() - ->assertRedirect(); - - - - -use Filament\Actions\Testing\TestAction; -use function Pest\Livewire\livewire; - -livewire(ListUsers::class) - ->callAction(TestAction::make('promote')->table($user), [ - 'role' => 'admin', - ]) - ->assertNotified(); - - - -### Correct Namespaces - -- Form fields (`TextInput`, `Select`, etc.): `Filament\Forms\Components\` -- Infolist entries (`TextEntry`, `IconEntry`, etc.): `Filament\Infolists\Components\` -- Layout components (`Grid`, `Section`, `Fieldset`, `Tabs`, `Wizard`, etc.): `Filament\Schemas\Components\` -- Schema utilities (`Get`, `Set`, etc.): `Filament\Schemas\Components\Utilities\` -- Actions (`DeleteAction`, `CreateAction`, etc.): `Filament\Actions\`. Never use `Filament\Tables\Actions\`, `Filament\Forms\Actions\`, or any other sub-namespace for actions. -- Icons: `Filament\Support\Icons\Heroicon` enum (e.g., `Heroicon::PencilSquare`) - -### Common Mistakes - -- **Never assume public file visibility.** File visibility is `private` by default. Always use `->visibility('public')` when public access is needed. -- **Never assume full-width layout.** `Grid`, `Section`, and `Fieldset` do not span all columns by default. Explicitly set column spans when needed. - -=== laravel/ai rules === - -## Laravel AI SDK - -- This application uses the Laravel AI SDK (`laravel/ai`) for all AI functionality. -- Activate the `developing-with-ai-sdk` skill when building, editing, updating, debugging, or testing AI agents, text generation, chat, streaming, structured output, tools, image generation, audio, transcription, embeddings, reranking, vector stores, files, conversation memory, or any AI provider integration (OpenAI, Anthropic, Gemini, Cohere, Groq, xAI, ElevenLabs, Jina, OpenRouter). - - +# Architecture & Data +* **Modular Monolith:** Strict boundaries; zero cross-module JOINs. +* **Design:** SOLID, KISS, MVC. Clean Apple style: minimalist, text-minimal, no animations. +* **Database:** PostgreSQL with SoftDeletes everywhere. +* **Logic:** Fat Models handle all DB interactions. +* **Shared Code:** Keep global helpers in the Laravel root. + +# Code Standards +* **Types:** `declare(strict_types=1);` in every file. +* **Language:** : English codebase. Translate each module inside of. +* **Comments:** Zero. Code must be entirely self-documenting. diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 00000000000..813e6d8e4c4 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,15 @@ +# Code of Conduct + +## Contributing + +For guidelines on how to contribute to this project, please see our [Contributing Guidelines](CONTRIBUTING.md). + +## License + +This project is licensed under an open-source license. For more information on choosing and adding a license, see [Choosing a license](https://docs.github.com/articles/adding-a-license-to-a-repository/). + +## Security Policy + +If you have discovered a security vulnerability, please email us at: **alp@visiosoft.com.tr** + +Please do not open a public GitHub issue for security vulnerabilities. We take security seriously and will respond to your report as soon as possible. diff --git a/Dockerfile b/Dockerfile index 5017a85a0ca..3bbd40cf560 100644 --- a/Dockerfile +++ b/Dockerfile @@ -15,8 +15,9 @@ RUN apk add --no-cache \ libxml2-dev \ icu-dev \ libzip-dev \ + postgresql-dev \ && docker-php-ext-configure gd --with-freetype --with-jpeg \ - && docker-php-ext-install pdo pdo_mysql mbstring exif pcntl bcmath gd opcache intl zip + && docker-php-ext-install pdo pdo_pgsql mbstring exif pcntl bcmath gd opcache intl zip COPY --from=composer:latest /usr/bin/composer /usr/bin/composer diff --git a/Dockerfile.dev b/Dockerfile.dev index 69302b77246..3316b357928 100644 --- a/Dockerfile.dev +++ b/Dockerfile.dev @@ -13,8 +13,9 @@ RUN apk add --no-cache \ freetype-dev \ oniguruma-dev \ libxml2-dev \ + postgresql-dev \ && docker-php-ext-configure gd --with-freetype --with-jpeg \ - && docker-php-ext-install pdo pdo_mysql mbstring exif pcntl bcmath gd + && docker-php-ext-install pdo pdo_pgsql mbstring exif pcntl bcmath gd COPY --from=composer:latest /usr/bin/composer /usr/bin/composer diff --git a/Modules/Admin/Database/migrations/2026_03_03_093635_create_activity_log_table.php b/Modules/Admin/Database/migrations/2026_03_03_093635_create_activity_log_table.php index 0fee00b1462..775d4e2c4b7 100644 --- a/Modules/Admin/Database/migrations/2026_03_03_093635_create_activity_log_table.php +++ b/Modules/Admin/Database/migrations/2026_03_03_093635_create_activity_log_table.php @@ -1,5 +1,7 @@ create(config('activitylog.table_name'), function (Blueprint $table): void { - $table->bigIncrements('id'); - $table->string('log_name')->nullable(); - $table->text('description'); - $table->nullableMorphs('subject', 'subject'); - $table->string('event')->nullable(); - $table->nullableMorphs('causer', 'causer'); - $table->json('properties')->nullable(); - $table->uuid('batch_uuid')->nullable(); - $table->timestamps(); - $table->index('log_name'); - }); + Schema::create('activity_log', function (Blueprint $table): void { + $table->id(); + $table->string('log_name')->nullable()->index(); + $table->text('description'); + $table->nullableMorphs('subject', 'subject'); + $table->string('event')->nullable(); + $table->nullableMorphs('causer', 'causer'); + $table->json('attribute_changes')->nullable(); + $table->json('properties')->nullable(); + $table->timestamps(); + }); } public function down(): void { - Schema::connection(config('activitylog.database_connection')) - ->dropIfExists(config('activitylog.table_name')); + Schema::dropIfExists('activity_log'); } }; diff --git a/Modules/Admin/Providers/AdminPanelProvider.php b/Modules/Admin/Providers/AdminPanelProvider.php index 6309c0157be..766e595d687 100644 --- a/Modules/Admin/Providers/AdminPanelProvider.php +++ b/Modules/Admin/Providers/AdminPanelProvider.php @@ -1,5 +1,7 @@ pages([Dashboard::class]) ->middleware([ diff --git a/Modules/Admin/Providers/AdminServiceProvider.php b/Modules/Admin/Providers/AdminServiceProvider.php index fe8e6a28607..4fdde515580 100644 --- a/Modules/Admin/Providers/AdminServiceProvider.php +++ b/Modules/Admin/Providers/AdminServiceProvider.php @@ -1,5 +1,7 @@ loadMigrationsFrom(module_path('Admin', 'Database/migrations')); } - public function register(): void - {} + public function register(): void {} } diff --git a/Modules/Admin/README.md b/Modules/Admin/README.md new file mode 100644 index 00000000000..081d7067215 --- /dev/null +++ b/Modules/Admin/README.md @@ -0,0 +1,9 @@ +# Admin + +Bootstraps the staff Filament panel at `/admin`. + +`AdminPanelProvider` registers the panel, its plugins (Breezy profile/2FA, impersonation, developer logins, file manager, activity log, state-fusion) and pulls in each domain module's Filament plugin (Category, Listing, Location, Site, User, Video) so their resources appear in the same panel without those modules depending on each other. + +`Support/Filament/ResourceTableActions` and `ResourceTableColumns` are shared, stateless factories for common table columns and row actions, reused across every module's Filament resource to keep resource classes thin. + +Also owns the `activity_log` table migration used by every model with the `LogsActivity` trait. diff --git a/Modules/Admin/Support/Filament/ResourceTableActions.php b/Modules/Admin/Support/Filament/ResourceTableActions.php index 3c699c5c33b..b1b1474ff5d 100644 --- a/Modules/Admin/Support/Filament/ResourceTableActions.php +++ b/Modules/Admin/Support/Filament/ResourceTableActions.php @@ -1,5 +1,7 @@ $childName) { - $childSlug = $data['slug'] . '-' . \Illuminate\Support\Str::slug($childName); + $childSlug = $data['slug'].'-'.Str::slug($childName); Category::updateOrCreate( ['slug' => $childSlug], ['name' => $childName, 'slug' => $childSlug, 'parent_id' => $parent->id, 'level' => 1, 'sort_order' => $i, 'is_active' => true] diff --git a/Modules/Category/Database/migrations/2024_01_01_000002_create_categories_table.php b/Modules/Category/Database/migrations/2024_01_01_000002_create_categories_table.php index a35aa1a3f9b..6d98324c53d 100644 --- a/Modules/Category/Database/migrations/2024_01_01_000002_create_categories_table.php +++ b/Modules/Category/Database/migrations/2024_01_01_000002_create_categories_table.php @@ -1,7 +1,10 @@ id(); $table->string('name'); - $table->string('slug')->unique(); + $table->string('slug'); $table->text('description')->nullable(); $table->string('icon')->nullable(); $table->foreignId('parent_id')->nullable()->constrained('categories')->nullOnDelete(); @@ -19,7 +22,10 @@ public function up(): void $table->integer('sort_order')->default(0); $table->boolean('is_active')->default(true); $table->timestamps(); + $table->softDeletes(); }); + + DB::statement('CREATE UNIQUE INDEX categories_slug_unique ON categories (slug) WHERE deleted_at IS NULL'); } public function down(): void diff --git a/Modules/Category/Filament/Admin/Resources/CategoryResource.php b/Modules/Category/Filament/Admin/Resources/CategoryResource.php index 774daec2f62..8beb5afbc31 100644 --- a/Modules/Category/Filament/Admin/Resources/CategoryResource.php +++ b/Modules/Category/Filament/Admin/Resources/CategoryResource.php @@ -1,5 +1,7 @@ schema([ - TextInput::make('name')->required()->maxLength(255)->live(onBlur: true)->afterStateUpdated(fn ($state, $set) => $set('slug', \Illuminate\Support\Str::slug($state))), + TextInput::make('name')->required()->maxLength(255)->live(onBlur: true)->afterStateUpdated(fn ($state, $set) => $set('slug', Str::slug($state))), TextInput::make('slug')->required()->maxLength(255)->unique(ignoreRecord: true), TextInput::make('description')->maxLength(500), TextInput::make('icon')->maxLength(100), diff --git a/Modules/Category/Filament/Admin/Resources/CategoryResource/Pages/CreateCategory.php b/Modules/Category/Filament/Admin/Resources/CategoryResource/Pages/CreateCategory.php index 841aa486a3f..a3af6649d1e 100644 --- a/Modules/Category/Filament/Admin/Resources/CategoryResource/Pages/CreateCategory.php +++ b/Modules/Category/Filament/Admin/Resources/CategoryResource/Pages/CreateCategory.php @@ -1,5 +1,7 @@ 'img/category/car.png', @@ -41,7 +45,7 @@ public function getActivitylogOptions(): LogOptions return LogOptions::defaults() ->logFillable() ->logOnlyDirty() - ->dontSubmitEmptyLogs(); + ->dontLogEmptyChanges(); } public function parent(): BelongsTo @@ -56,7 +60,7 @@ public function children(): HasMany public function listings(): HasMany { - return $this->hasMany(\Modules\Listing\Models\Listing::class); + return $this->hasMany(Listing::class); } public function scopeActive(Builder $query): Builder @@ -353,12 +357,12 @@ private function collectAncestorIds(): array public function listingCustomFields(): HasMany { - return $this->hasMany(\Modules\Listing\Models\ListingCustomField::class); + return $this->hasMany(ListingCustomField::class); } public function activeListings(): HasMany { - return $this->hasMany(\Modules\Listing\Models\Listing::class)->where('status', 'active'); + return $this->hasMany(Listing::class)->where('status', 'active'); } public function resolvedIconPath(): ?string diff --git a/Modules/Category/Providers/CategoryServiceProvider.php b/Modules/Category/Providers/CategoryServiceProvider.php index 458f335cc6f..2e406a41ad6 100644 --- a/Modules/Category/Providers/CategoryServiceProvider.php +++ b/Modules/Category/Providers/CategoryServiceProvider.php @@ -1,5 +1,7 @@ loadViewsFrom(module_path($this->moduleName, 'resources/views'), 'category'); } - public function register(): void - {} + public function register(): void {} } diff --git a/Modules/Category/README.md b/Modules/Category/README.md new file mode 100644 index 00000000000..0d71e2da8c7 --- /dev/null +++ b/Modules/Category/README.md @@ -0,0 +1,9 @@ +# Category + +The classifieds category taxonomy: a self-referencing tree (`parent_id`) with slug, icon, sort order and active flag. + +- Public routes/controller render the category index and directory pages. +- `Category` model carries the tree-building and lookup logic (root/child queries, listing counts per category, header navigation items, admin hierarchy tree) so controllers and Filament resources stay thin. +- `Filament/Admin/Resources/CategoryResource` manages categories from the admin panel. + +Other modules (Listing, Favorite) depend on `Category` for classification but never query its table directly — they go through the model's public API. diff --git a/Modules/Category/resources/views/index.blade.php b/Modules/Category/resources/views/index.blade.php deleted file mode 100644 index b9ec195522d..00000000000 --- a/Modules/Category/resources/views/index.blade.php +++ /dev/null @@ -1,5 +0,0 @@ -@extends('app::layouts.app') - -@section('content') - @include('category::partials.index-content', ['categories' => $categories]) -@endsection diff --git a/Modules/Category/resources/views/partials/index-content.blade.php b/Modules/Category/resources/views/partials/index-content.blade.php deleted file mode 100644 index 97d428b77dc..00000000000 --- a/Modules/Category/resources/views/partials/index-content.blade.php +++ /dev/null @@ -1,68 +0,0 @@ -@php - $categoryCount = $categories->count(); -@endphp - -
-
-
-
-

All categories

-

A single directory view with the same spacing and chrome used across the frontend.

-
-

{{ number_format($categoryCount, 0, '.', ',') }} categories

-
- -
- @foreach($categories as $category) - @php - $childNames = $category->children - ->take(3) - ->pluck('name') - ->filter() - ->implode(' · '); - $extraChildCount = max($category->children->count() - 3, 0); - $iconUrl = $category->iconUrl(); - $iconLabel = strtoupper(\Illuminate\Support\Str::substr($category->name, 0, 1)); - @endphp - -
- - @if($iconUrl) - {{ $category->name }} - @else - {{ $iconLabel }} - @endif - - - {{ number_format($category->children->count(), 0, '.', ',') }} subcategories - -
- -
-

- {{ $category->name }} -

-

- {{ $childNames !== '' ? $childNames : 'Open this category to browse available listings and subcategories.' }} - @if($extraChildCount > 0) - +{{ $extraChildCount }} more - @endif -

-
- -
- - Explore category - - -
-
- @endforeach -
-
-
diff --git a/Modules/Category/resources/views/themes/default/index.blade.php b/Modules/Category/resources/views/themes/default/index.blade.php index b9ec195522d..a3ab2a14914 100644 --- a/Modules/Category/resources/views/themes/default/index.blade.php +++ b/Modules/Category/resources/views/themes/default/index.blade.php @@ -1,5 +1,49 @@ -@extends('app::layouts.app') +@extends('site::layouts.app') + +@section('title', __('site::messages.categories')) +@section('description', __('site::messages.hero_lead')) @section('content') - @include('category::partials.index-content', ['categories' => $categories]) +
+
+
+ +

{{ __('site::messages.all_categories') }}

+
+ + @forelse($categories as $category) +
+
+

{{ $category->getAttribute('name') }}

+ + {{ __('site::messages.view_all') }} + +
+ + @if($category->getRelation('children')->isNotEmpty()) +
+ @foreach($category->getRelation('children') as $child) + + + @if($child->iconUrl()) + + @else + + @endif + + {{ $child->getAttribute('name') }} + + @endforeach +
+ @endif +
+ @empty + + @endforelse +
+
@endsection diff --git a/Modules/Category/resources/views/themes/otoplus/index.blade.php b/Modules/Category/resources/views/themes/otoplus/index.blade.php deleted file mode 100644 index b9ec195522d..00000000000 --- a/Modules/Category/resources/views/themes/otoplus/index.blade.php +++ /dev/null @@ -1,5 +0,0 @@ -@extends('app::layouts.app') - -@section('content') - @include('category::partials.index-content', ['categories' => $categories]) -@endsection diff --git a/Modules/Category/routes/web.php b/Modules/Category/routes/web.php index 5ce9962d63c..6c268a0b49a 100644 --- a/Modules/Category/routes/web.php +++ b/Modules/Category/routes/web.php @@ -1,4 +1,6 @@ user()->getKey(); - $messageFilter = $this->resolveMessageFilter($request); - - [ - 'conversations' => $conversations, - 'selectedConversation' => $selectedConversation, - ] = $this->resolveInboxState( - $userId, - $messageFilter, - $request->integer('conversation'), - false, - ); - - return response()->json([ - 'list_html' => $this->renderInboxList($conversations, $messageFilter, $selectedConversation), - 'thread_html' => $this->renderInboxThread($selectedConversation, $messageFilter), - 'selected_conversation_id' => $selectedConversation ? (int) $selectedConversation->getKey() : null, - 'counts' => [ - 'unread_messages_total' => Conversation::unreadCountForUser($userId), - ], - ]); - } - - public function start(Request $request, Listing $listing): RedirectResponse | JsonResponse + public function start(Request $request, Listing $listing): RedirectResponse|JsonResponse { $user = $request->user(); @@ -124,7 +101,7 @@ public function start(Request $request, Listing $listing): RedirectResponse | Js ->with('success', $messageBody !== '' ? 'Message sent.' : 'Conversation started.'); } - public function send(Request $request, Conversation $conversation): RedirectResponse | JsonResponse + public function send(Request $request, Conversation $conversation): RedirectResponse|JsonResponse { $user = $request->user(); $userId = (int) $user->getKey(); @@ -275,11 +252,11 @@ private function broadcastMessageCreated( if ($participantId === $senderId) { broadcast($event)->toOthers(); + continue; } broadcast($event); } } - } diff --git a/Modules/Conversation/App/Models/Conversation.php b/Modules/Conversation/App/Models/Conversation.php index 144faf6f983..6e4f3cf9596 100644 --- a/Modules/Conversation/App/Models/Conversation.php +++ b/Modules/Conversation/App/Models/Conversation.php @@ -1,17 +1,20 @@ loadMigrationsFrom(module_path('Conversation', 'Database/migrations')); $this->loadRoutesFrom(module_path('Conversation', 'routes/web.php')); $this->loadViewsFrom(module_path('Conversation', 'resources/views'), 'conversation'); + $this->loadTranslationsFrom(module_path('Conversation', 'lang'), 'conversation'); Broadcast::channel('users.{id}.inbox', function ($user, $id): bool { return (int) $user->getKey() === (int) $id; diff --git a/Modules/Conversation/App/Support/QuickMessageCatalog.php b/Modules/Conversation/App/Support/QuickMessageCatalog.php index 2680437a716..1e452a633a7 100644 --- a/Modules/Conversation/App/Support/QuickMessageCatalog.php +++ b/Modules/Conversation/App/Support/QuickMessageCatalog.php @@ -1,5 +1,7 @@ foreignId('buyer_id')->constrained('users')->cascadeOnDelete(); $table->timestamp('last_message_at')->nullable(); $table->timestamps(); + $table->softDeletes(); - $table->unique(['listing_id', 'buyer_id']); $table->index(['seller_id', 'last_message_at']); $table->index(['buyer_id', 'last_message_at']); }); + DB::statement('CREATE UNIQUE INDEX conversations_listing_id_buyer_id_unique ON conversations (listing_id, buyer_id) WHERE deleted_at IS NULL'); + Schema::create('conversation_messages', function (Blueprint $table): void { $table->id(); $table->foreignId('conversation_id')->constrained('conversations')->cascadeOnDelete(); @@ -28,6 +33,7 @@ public function up(): void $table->text('body'); $table->timestamp('read_at')->nullable(); $table->timestamps(); + $table->softDeletes(); $table->index(['conversation_id', 'created_at']); $table->index(['conversation_id', 'read_at']); diff --git a/Modules/Conversation/README.md b/Modules/Conversation/README.md new file mode 100644 index 00000000000..7b51f412317 --- /dev/null +++ b/Modules/Conversation/README.md @@ -0,0 +1,9 @@ +# Conversation + +Realtime buyer/seller messaging tied to a listing. + +- `Conversation` (one per listing/buyer pair) and `ConversationMessage` models hold the inbox and read-state logic. +- Messages broadcast over Laravel Reverb/Echo on a private `users.{id}.inbox` channel via the `InboxMessageCreated` and `ConversationReadUpdated` events. +- The inbox UI (list pane + thread pane) lives under `resources/views`, backed by `resources/assets/js/conversation.js` for live updates. + +A conversation is started from a listing's detail page and always links back to the `Listing` and `User` models by relationship, never by cross-module query. diff --git a/Modules/Conversation/lang/en/messages.php b/Modules/Conversation/lang/en/messages.php new file mode 100644 index 00000000000..0a13ccc66e5 --- /dev/null +++ b/Modules/Conversation/lang/en/messages.php @@ -0,0 +1,15 @@ + 'Inbox', + 'conversations' => 'Conversations', + 'no_conversations' => 'No conversations yet', + 'no_conversations_hint' => 'Messages from buyers and sellers appear here.', + 'message_placeholder' => 'Write a message', + 'send' => 'Send', + 'select_conversation' => 'Select a conversation', + 'quick_replies' => 'Quick replies', + 'back' => 'Back', +]; diff --git a/Modules/Conversation/lang/tr/messages.php b/Modules/Conversation/lang/tr/messages.php new file mode 100644 index 00000000000..ed354dcc824 --- /dev/null +++ b/Modules/Conversation/lang/tr/messages.php @@ -0,0 +1,15 @@ + 'Mesajlar', + 'conversations' => 'Görüşmeler', + 'no_conversations' => 'Henüz görüşme yok', + 'no_conversations_hint' => 'Alıcı ve satıcılardan gelen mesajlar burada görünür.', + 'message_placeholder' => 'Bir mesaj yazın', + 'send' => 'Gönder', + 'select_conversation' => 'Bir görüşme seçin', + 'quick_replies' => 'Hızlı yanıtlar', + 'back' => 'Geri', +]; diff --git a/Modules/Conversation/resources/assets/js/conversation.js b/Modules/Conversation/resources/assets/js/conversation.js deleted file mode 100644 index 00c36d1183e..00000000000 --- a/Modules/Conversation/resources/assets/js/conversation.js +++ /dev/null @@ -1,605 +0,0 @@ -const onReady = (callback) => { - if (document.readyState === 'loading') { - document.addEventListener('DOMContentLoaded', callback, { once: true }); - return; - } - - callback(); -}; - -const csrfToken = () => document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || ''; - -const socketHeaders = () => { - const socketId = window.Echo?.socketId?.(); - - return socketId ? { 'X-Socket-ID': socketId } : {}; -}; - -const jsonHeaders = () => ({ - Accept: 'application/json', - 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8', - 'X-CSRF-TOKEN': csrfToken(), - 'X-Requested-With': 'XMLHttpRequest', - ...socketHeaders(), -}); - -const toFormBody = (values) => { - const params = new URLSearchParams(); - - Object.entries(values).forEach(([key, value]) => { - if (value === null || value === undefined) { - return; - } - - params.append(key, String(value)); - }); - - return params.toString(); -}; - -const formatCount = (count) => (count > 99 ? '99+' : String(count)); - -const setBadgeCount = (badge, count) => { - if (!badge) { - return; - } - - if (!count || count < 1) { - badge.textContent = '0'; - badge.classList.add('hidden'); - return; - } - - badge.textContent = formatCount(count); - badge.classList.remove('hidden'); -}; - -const updateHeaderInboxBadge = (count) => { - setBadgeCount(document.querySelector('[data-header-inbox-badge]'), count); -}; - -const subscribeInboxChannel = () => { - const body = document.body; - const userId = String(body.dataset.authUserId || '').trim(); - const channelName = String(body.dataset.inboxChannel || '').trim(); - - if (!userId || !channelName || !window.Echo?.private || window.__ocInboxChannel === channelName) { - return; - } - - const channel = window.Echo.private(channelName); - - channel.listen('.inbox.message.created', (payload) => { - updateHeaderInboxBadge(payload?.counts?.unread_messages_total ?? 0); - document.dispatchEvent(new CustomEvent('oc:inbox-message-created', { detail: payload })); - }); - - channel.listen('.inbox.read.updated', (payload) => { - updateHeaderInboxBadge(payload?.counts?.unread_messages_total ?? 0); - document.dispatchEvent(new CustomEvent('oc:inbox-read-updated', { detail: payload })); - }); - - window.__ocInboxChannel = channelName; -}; - -const buildMessageItem = (message) => { - const item = document.createElement('div'); - item.dataset.messageId = String(message.id); - item.className = `lt-chat-item${message.is_mine ? ' is-mine' : ''}`; - - const bubble = document.createElement('div'); - bubble.className = 'lt-chat-bubble'; - bubble.textContent = message.body; - - const time = document.createElement('span'); - time.className = 'lt-chat-time'; - time.textContent = message.time || ''; - - item.append(bubble, time); - - return item; -}; - -const appendInlineChatMessage = (thread, emptyState, message) => { - if (!thread || !message?.body || thread.querySelector(`[data-message-id="${message.id}"]`)) { - return; - } - - const item = buildMessageItem(message); - thread.appendChild(item); - emptyState?.classList.add('is-hidden'); - thread.scrollTop = thread.scrollHeight; -}; - -const initInlineListingChat = () => { - const root = document.querySelector('[data-inline-chat]'); - - if (!root || root.dataset.chatBound === '1') { - return; - } - - root.dataset.chatBound = '1'; - - const launcher = root.querySelector('[data-inline-chat-launcher]'); - const panel = root.querySelector('[data-inline-chat-panel]'); - const thread = root.querySelector('[data-inline-chat-thread]'); - const emptyState = root.querySelector('[data-inline-chat-empty]'); - const form = root.querySelector('[data-inline-chat-form]'); - const input = root.querySelector('[data-inline-chat-input]'); - const error = root.querySelector('[data-inline-chat-error]'); - const submitButton = root.querySelector('[data-inline-chat-submit]'); - const launcherBadge = root.querySelector('[data-inline-chat-badge]'); - - const currentConversationId = () => String(root.dataset.conversationId || '').trim(); - - const resolveReadUrl = () => { - if (root.dataset.readUrl) { - return root.dataset.readUrl; - } - - const conversationId = currentConversationId(); - const template = String(root.dataset.readUrlTemplate || ''); - - return conversationId && template ? template.replace('__CONVERSATION__', conversationId) : ''; - }; - - const setState = (state) => { - const isOpen = state === 'open' || state === 'sending'; - - root.dataset.state = state; - root.classList.toggle('is-open', isOpen); - root.classList.toggle('is-collapsed', state === 'collapsed'); - root.classList.toggle('is-sending', state === 'sending'); - - launcher?.toggleAttribute('hidden', isOpen); - panel?.toggleAttribute('hidden', !isOpen); - - if (isOpen) { - window.requestAnimationFrame(() => { - thread?.scrollTo({ top: thread.scrollHeight }); - input?.focus(); - }); - } - }; - - const showError = (message) => { - if (!error) { - return; - } - - if (!message) { - error.textContent = ''; - error.classList.add('is-hidden'); - return; - } - - error.textContent = message; - error.classList.remove('is-hidden'); - }; - - const setUnreadCount = (count) => { - setBadgeCount(launcherBadge, count); - }; - - const markConversationRead = async () => { - const readUrl = resolveReadUrl(); - - if (!readUrl || !currentConversationId()) { - return; - } - - const response = await fetch(readUrl, { - method: 'POST', - headers: jsonHeaders(), - }); - - const payload = await response.json().catch(() => ({})); - - if (!response.ok) { - return; - } - - setUnreadCount(payload?.conversation?.unread_count ?? 0); - updateHeaderInboxBadge(payload?.counts?.unread_messages_total ?? 0); - }; - - const openChat = async (event) => { - event?.preventDefault(); - event?.stopPropagation(); - - if (root.dataset.state === 'open' || root.dataset.state === 'sending') { - return; - } - - showError(''); - setState('open'); - await markConversationRead(); - }; - - document.querySelectorAll('[data-inline-chat-trigger]').forEach((button) => { - button.addEventListener('click', openChat); - }); - - launcher?.addEventListener('click', openChat); - - root.querySelector('[data-inline-chat-close]')?.addEventListener('click', (event) => { - event.preventDefault(); - event.stopPropagation(); - if (root.dataset.state === 'collapsed') { - return; - } - - showError(''); - setState('collapsed'); - }); - - root.addEventListener('click', (event) => { - if (event.target.closest('[data-inline-chat-panel]')) { - event.stopPropagation(); - } - }); - - form?.addEventListener('submit', async (event) => { - event.preventDefault(); - event.stopPropagation(); - - if (!input || !submitButton || root.dataset.state === 'sending') { - return; - } - - const message = input.value.trim(); - if (message === '') { - showError('Message cannot be empty.'); - input.focus(); - return; - } - - const targetUrl = root.dataset.sendUrl || root.dataset.startUrl; - if (!targetUrl) { - showError('Messaging is not available right now.'); - return; - } - - showError(''); - submitButton.disabled = true; - setState('sending'); - - try { - const response = await fetch(targetUrl, { - method: 'POST', - headers: jsonHeaders(), - body: toFormBody({ message }), - }); - - const payload = await response.json().catch(() => ({})); - if (!response.ok) { - throw new Error(payload?.message || payload?.errors?.message?.[0] || 'Message could not be sent.'); - } - - if (payload.send_url) { - root.dataset.sendUrl = payload.send_url; - } - - if (payload.read_url) { - root.dataset.readUrl = payload.read_url; - } - - if (payload.conversation_id) { - root.dataset.conversationId = String(payload.conversation_id); - } - - if (payload.message) { - appendInlineChatMessage(thread, emptyState, payload.message); - } - - updateHeaderInboxBadge(payload?.counts?.unread_messages_total ?? 0); - setUnreadCount(payload?.conversation?.unread_count ?? 0); - input.value = ''; - setState('open'); - } catch (requestError) { - showError(requestError instanceof Error ? requestError.message : 'Message could not be sent.'); - setState('open'); - } finally { - submitButton.disabled = false; - } - }); - - document.addEventListener('oc:inbox-message-created', async ({ detail }) => { - if (String(detail?.conversation?.id || '') !== currentConversationId()) { - return; - } - - if (root.dataset.state === 'open') { - appendInlineChatMessage(thread, emptyState, detail.message); - - if (!detail?.message?.is_mine && document.visibilityState === 'visible') { - await markConversationRead(); - } - - return; - } - - if (!detail?.message?.is_mine) { - setUnreadCount(detail?.conversation?.unread_count ?? 0); - } - }); - - document.addEventListener('oc:inbox-read-updated', ({ detail }) => { - if (String(detail?.conversation?.id || '') !== currentConversationId()) { - return; - } - - setUnreadCount(detail?.conversation?.unread_count ?? 0); - }); - - document.addEventListener('visibilitychange', async () => { - if (document.visibilityState === 'visible' && root.dataset.state === 'open') { - await markConversationRead(); - } - }); - - setState('collapsed'); -}; - -const buildInboxMessageItem = (message) => { - const wrapper = document.createElement('div'); - wrapper.dataset.messageId = String(message.id); - wrapper.className = `mb-4 flex ${message.is_mine ? 'justify-end' : 'justify-start'}`; - - const shell = document.createElement('div'); - shell.className = 'max-w-[80%]'; - - const bubble = document.createElement('div'); - bubble.className = `${message.is_mine ? 'bg-amber-100 text-slate-900' : 'bg-white text-slate-900 border border-slate-200'} rounded-2xl px-4 py-2 text-base shadow-sm`; - bubble.textContent = message.body; - - const time = document.createElement('p'); - time.className = `text-xs text-slate-500 mt-1 ${message.is_mine ? 'text-right' : 'text-left'}`; - time.textContent = message.time || ''; - - shell.append(bubble, time); - wrapper.appendChild(shell); - - return wrapper; -}; - -const initInboxRealtime = () => { - const root = document.querySelector('[data-inbox-root]'); - - if (!root) { - return; - } - - const listContainer = root.querySelector('[data-inbox-list-container]'); - const threadContainer = root.querySelector('[data-inbox-thread-container]'); - - if (!listContainer || !threadContainer) { - return; - } - - const currentSelectedConversationId = () => { - const panel = threadContainer.querySelector('[data-inbox-thread-panel]'); - - return String( - root.dataset.selectedConversationId || - panel?.dataset.selectedConversationId || - '', - ).trim(); - }; - - const readUrlFor = (conversationId) => { - const template = String(root.dataset.readUrlTemplate || ''); - - return conversationId && template ? template.replace('__CONVERSATION__', conversationId) : ''; - }; - - const currentFilter = () => { - const params = new URLSearchParams(window.location.search); - - return params.get('message_filter') || 'all'; - }; - - const syncBrowserUrl = (conversationId) => { - const url = new URL(window.location.href); - const filter = currentFilter(); - - if (filter === 'all') { - url.searchParams.delete('message_filter'); - } else { - url.searchParams.set('message_filter', filter); - } - - if (conversationId) { - url.searchParams.set('conversation', conversationId); - } else { - url.searchParams.delete('conversation'); - } - - window.history.replaceState({}, '', url); - }; - - const refreshState = async ({ conversationId = currentSelectedConversationId(), scrollToBottom = false } = {}) => { - const params = new URLSearchParams(); - - if (currentFilter() !== 'all') { - params.set('message_filter', currentFilter()); - } - - if (conversationId) { - params.set('conversation', conversationId); - } - - const targetUrl = `${root.dataset.stateUrl}${params.toString() ? `?${params.toString()}` : ''}`; - const response = await fetch(targetUrl, { - headers: { - Accept: 'application/json', - 'X-Requested-With': 'XMLHttpRequest', - }, - }); - - if (!response.ok) { - return; - } - - const payload = await response.json().catch(() => null); - if (!payload) { - return; - } - - listContainer.innerHTML = payload.list_html || ''; - threadContainer.innerHTML = payload.thread_html || ''; - root.dataset.selectedConversationId = payload.selected_conversation_id ? String(payload.selected_conversation_id) : ''; - updateHeaderInboxBadge(payload?.counts?.unread_messages_total ?? 0); - syncBrowserUrl(root.dataset.selectedConversationId); - - if (scrollToBottom) { - const thread = threadContainer.querySelector('[data-inbox-thread]'); - thread?.scrollTo({ top: thread.scrollHeight }); - } - }; - - const showThreadError = (message) => { - const error = threadContainer.querySelector('[data-inbox-send-error]'); - - if (!error) { - return; - } - - if (!message) { - error.textContent = ''; - error.classList.add('hidden'); - return; - } - - error.textContent = message; - error.classList.remove('hidden'); - }; - - const appendInboxMessage = (message) => { - const thread = threadContainer.querySelector('[data-inbox-thread]'); - const emptyState = threadContainer.querySelector('[data-inbox-empty]'); - - if (!thread || !message?.body || thread.querySelector(`[data-message-id="${message.id}"]`)) { - return; - } - - if (emptyState) { - emptyState.remove(); - } - - thread.appendChild(buildInboxMessageItem(message)); - thread.scrollTop = thread.scrollHeight; - }; - - const markConversationRead = async (conversationId) => { - const readUrl = readUrlFor(conversationId); - - if (!readUrl) { - return; - } - - const response = await fetch(readUrl, { - method: 'POST', - headers: jsonHeaders(), - }); - - const payload = await response.json().catch(() => null); - - if (payload) { - updateHeaderInboxBadge(payload?.counts?.unread_messages_total ?? 0); - } - }; - - root.addEventListener('submit', async (event) => { - const form = event.target.closest('[data-inbox-send-form]'); - - if (!form) { - return; - } - - event.preventDefault(); - - const formData = new FormData(form); - const message = String(formData.get('message') || '').trim(); - const submitButton = form.querySelector('[data-inbox-send-button]') || form.querySelector('button[type="submit"]'); - const textInput = threadContainer.querySelector('[data-inbox-message-input]'); - - if (message === '') { - showThreadError('Message cannot be empty.'); - textInput?.focus(); - return; - } - - showThreadError(''); - submitButton?.setAttribute('disabled', 'disabled'); - - try { - const response = await fetch(form.action, { - method: 'POST', - headers: jsonHeaders(), - body: new URLSearchParams(formData).toString(), - }); - - const payload = await response.json().catch(() => ({})); - - if (!response.ok) { - throw new Error(payload?.message || payload?.errors?.message?.[0] || 'Message could not be sent.'); - } - - if (payload.message && String(payload.conversation_id || '') === currentSelectedConversationId()) { - appendInboxMessage(payload.message); - } - - if (textInput && form.contains(textInput)) { - textInput.value = ''; - textInput.focus(); - } - - root.dataset.selectedConversationId = String(payload.conversation_id || currentSelectedConversationId()); - await refreshState({ conversationId: root.dataset.selectedConversationId, scrollToBottom: true }); - } catch (requestError) { - showThreadError(requestError instanceof Error ? requestError.message : 'Message could not be sent.'); - } finally { - submitButton?.removeAttribute('disabled'); - } - }); - - document.addEventListener('oc:inbox-message-created', async ({ detail }) => { - const selectedConversationId = currentSelectedConversationId(); - const eventConversationId = String(detail?.conversation?.id || ''); - - if (selectedConversationId && eventConversationId === selectedConversationId) { - appendInboxMessage(detail.message); - - if (!detail?.message?.is_mine && document.visibilityState === 'visible') { - await markConversationRead(selectedConversationId); - } - - await refreshState({ conversationId: selectedConversationId, scrollToBottom: true }); - return; - } - - await refreshState({ conversationId: selectedConversationId }); - }); - - document.addEventListener('oc:inbox-read-updated', async () => { - await refreshState({ conversationId: currentSelectedConversationId() }); - }); - - document.addEventListener('visibilitychange', async () => { - const selectedConversationId = currentSelectedConversationId(); - - if (document.visibilityState !== 'visible' || !selectedConversationId) { - return; - } - - await markConversationRead(selectedConversationId); - await refreshState({ conversationId: selectedConversationId }); - }); -}; - -onReady(() => { - subscribeInboxChannel(); - initInlineListingChat(); - initInboxRealtime(); -}); diff --git a/Modules/Conversation/resources/views/inbox.blade.php b/Modules/Conversation/resources/views/inbox.blade.php index 2d7b73056e5..674e7c09c18 100644 --- a/Modules/Conversation/resources/views/inbox.blade.php +++ b/Modules/Conversation/resources/views/inbox.blade.php @@ -1,58 +1,110 @@ -@extends('app::layouts.app') - -@section('title', 'Inbox') - -@section('content') -
-
- @include('panel::partials.sidebar', ['activeMenu' => 'inbox']) - -
- @include('panel::partials.page-header', [ - 'title' => 'Inbox', - 'description' => 'Read and reply to buyer messages from the same panel shell used across the site.', - 'actions' => $requiresLogin ?? false - ? new \Illuminate\Support\HtmlString('Log in') - : null, - ]) - -
- @if($requiresLogin ?? false) -
-
-

Inbox

-

Stay on this page and log in when you want to access your conversations.

-
-
- @endif +@extends('panel::layouts.panel', ['panelSection' => 'inbox']) -
-
- @include('conversation::partials.inbox-list-pane', [ - 'conversations' => $conversations, - 'messageFilter' => $messageFilter, - 'selectedConversation' => $selectedConversation, - ]) -
+@section('title', __('conversation::messages.inbox')) + +@php + $viewerId = (int) auth()->id(); +@endphp + +@section('panel_content') +
+
+

{{ __('conversation::messages.inbox') }}

+

{{ trans_choice('site::messages.results_count', $conversations->count(), ['count' => $conversations->count()]) }}

+
+
+ +@if($conversations->isEmpty()) + + {{ __('site::messages.browse') }} + +@else +
+
+ @foreach($conversations as $conversation) + @php + $partner = $conversation->partnerFor($viewerId); + $unread = $conversation->unreadCountForParticipant($viewerId); + $isActive = $selectedConversation !== null && (int) $selectedConversation->getKey() === (int) $conversation->getKey(); + @endphp + + {{ \App\Support\UserDirectory::initials((string) $partner?->getAttribute('name')) }} + + {{ $partner?->getAttribute('name') ?? '—' }} + {{ $conversation->getRelation('lastMessage')?->getAttribute('body') ?? '' }} + + @if($unread > 0) + {{ $unread > 99 ? '99+' : $unread }} + @endif + + @endforeach +
-
- @include('conversation::partials.inbox-thread-pane', [ - 'selectedConversation' => $selectedConversation, - 'messageFilter' => $messageFilter, - 'quickMessages' => $quickMessages, - ]) +
+ @if($selectedConversation) + @php + $partner = $selectedConversation->partnerFor($viewerId); + $listing = $selectedConversation->getRelation('listing'); + @endphp + +
+
+ + {{ \App\Support\UserDirectory::initials((string) $partner?->getAttribute('name')) }} +
+ {{ $partner?->getAttribute('name') ?? '—' }} + @if($listing) + {{ $listing->getAttribute('title') }} + @endif +
+
+ +
    + @foreach($selectedConversation->getRelation('messages') as $message) + @php $outgoing = (int) $message->getAttribute('sender_id') === $viewerId; @endphp +
  • +

    {{ $message->getAttribute('body') }}

    + +
  • + @endforeach +
+ + @if(! empty($quickMessages)) +
+ @foreach($quickMessages as $quick) + + @endforeach +
+ @endif + +
+ @csrf + + + +
+
+ @else +
+

{{ __('conversation::messages.select_conversation') }}

-
-
-
+ @endif +
- +@endif @endsection diff --git a/Modules/Conversation/resources/views/partials/inbox-list-pane.blade.php b/Modules/Conversation/resources/views/partials/inbox-list-pane.blade.php deleted file mode 100644 index 1dd7b735b6f..00000000000 --- a/Modules/Conversation/resources/views/partials/inbox-list-pane.blade.php +++ /dev/null @@ -1,61 +0,0 @@ -
-
-

Filters

- -
-
- @forelse($conversations as $conversation) - @php - $conversationListing = $conversation->listing; - $partner = (int) $conversation->buyer_id === (int) auth()->id() ? $conversation->seller : $conversation->buyer; - $isSelected = $selectedConversation && (int) $selectedConversation->id === (int) $conversation->id; - $conversationImage = $conversationListing?->primaryImageData('thumb'); - $lastMessage = trim((string) ($conversation->lastMessage?->body ?? '')); - @endphp - -
-
- @if($conversationImage) - @include('listing::partials.responsive-image', [ - 'image' => $conversationImage, - 'alt' => $conversationListing?->title, - 'class' => 'w-full h-full object-cover', - ]) - @else -
Listing
- @endif -
-
-
-

{{ $partner?->name ?? 'User' }}

-

{{ $conversation->last_message_at?->format('d.m.Y') }}

-
-

{{ $conversationListing?->title ?? 'Listing removed' }}

-

- {{ $lastMessage !== '' ? $lastMessage : 'No messages yet' }} -

-
- @if($conversation->unread_count > 0) - - {{ $conversation->unread_count }} - - @endif -
-
- @empty -
- No conversations yet. -
- @endforelse -
-
diff --git a/Modules/Conversation/resources/views/partials/inbox-thread-pane.blade.php b/Modules/Conversation/resources/views/partials/inbox-thread-pane.blade.php deleted file mode 100644 index 9e54a4fcc7e..00000000000 --- a/Modules/Conversation/resources/views/partials/inbox-thread-pane.blade.php +++ /dev/null @@ -1,81 +0,0 @@ -
- @if($selectedConversation) - @php - $activeListing = $selectedConversation->listing; - $activePartner = (int) $selectedConversation->buyer_id === (int) auth()->id() - ? $selectedConversation->seller - : $selectedConversation->buyer; - $activePriceLabel = $activeListing && !is_null($activeListing->price) - ? number_format((float) $activeListing->price, 0).' '.($activeListing->currency ?? 'TL') - : null; - @endphp -
-
- {{ strtoupper(substr((string) ($activePartner?->name ?? 'K'), 0, 1)) }} -
-
-

{{ $activePartner?->name ?? 'User' }}

-

{{ $activeListing?->title ?? 'Listing removed' }}

-
- @if($activePriceLabel) -
{{ $activePriceLabel }}
- @endif -
- -
- @forelse($selectedConversation->messages as $message) - @php $isMine = (int) $message->sender_id === (int) auth()->id(); @endphp -
-
-
- {{ $message->body }} -
-

- {{ $message->created_at?->format('H:i') }} -

-
-
- @empty -
-
-

No messages yet.

-

Use a quick reply or send the first message below.

-
-
- @endforelse -
- -
-
- @foreach($quickMessages as $quickMessage) -
- @csrf - - - -
- @endforeach -
-
- @csrf - - - -
- -
- @else -
-
-

Choose a conversation to start messaging.

-

Start a new chat from a listing detail page or continue an existing thread here.

-
-
- @endif -
diff --git a/Modules/Conversation/routes/web.php b/Modules/Conversation/routes/web.php index c4832c66ba5..8a81220d314 100644 --- a/Modules/Conversation/routes/web.php +++ b/Modules/Conversation/routes/web.php @@ -1,12 +1,13 @@ group(function () { - Route::prefix('panel')->name('panel.')->group(function () { + Route::middleware('auth')->prefix('panel')->name('panel.')->group(function () { Route::get('/inbox', [ConversationController::class, 'inbox'])->name('inbox.index'); - Route::middleware('auth')->get('/inbox/state', [ConversationController::class, 'state'])->name('inbox.state'); }); Route::middleware('auth')->name('conversations.')->group(function () { diff --git a/Modules/Demo/App/Console/CleanupDemoCommand.php b/Modules/Demo/App/Console/CleanupDemoCommand.php index 97286a39764..ba22a17dec3 100644 --- a/Modules/Demo/App/Console/CleanupDemoCommand.php +++ b/Modules/Demo/App/Console/CleanupDemoCommand.php @@ -1,5 +1,7 @@ \Database\Seeders\DatabaseSeeder::class, + '--class' => DatabaseSeeder::class, '--database' => $this->defaultConnection, '--force' => true, ]); diff --git a/Modules/Demo/App/Support/DemoStoragePath.php b/Modules/Demo/App/Support/DemoStoragePath.php index 3be0eeefe05..2ab418d9689 100644 --- a/Modules/Demo/App/Support/DemoStoragePath.php +++ b/Modules/Demo/App/Support/DemoStoragePath.php @@ -1,5 +1,7 @@ call([ - \Modules\User\Database\Seeders\UserWorkspaceSeeder::class, + DatabaseSeeder::class, ]); } } diff --git a/Modules/Demo/Database/migrations/2026_03_07_000000_create_demo_instances_table.php b/Modules/Demo/Database/migrations/2026_03_07_000000_create_demo_instances_table.php index bee2c384a5b..2e05660f45e 100644 --- a/Modules/Demo/Database/migrations/2026_03_07_000000_create_demo_instances_table.php +++ b/Modules/Demo/Database/migrations/2026_03_07_000000_create_demo_instances_table.php @@ -1,5 +1,7 @@ timestamp('prepared_at'); $table->timestamp('expires_at')->index(); $table->timestamps(); + $table->softDeletes(); }); } diff --git a/Modules/Demo/README.md b/Modules/Demo/README.md new file mode 100644 index 00000000000..093b1b735f2 --- /dev/null +++ b/Modules/Demo/README.md @@ -0,0 +1,8 @@ +# Demo + +Provisions a temporary, per-visitor marketplace so anyone can try the product without a real account. + +- `DemoSchemaManager` creates a private PostgreSQL schema per visitor, seeds it, and logs them in as the seeded admin. +- `demo:prepare` and `demo:cleanup` artisan commands provision and reap demo schemas; expired demos are cleaned up on an hourly schedule. +- `ResolveDemoRequest` middleware switches the active database schema/connection for the current request based on the visitor's demo session. +- `DemoInstance` tracks each provisioned schema's lifetime; it is a purely ephemeral/technical record and is not soft-deleted like the rest of the app's domain models. diff --git a/Modules/Demo/routes/web.php b/Modules/Demo/routes/web.php index dd5ac673826..c3749b7add7 100644 --- a/Modules/Demo/routes/web.php +++ b/Modules/Demo/routes/web.php @@ -1,5 +1,7 @@ redirector->sanitize((string) $request->input('redirect_to', '')); diff --git a/Modules/Favorite/App/Models/FavoriteSearch.php b/Modules/Favorite/App/Models/FavoriteSearch.php index ea4d0e4ea07..c86d571a4bc 100644 --- a/Modules/Favorite/App/Models/FavoriteSearch.php +++ b/Modules/Favorite/App/Models/FavoriteSearch.php @@ -1,13 +1,18 @@ 'array']; diff --git a/Modules/Favorite/App/Providers/FavoriteServiceProvider.php b/Modules/Favorite/App/Providers/FavoriteServiceProvider.php index 7b340c71a7c..43290e6fa9b 100644 --- a/Modules/Favorite/App/Providers/FavoriteServiceProvider.php +++ b/Modules/Favorite/App/Providers/FavoriteServiceProvider.php @@ -1,5 +1,7 @@ loadMigrationsFrom(module_path('Favorite', 'Database/migrations')); $this->loadRoutesFrom(module_path('Favorite', 'routes/web.php')); $this->loadViewsFrom(module_path('Favorite', 'resources/views'), 'favorite'); + $this->loadTranslationsFrom(module_path('Favorite', 'lang'), 'favorite'); } public function register(): void {} diff --git a/Modules/Favorite/Database/Seeders/FavoriteDemoSeeder.php b/Modules/Favorite/Database/Seeders/FavoriteDemoSeeder.php index fec3fd517f3..c18f048e4f8 100644 --- a/Modules/Favorite/Database/Seeders/FavoriteDemoSeeder.php +++ b/Modules/Favorite/Database/Seeders/FavoriteDemoSeeder.php @@ -1,8 +1,11 @@ favoriteListings()->syncWithoutDetaching($payload); } - private function seedFavoriteSeller(User $user, User $seller, \Illuminate\Support\Carbon $timestamp): void + private function seedFavoriteSeller(User $user, User $seller, Carbon $timestamp): void { if ((int) $user->getKey() === (int) $seller->getKey()) { return; diff --git a/Modules/Favorite/Database/migrations/2026_03_04_000000_create_favorites_tables.php b/Modules/Favorite/Database/migrations/2026_03_04_000000_create_favorites_tables.php index 350194c75ea..d438fbd2275 100644 --- a/Modules/Favorite/Database/migrations/2026_03_04_000000_create_favorites_tables.php +++ b/Modules/Favorite/Database/migrations/2026_03_04_000000_create_favorites_tables.php @@ -1,7 +1,10 @@ json('filters')->nullable(); $table->string('signature', 64); $table->timestamps(); - - $table->unique(['user_id', 'signature']); + $table->softDeletes(); }); + + DB::statement('CREATE UNIQUE INDEX favorite_searches_user_id_signature_unique ON favorite_searches (user_id, signature) WHERE deleted_at IS NULL'); } public function down(): void diff --git a/Modules/Favorite/README.md b/Modules/Favorite/README.md new file mode 100644 index 00000000000..a2f72d820c4 --- /dev/null +++ b/Modules/Favorite/README.md @@ -0,0 +1,7 @@ +# Favorite + +Everything a buyer can save for later: favorited listings, favorited sellers, and saved searches. + +- Favoriting a listing/seller is a simple pivot toggle, implemented on the `User` model (`toggleFavoriteListing`, `toggleFavoriteSeller`). +- `FavoriteSearch` is the one real entity this module owns: a named, de-duplicated (by signature) saved search with its filters serialized as JSON. +- The favorites index page lists saved listings, sellers, and searches for the current user. diff --git a/Modules/Favorite/lang/en/messages.php b/Modules/Favorite/lang/en/messages.php new file mode 100644 index 00000000000..4631bb4a8cd --- /dev/null +++ b/Modules/Favorite/lang/en/messages.php @@ -0,0 +1,16 @@ + 'Favorites', + 'saved_listings' => 'Saved listings', + 'saved_searches' => 'Saved searches', + 'followed_sellers' => 'Followed sellers', + 'no_favorites' => 'Nothing saved yet', + 'no_favorites_hint' => 'Tap the heart on any listing to keep it here.', + 'remove' => 'Remove', + 'search_saved' => 'Search saved.', + 'search_removed' => 'Saved search removed.', + 'run_search' => 'Run search', +]; diff --git a/Modules/Favorite/lang/tr/messages.php b/Modules/Favorite/lang/tr/messages.php new file mode 100644 index 00000000000..9911f2e7864 --- /dev/null +++ b/Modules/Favorite/lang/tr/messages.php @@ -0,0 +1,16 @@ + 'Favoriler', + 'saved_listings' => 'Kaydedilen ilanlar', + 'saved_searches' => 'Kayıtlı aramalar', + 'followed_sellers' => 'Takip edilen satıcılar', + 'no_favorites' => 'Henüz kayıt yok', + 'no_favorites_hint' => 'İlanlardaki kalbe dokunarak buraya ekleyin.', + 'remove' => 'Kaldır', + 'search_saved' => 'Arama kaydedildi.', + 'search_removed' => 'Kayıtlı arama silindi.', + 'run_search' => 'Aramayı çalıştır', +]; diff --git a/Modules/Favorite/resources/views/index.blade.php b/Modules/Favorite/resources/views/index.blade.php index bc2e87d3485..69e1ab6543b 100644 --- a/Modules/Favorite/resources/views/index.blade.php +++ b/Modules/Favorite/resources/views/index.blade.php @@ -1,231 +1,122 @@ -@extends('app::layouts.app') +@extends('site::layouts.app') -@section('title', 'Favorites') +@section('title', __('favorite::messages.favorites')) + +@php + $tabs = [ + 'listings' => __('favorite::messages.saved_listings'), + 'searches' => __('favorite::messages.saved_searches'), + 'sellers' => __('favorite::messages.followed_sellers'), + ]; +@endphp @section('content') -
-
- @include('panel::partials.sidebar', ['activeMenu' => 'favorites', 'activeFavoritesTab' => $activeTab]) +
+
+
+

{{ __('favorite::messages.favorites') }}

+
-
- @if($requiresLogin ?? false) -
-
-

Favorites

-

Stay on this page and log in when you want to sync saved listings, searches, and sellers.

-
- - Log in - -
- @endif + @if($requiresLogin) + + {{ __('site::messages.login') }} + + @else + @if($activeTab === 'listings') - @php - $listingTabQuery = array_filter([ - 'tab' => 'listings', - 'status' => $statusFilter, - 'category' => $selectedCategoryId, - ], fn ($value) => !is_null($value) && $value !== ''); - @endphp -
-

Saved Listings

- -
+ - - + + + + + + -
-
-
- - - - - - - - - - - @forelse($favoriteListings as $listing) - @php - $listingImage = $listing->primaryImageData('card'); - $priceLabel = $listing->price ? number_format((float) $listing->price, 0).' '.$listing->currency : 'Free'; - $meta = collect([ - $listing->category?->name, - $listing->city, - $listing->country, - ])->filter()->join(' › '); - $conversationId = $buyerConversationListingMap[$listing->id] ?? null; - $isOwnListing = (int) $listing->user_id === (int) auth()->id(); - $canMessageListing = !is_null($listing->user_id) && ! $isOwnListing; - @endphp - - - - - - - @empty - - - - @endforelse - -
ListingPriceMessaging
-
- - @if($listingImage) - @include('listing::partials.responsive-image', [ - 'image' => $listingImage, - 'alt' => $listing->title, - 'class' => 'w-full h-full object-cover', - ]) - @else -
No image
- @endif -
-
- - {{ $listing->title }} + @if($favoriteListings->isNotEmpty()) +
+ @foreach($favoriteListings as $listing) + + @endforeach +
+ {{ $favoriteListings->links('components.pagination') }} + @else + +
{{ __('site::messages.browse') }} + + @endif + @elseif($activeTab === 'searches') + @if($favoriteSearches->isNotEmpty()) +
+
+ @foreach($favoriteSearches as $search) +
+
+

{{ $search->getAttribute('term') ?: __('site::messages.all_listings') }}

+

+ +

+
+
+ + {{ __('favorite::messages.run_search') }} -

{{ $meta !== '' ? $meta : 'No category or location data' }}

-

Saved on: {{ $listing->pivot->created_at?->format('M j, Y') }}

+
+ @csrf + @method('DELETE') + +
-
{{ $priceLabel }} - @if($canMessageListing) - @if($conversationId) - - Open chat - - @else -
- @csrf - -
- @endif - @else - {{ $isOwnListing ? 'Your own listing' : 'Seller unavailable' }} - @endif -
-
- @csrf - -
-
- No saved listings yet. -
-
- -
- * Listings saved within the last year are shown here. -
- - @if($favoriteListings?->hasPages()) -
{{ $favoriteListings->links() }}
- @endif - @endif - - @if($activeTab === 'searches') -
-

Saved Searches

-

Return to your saved searches with one click.

-
-
- @forelse($favoriteSearches as $favoriteSearch) - @php - $searchUrl = route('listings.index', array_filter([ - 'search' => $favoriteSearch->search_term, - 'category' => $favoriteSearch->category_id, - ])); - @endphp -
-
-

{{ $favoriteSearch->label ?: 'Saved search' }}

-

- @if($favoriteSearch->search_term) Search: "{{ $favoriteSearch->search_term }}" · @endif - @if($favoriteSearch->category) Category: {{ $favoriteSearch->category->name }} · @endif - Saved: {{ $favoriteSearch->created_at?->format('M j, Y H:i') }} -

-
-
- - Open search - -
- @csrf - @method('DELETE') - -
-
-
- @empty -
- No saved searches yet. -
- @endforelse -
- @if($favoriteSearches?->hasPages()) -
{{ $favoriteSearches->links() }}
- @endif - @endif - - @if($activeTab === 'sellers') -
-

Saved Sellers

-

Manage the sellers you want to follow here.

-
- - @if($favoriteSellers?->hasPages()) -
{{ $favoriteSellers->links() }}
- @endif +
+ {{ $favoriteSellers->links('components.pagination') }} + @else + + @endif @endif - + @endif
@endsection diff --git a/Modules/Favorite/routes/web.php b/Modules/Favorite/routes/web.php index 79d30bf97c9..c2af9fb92e9 100644 --- a/Modules/Favorite/routes/web.php +++ b/Modules/Favorite/routes/web.php @@ -1,5 +1,7 @@ statusForIndex($index)) { 'expired' => now()->subDays(4 + ($index % 5)), diff --git a/Modules/Listing/Database/Seeders/ListingSeeder.php b/Modules/Listing/Database/Seeders/ListingSeeder.php index 7160aaf79b4..aef52f116a2 100644 --- a/Modules/Listing/Database/Seeders/ListingSeeder.php +++ b/Modules/Listing/Database/Seeders/ListingSeeder.php @@ -1,5 +1,7 @@ resolveSeederUsers(); @@ -42,8 +46,8 @@ public function run(): void foreach ($categories as $category) { foreach ($users as $user) { - if ($assignedImageIndex >= $imagePool->count()) { - continue; + if ($assignedImageIndex >= self::MAX_DEMO_LISTINGS) { + break 2; } $listingData = $this->buildListingData( @@ -52,7 +56,7 @@ public function run(): void $countries, $turkeyCities, $user, - $imagePool->get($assignedImageIndex) + $imagePool->get($assignedImageIndex % $imagePool->count()) ); $listing = $this->upsertListing($listingData, $category, $user); $plannedSlugs[] = $listing->slug; diff --git a/Modules/Listing/Database/migrations/2024_01_01_000003_create_listings_table.php b/Modules/Listing/Database/migrations/2024_01_01_000003_create_listings_table.php index 74edd779686..0bf76a22fac 100644 --- a/Modules/Listing/Database/migrations/2024_01_01_000003_create_listings_table.php +++ b/Modules/Listing/Database/migrations/2024_01_01_000003_create_listings_table.php @@ -1,7 +1,10 @@ id(); $table->string('title'); - $table->string('slug')->unique(); + $table->string('slug'); $table->text('description')->nullable(); $table->decimal('price', 10, 2)->nullable(); $table->string('currency', 3)->default('USD'); @@ -30,11 +33,14 @@ public function up(): void $table->decimal('latitude', 10, 7)->nullable(); $table->decimal('longitude', 10, 7)->nullable(); $table->timestamps(); + $table->softDeletes(); }); + DB::statement('CREATE UNIQUE INDEX listings_slug_unique ON listings (slug) WHERE deleted_at IS NULL'); + Schema::create('listing_custom_fields', function (Blueprint $table): void { $table->id(); - $table->string('name')->unique(); + $table->string('name'); $table->string('label'); $table->string('type', 32); $table->foreignId('category_id')->nullable()->constrained('categories')->nullOnDelete(); @@ -45,7 +51,10 @@ public function up(): void $table->boolean('is_active')->default(true); $table->unsignedInteger('sort_order')->default(0); $table->timestamps(); + $table->softDeletes(); }); + + DB::statement('CREATE UNIQUE INDEX listing_custom_fields_name_unique ON listing_custom_fields (name) WHERE deleted_at IS NULL'); } public function down(): void diff --git a/Modules/Listing/Database/migrations/2026_03_03_085614_create_media_table.php b/Modules/Listing/Database/migrations/2026_03_03_085614_create_media_table.php index e1fa63b1e07..f04e044cbb6 100644 --- a/Modules/Listing/Database/migrations/2026_03_03_085614_create_media_table.php +++ b/Modules/Listing/Database/migrations/2026_03_03_085614_create_media_table.php @@ -1,5 +1,7 @@ getAttribute('user_id') === null ? null : (int) $listing->getAttribute('user_id'); + $sellerReviewSummary = $sellerId === null + ? ['total' => 0, 'average' => 0.0] + : Review::summaryForSeller($sellerId); + $bestOffer = Offer::highestPendingForListing((int) $listing->getKey()); + $reportReasons = Report::reasons(); + return view($this->themes->view('listing', 'show'), compact( 'listing', + 'sellerId', + 'sellerReviewSummary', + 'bestOffer', + 'reportReasons', 'isListingFavorited', 'isSellerFavorited', 'presentableCustomFields', diff --git a/Modules/Listing/ListingPlugin.php b/Modules/Listing/ListingPlugin.php index da986fcb36e..42bb2e05a53 100644 --- a/Modules/Listing/ListingPlugin.php +++ b/Modules/Listing/ListingPlugin.php @@ -1,5 +1,7 @@ logFillable() ->logOnlyDirty() - ->dontSubmitEmptyLogs(); + ->dontLogEmptyChanges(); } public function category() @@ -138,20 +141,7 @@ public function scopeSearchTerm(Builder $query, string $search): Builder public function scopeWithPanelIndexState(Builder $query): Builder { - return $query - ->with('category:id,name') - ->withCount('favoritedByUsers') - ->withCount('videos') - ->withCount([ - 'videos as ready_videos_count' => fn (Builder $videoQuery): Builder => $videoQuery - ->whereNotNull('path') - ->where('is_active', true), - 'videos as pending_videos_count' => fn (Builder $videoQuery): Builder => $videoQuery - ->whereIn('status', [ - VideoStatus::Pending->value, - VideoStatus::Processing->value, - ]), - ]); + return $query->with('category:id,name'); } public function scopeForCategory(Builder $query, ?int $categoryId): Builder @@ -401,6 +391,14 @@ public static function panelIndexDataForUser(User $user, string $search, string ->paginate(10) ->withQueryString(); + $favoriteCounts = FavoriteDirectory::listingFavoriteCounts( + $listings->getCollection()->map(static fn (self $listing): int => (int) $listing->getKey())->all() + ); + + $listings->getCollection()->each(static function (self $listing) use ($favoriteCounts): void { + $listing->setAttribute('favorited_by_users_count', $favoriteCounts[(int) $listing->getKey()] ?? 0); + }); + return [ 'listings' => $listings, 'counts' => static::panelStatusCountsForUser($user->getKey()), @@ -604,9 +602,6 @@ public function canRevealContactTo(?User $user): bool return (int) $this->user_id !== (int) $user->getKey(); } - /** - * @return array{phone: string, email: string} - */ public function contactDetailsFor(?User $user): array { $canAccess = $this->canRevealContactTo($user) @@ -743,7 +738,7 @@ private function applyAdminStatus(mixed $status): void }, }; - if ($this->status::class === $target) { + if ($target === $this->status::class) { return; } diff --git a/Modules/Listing/Models/ListingCustomField.php b/Modules/Listing/Models/ListingCustomField.php index 13c4fce0637..fb03b437c15 100644 --- a/Modules/Listing/Models/ListingCustomField.php +++ b/Modules/Listing/Models/ListingCustomField.php @@ -1,14 +1,19 @@ belongsTo(\Modules\Category\Models\Category::class); + return $this->belongsTo(Category::class); } public function scopeActive(Builder $query): Builder diff --git a/Modules/Listing/Providers/ListingServiceProvider.php b/Modules/Listing/Providers/ListingServiceProvider.php index 04e4bfd278c..05b7bd4d6d8 100644 --- a/Modules/Listing/Providers/ListingServiceProvider.php +++ b/Modules/Listing/Providers/ListingServiceProvider.php @@ -1,5 +1,7 @@ loadViewsFrom(module_path($this->moduleName, 'resources/views'), $this->moduleNameLower); $this->loadMigrationsFrom(module_path($this->moduleName, 'Database/migrations')); $this->loadRoutesFrom(module_path($this->moduleName, 'routes/web.php')); + $this->loadTranslationsFrom(module_path($this->moduleName, 'lang'), $this->moduleNameLower); } - public function register(): void - {} + public function register(): void {} } diff --git a/Modules/Listing/README.md b/Modules/Listing/README.md new file mode 100644 index 00000000000..53454890fe1 --- /dev/null +++ b/Modules/Listing/README.md @@ -0,0 +1,10 @@ +# Listing + +The core classifieds entity: title, price, category, location, media, custom fields, and a status lifecycle (pending, active, sold, expired) modeled with `spatie/laravel-model-states`. + +- `Listing` is a deliberately fat model: browse/search scopes, media handling (gallery/card/thumbnail conversions via Spatie MediaLibrary), status transitions, and every read model used by the public pages and the panel dashboard all live here rather than in controllers. +- Public browsing (`ListingController`) renders through the pluggable theme system (`Modules/Theme`) — see `resources/views/themes/default` for the active theme and `resources/views/themes/README.md` for how theming works. +- `ListingCustomField` defines the dynamic, per-category form fields shown when creating a listing. +- `Filament/Admin/Resources/ListingResource` is the staff admin view, including moderation and dashboard widgets. + +Depends on `Category`, `Location`, `User`, `Video`, and `Conversation` only through their models' public relationships/APIs — never raw joins across module tables. diff --git a/Modules/Listing/States/ActiveListingStatus.php b/Modules/Listing/States/ActiveListingStatus.php index 5f41e2ac33f..4ab00792cd2 100644 --- a/Modules/Listing/States/ActiveListingStatus.php +++ b/Modules/Listing/States/ActiveListingStatus.php @@ -1,5 +1,7 @@ 'مجاني', + 'contact_seller' => 'التواصل مع البائع', + 'view' => 'عرض', + 'post_listing' => 'أضف إعلانًا', + 'featured_listings' => 'إعلانات مميزة', + 'featured' => 'مميز', + 'description' => 'الوصف', + 'similar_listings' => 'إعلانات مشابهة', + 'save_listing' => 'حفظ الإعلان', + 'saved' => 'محفوظ', + 'save_seller' => 'حفظ البائع', + 'seller_saved' => 'تم حفظ البائع', + 'message_seller' => 'مراسلة البائع', + 'open_chat' => 'فتح المحادثة', + 'listing_details' => 'تفاصيل الإعلان', + 'videos' => 'الفيديوهات', + 'log_in_to_save' => 'سجّل الدخول للحفظ', + 'log_in_to_contact_seller' => 'سجّل الدخول للتواصل مع البائع', + 'location_not_specified' => 'الموقع غير محدد', + 'no_image' => 'لا توجد صورة', + 'price_on_request' => 'السعر عند الطلب', + 'no_listings_yet' => 'لا توجد إعلانات بعد.', + 'no_listings_match_filter' => 'لا توجد إعلانات مطابقة لهذا الفلتر.', + 'posted_at' => 'نُشر :time', +]; diff --git a/Modules/Listing/lang/de/messages.php b/Modules/Listing/lang/de/messages.php new file mode 100644 index 00000000000..eaead61e1c5 --- /dev/null +++ b/Modules/Listing/lang/de/messages.php @@ -0,0 +1,30 @@ + 'Kostenlos', + 'contact_seller' => 'Verkäufer kontaktieren', + 'view' => 'Ansehen', + 'post_listing' => 'Anzeige aufgeben', + 'featured_listings' => 'Empfohlene Anzeigen', + 'featured' => 'Empfohlen', + 'description' => 'Beschreibung', + 'similar_listings' => 'Ähnliche Anzeigen', + 'save_listing' => 'Anzeige speichern', + 'saved' => 'Gespeichert', + 'save_seller' => 'Verkäufer speichern', + 'seller_saved' => 'Verkäufer gespeichert', + 'message_seller' => 'Nachricht an Verkäufer', + 'open_chat' => 'Chat öffnen', + 'listing_details' => 'Anzeigendetails', + 'videos' => 'Videos', + 'log_in_to_save' => 'Zum Speichern anmelden', + 'log_in_to_contact_seller' => 'Zum Kontaktieren anmelden', + 'location_not_specified' => 'Standort nicht angegeben', + 'no_image' => 'Kein Bild', + 'price_on_request' => 'Preis auf Anfrage', + 'no_listings_yet' => 'Noch keine Anzeigen.', + 'no_listings_match_filter' => 'Keine Anzeigen entsprechen diesem Filter.', + 'posted_at' => 'Veröffentlicht :time', +]; diff --git a/Modules/Listing/lang/en/messages.php b/Modules/Listing/lang/en/messages.php new file mode 100644 index 00000000000..6f74919b620 --- /dev/null +++ b/Modules/Listing/lang/en/messages.php @@ -0,0 +1,30 @@ + 'Free', + 'contact_seller' => 'Contact Seller', + 'view' => 'View', + 'post_listing' => 'Post Listing', + 'featured_listings' => 'Featured Listings', + 'featured' => 'Featured', + 'description' => 'Description', + 'similar_listings' => 'Similar listings', + 'save_listing' => 'Save listing', + 'saved' => 'Saved', + 'save_seller' => 'Save seller', + 'seller_saved' => 'Seller saved', + 'message_seller' => 'Message seller', + 'open_chat' => 'Open chat', + 'listing_details' => 'Listing details', + 'videos' => 'Videos', + 'log_in_to_save' => 'Log in to save', + 'log_in_to_contact_seller' => 'Log in to contact seller', + 'location_not_specified' => 'Location not specified', + 'no_image' => 'No image', + 'price_on_request' => 'Price on request', + 'no_listings_yet' => 'No listings yet.', + 'no_listings_match_filter' => 'No listings match this filter.', + 'posted_at' => 'Posted :time', +]; diff --git a/Modules/Listing/lang/es/messages.php b/Modules/Listing/lang/es/messages.php new file mode 100644 index 00000000000..f7b314b85d3 --- /dev/null +++ b/Modules/Listing/lang/es/messages.php @@ -0,0 +1,30 @@ + 'Gratis', + 'contact_seller' => 'Contactar al vendedor', + 'view' => 'Ver', + 'post_listing' => 'Publicar anuncio', + 'featured_listings' => 'Anuncios destacados', + 'featured' => 'Destacado', + 'description' => 'Descripción', + 'similar_listings' => 'Anuncios similares', + 'save_listing' => 'Guardar anuncio', + 'saved' => 'Guardado', + 'save_seller' => 'Guardar vendedor', + 'seller_saved' => 'Vendedor guardado', + 'message_seller' => 'Enviar mensaje al vendedor', + 'open_chat' => 'Abrir chat', + 'listing_details' => 'Detalles del anuncio', + 'videos' => 'Videos', + 'log_in_to_save' => 'Inicia sesión para guardar', + 'log_in_to_contact_seller' => 'Inicia sesión para contactar al vendedor', + 'location_not_specified' => 'Ubicación no especificada', + 'no_image' => 'Sin imagen', + 'price_on_request' => 'Precio a consultar', + 'no_listings_yet' => 'Aún no hay anuncios.', + 'no_listings_match_filter' => 'Ningún anuncio coincide con este filtro.', + 'posted_at' => 'Publicado :time', +]; diff --git a/Modules/Listing/lang/fr/messages.php b/Modules/Listing/lang/fr/messages.php new file mode 100644 index 00000000000..7bb0e968e9d --- /dev/null +++ b/Modules/Listing/lang/fr/messages.php @@ -0,0 +1,30 @@ + 'Gratuit', + 'contact_seller' => 'Contacter le vendeur', + 'view' => 'Voir', + 'post_listing' => 'Publier une annonce', + 'featured_listings' => 'Annonces à la une', + 'featured' => 'À la une', + 'description' => 'Description', + 'similar_listings' => 'Annonces similaires', + 'save_listing' => "Enregistrer l'annonce", + 'saved' => 'Enregistré', + 'save_seller' => 'Enregistrer le vendeur', + 'seller_saved' => 'Vendeur enregistré', + 'message_seller' => 'Envoyer un message au vendeur', + 'open_chat' => 'Ouvrir la discussion', + 'listing_details' => "Détails de l'annonce", + 'videos' => 'Vidéos', + 'log_in_to_save' => 'Connectez-vous pour enregistrer', + 'log_in_to_contact_seller' => 'Connectez-vous pour contacter le vendeur', + 'location_not_specified' => 'Emplacement non précisé', + 'no_image' => 'Aucune image', + 'price_on_request' => 'Prix sur demande', + 'no_listings_yet' => "Aucune annonce pour l'instant.", + 'no_listings_match_filter' => 'Aucune annonce ne correspond à ce filtre.', + 'posted_at' => 'Publié :time', +]; diff --git a/Modules/Listing/lang/it/messages.php b/Modules/Listing/lang/it/messages.php new file mode 100644 index 00000000000..8d80f987f1a --- /dev/null +++ b/Modules/Listing/lang/it/messages.php @@ -0,0 +1,30 @@ + 'Gratis', + 'contact_seller' => 'Contatta il venditore', + 'view' => 'Vedi', + 'post_listing' => 'Pubblica annuncio', + 'featured_listings' => 'Annunci in evidenza', + 'featured' => 'In evidenza', + 'description' => 'Descrizione', + 'similar_listings' => 'Annunci simili', + 'save_listing' => 'Salva annuncio', + 'saved' => 'Salvato', + 'save_seller' => 'Salva venditore', + 'seller_saved' => 'Venditore salvato', + 'message_seller' => 'Scrivi al venditore', + 'open_chat' => 'Apri chat', + 'listing_details' => "Dettagli dell'annuncio", + 'videos' => 'Video', + 'log_in_to_save' => 'Accedi per salvare', + 'log_in_to_contact_seller' => 'Accedi per contattare il venditore', + 'location_not_specified' => 'Posizione non specificata', + 'no_image' => 'Nessuna immagine', + 'price_on_request' => 'Prezzo su richiesta', + 'no_listings_yet' => 'Ancora nessun annuncio.', + 'no_listings_match_filter' => 'Nessun annuncio corrisponde a questo filtro.', + 'posted_at' => 'Pubblicato :time', +]; diff --git a/Modules/Listing/lang/ja/messages.php b/Modules/Listing/lang/ja/messages.php new file mode 100644 index 00000000000..ae0e27b2ad8 --- /dev/null +++ b/Modules/Listing/lang/ja/messages.php @@ -0,0 +1,30 @@ + '無料', + 'contact_seller' => '出品者に連絡', + 'view' => '見る', + 'post_listing' => '出品する', + 'featured_listings' => '注目の出品', + 'featured' => '注目', + 'description' => '説明', + 'similar_listings' => '類似の出品', + 'save_listing' => 'この出品を保存', + 'saved' => '保存済み', + 'save_seller' => '出品者を保存', + 'seller_saved' => '出品者を保存しました', + 'message_seller' => '出品者にメッセージ', + 'open_chat' => 'チャットを開く', + 'listing_details' => '出品の詳細', + 'videos' => '動画', + 'log_in_to_save' => '保存するにはログイン', + 'log_in_to_contact_seller' => '連絡するにはログイン', + 'location_not_specified' => '場所が指定されていません', + 'no_image' => '画像なし', + 'price_on_request' => '価格応相談', + 'no_listings_yet' => 'まだ出品はありません。', + 'no_listings_match_filter' => 'この条件に一致する出品はありません。', + 'posted_at' => ':time に投稿', +]; diff --git a/Modules/Listing/lang/pt/messages.php b/Modules/Listing/lang/pt/messages.php new file mode 100644 index 00000000000..33206b08c29 --- /dev/null +++ b/Modules/Listing/lang/pt/messages.php @@ -0,0 +1,30 @@ + 'Grátis', + 'contact_seller' => 'Contatar vendedor', + 'view' => 'Ver', + 'post_listing' => 'Publicar anúncio', + 'featured_listings' => 'Anúncios em destaque', + 'featured' => 'Destaque', + 'description' => 'Descrição', + 'similar_listings' => 'Anúncios semelhantes', + 'save_listing' => 'Guardar anúncio', + 'saved' => 'Guardado', + 'save_seller' => 'Guardar vendedor', + 'seller_saved' => 'Vendedor guardado', + 'message_seller' => 'Enviar mensagem ao vendedor', + 'open_chat' => 'Abrir conversa', + 'listing_details' => 'Detalhes do anúncio', + 'videos' => 'Vídeos', + 'log_in_to_save' => 'Inicie sessão para guardar', + 'log_in_to_contact_seller' => 'Inicie sessão para contatar o vendedor', + 'location_not_specified' => 'Localização não especificada', + 'no_image' => 'Sem imagem', + 'price_on_request' => 'Preço sob consulta', + 'no_listings_yet' => 'Ainda não há anúncios.', + 'no_listings_match_filter' => 'Nenhum anúncio corresponde a este filtro.', + 'posted_at' => 'Publicado :time', +]; diff --git a/Modules/Listing/lang/ru/messages.php b/Modules/Listing/lang/ru/messages.php new file mode 100644 index 00000000000..85e602eafa5 --- /dev/null +++ b/Modules/Listing/lang/ru/messages.php @@ -0,0 +1,30 @@ + 'Бесплатно', + 'contact_seller' => 'Связаться с продавцом', + 'view' => 'Просмотр', + 'post_listing' => 'Разместить объявление', + 'featured_listings' => 'Рекомендуемые объявления', + 'featured' => 'Рекомендуемое', + 'description' => 'Описание', + 'similar_listings' => 'Похожие объявления', + 'save_listing' => 'Сохранить объявление', + 'saved' => 'Сохранено', + 'save_seller' => 'Сохранить продавца', + 'seller_saved' => 'Продавец сохранён', + 'message_seller' => 'Написать продавцу', + 'open_chat' => 'Открыть чат', + 'listing_details' => 'Детали объявления', + 'videos' => 'Видео', + 'log_in_to_save' => 'Войдите, чтобы сохранить', + 'log_in_to_contact_seller' => 'Войдите, чтобы связаться с продавцом', + 'location_not_specified' => 'Местоположение не указано', + 'no_image' => 'Нет изображения', + 'price_on_request' => 'Цена по запросу', + 'no_listings_yet' => 'Пока нет объявлений.', + 'no_listings_match_filter' => 'Нет объявлений, соответствующих фильтру.', + 'posted_at' => 'Опубликовано :time', +]; diff --git a/Modules/Listing/lang/tr/messages.php b/Modules/Listing/lang/tr/messages.php new file mode 100644 index 00000000000..bed51934def --- /dev/null +++ b/Modules/Listing/lang/tr/messages.php @@ -0,0 +1,30 @@ + 'Ücretsiz', + 'contact_seller' => 'Satıcıyla İletişime Geç', + 'view' => 'Görüntüle', + 'post_listing' => 'İlan Ver', + 'featured_listings' => 'Öne Çıkan İlanlar', + 'featured' => 'Öne Çıkan', + 'description' => 'Açıklama', + 'similar_listings' => 'Benzer ilanlar', + 'save_listing' => 'İlanı kaydet', + 'saved' => 'Kaydedildi', + 'save_seller' => 'Satıcıyı kaydet', + 'seller_saved' => 'Satıcı kaydedildi', + 'message_seller' => 'Satıcıya mesaj gönder', + 'open_chat' => 'Sohbeti aç', + 'listing_details' => 'İlan detayları', + 'videos' => 'Videolar', + 'log_in_to_save' => 'Kaydetmek için giriş yapın', + 'log_in_to_contact_seller' => 'Satıcıyla iletişim için giriş yapın', + 'location_not_specified' => 'Konum belirtilmedi', + 'no_image' => 'Görsel yok', + 'price_on_request' => 'Fiyat sorunuz', + 'no_listings_yet' => 'Henüz ilan yok.', + 'no_listings_match_filter' => 'Bu filtreyle eşleşen ilan yok.', + 'posted_at' => ':time paylaşıldı', +]; diff --git a/Modules/Listing/lang/zh/messages.php b/Modules/Listing/lang/zh/messages.php new file mode 100644 index 00000000000..0331efeea92 --- /dev/null +++ b/Modules/Listing/lang/zh/messages.php @@ -0,0 +1,30 @@ + '免费', + 'contact_seller' => '联系卖家', + 'view' => '查看', + 'post_listing' => '发布信息', + 'featured_listings' => '精选列表', + 'featured' => '精选', + 'description' => '描述', + 'similar_listings' => '相似列表', + 'save_listing' => '收藏该列表', + 'saved' => '已收藏', + 'save_seller' => '收藏卖家', + 'seller_saved' => '卖家已收藏', + 'message_seller' => '给卖家发消息', + 'open_chat' => '打开聊天', + 'listing_details' => '列表详情', + 'videos' => '视频', + 'log_in_to_save' => '登录后收藏', + 'log_in_to_contact_seller' => '登录后联系卖家', + 'location_not_specified' => '未指定位置', + 'no_image' => '暂无图片', + 'price_on_request' => '价格面议', + 'no_listings_yet' => '暂无列表。', + 'no_listings_match_filter' => '没有符合此筛选条件的列表。', + 'posted_at' => '发布于 :time', +]; diff --git a/Modules/Listing/resources/views/index.blade.php b/Modules/Listing/resources/views/index.blade.php deleted file mode 100644 index 561bb9a1b65..00000000000 --- a/Modules/Listing/resources/views/index.blade.php +++ /dev/null @@ -1,7 +0,0 @@ -@extends('app::layouts.app') - -@section('title', trim((string) ($selectedCategory?->name ?? '')) !== '' ? trim((string) $selectedCategory->name).' Listings and Prices' : 'All Listings and Prices') - -@section('content') - @include('listing::partials.index-content') -@endsection diff --git a/Modules/Listing/resources/views/partials/index-content.blade.php b/Modules/Listing/resources/views/partials/index-content.blade.php deleted file mode 100644 index c26caae629b..00000000000 --- a/Modules/Listing/resources/views/partials/index-content.blade.php +++ /dev/null @@ -1,383 +0,0 @@ -@php - $allListingsCount = isset($allListingsTotal) ? (int) $allListingsTotal : (int) $listings->total(); - $resultListingsCount = isset($filteredListingsTotal) ? (int) $filteredListingsTotal : (int) $listings->total(); - $activeCategoryName = $selectedCategory?->name ? trim((string) $selectedCategory->name) : ''; - $seoHeading = $activeCategoryName !== '' - ? $activeCategoryName.' Listings and Prices' - : 'All Listings and Prices'; - $canSaveSearch = $search !== '' || ! is_null($categoryId); - $normalizeQuery = static fn ($value): bool => ! is_null($value) && $value !== ''; - $baseCategoryQuery = array_filter([ - 'search' => $search !== '' ? $search : null, - 'user' => $sellerUserId ?? null, - 'country' => $countryId, - 'city' => $cityId, - 'min_price' => $minPriceInput !== '' ? $minPriceInput : null, - 'max_price' => $maxPriceInput !== '' ? $maxPriceInput : null, - 'date_filter' => $dateFilter !== 'all' ? $dateFilter : null, - 'sort' => $sort !== 'smart' ? $sort : null, - ], $normalizeQuery); - $clearFiltersQuery = array_filter([ - 'search' => $search !== '' ? $search : null, - 'user' => $sellerUserId ?? null, - ], $normalizeQuery); - $activeFilterCount = collect([ - $categoryId, - $countryId, - $cityId, - $sellerUserId, - $minPriceInput !== '' ? $minPriceInput : null, - $maxPriceInput !== '' ? $maxPriceInput : null, - $dateFilter !== 'all' ? $dateFilter : null, - ])->filter($normalizeQuery)->count(); -@endphp - -
-

{{ $seoHeading }}

- -
- - -
-
-
- -
- @if($search !== '') - - @endif - @if($categoryId) - - @endif - @if(! empty($sellerUserId)) - - @endif - @if($countryId) - - @endif - @if($cityId) - - @endif - @if($minPriceInput !== '') - - @endif - @if($maxPriceInput !== '') - - @endif - @if($dateFilter !== 'all') - - @endif - -
-
-

- {{ number_format($resultListingsCount) }} - {{ $activeCategoryName !== '' ? ' listings in '.$activeCategoryName : ' listings found' }} -

-
- - - - @if($listings->isEmpty()) -
- No listings match this filter. -
- @else -
- @foreach($listings as $listing) - @php - $listingImage = $listing->primaryImageData('card'); - $isFavorited = in_array($listing->id, $favoriteListingIds ?? [], true); - $priceValue = ! is_null($listing->price) ? (float) $listing->price : null; - $locationParts = array_filter([ - trim((string) ($listing->city ?? '')), - trim((string) ($listing->country ?? '')), - ], fn ($value) => $value !== ''); - $locationText = implode(', ', $locationParts); - @endphp -
-
- @if($listingImage) - - @include('listing::partials.responsive-image', [ - 'image' => $listingImage, - 'alt' => $listing->title, - 'class' => 'w-full h-full object-cover', - ]) - - @else - - - - - - @endif - - @if($listing->is_featured) - - Featured - - @endif - -
- @auth -
- @csrf - -
- @else - - ♥ - - @endauth -
-
- -
- -

- @if(!is_null($priceValue) && $priceValue > 0) - {{ number_format($priceValue, 0) }} {{ $listing->currency }} - @else - Free - @endif -

-

- {{ $listing->title }} -

-
- -

- {{ $listing->category?->name ?: 'No category' }} -

- -
- {{ $locationText !== '' ? $locationText : 'Location not specified' }} - {{ $listing->created_at?->format('M j, Y') }} -
-
-
- @endforeach -
- @endif - -
- {{ $listings->links() }} -
-
-
-
diff --git a/Modules/Listing/resources/views/show.blade.php b/Modules/Listing/resources/views/show.blade.php deleted file mode 100644 index 50e27b959df..00000000000 --- a/Modules/Listing/resources/views/show.blade.php +++ /dev/null @@ -1,115 +0,0 @@ -@extends('app::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 -
-
-
-
- -
-
-
-

{{ $displayTitle }}

- - @if($hasPrice) - @if($priceValue > 0) - {{ number_format($priceValue, 0) }} {{ $listing->currency ?? 'USD' }} - @else - Free - @endif - @else - Price on request - @endif - -
-
- @auth -
- @csrf - -
- @if($listing->user && (int) $listing->user->id !== (int) auth()->id()) -
- @csrf - -
- @if($existingConversationId) - - Open chat - - @else -
- @csrf - -
- @endif - @endif - @else - - Log in to save - - @endauth -
-

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

-

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

-
-

Description

-

{{ $displayDescription }}

-
- @if(($presentableCustomFields ?? []) !== []) -
-

Listing details

-
- @foreach($presentableCustomFields as $field) -
-

{{ $field['label'] }}

-

{{ $field['value'] }}

-
- @endforeach -
-
- @endif -
-

Contact Seller

- @if($listing->user) -

Name: {{ $listing->user->name }}

- @endif - @if($listing->contact_phone) -

Phone: {{ $listing->contact_phone }}

- @endif - @if($listing->contact_email) -

Email: {{ $listing->contact_email }}

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

No contact details provided.

- @endif -
- -
-
-
-
-@endsection diff --git a/Modules/Listing/resources/views/themes/README.md b/Modules/Listing/resources/views/themes/README.md index 7984fb255c6..0052b981fe2 100644 --- a/Modules/Listing/resources/views/themes/README.md +++ b/Modules/Listing/resources/views/themes/README.md @@ -1,6 +1,6 @@ # Listing Theme Contract -Active template is resolved from `config('theme.modules.listing')`. +Active template is resolved from `config('theme.modules.listing')`, defaulting to `default`. Directory structure: @@ -11,7 +11,6 @@ Fallback order: 1. `listing::themes.{active}.{view}` 2. `listing::themes.default.{view}` -3. `listing::{view}` To add a new theme: diff --git a/Modules/Listing/resources/views/themes/default/index.blade.php b/Modules/Listing/resources/views/themes/default/index.blade.php index 561bb9a1b65..1c118e2999c 100644 --- a/Modules/Listing/resources/views/themes/default/index.blade.php +++ b/Modules/Listing/resources/views/themes/default/index.blade.php @@ -1,7 +1,190 @@ -@extends('app::layouts.app') +@extends('site::layouts.app') -@section('title', trim((string) ($selectedCategory?->name ?? '')) !== '' ? trim((string) $selectedCategory->name).' Listings and Prices' : 'All Listings and Prices') +@section('title', $selectedCategory?->getAttribute('name') ?? __('site::messages.all_listings')) +@section('description', __('site::messages.hero_lead')) + +@php + $sortOptions = [ + 'smart' => __('site::messages.sort_relevance'), + 'newest' => __('site::messages.sort_newest'), + 'oldest' => __('site::messages.sort_oldest'), + 'price_asc' => __('site::messages.sort_price_asc'), + 'price_desc' => __('site::messages.sort_price_desc'), + ]; + $dateOptions = [ + 'all' => __('site::messages.any_time'), + 'today' => __('site::messages.today'), + 'week' => __('site::messages.this_week'), + 'month' => __('site::messages.this_month'), + ]; +@endphp @section('content') - @include('listing::partials.index-content') +
+
+ + +
+
+

{{ $selectedCategory?->getAttribute('name') ?? __('site::messages.all_listings') }}

+

{{ trans_choice('site::messages.results_count', $filteredListingsTotal, ['count' => number_format($filteredListingsTotal)]) }}

+
+ + @auth +
+ @csrf + + + +
+ @endauth +
+ +
+ +
+
+ + +
+
+ @foreach(request()->except(['sort', 'page']) as $key => $value) + @if(is_scalar($value)) + + @endif + @endforeach + + +
+ +
+ + +
+
+
+ + @if($listings->isNotEmpty()) +
+ @foreach($listings as $listing) + + @endforeach +
+ + {{ $listings->links('components.pagination') }} + @else + + {{ __('site::messages.reset') }} + + @endif +
+ +
+
+
@endsection diff --git a/Modules/Listing/resources/views/themes/default/show.blade.php b/Modules/Listing/resources/views/themes/default/show.blade.php index 244530ac13e..53216126178 100644 --- a/Modules/Listing/resources/views/themes/default/show.blade.php +++ b/Modules/Listing/resources/views/themes/default/show.blade.php @@ -1,128 +1,302 @@ -@extends('app::layouts.app') -@section('content') -@php - $title = trim((string) ($listing->title ?? '')); - $displayTitle = ($title !== '' && preg_match('/[\pL\pN]/u', $title)) ? $title : 'Untitled listing'; +@extends('site::layouts.app') - $city = trim((string) ($listing->city ?? '')); - $country = trim((string) ($listing->country ?? '')); - $location = implode(', ', array_filter([$city, $country], fn ($value) => $value !== '')); +@section('title', $listing->getAttribute('title')) +@section('description', \Illuminate\Support\Str::limit(strip_tags((string) $listing->getAttribute('description')), 155)) +@section('og_image', $listing->primaryImageUrl('gallery') ?? '') - $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; +@php + $seller = $listing->getRelation('user'); + $viewer = auth()->user(); + $isOwner = $viewer !== null && $sellerId !== null && (int) $viewer->getKey() === $sellerId; + $city = trim((string) $listing->getAttribute('city')); + $country = trim((string) $listing->getAttribute('country')); + $place = collect([$city, $country])->filter()->implode(', '); + $createdAt = $listing->getAttribute('created_at'); + $canContact = $viewer !== null && ! $isOwner && $sellerId !== null; @endphp -
-
-
-
- -
-
-
-

{{ $displayTitle }}

- - @if($hasPrice) - @if($priceValue > 0) - {{ number_format($priceValue, 0) }} {{ $listing->currency ?? 'USD' }} - @else - Free - @endif - @else - Price on request - @endif - -
-
- @auth -
- @csrf - -
- @if($listing->user && (int) $listing->user->id !== (int) auth()->id()) -
- @csrf - -
- @if($existingConversationId) - - Open chat - - @else -
- @csrf - -
+ + 1 / {{ count($gallery) }} @endif +
+ + @if(count($gallery) > 0) + @endif - @else - - Log in to save - - @endauth -
-

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

-

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

-
-

Description

-

{{ $displayDescription }}

-
- @if(($listingVideos ?? collect())->isNotEmpty()) -
-

Videos

-
- @foreach($listingVideos as $video) -
- -

{{ $video->titleLabel() }}

+ + +
+
+
+

{{ $listing->panelPriceLabel() }}

+

{{ $listing->getAttribute('title') }}

+
+ @if($place !== '') + {{ $place }} + @endif + @if($createdAt) + + @endif + {{ trans_choice('site::messages.views', (int) $listing->getAttribute('view_count'), ['count' => (int) $listing->getAttribute('view_count')]) }} +
+
+ + @if(filled($listing->getAttribute('description'))) +
+

{{ __('site::messages.description') }}

+
{!! nl2br(e($listing->getAttribute('description'))) !!}
+
+ @endif + + @if(! empty($presentableCustomFields)) +
+

{{ __('site::messages.details') }}

+
+ @foreach($presentableCustomFields as $field) +
+
{{ $field['label'] }}
+
{{ $field['value'] }}
+
+ @endforeach +
+
+ @endif + +
+
+
+
{{ __('site::messages.listing_id') }}
+
#{{ $listing->getKey() }}
+
+ @if($listing->getRelation('category')) +
+
{{ __('site::messages.category') }}
+
{{ $listing->getRelation('category')->getAttribute('name') }}
+
+ @endif +
- @endforeach
-
+ + + @if($listingVideos->isNotEmpty()) +
+

{{ __('site::messages.details') }}

+
+ @foreach($listingVideos as $video) + + @endforeach +
+
@endif - @if(($presentableCustomFields ?? []) !== []) -
-

Listing details

-
- @foreach($presentableCustomFields as $field) -
-

{{ $field['label'] }}

-

{{ $field['value'] }}

+ +
+ + {{ __('site::messages.safety_note') }} +
+
+ +
+ + + @if($canContact && $listing->getAttribute('price') !== null) +
+
+

{{ __('offer::messages.make_offer') }}

+ @if($bestOffer) + {{ __('offer::messages.best_offer') }} {{ $bestOffer->amountLabel() }} + @endif +
+
+ @csrf +
+ + + + {{ $listing->getAttribute('currency') }} + +
+
+ + +
+ +
+
@endif -
-

Contact Seller

- @if($listing->user) -

Name: {{ $listing->user->name }}

- @endif - @if($listing->contact_phone) -

Phone: {{ $listing->contact_phone }}

- @endif - @if($listing->contact_email) -

Email: {{ $listing->contact_email }}

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

No contact details provided.

- @endif + + @auth + @unless($isOwner) +
+
+ + + +
+
+ @endunless + @endauth + +
+ + @if($relatedListings->isNotEmpty()) +
+
+

{{ __('site::messages.similar_listings') }}

-
- ← Back to listings +
+ @foreach($relatedListings->take(10) as $related) + + @endforeach
-
-
+ + @endif
+ +@if($canContact) +
+
+ @csrf + +
+
+@endif @endsection diff --git a/Modules/Listing/resources/views/themes/otoplus/index.blade.php b/Modules/Listing/resources/views/themes/otoplus/index.blade.php deleted file mode 100644 index 561bb9a1b65..00000000000 --- a/Modules/Listing/resources/views/themes/otoplus/index.blade.php +++ /dev/null @@ -1,7 +0,0 @@ -@extends('app::layouts.app') - -@section('title', trim((string) ($selectedCategory?->name ?? '')) !== '' ? trim((string) $selectedCategory->name).' Listings and Prices' : 'All Listings and Prices') - -@section('content') - @include('listing::partials.index-content') -@endsection diff --git a/Modules/Listing/resources/views/themes/otoplus/show.blade.php b/Modules/Listing/resources/views/themes/otoplus/show.blade.php deleted file mode 100644 index cdcc4ccf41e..00000000000 --- a/Modules/Listing/resources/views/themes/otoplus/show.blade.php +++ /dev/null @@ -1,854 +0,0 @@ -@extends('app::layouts.app') - -@section('content') -@php - $title = trim((string) ($listing->title ?? '')); - $displayTitle = $title !== '' ? $title : 'Untitled listing'; - - $priceLabel = 'Price on request'; - if (! is_null($listing->price)) { - $priceValue = (float) $listing->price; - $formattedPrice = number_format($priceValue, 2, '.', ','); - $formattedPrice = rtrim(rtrim($formattedPrice, '0'), '.'); - $priceLabel = $priceValue > 0 - ? $formattedPrice.' '.($listing->currency ?: 'TRY') - : 'Free'; - } - - $locationLabel = collect([$listing->city, $listing->country]) - ->filter(fn ($value) => is_string($value) && trim($value) !== '') - ->implode(' / '); - - $publishedAt = $listing->created_at?->format('M j, Y') ?? 'Recently'; - $postedAgo = $listing->created_at?->diffForHumans() ?? 'Listed recently'; - $galleryImages = collect($gallery ?? []) - ->filter(fn ($value) => is_array($value) && is_array($value['gallery'] ?? null)) - ->values() - ->all(); - $initialGalleryImage = $galleryImages[0]['gallery'] ?? null; - $galleryCount = count($galleryImages); - - $description = trim((string) ($listing->description ?? '')); - $displayDescription = $description !== '' ? $description : 'No description added for this listing.'; - - $sellerName = trim((string) ($listing->user?->name ?? 'Marketplace Seller')); - $sellerInitial = mb_strtoupper(mb_substr($sellerName, 0, 1)); - $sellerMemberText = $listing->user?->created_at - ? 'Member since '.$listing->user->created_at->format('M Y') - : 'New seller'; - - $referenceCode = '#'.str_pad((string) $listing->getKey(), 8, '0', STR_PAD_LEFT); - $isOwnListing = auth()->check() && (int) auth()->id() === (int) $listing->user_id; - $canStartConversation = auth()->check() && $listing->user && ! $isOwnListing; - $loginRedirectRoute = route('login', ['redirect' => request()->fullUrl()]); - $chatConversation = $detailConversation ?? null; - $chatMessages = $chatConversation?->messages ?? collect(); - $chatSendUrl = $chatConversation ? route('conversations.messages.send', $chatConversation) : ''; - $chatReadUrl = $chatConversation ? route('conversations.read', $chatConversation) : ''; - $chatStartUrl = route('conversations.start', $listing); - $chatUnreadCount = max(0, (int) ($chatConversation?->unread_count ?? 0)); - - $hasContactDetails = $listing->hasContactDetails(); - $hasContactPhone = filled($listing->contact_phone); - $hasContactEmail = filled($listing->contact_email); - $canRevealContact = auth()->check() && ! $isOwnListing && $hasContactDetails; - $contactRevealUrl = route('listings.contact', $listing); - - $primaryContactHref = null; - $primaryContactLabel = 'Call'; - $primaryContactType = null; - - if ($isOwnListing) { - if ($hasContactPhone) { - $primaryContactHref = 'tel:'.preg_replace('/\s+/', '', (string) $listing->contact_phone); - $primaryContactLabel = 'Call'; - $primaryContactType = 'phone'; - } elseif ($hasContactEmail) { - $primaryContactHref = 'mailto:'.$listing->contact_email; - $primaryContactLabel = 'Email'; - $primaryContactType = 'email'; - } - } elseif ($hasContactDetails) { - if ($hasContactPhone) { - $primaryContactLabel = 'Call'; - $primaryContactType = 'phone'; - } elseif ($hasContactEmail) { - $primaryContactLabel = 'Email'; - $primaryContactType = 'email'; - } - } - - $reportEmail = config('mail.from.address', 'support@example.com'); - $reportUrl = 'mailto:'.$reportEmail.'?subject='.rawurlencode('Report listing '.$referenceCode); - $shareUrl = route('listings.show', $listing); - - $detailRows = collect([ - ['label' => 'Listing ID', 'value' => $referenceCode], - ['label' => 'Published', 'value' => $publishedAt], - ['label' => 'Category', 'value' => $listing->category?->name ?? 'General'], - ['label' => 'Location', 'value' => $locationLabel !== '' ? $locationLabel : 'Not specified'], - ]) - ->merge( - collect($presentableCustomFields ?? [])->map(fn (array $field) => [ - 'label' => trim((string) ($field['label'] ?? '')), - 'value' => trim((string) ($field['value'] ?? '')), - ]) - ) - ->filter(fn (array $item) => $item['label'] !== '' && $item['value'] !== '') - ->unique(fn (array $item) => mb_strtolower($item['label'])) - ->values(); - $summaryRows = $detailRows->take(8); - $sellerListingsUrl = $listing->user ? route('listings.index', ['user' => $listing->user->getKey()]) : route('listings.index'); - $locationText = $locationLabel !== '' ? $locationLabel : 'Location not specified'; -@endphp - -
- - -
-
-

{{ $referenceCode }}

-

{{ $displayTitle }}

-
- {{ $listing->category?->name ?? 'Marketplace listing' }} - {{ $sellerName }} - {{ $postedAgo }} -
-
- -
-
- - - @auth -
- @csrf - -
- @else - Save listing - @endauth - - -
-
-
- -
- - -
-
-
{{ $priceLabel }}
-
{{ $publishedAt }}
-
- -
{{ $locationText }}
- -
- @foreach($summaryRows as $row) -
- {{ $row['label'] }} - {{ $row['value'] }} -
- @endforeach -
- - Report this listing -
- - -
- -
-
- - -
- -
-
- @foreach($detailRows as $row) -
- {{ $row['label'] }} - {{ $row['value'] }} -
- @endforeach -
-
- -
-
- {!! nl2br(e($displayDescription)) !!} -
-
-
- - @if(($listingVideos ?? collect())->isNotEmpty()) -
-
-
-

Videos

-

Additional media attached to the listing.

-
-
- -
- @foreach($listingVideos as $video) -
- -

{{ $video->titleLabel() }}

-
- @endforeach -
-
- @endif - -
-
-
- @if(! $listing->user) - - @elseif($canStartConversation) - - @elseif($isOwnListing) - - @else - Message - @endif - - @if($primaryContactHref) - {{ $primaryContactLabel }} - @elseif($primaryContactType && $canRevealContact) - - @elseif($primaryContactType && ! auth()->check()) - {{ $primaryContactLabel }} - @else - - @endif -
-
-
- - @if($canStartConversation) - - @endif - - @if(($relatedListings ?? collect())->isNotEmpty() || ($themePillCategories ?? collect())->isNotEmpty()) - - @endif -
- - -@endsection diff --git a/Modules/Listing/routes/web.php b/Modules/Listing/routes/web.php index cacf366d205..f566a25704b 100644 --- a/Modules/Listing/routes/web.php +++ b/Modules/Listing/routes/web.php @@ -1,4 +1,6 @@ id(); $table->string('name'); - $table->string('code', 3)->unique(); + $table->string('code', 3); $table->string('phone_code', 10)->nullable(); $table->string('flag', 10)->nullable(); $table->boolean('is_active')->default(true); $table->timestamps(); + $table->softDeletes(); }); + DB::statement('CREATE UNIQUE INDEX countries_code_unique ON countries (code) WHERE deleted_at IS NULL'); + Schema::create('cities', function (Blueprint $table) { $table->id(); $table->string('name'); $table->foreignId('country_id')->constrained('countries')->cascadeOnDelete(); $table->boolean('is_active')->default(true); $table->timestamps(); + $table->softDeletes(); }); Schema::create('districts', function (Blueprint $table) { @@ -32,6 +39,7 @@ public function up(): void $table->foreignId('city_id')->constrained('cities')->cascadeOnDelete(); $table->boolean('is_active')->default(true); $table->timestamps(); + $table->softDeletes(); }); Schema::create('neighborhoods', function (Blueprint $table) { @@ -40,6 +48,7 @@ public function up(): void $table->foreignId('district_id')->constrained('districts')->cascadeOnDelete(); $table->boolean('is_active')->default(true); $table->timestamps(); + $table->softDeletes(); }); } diff --git a/Modules/Location/Filament/Admin/Resources/CityResource.php b/Modules/Location/Filament/Admin/Resources/CityResource.php index 9e2dc971aac..cb88464936e 100644 --- a/Modules/Location/Filament/Admin/Resources/CityResource.php +++ b/Modules/Location/Filament/Admin/Resources/CityResource.php @@ -1,5 +1,7 @@ logFillable() ->logOnlyDirty() - ->dontSubmitEmptyLogs(); + ->dontLogEmptyChanges(); } public function country(): BelongsTo diff --git a/Modules/Location/Models/Country.php b/Modules/Location/Models/Country.php index d93ba22d702..a943a37d77d 100644 --- a/Modules/Location/Models/Country.php +++ b/Modules/Location/Models/Country.php @@ -1,16 +1,19 @@ logFillable() ->logOnlyDirty() - ->dontSubmitEmptyLogs(); + ->dontLogEmptyChanges(); } public function cities(): HasMany diff --git a/Modules/Location/Models/District.php b/Modules/Location/Models/District.php index 32866899f87..373a475c8bb 100644 --- a/Modules/Location/Models/District.php +++ b/Modules/Location/Models/District.php @@ -1,15 +1,20 @@ 'boolean']; public function getActivitylogOptions(): LogOptions @@ -17,8 +22,11 @@ public function getActivitylogOptions(): LogOptions return LogOptions::defaults() ->logFillable() ->logOnlyDirty() - ->dontSubmitEmptyLogs(); + ->dontLogEmptyChanges(); } - public function city() { return $this->belongsTo(City::class); } + public function city() + { + return $this->belongsTo(City::class); + } } diff --git a/Modules/Location/Providers/LocationServiceProvider.php b/Modules/Location/Providers/LocationServiceProvider.php index 3f2383f99e9..c35023241a1 100644 --- a/Modules/Location/Providers/LocationServiceProvider.php +++ b/Modules/Location/Providers/LocationServiceProvider.php @@ -1,5 +1,7 @@ loadRoutesFrom(module_path($this->moduleName, 'routes/web.php')); } - public function register(): void - {} + public function register(): void {} } diff --git a/Modules/Location/README.md b/Modules/Location/README.md new file mode 100644 index 00000000000..97cb67fc287 --- /dev/null +++ b/Modules/Location/README.md @@ -0,0 +1,9 @@ +# Location + +The geographic hierarchy used for listing locations and browse filters: `Country` → `City` → `District` → neighborhood. + +- Each level model carries its own lookup/option-building helpers (`nameOptions`, `districtPayloads`, `resolveLookup`, etc.) used by both the public location picker and the panel's quick-create form. +- `LocationLookupController` exposes a small JSON endpoint for cascading country → city → district selects. +- `Filament/Admin/Resources` manage countries, cities, and districts from the admin panel. + +Listing does not join against these tables directly; it stores denormalized `city`/`country` strings plus latitude/longitude, populated through this module's lookups at creation time. diff --git a/Modules/Location/Support/CountryCodeManager.php b/Modules/Location/Support/CountryCodeManager.php index 7b8ada62aeb..24c565627eb 100644 --- a/Modules/Location/Support/CountryCodeManager.php +++ b/Modules/Location/Support/CountryCodeManager.php @@ -1,5 +1,7 @@ orderBy('id')->pluck('id')->map(static fn (mixed $id): int => (int) $id)->all(); + + if ($userIds === []) { + return; + } + + foreach ($userIds as $position => $userId) { + foreach ($this->samples() as $index => $sample) { + $isRead = ($position + $index) % 3 === 0; + + UserNotification::query()->create([ + 'user_id' => $userId, + 'type' => $sample['type'], + 'title' => $sample['title'], + 'body' => $sample['body'], + 'action_url' => $sample['action_url'], + 'read_at' => $isRead ? now()->subDays($index + 1) : null, + 'created_at' => now()->subDays($index)->subHours($position), + 'updated_at' => now()->subDays($index)->subHours($position), + ]); + } + } + } + + private function samples(): array + { + return [ + [ + 'type' => UserNotification::TYPE_OFFER, + 'title' => 'New offer received', + 'body' => 'A buyer sent an offer on one of your listings.', + 'action_url' => '/panel/offers', + ], + [ + 'type' => UserNotification::TYPE_MESSAGE, + 'title' => 'New message', + 'body' => 'You have an unread message in your inbox.', + 'action_url' => '/panel/inbox', + ], + [ + 'type' => UserNotification::TYPE_REVIEW, + 'title' => 'New review', + 'body' => 'Someone rated their experience with you.', + 'action_url' => '/panel/reviews', + ], + [ + 'type' => UserNotification::TYPE_LISTING, + 'title' => 'Listing expiring soon', + 'body' => 'One of your listings ends in three days. Republish it to stay visible.', + 'action_url' => '/panel/my-listings', + ], + [ + 'type' => UserNotification::TYPE_SAVED_SEARCH, + 'title' => 'New results for a saved search', + 'body' => 'Fresh listings match one of your saved searches.', + 'action_url' => '/favorites', + ], + ]; + } +} diff --git a/Modules/Notification/Database/migrations/2026_08_01_000000_create_user_notifications_table.php b/Modules/Notification/Database/migrations/2026_08_01_000000_create_user_notifications_table.php new file mode 100644 index 00000000000..393f41bf6e0 --- /dev/null +++ b/Modules/Notification/Database/migrations/2026_08_01_000000_create_user_notifications_table.php @@ -0,0 +1,32 @@ +id(); + $table->unsignedBigInteger('user_id')->index(); + $table->string('type', 48); + $table->string('title'); + $table->text('body')->nullable(); + $table->string('action_url')->nullable(); + $table->timestamp('read_at')->nullable(); + $table->timestamps(); + $table->softDeletes(); + + $table->index(['user_id', 'read_at']); + }); + } + + public function down(): void + { + Schema::dropIfExists('user_notifications'); + } +}; diff --git a/Modules/Notification/Filament/Admin/Resources/UserNotificationResource.php b/Modules/Notification/Filament/Admin/Resources/UserNotificationResource.php new file mode 100644 index 00000000000..ed1d21a8f76 --- /dev/null +++ b/Modules/Notification/Filament/Admin/Resources/UserNotificationResource.php @@ -0,0 +1,71 @@ +schema([ + TextInput::make('user_id')->numeric()->required(), + TextInput::make('type')->maxLength(48)->required(), + TextInput::make('title')->maxLength(150)->required(), + Textarea::make('body')->rows(3), + TextInput::make('action_url')->maxLength(255), + ]); + } + + public static function table(Table $table): Table + { + return $table->columns([ + ResourceTableColumns::id(), + TextColumn::make('title')->searchable()->limit(48), + TextColumn::make('type')->badge()->sortable(), + TextColumn::make('user_id')->label('User')->sortable(), + TextColumn::make('read_at')->dateTime()->label('Read')->placeholder('Unread'), + ResourceTableColumns::createdAtHidden(), + ])->defaultSort('id', 'desc')->filters([ + SelectFilter::make('type')->options([ + 'offer' => 'Offer', 'message' => 'Message', 'review' => 'Review', + 'listing' => 'Listing', 'promotion' => 'Promotion', 'saved_search' => 'Saved search', + ]), + ])->actions(ResourceTableActions::editDelete()); + } + + public static function getPages(): array + { + return [ + 'index' => Pages\ListUserNotificationResourceRecords::route('/'), + 'create' => Pages\CreateUserNotificationResourceRecord::route('/create'), + 'edit' => Pages\EditUserNotificationResourceRecord::route('/{record}/edit'), + ]; + } +} diff --git a/Modules/Notification/Filament/Admin/Resources/UserNotificationResource/Pages/CreateUserNotificationResourceRecord.php b/Modules/Notification/Filament/Admin/Resources/UserNotificationResource/Pages/CreateUserNotificationResourceRecord.php new file mode 100644 index 00000000000..42eed50c230 --- /dev/null +++ b/Modules/Notification/Filament/Admin/Resources/UserNotificationResource/Pages/CreateUserNotificationResourceRecord.php @@ -0,0 +1,13 @@ +user()->getKey(); + + return view('notification::index', [ + 'notifications' => UserNotification::paginateForUser($userId), + 'unreadCount' => UserNotification::unreadCountForUser($userId), + ]); + } + + public function read(Request $request, UserNotification $notification): RedirectResponse + { + abort_unless($notification->belongsToUser((int) $request->user()->getKey()), 403); + + $notification->markRead(); + + $target = $notification->getAttribute('action_url'); + + return is_string($target) && $target !== '' + ? redirect()->away($target) + : back(); + } + + public function readAll(Request $request): RedirectResponse + { + UserNotification::markAllReadForUser((int) $request->user()->getKey()); + + return back()->with('success', __('notification::messages.all_read')); + } +} diff --git a/Modules/Notification/Models/UserNotification.php b/Modules/Notification/Models/UserNotification.php new file mode 100644 index 00000000000..85a0755bc0c --- /dev/null +++ b/Modules/Notification/Models/UserNotification.php @@ -0,0 +1,130 @@ + 'datetime', + ]; + + public function scopeForUser(Builder $query, int $userId): Builder + { + return $query->where('user_id', $userId); + } + + public function scopeUnread(Builder $query): Builder + { + return $query->whereNull('read_at'); + } + + public static function publish(int $userId, string $type, string $title, ?string $body = null, ?string $actionUrl = null): self + { + $notification = static::query()->make([ + 'user_id' => $userId, + 'type' => $type, + 'title' => $title, + 'body' => $body, + 'action_url' => $actionUrl, + ]); + + $notification->save(); + + return $notification; + } + + public static function unreadCountForUser(int $userId): int + { + return (int) static::query()->forUser($userId)->unread()->count(); + } + + public static function paginateForUser(int $userId, int $perPage = 20): LengthAwarePaginator + { + return static::query() + ->forUser($userId) + ->latest('id') + ->paginate($perPage) + ->withQueryString(); + } + + public static function latestForUser(int $userId, int $limit = 6): Collection + { + return static::query() + ->forUser($userId) + ->latest('id') + ->limit($limit) + ->get(); + } + + public static function markAllReadForUser(int $userId): void + { + static::query() + ->forUser($userId) + ->unread() + ->update(['read_at' => now()]); + } + + public function markRead(): void + { + if ($this->getAttribute('read_at') !== null) { + return; + } + + $this->forceFill(['read_at' => now()])->save(); + } + + public function isUnread(): bool + { + return $this->getAttribute('read_at') === null; + } + + public function belongsToUser(int $userId): bool + { + return (int) $this->getAttribute('user_id') === $userId; + } + + public function iconName(): string + { + return match ((string) $this->getAttribute('type')) { + self::TYPE_OFFER => 'tag', + self::TYPE_MESSAGE => 'mail', + self::TYPE_REVIEW => 'star', + self::TYPE_PROMOTION => 'sparkle', + self::TYPE_SAVED_SEARCH => 'search', + default => 'bell', + }; + } +} diff --git a/Modules/Notification/NotificationPlugin.php b/Modules/Notification/NotificationPlugin.php new file mode 100644 index 00000000000..1bcc32dc524 --- /dev/null +++ b/Modules/Notification/NotificationPlugin.php @@ -0,0 +1,31 @@ +discoverResources( + in: module_path('Notification', 'Filament/Admin/Resources'), + for: 'Modules\\Notification\\Filament\\Admin\\Resources', + ); + } + + public function boot(Panel $panel): void {} +} diff --git a/Modules/Notification/Providers/NotificationServiceProvider.php b/Modules/Notification/Providers/NotificationServiceProvider.php new file mode 100644 index 00000000000..3de58d64ccb --- /dev/null +++ b/Modules/Notification/Providers/NotificationServiceProvider.php @@ -0,0 +1,20 @@ +loadMigrationsFrom(module_path('Notification', 'Database/migrations')); + $this->loadRoutesFrom(module_path('Notification', 'routes/web.php')); + $this->loadViewsFrom(module_path('Notification', 'resources/views'), 'notification'); + $this->loadTranslationsFrom(module_path('Notification', 'lang'), 'notification'); + } + + public function register(): void {} +} diff --git a/Modules/Notification/lang/en/messages.php b/Modules/Notification/lang/en/messages.php new file mode 100644 index 00000000000..11d002ac786 --- /dev/null +++ b/Modules/Notification/lang/en/messages.php @@ -0,0 +1,13 @@ + 'Notifications', + 'unread' => 'Unread', + 'mark_all_read' => 'Mark all as read', + 'all_read' => 'All notifications marked as read.', + 'empty' => 'No notifications', + 'empty_hint' => 'Offers, messages and reviews show up here.', + 'view' => 'View', +]; diff --git a/Modules/Notification/lang/tr/messages.php b/Modules/Notification/lang/tr/messages.php new file mode 100644 index 00000000000..2848892bdfc --- /dev/null +++ b/Modules/Notification/lang/tr/messages.php @@ -0,0 +1,13 @@ + 'Bildirimler', + 'unread' => 'Okunmamış', + 'mark_all_read' => 'Tümünü okundu işaretle', + 'all_read' => 'Tüm bildirimler okundu olarak işaretlendi.', + 'empty' => 'Bildirim yok', + 'empty_hint' => 'Teklifler, mesajlar ve değerlendirmeler burada görünür.', + 'view' => 'Görüntüle', +]; diff --git a/Modules/Notification/module.json b/Modules/Notification/module.json new file mode 100644 index 00000000000..181e6e86bb2 --- /dev/null +++ b/Modules/Notification/module.json @@ -0,0 +1,11 @@ +{ + "name": "Notification", + "alias": "notification", + "description": "", + "keywords": [], + "priority": 0, + "providers": [ + "Modules\\Notification\\Providers\\NotificationServiceProvider" + ], + "files": [] +} diff --git a/Modules/Notification/resources/views/index.blade.php b/Modules/Notification/resources/views/index.blade.php new file mode 100644 index 00000000000..25b73f6edb9 --- /dev/null +++ b/Modules/Notification/resources/views/index.blade.php @@ -0,0 +1,60 @@ +@extends('panel::layouts.panel', ['panelSection' => 'notifications']) + +@section('title', __('notification::messages.notifications')) + +@section('panel_content') +
+
+

{{ __('notification::messages.notifications') }}

+

{{ $unreadCount }} {{ __('notification::messages.unread') }}

+
+ @if($unreadCount > 0) +
+ @csrf + +
+ @endif +
+ +@if($notifications->isNotEmpty()) +
+
+ @foreach($notifications as $notification) +
+ $notification->isUnread()])> + + + +
+

+ {{ $notification->getAttribute('title') }} + @if($notification->isUnread()) + {{ __('notification::messages.unread') }} + @endif +

+ @if(filled($notification->getAttribute('body'))) +

{{ $notification->getAttribute('body') }}

+ @endif +
+ +
+
+ +
+
+ @csrf + +
+
+
+ @endforeach +
+
+ + {{ $notifications->links('components.pagination') }} +@else + +@endif +@endsection diff --git a/Modules/Notification/routes/web.php b/Modules/Notification/routes/web.php new file mode 100644 index 00000000000..fc9c49ed6b2 --- /dev/null +++ b/Modules/Notification/routes/web.php @@ -0,0 +1,12 @@ +prefix('panel')->name('panel.notifications.')->group(function (): void { + Route::get('/notifications', [NotificationController::class, 'index'])->name('index'); + Route::post('/notifications/{notification}/read', [NotificationController::class, 'read'])->name('read'); + Route::post('/notifications/read-all', [NotificationController::class, 'readAll'])->name('read-all'); +}); diff --git a/Modules/Offer/Database/Seeders/OfferSeeder.php b/Modules/Offer/Database/Seeders/OfferSeeder.php new file mode 100644 index 00000000000..1f247954b56 --- /dev/null +++ b/Modules/Offer/Database/Seeders/OfferSeeder.php @@ -0,0 +1,78 @@ +orderBy('id')->pluck('id')->map(static fn (mixed $id): int => (int) $id)->all(); + + if (count($userIds) < 2) { + return; + } + + $listings = Listing::query() + ->active() + ->whereNotNull('user_id') + ->whereNotNull('price') + ->orderBy('id') + ->limit(24) + ->get(['id', 'user_id', 'price', 'currency']); + + $messages = $this->messages(); + $statuses = [Offer::STATUS_PENDING, Offer::STATUS_PENDING, Offer::STATUS_ACCEPTED, Offer::STATUS_DECLINED]; + $index = 0; + + foreach ($listings as $listing) { + $sellerId = (int) $listing->getAttribute('user_id'); + $candidates = array_values(array_filter($userIds, static fn (int $id): bool => $id !== $sellerId)); + + if ($candidates === []) { + continue; + } + + $price = (float) $listing->getAttribute('price'); + $ratios = [0.75, 0.85, 0.9]; + + foreach (array_slice($ratios, 0, ($index % 2) + 1) as $step => $ratio) { + $buyerId = $candidates[($index + $step) % count($candidates)]; + $status = $statuses[($index + $step) % count($statuses)]; + + Offer::query()->create([ + 'listing_id' => (int) $listing->getKey(), + 'buyer_id' => $buyerId, + 'seller_id' => $sellerId, + 'amount' => round($price * $ratio, 2), + 'currency' => (string) $listing->getAttribute('currency'), + 'message' => $messages[($index + $step) % count($messages)], + 'status' => $status, + 'responded_at' => $status === Offer::STATUS_PENDING ? null : now()->subDays($step + 1), + 'created_at' => now()->subDays(($index % 20) + 1), + 'updated_at' => now()->subDays(($index % 20) + 1), + ]); + } + + $index++; + } + } + + private function messages(): array + { + return [ + 'Would you accept this if I collect today?', + 'Cash ready, can meet this evening near the centre.', + 'Interested. Is the price negotiable for a quick sale?', + 'Happy to pay the full amount if you can deliver.', + 'Can we agree on this? I can pick it up at the weekend.', + null, + ]; + } +} diff --git a/Modules/Offer/Database/migrations/2026_08_01_000000_create_offers_table.php b/Modules/Offer/Database/migrations/2026_08_01_000000_create_offers_table.php new file mode 100644 index 00000000000..502c19d68d2 --- /dev/null +++ b/Modules/Offer/Database/migrations/2026_08_01_000000_create_offers_table.php @@ -0,0 +1,35 @@ +id(); + $table->unsignedBigInteger('listing_id')->index(); + $table->unsignedBigInteger('buyer_id')->index(); + $table->unsignedBigInteger('seller_id')->index(); + $table->decimal('amount', 10, 2); + $table->string('currency', 3)->default('USD'); + $table->text('message')->nullable(); + $table->string('status', 24)->default('pending'); + $table->timestamp('responded_at')->nullable(); + $table->timestamps(); + $table->softDeletes(); + + $table->index(['seller_id', 'status']); + $table->index(['listing_id', 'status']); + }); + } + + public function down(): void + { + Schema::dropIfExists('offers'); + } +}; diff --git a/Modules/Offer/Filament/Admin/Resources/OfferResource.php b/Modules/Offer/Filament/Admin/Resources/OfferResource.php new file mode 100644 index 00000000000..0d48c1c1156 --- /dev/null +++ b/Modules/Offer/Filament/Admin/Resources/OfferResource.php @@ -0,0 +1,76 @@ +schema([ + TextInput::make('listing_id')->numeric()->required(), + TextInput::make('buyer_id')->numeric()->required(), + TextInput::make('seller_id')->numeric()->required(), + TextInput::make('amount')->numeric()->required(), + TextInput::make('currency')->maxLength(3)->required(), + Textarea::make('message')->rows(3)->maxLength(500), + Select::make('status')->options(fn (): array => collect(Offer::statuses())->mapWithKeys( + static fn (string $status): array => [$status => __('offer::messages.status_'.$status)] + )->all())->required(), + ]); + } + + public static function table(Table $table): Table + { + return $table->columns([ + ResourceTableColumns::id(), + TextColumn::make('listing_id')->label('Listing')->sortable(), + TextColumn::make('amount')->money(fn (Offer $record): string => (string) $record->getAttribute('currency'))->sortable(), + TextColumn::make('status')->badge()->sortable(), + TextColumn::make('buyer_id')->label('Buyer'), + TextColumn::make('seller_id')->label('Seller'), + ResourceTableColumns::createdAtHidden(), + ])->defaultSort('id', 'desc')->filters([ + SelectFilter::make('status')->options(fn (): array => collect(Offer::statuses())->mapWithKeys( + static fn (string $status): array => [$status => __('offer::messages.status_'.$status)] + )->all()), + ])->actions(ResourceTableActions::editDelete()); + } + + public static function getPages(): array + { + return [ + 'index' => Pages\ListOfferResourceRecords::route('/'), + 'create' => Pages\CreateOfferResourceRecord::route('/create'), + 'edit' => Pages\EditOfferResourceRecord::route('/{record}/edit'), + ]; + } +} diff --git a/Modules/Offer/Filament/Admin/Resources/OfferResource/Pages/CreateOfferResourceRecord.php b/Modules/Offer/Filament/Admin/Resources/OfferResource/Pages/CreateOfferResourceRecord.php new file mode 100644 index 00000000000..0a9c8a2792e --- /dev/null +++ b/Modules/Offer/Filament/Admin/Resources/OfferResource/Pages/CreateOfferResourceRecord.php @@ -0,0 +1,13 @@ +user()->getKey(); + $status = (string) $request->query('status', 'all'); + $direction = $request->query('direction') === 'sent' ? 'sent' : 'received'; + + $offers = $direction === 'sent' + ? Offer::sentByBuyer($userId) + : Offer::receivedBySeller($userId, $status); + + $listings = ListingDirectory::resolve( + $offers->getCollection()->map(static fn (Offer $offer): int => $offer->listingId())->all() + ); + + $counterpartIds = $offers->getCollection() + ->map(static fn (Offer $offer): int => $direction === 'sent' + ? (int) $offer->getAttribute('seller_id') + : $offer->buyerId()) + ->all(); + + return view('offer::index', [ + 'offers' => $offers, + 'listings' => $listings, + 'people' => UserDirectory::resolve($counterpartIds), + 'counts' => Offer::statusCountsForSeller($userId), + 'status' => $status, + 'direction' => $direction, + ]); + } + + public function store(Request $request, int $listing): RedirectResponse + { + $validated = $request->validate([ + 'amount' => ['required', 'numeric', 'min:1', 'max:99999999'], + 'message' => ['nullable', 'string', 'max:500'], + ]); + + $summary = ListingDirectory::find($listing); + + if ($summary === null || $summary['seller_id'] === null) { + abort(404); + } + + $buyerId = (int) $request->user()->getKey(); + + if ($buyerId === $summary['seller_id']) { + return back()->with('error', __('offer::messages.cannot_offer_own')); + } + + $offer = Offer::place( + $listing, + $buyerId, + $summary['seller_id'], + (float) $validated['amount'], + (string) $request->input('currency', config('app.default_currency', 'USD')), + $validated['message'] ?? null, + ); + + UserNotification::publish( + $summary['seller_id'], + UserNotification::TYPE_OFFER, + __('offer::messages.notification_received_title'), + __('offer::messages.notification_received_body', [ + 'amount' => $offer->amountLabel(), + 'listing' => $summary['title'], + ]), + route('panel.offers.index'), + ); + + return back()->with('success', __('offer::messages.sent')); + } + + public function accept(Request $request, Offer $offer): RedirectResponse + { + $this->authorizeSeller($request, $offer); + + $offer->accept(); + $this->notifyBuyer($offer, __('offer::messages.notification_accepted_title'), __('offer::messages.notification_accepted_body', [ + 'listing' => ListingDirectory::titleFor($offer->listingId()), + ])); + + return back()->with('success', __('offer::messages.accepted')); + } + + public function decline(Request $request, Offer $offer): RedirectResponse + { + $this->authorizeSeller($request, $offer); + + $offer->decline(); + $this->notifyBuyer($offer, __('offer::messages.notification_declined_title'), __('offer::messages.notification_declined_body', [ + 'listing' => ListingDirectory::titleFor($offer->listingId()), + ])); + + return back()->with('success', __('offer::messages.declined')); + } + + public function withdraw(Request $request, Offer $offer): RedirectResponse + { + abort_unless($offer->belongsToBuyer((int) $request->user()->getKey()), 403); + + $offer->withdraw(); + + return back()->with('success', __('offer::messages.withdrawn')); + } + + private function authorizeSeller(Request $request, Offer $offer): void + { + abort_unless($offer->belongsToSeller((int) $request->user()->getKey()), 403); + abort_unless($offer->isPending(), 422); + } + + private function notifyBuyer(Offer $offer, string $title, string $body): void + { + UserNotification::publish( + $offer->buyerId(), + UserNotification::TYPE_OFFER, + $title, + $body, + route('panel.offers.index', ['direction' => 'sent']), + ); + } +} diff --git a/Modules/Offer/Models/Offer.php b/Modules/Offer/Models/Offer.php new file mode 100644 index 00000000000..0838adb74e6 --- /dev/null +++ b/Modules/Offer/Models/Offer.php @@ -0,0 +1,223 @@ + 'decimal:2', + 'responded_at' => 'datetime', + ]; + + public static function statuses(): array + { + return [ + self::STATUS_PENDING, + self::STATUS_ACCEPTED, + self::STATUS_DECLINED, + self::STATUS_WITHDRAWN, + ]; + } + + public function scopePending(Builder $query): Builder + { + return $query->where('status', self::STATUS_PENDING); + } + + public function scopeForSeller(Builder $query, int $sellerId): Builder + { + return $query->where('seller_id', $sellerId); + } + + public function scopeForBuyer(Builder $query, int $buyerId): Builder + { + return $query->where('buyer_id', $buyerId); + } + + public function scopeForListing(Builder $query, int $listingId): Builder + { + return $query->where('listing_id', $listingId); + } + + public function scopeWithStatus(Builder $query, string $status): Builder + { + return in_array($status, self::statuses(), true) + ? $query->where('status', $status) + : $query; + } + + public static function place(int $listingId, int $buyerId, int $sellerId, float $amount, string $currency, ?string $message): self + { + static::query() + ->forListing($listingId) + ->forBuyer($buyerId) + ->pending() + ->update(['status' => self::STATUS_WITHDRAWN, 'responded_at' => now()]); + + $offer = static::query()->make([ + 'listing_id' => $listingId, + 'buyer_id' => $buyerId, + 'seller_id' => $sellerId, + 'amount' => $amount, + 'currency' => $currency, + 'message' => $message, + 'status' => self::STATUS_PENDING, + ]); + + $offer->save(); + + return $offer; + } + + public static function receivedBySeller(int $sellerId, string $status, int $perPage = 12): LengthAwarePaginator + { + return static::query() + ->forSeller($sellerId) + ->withStatus($status) + ->latest('id') + ->paginate($perPage) + ->withQueryString(); + } + + public static function sentByBuyer(int $buyerId, int $perPage = 12): LengthAwarePaginator + { + return static::query() + ->forBuyer($buyerId) + ->latest('id') + ->paginate($perPage) + ->withQueryString(); + } + + public static function pendingCountForSeller(int $sellerId): int + { + return (int) static::query()->forSeller($sellerId)->pending()->count(); + } + + public static function statusCountsForSeller(int $sellerId): array + { + $counts = static::query() + ->forSeller($sellerId) + ->selectRaw('status, COUNT(*) as aggregate') + ->groupBy('status') + ->pluck('aggregate', 'status'); + + return [ + 'all' => (int) $counts->sum(), + self::STATUS_PENDING => (int) ($counts[self::STATUS_PENDING] ?? 0), + self::STATUS_ACCEPTED => (int) ($counts[self::STATUS_ACCEPTED] ?? 0), + self::STATUS_DECLINED => (int) ($counts[self::STATUS_DECLINED] ?? 0), + ]; + } + + public static function highestPendingForListing(int $listingId): ?self + { + return static::query() + ->forListing($listingId) + ->pending() + ->orderByDesc('amount') + ->first(); + } + + public static function listingIdsWithOffersForSeller(int $sellerId): array + { + return static::query() + ->forSeller($sellerId) + ->pluck('listing_id') + ->map(static fn (mixed $value): int => (int) $value) + ->unique() + ->values() + ->all(); + } + + public function accept(): void + { + $this->forceFill([ + 'status' => self::STATUS_ACCEPTED, + 'responded_at' => now(), + ])->save(); + } + + public function decline(): void + { + $this->forceFill([ + 'status' => self::STATUS_DECLINED, + 'responded_at' => now(), + ])->save(); + } + + public function withdraw(): void + { + $this->forceFill([ + 'status' => self::STATUS_WITHDRAWN, + 'responded_at' => now(), + ])->save(); + } + + public function isPending(): bool + { + return $this->getAttribute('status') === self::STATUS_PENDING; + } + + public function belongsToSeller(int $sellerId): bool + { + return (int) $this->getAttribute('seller_id') === $sellerId; + } + + public function belongsToBuyer(int $buyerId): bool + { + return (int) $this->getAttribute('buyer_id') === $buyerId; + } + + public function listingId(): int + { + return (int) $this->getAttribute('listing_id'); + } + + public function buyerId(): int + { + return (int) $this->getAttribute('buyer_id'); + } + + public function amountLabel(): string + { + return number_format((float) $this->getAttribute('amount'), 2).' '.(string) $this->getAttribute('currency'); + } + + public function statusTone(): string + { + return match ((string) $this->getAttribute('status')) { + self::STATUS_ACCEPTED => 'positive', + self::STATUS_DECLINED => 'critical', + self::STATUS_WITHDRAWN => 'default', + default => 'caution', + }; + } +} diff --git a/Modules/Offer/OfferPlugin.php b/Modules/Offer/OfferPlugin.php new file mode 100644 index 00000000000..7d3db64db60 --- /dev/null +++ b/Modules/Offer/OfferPlugin.php @@ -0,0 +1,31 @@ +discoverResources( + in: module_path('Offer', 'Filament/Admin/Resources'), + for: 'Modules\\Offer\\Filament\\Admin\\Resources', + ); + } + + public function boot(Panel $panel): void {} +} diff --git a/Modules/Offer/Providers/OfferServiceProvider.php b/Modules/Offer/Providers/OfferServiceProvider.php new file mode 100644 index 00000000000..a935a97c644 --- /dev/null +++ b/Modules/Offer/Providers/OfferServiceProvider.php @@ -0,0 +1,20 @@ +loadMigrationsFrom(module_path('Offer', 'Database/migrations')); + $this->loadRoutesFrom(module_path('Offer', 'routes/web.php')); + $this->loadViewsFrom(module_path('Offer', 'resources/views'), 'offer'); + $this->loadTranslationsFrom(module_path('Offer', 'lang'), 'offer'); + } + + public function register(): void {} +} diff --git a/Modules/Offer/lang/en/messages.php b/Modules/Offer/lang/en/messages.php new file mode 100644 index 00000000000..9d7a25a7b18 --- /dev/null +++ b/Modules/Offer/lang/en/messages.php @@ -0,0 +1,36 @@ + 'Offers', + 'make_offer' => 'Make an offer', + 'your_offer' => 'Your offer', + 'offer_message' => 'Message to seller', + 'send_offer' => 'Send offer', + 'sent' => 'Offer sent to the seller.', + 'accepted' => 'Offer accepted.', + 'declined' => 'Offer declined.', + 'withdrawn' => 'Offer withdrawn.', + 'cannot_offer_own' => 'You cannot make an offer on your own listing.', + 'received' => 'Received', + 'sent_tab' => 'Sent', + 'accept' => 'Accept', + 'decline' => 'Decline', + 'withdraw' => 'Withdraw', + 'status_pending' => 'Pending', + 'status_accepted' => 'Accepted', + 'status_declined' => 'Declined', + 'status_withdrawn' => 'Withdrawn', + 'no_offers' => 'No offers yet', + 'no_offers_hint' => 'Offers from buyers appear here.', + 'all' => 'All', + 'best_offer' => 'Best offer', + 'notification_received_title' => 'New offer', + 'notification_received_body' => 'You received :amount for :listing.', + 'notification_accepted_title' => 'Offer accepted', + 'notification_accepted_body' => 'Your offer for :listing was accepted.', + 'notification_declined_title' => 'Offer declined', + 'notification_declined_body' => 'Your offer for :listing was declined.', + 'sign_in_to_offer' => 'Sign in to make an offer.', +]; diff --git a/Modules/Offer/lang/tr/messages.php b/Modules/Offer/lang/tr/messages.php new file mode 100644 index 00000000000..ca4b0a179ce --- /dev/null +++ b/Modules/Offer/lang/tr/messages.php @@ -0,0 +1,36 @@ + 'Teklifler', + 'make_offer' => 'Teklif ver', + 'your_offer' => 'Teklifiniz', + 'offer_message' => 'Satıcıya mesaj', + 'send_offer' => 'Teklifi gönder', + 'sent' => 'Teklifiniz satıcıya iletildi.', + 'accepted' => 'Teklif kabul edildi.', + 'declined' => 'Teklif reddedildi.', + 'withdrawn' => 'Teklif geri çekildi.', + 'cannot_offer_own' => 'Kendi ilanınıza teklif veremezsiniz.', + 'received' => 'Gelen', + 'sent_tab' => 'Giden', + 'accept' => 'Kabul et', + 'decline' => 'Reddet', + 'withdraw' => 'Geri çek', + 'status_pending' => 'Bekliyor', + 'status_accepted' => 'Kabul edildi', + 'status_declined' => 'Reddedildi', + 'status_withdrawn' => 'Geri çekildi', + 'no_offers' => 'Henüz teklif yok', + 'no_offers_hint' => 'Alıcılardan gelen teklifler burada görünür.', + 'all' => 'Tümü', + 'best_offer' => 'En iyi teklif', + 'notification_received_title' => 'Yeni teklif', + 'notification_received_body' => ':listing için :amount teklif aldınız.', + 'notification_accepted_title' => 'Teklif kabul edildi', + 'notification_accepted_body' => ':listing için teklifiniz kabul edildi.', + 'notification_declined_title' => 'Teklif reddedildi', + 'notification_declined_body' => ':listing için teklifiniz reddedildi.', + 'sign_in_to_offer' => 'Teklif vermek için giriş yapın.', +]; diff --git a/Modules/Offer/module.json b/Modules/Offer/module.json new file mode 100644 index 00000000000..49ae7fff0c3 --- /dev/null +++ b/Modules/Offer/module.json @@ -0,0 +1,11 @@ +{ + "name": "Offer", + "alias": "offer", + "description": "", + "keywords": [], + "priority": 0, + "providers": [ + "Modules\\Offer\\Providers\\OfferServiceProvider" + ], + "files": [] +} diff --git a/Modules/Offer/resources/views/index.blade.php b/Modules/Offer/resources/views/index.blade.php new file mode 100644 index 00000000000..03cfd412339 --- /dev/null +++ b/Modules/Offer/resources/views/index.blade.php @@ -0,0 +1,109 @@ +@extends('panel::layouts.panel', ['panelSection' => 'offers']) + +@section('title', __('offer::messages.offers')) + +@php + $statusTabs = [ + 'all' => __('offer::messages.all'), + 'pending' => __('offer::messages.status_pending'), + 'accepted' => __('offer::messages.status_accepted'), + 'declined' => __('offer::messages.status_declined'), + ]; + $tones = ['positive' => 'badge--positive', 'critical' => 'badge--critical', 'caution' => 'badge--caution', 'default' => '']; +@endphp + +@section('panel_content') +
+
+

{{ __('offer::messages.offers') }}

+

{{ trans_choice('site::messages.results_count', $offers->total(), ['count' => $offers->total()]) }}

+
+
+ + + +@if($direction === 'received') + +@endif + +@if($offers->isNotEmpty()) +
+
+ @foreach($offers as $offer) + @php + $listing = $listings[$offer->listingId()] ?? null; + $person = $people[$direction === 'sent' ? (int) $offer->getAttribute('seller_id') : $offer->buyerId()] ?? null; + @endphp +
+
+ @if($listing && $listing['image']) + + @else + + @endif +
+ +
+

+ @if($listing) + {{ $listing['title'] }} + @else + #{{ $offer->listingId() }} + @endif +

+
+ {{ $offer->amountLabel() }} + @if($listing) + {{ __('panel::messages.price') }}: {{ $listing['price'] }} + @endif + {{ __('offer::messages.status_'.$offer->getAttribute('status')) }} + @if($person) + {{ $person['name'] }} + @endif + +
+ @if(filled($offer->getAttribute('message'))) +

{{ $offer->getAttribute('message') }}

+ @endif +
+ +
+ @if($direction === 'received' && $offer->isPending()) +
+ @csrf + +
+
+ @csrf + +
+ @elseif($direction === 'sent' && $offer->isPending()) +
+ @csrf + +
+ @endif +
+
+ @endforeach +
+
+ + {{ $offers->links('components.pagination') }} +@else + +@endif +@endsection diff --git a/Modules/Offer/routes/web.php b/Modules/Offer/routes/web.php new file mode 100644 index 00000000000..3e892848c6c --- /dev/null +++ b/Modules/Offer/routes/web.php @@ -0,0 +1,21 @@ +group(function (): void { + Route::get('/panel/offers', [OfferController::class, 'index'])->name('panel.offers.index'); + + Route::name('offers.')->group(function (): void { + Route::post('/listings/{listing}/offers', [OfferController::class, 'store']) + ->whereNumber('listing') + ->middleware('throttle:20,1') + ->name('store'); + + Route::post('/offers/{offer}/accept', [OfferController::class, 'accept'])->name('accept'); + Route::post('/offers/{offer}/decline', [OfferController::class, 'decline'])->name('decline'); + Route::post('/offers/{offer}/withdraw', [OfferController::class, 'withdraw'])->name('withdraw'); + }); +}); diff --git a/Modules/Page/Database/Seeders/PageSeeder.php b/Modules/Page/Database/Seeders/PageSeeder.php new file mode 100644 index 00000000000..024ec900f70 --- /dev/null +++ b/Modules/Page/Database/Seeders/PageSeeder.php @@ -0,0 +1,78 @@ +records() as $record) { + Page::query()->updateOrCreate(['slug' => $record['slug']], $record); + } + } + + private function records(): array + { + return [ + [ + 'slug' => 'about', + 'title' => 'About us', + 'excerpt' => 'A marketplace built for people who want to buy and sell without friction.', + 'placement' => Page::PLACEMENT_FOOTER, + 'sort_order' => 1, + 'meta_description' => 'Learn how OpenClassify connects local buyers and sellers.', + 'body' => "OpenClassify is a classifieds marketplace for local communities. We connect people who want to sell things they no longer need with people looking for a good deal nearby.\n\nEvery listing is created by a real member of the community. We keep the platform simple: post an item in under a minute, talk directly to the buyer or seller, and close the deal on your own terms.\n\nOur team reviews reported content, keeps the categories tidy, and works to make the marketplace safe for everyone.", + ], + [ + 'slug' => 'how-it-works', + 'title' => 'How it works', + 'excerpt' => 'Post, chat, and close the deal in three steps.', + 'placement' => Page::PLACEMENT_HELP, + 'sort_order' => 1, + 'meta_description' => 'Three steps to sell or buy on OpenClassify.', + 'body' => "Create your listing\nAdd photos, a clear title, an honest description, and a price. Choose the right category so buyers can find you.\n\nTalk to buyers\nBuyers message you directly or send a price offer. Reply from your inbox on any device.\n\nClose the deal\nAgree on a price, meet in a safe public place, and mark the listing as sold when you are done.", + ], + [ + 'slug' => 'safety-tips', + 'title' => 'Safety tips', + 'excerpt' => 'Simple rules that keep every trade safe.', + 'placement' => Page::PLACEMENT_HELP, + 'sort_order' => 2, + 'meta_description' => 'Stay safe while buying and selling on OpenClassify.', + 'body' => "Meet in public\nChoose a busy, well-lit place for the handover. Bring a friend when the item is valuable.\n\nInspect before you pay\nCheck the item carefully and test anything electronic before money changes hands.\n\nNever send money in advance\nAvoid wire transfers, gift cards, and deposits to people you have not met.\n\nKeep the conversation on the platform\nMessages stay on record, which helps if something goes wrong.\n\nReport anything suspicious\nUse the report button on any listing or seller profile. Our team reviews every report.", + ], + [ + 'slug' => 'terms', + 'title' => 'Terms of service', + 'excerpt' => 'The rules for using the marketplace.', + 'placement' => Page::PLACEMENT_LEGAL, + 'sort_order' => 1, + 'meta_description' => 'Terms of service for the OpenClassify marketplace.', + 'body' => "Using the marketplace\nBy creating an account you agree to post accurate listings and to treat other members with respect.\n\nProhibited items\nWeapons, counterfeit goods, illegal substances, live animals, and stolen property may not be listed.\n\nAccount responsibility\nYou are responsible for the activity on your account and for keeping your credentials private.\n\nContent ownership\nYou keep ownership of the photos and text you upload and grant us permission to display them on the marketplace.\n\nTermination\nWe may suspend accounts that break these terms or that put other members at risk.", + ], + [ + 'slug' => 'privacy', + 'title' => 'Privacy policy', + 'excerpt' => 'What we collect and how we use it.', + 'placement' => Page::PLACEMENT_LEGAL, + 'sort_order' => 2, + 'meta_description' => 'How OpenClassify handles your personal data.', + 'body' => "What we collect\nWe store the account details you provide, the listings you publish, and the messages you exchange on the platform.\n\nHow we use it\nYour data is used to operate the marketplace, to show your listings to buyers, and to keep the platform safe.\n\nWhat we share\nWe never sell personal data. Contact details are only shown to members you choose to share them with.\n\nYour choices\nYou can edit your profile, delete your listings, or close your account at any time from your panel.", + ], + [ + 'slug' => 'contact', + 'title' => 'Contact', + 'excerpt' => 'Reach the team behind the marketplace.', + 'placement' => Page::PLACEMENT_FOOTER, + 'sort_order' => 2, + 'meta_description' => 'Contact the OpenClassify support team.', + 'body' => "Support\nWrite to support@openclassify.test and we will reply within one business day.\n\nTrust and safety\nReport listings and profiles directly from the report button. Urgent cases reach our moderation team first.\n\nPartnerships\nFor business enquiries, write to partners@openclassify.test.", + ], + ]; + } +} diff --git a/Modules/Page/Database/migrations/2026_08_01_000000_create_pages_table.php b/Modules/Page/Database/migrations/2026_08_01_000000_create_pages_table.php new file mode 100644 index 00000000000..f2b225a0ee8 --- /dev/null +++ b/Modules/Page/Database/migrations/2026_08_01_000000_create_pages_table.php @@ -0,0 +1,38 @@ +id(); + $table->string('title'); + $table->string('slug'); + $table->string('excerpt')->nullable(); + $table->text('body')->nullable(); + $table->string('meta_title')->nullable(); + $table->string('meta_description', 320)->nullable(); + $table->string('placement', 24)->default('footer'); + $table->boolean('is_published')->default(true); + $table->unsignedInteger('sort_order')->default(0); + $table->timestamps(); + $table->softDeletes(); + + $table->index(['is_published', 'placement']); + }); + + DB::statement('CREATE UNIQUE INDEX pages_slug_unique ON pages (slug) WHERE deleted_at IS NULL'); + } + + public function down(): void + { + Schema::dropIfExists('pages'); + } +}; diff --git a/Modules/Page/Filament/Admin/Resources/PageResource.php b/Modules/Page/Filament/Admin/Resources/PageResource.php new file mode 100644 index 00000000000..34b35d58a76 --- /dev/null +++ b/Modules/Page/Filament/Admin/Resources/PageResource.php @@ -0,0 +1,85 @@ +schema([ + TextInput::make('title')->required()->maxLength(150)->live(onBlur: true) + ->afterStateUpdated(static fn (Set $set, ?string $state): mixed => $set('slug', Str::slug((string) $state))), + TextInput::make('slug')->required()->maxLength(150)->unique(ignoreRecord: true), + TextInput::make('excerpt')->maxLength(255), + Textarea::make('body')->rows(14)->columnSpanFull(), + Select::make('placement')->options(fn (): array => collect(Page::placements())->mapWithKeys( + static fn (string $placement): array => [$placement => ucfirst($placement)] + )->all())->required(), + TextInput::make('meta_title')->maxLength(150), + Textarea::make('meta_description')->rows(2)->maxLength(320), + TextInput::make('sort_order')->numeric()->default(0), + Toggle::make('is_published')->default(true), + ]); + } + + public static function table(Table $table): Table + { + return $table->columns([ + ResourceTableColumns::id(), + TextColumn::make('title')->searchable()->sortable(), + TextColumn::make('slug')->searchable(), + TextColumn::make('placement')->badge()->sortable(), + IconColumn::make('is_published')->boolean()->label('Published'), + TextColumn::make('sort_order')->sortable(), + ResourceTableColumns::createdAtHidden(), + ])->defaultSort('id', 'desc')->filters([ + SelectFilter::make('placement')->options(fn (): array => collect(Page::placements())->mapWithKeys( + static fn (string $placement): array => [$placement => ucfirst($placement)] + )->all()), + TernaryFilter::make('is_published')->label('Published'), + ])->actions(ResourceTableActions::editDelete()); + } + + public static function getPages(): array + { + return [ + 'index' => Pages\ListPageResourceRecords::route('/'), + 'create' => Pages\CreatePageResourceRecord::route('/create'), + 'edit' => Pages\EditPageResourceRecord::route('/{record}/edit'), + ]; + } +} diff --git a/Modules/Page/Filament/Admin/Resources/PageResource/Pages/CreatePageResourceRecord.php b/Modules/Page/Filament/Admin/Resources/PageResource/Pages/CreatePageResourceRecord.php new file mode 100644 index 00000000000..bfde237bdb4 --- /dev/null +++ b/Modules/Page/Filament/Admin/Resources/PageResource/Pages/CreatePageResourceRecord.php @@ -0,0 +1,13 @@ + $page, + ]); + } + + public function sitemap(): Response + { + $entries = collect([ + ['url' => route('home'), 'updated' => now()->toAtomString()], + ['url' => route('listings.index'), 'updated' => now()->toAtomString()], + ['url' => route('categories.index'), 'updated' => now()->toAtomString()], + ['url' => route('promotions.plans'), 'updated' => now()->toAtomString()], + ])->concat( + Page::publishedSlugs()->map(static fn (Page $page): array => [ + 'url' => route('pages.show', ['slug' => $page->getAttribute('slug')]), + 'updated' => $page->getAttribute('updated_at')?->toAtomString(), + ]) + ); + + $lines = ['', '']; + + foreach ($entries as $entry) { + $lines[] = ' '; + $lines[] = ' '.e($entry['url']).''; + + if ($entry['updated'] !== null) { + $lines[] = ' '.e($entry['updated']).''; + } + + $lines[] = ' '; + } + + $lines[] = ''; + + return response(implode("\n", $lines), 200) + ->header('Content-Type', 'application/xml'); + } + + public function robots(): Response + { + $lines = [ + 'User-agent: *', + 'Allow: /', + 'Disallow: /panel/', + 'Disallow: /admin/', + 'Sitemap: '.route('sitemap'), + ]; + + return response(implode("\n", $lines)."\n", 200) + ->header('Content-Type', 'text/plain'); + } +} diff --git a/Modules/Page/Models/Page.php b/Modules/Page/Models/Page.php new file mode 100644 index 00000000000..c68bc204b47 --- /dev/null +++ b/Modules/Page/Models/Page.php @@ -0,0 +1,121 @@ + 'boolean', + 'sort_order' => 'integer', + ]; + + public static function placements(): array + { + return [ + self::PLACEMENT_FOOTER, + self::PLACEMENT_LEGAL, + self::PLACEMENT_HELP, + ]; + } + + public function scopePublished(Builder $query): Builder + { + return $query->where('is_published', true); + } + + public function scopeWithPlacement(Builder $query, string $placement): Builder + { + return $query->where('placement', $placement); + } + + public static function findPublished(string $slug): ?self + { + return static::query()->published()->where('slug', $slug)->first(); + } + + public static function navigation(string $placement): Collection + { + return static::query() + ->published() + ->withPlacement($placement) + ->orderBy('sort_order') + ->orderBy('title') + ->get(['id', 'title', 'slug']); + } + + public static function publishedSlugs(): Collection + { + return static::query() + ->published() + ->orderBy('sort_order') + ->get(['slug', 'updated_at']); + } + + public static function uniqueSlug(string $title): string + { + $base = Str::slug($title); + $base = $base !== '' ? $base : 'page'; + $slug = $base; + $suffix = 1; + + while (static::query()->where('slug', $slug)->exists()) { + $suffix++; + $slug = $base.'-'.$suffix; + } + + return $slug; + } + + public function metaTitleValue(): string + { + $meta = (string) $this->getAttribute('meta_title'); + + return $meta !== '' ? $meta : (string) $this->getAttribute('title'); + } + + public function metaDescriptionValue(): string + { + $meta = (string) $this->getAttribute('meta_description'); + + return $meta !== '' ? $meta : (string) $this->getAttribute('excerpt'); + } + + public function bodyParagraphs(): array + { + $body = (string) $this->getAttribute('body'); + + return collect(preg_split('/\R{2,}/', $body) ?: []) + ->map(static fn (string $block): string => trim($block)) + ->filter(static fn (string $block): bool => $block !== '') + ->values() + ->all(); + } +} diff --git a/Modules/Page/PagePlugin.php b/Modules/Page/PagePlugin.php new file mode 100644 index 00000000000..dabe5252dc4 --- /dev/null +++ b/Modules/Page/PagePlugin.php @@ -0,0 +1,31 @@ +discoverResources( + in: module_path('Page', 'Filament/Admin/Resources'), + for: 'Modules\\Page\\Filament\\Admin\\Resources', + ); + } + + public function boot(Panel $panel): void {} +} diff --git a/Modules/Page/Providers/PageServiceProvider.php b/Modules/Page/Providers/PageServiceProvider.php new file mode 100644 index 00000000000..778ae896015 --- /dev/null +++ b/Modules/Page/Providers/PageServiceProvider.php @@ -0,0 +1,20 @@ +loadMigrationsFrom(module_path('Page', 'Database/migrations')); + $this->loadRoutesFrom(module_path('Page', 'routes/web.php')); + $this->loadViewsFrom(module_path('Page', 'resources/views'), 'page'); + $this->loadTranslationsFrom(module_path('Page', 'lang'), 'page'); + } + + public function register(): void {} +} diff --git a/Modules/Page/lang/en/messages.php b/Modules/Page/lang/en/messages.php new file mode 100644 index 00000000000..d7cb8e9c69f --- /dev/null +++ b/Modules/Page/lang/en/messages.php @@ -0,0 +1,9 @@ + 'Pages', + 'last_updated' => 'Last updated :date', + 'back_home' => 'Back to home', +]; diff --git a/Modules/Page/lang/tr/messages.php b/Modules/Page/lang/tr/messages.php new file mode 100644 index 00000000000..e45938bb321 --- /dev/null +++ b/Modules/Page/lang/tr/messages.php @@ -0,0 +1,9 @@ + 'Sayfalar', + 'last_updated' => 'Son güncelleme :date', + 'back_home' => 'Ana sayfaya dön', +]; diff --git a/Modules/Page/module.json b/Modules/Page/module.json new file mode 100644 index 00000000000..3666be4bae3 --- /dev/null +++ b/Modules/Page/module.json @@ -0,0 +1,11 @@ +{ + "name": "Page", + "alias": "page", + "description": "", + "keywords": [], + "priority": 0, + "providers": [ + "Modules\\Page\\Providers\\PageServiceProvider" + ], + "files": [] +} diff --git a/Modules/Page/resources/views/show.blade.php b/Modules/Page/resources/views/show.blade.php new file mode 100644 index 00000000000..c944e69e3ad --- /dev/null +++ b/Modules/Page/resources/views/show.blade.php @@ -0,0 +1,43 @@ +@extends('site::layouts.app') + +@section('title', $page->metaTitleValue()) +@section('description', $page->metaDescriptionValue()) + +@section('content') +
+
+ + +
+

{{ $page->getAttribute('title') }}

+ @if(filled($page->getAttribute('excerpt'))) +

{{ $page->getAttribute('excerpt') }}

+ @endif +
+ +
+
+
+ @foreach($page->bodyParagraphs() as $paragraph) + @php $lines = preg_split('/\R/', $paragraph); @endphp + @if(count($lines) > 1) +

{{ array_shift($lines) }}

+

{{ implode(' ', $lines) }}

+ @else +

{{ $paragraph }}

+ @endif + @endforeach +
+
+
+ +

+ {{ __('page::messages.last_updated', ['date' => $page->getAttribute('updated_at')?->isoFormat('LL')]) }} +

+
+
+@endsection diff --git a/Modules/Page/routes/web.php b/Modules/Page/routes/web.php new file mode 100644 index 00000000000..4842b7a3353 --- /dev/null +++ b/Modules/Page/routes/web.php @@ -0,0 +1,12 @@ +group(function (): void { + Route::get('/sitemap.xml', [PageController::class, 'sitemap'])->name('sitemap'); + Route::get('/robots.txt', [PageController::class, 'robots'])->name('robots'); + Route::get('/pages/{slug}', [PageController::class, 'show'])->name('pages.show'); +}); diff --git a/Modules/Panel/App/Http/Controllers/PanelController.php b/Modules/Panel/App/Http/Controllers/PanelController.php index 1d986b4433f..3f27c5e1d9b 100644 --- a/Modules/Panel/App/Http/Controllers/PanelController.php +++ b/Modules/Panel/App/Http/Controllers/PanelController.php @@ -1,5 +1,7 @@ route('panel.listings.index'); + $userId = (int) $request->user()->getKey(); + + return view('panel::dashboard', [ + 'stats' => [ + 'active' => Listing::query()->ownedByUser($userId)->active()->count(), + 'views' => (int) Listing::query()->ownedByUser($userId)->sum('view_count'), + 'offers' => Offer::pendingCountForSeller($userId), + 'reviews' => Review::summaryForSeller($userId), + ], + 'recentListings' => Listing::query() + ->ownedByUser($userId) + ->latest('id') + ->limit(5) + ->get(), + 'notifications' => UserNotification::latestForUser($userId), + ]); } public function create(): View diff --git a/Modules/Panel/App/Http/Requests/StoreVideoRequest.php b/Modules/Panel/App/Http/Requests/StoreVideoRequest.php index c4729d7f673..51da71a51b5 100644 --- a/Modules/Panel/App/Http/Requests/StoreVideoRequest.php +++ b/Modules/Panel/App/Http/Requests/StoreVideoRequest.php @@ -1,5 +1,7 @@ loadRoutesFrom(module_path('Panel', 'routes/web.php')); $this->loadViewsFrom(module_path('Panel', 'resources/views'), 'panel'); + $this->loadTranslationsFrom(module_path('Panel', 'lang'), 'panel'); Livewire::component('panel-quick-listing-form', PanelQuickListingForm::class); } diff --git a/Modules/Panel/README.md b/Modules/Panel/README.md new file mode 100644 index 00000000000..b3321ed439b --- /dev/null +++ b/Modules/Panel/README.md @@ -0,0 +1,7 @@ +# Panel + +The authenticated user's self-service dashboard — everything a seller does after logging in: manage their own listings, upload/manage videos, edit their profile. + +- Blade views + a single Livewire component (`PanelQuickListingForm`) drive the multi-step "quick create listing" wizard; all persistence is delegated to `Listing`, `Video`, and `Profile` model methods rather than queried inline. +- Routes are scoped under `panel/*` and require authentication. +- Visually this is the app's dashboard surface, distinct from the public storefront (`Site`/`Listing` themes) and from the staff Filament admin (`Admin`). diff --git a/Modules/Panel/lang/en/messages.php b/Modules/Panel/lang/en/messages.php new file mode 100644 index 00000000000..6c17758f3f0 --- /dev/null +++ b/Modules/Panel/lang/en/messages.php @@ -0,0 +1,55 @@ + 'Dashboard', + 'overview' => 'Overview', + 'my_listings' => 'My listings', + 'new_listing' => 'New listing', + 'edit' => 'Edit', + 'delete' => 'Delete', + 'view' => 'View', + 'mark_sold' => 'Mark as sold', + 'republish' => 'Republish', + 'videos' => 'Videos', + 'inbox' => 'Inbox', + 'profile' => 'Profile', + 'statistics' => 'Statistics', + 'total_views' => 'Total views', + 'active' => 'Active', + 'pending' => 'Pending', + 'sold' => 'Sold', + 'expired' => 'Expired', + 'all' => 'All', + 'no_listings' => 'You have no listings yet', + 'no_listings_hint' => 'Publish your first listing to start selling.', + 'search_listings' => 'Search your listings', + 'status' => 'Status', + 'title' => 'Title', + 'price' => 'Price', + 'category' => 'Category', + 'description' => 'Description', + 'contact_phone' => 'Contact phone', + 'contact_email' => 'Contact email', + 'location' => 'Location', + 'photos' => 'Photos', + 'save' => 'Save', + 'cancel' => 'Cancel', + 'back_to_listings' => 'Back to listings', + 'confirm_delete' => 'Delete this listing permanently?', + 'listing_updated' => 'Listing updated.', + 'listing_deleted' => 'Listing deleted.', + 'listing_sold' => 'Listing marked as sold.', + 'listing_republished' => 'Listing republished.', + 'saved_searches' => 'Saved searches', + 'favorites' => 'Favorites', + 'reviews' => 'Reviews', + 'expires' => 'Expires', + 'welcome' => 'Welcome back, :name', + 'quick_actions' => 'Quick actions', + 'recent_activity' => 'Recent activity', + 'photos_hint' => 'Add up to eight clear photos. The first one is the cover.', + 'uploading' => 'Uploading', + 'publishing' => 'Publishing', +]; diff --git a/Modules/Panel/lang/tr/messages.php b/Modules/Panel/lang/tr/messages.php new file mode 100644 index 00000000000..a7c80f4b927 --- /dev/null +++ b/Modules/Panel/lang/tr/messages.php @@ -0,0 +1,55 @@ + 'Panel', + 'overview' => 'Genel bakış', + 'my_listings' => 'İlanlarım', + 'new_listing' => 'Yeni ilan', + 'edit' => 'Düzenle', + 'delete' => 'Sil', + 'view' => 'Görüntüle', + 'mark_sold' => 'Satıldı olarak işaretle', + 'republish' => 'Yeniden yayınla', + 'videos' => 'Videolar', + 'inbox' => 'Mesajlar', + 'profile' => 'Profil', + 'statistics' => 'İstatistikler', + 'total_views' => 'Toplam görüntülenme', + 'active' => 'Aktif', + 'pending' => 'Bekliyor', + 'sold' => 'Satıldı', + 'expired' => 'Süresi doldu', + 'all' => 'Tümü', + 'no_listings' => 'Henüz ilanınız yok', + 'no_listings_hint' => 'Satışa başlamak için ilk ilanınızı yayınlayın.', + 'search_listings' => 'İlanlarınızda arayın', + 'status' => 'Durum', + 'title' => 'Başlık', + 'price' => 'Fiyat', + 'category' => 'Kategori', + 'description' => 'Açıklama', + 'contact_phone' => 'İletişim telefonu', + 'contact_email' => 'İletişim e-postası', + 'location' => 'Konum', + 'photos' => 'Fotoğraflar', + 'save' => 'Kaydet', + 'cancel' => 'Vazgeç', + 'back_to_listings' => 'İlanlara dön', + 'confirm_delete' => 'Bu ilan kalıcı olarak silinsin mi?', + 'listing_updated' => 'İlan güncellendi.', + 'listing_deleted' => 'İlan silindi.', + 'listing_sold' => 'İlan satıldı olarak işaretlendi.', + 'listing_republished' => 'İlan yeniden yayınlandı.', + 'saved_searches' => 'Kayıtlı aramalar', + 'favorites' => 'Favoriler', + 'reviews' => 'Değerlendirmeler', + 'expires' => 'Bitiş', + 'welcome' => 'Tekrar hoş geldiniz, :name', + 'quick_actions' => 'Hızlı işlemler', + 'recent_activity' => 'Son hareketler', + 'photos_hint' => 'En fazla sekiz net fotoğraf ekleyin. İlki kapak olur.', + 'uploading' => 'Yükleniyor', + 'publishing' => 'Yayınlanıyor', +]; diff --git a/Modules/Panel/resources/views/create.blade.php b/Modules/Panel/resources/views/create.blade.php index 10b6571e28e..8bb7d0a76e3 100644 --- a/Modules/Panel/resources/views/create.blade.php +++ b/Modules/Panel/resources/views/create.blade.php @@ -1,8 +1,7 @@ -@extends('app::layouts.app') +@extends('site::layouts.app') -@section('title', 'New Listing') - -@section('simple_page', '1') +@section('title', __('panel::messages.new_listing')) +@section('chromeless', '0') @section('content') diff --git a/Modules/Panel/resources/views/dashboard.blade.php b/Modules/Panel/resources/views/dashboard.blade.php new file mode 100644 index 00000000000..a1a008e69bf --- /dev/null +++ b/Modules/Panel/resources/views/dashboard.blade.php @@ -0,0 +1,107 @@ +@extends('panel::layouts.panel', ['panelSection' => 'dashboard']) + +@section('title', __('panel::messages.dashboard')) + +@section('panel_content') +
+
+

{{ __('panel::messages.welcome', ['name' => auth()->user()->getDisplayName()]) }}

+

{{ __('panel::messages.overview') }}

+
+ + + {{ __('panel::messages.new_listing') }} + +
+ +
+
+ {{ number_format($stats['active']) }} + {{ __('panel::messages.active') }} +
+
+ {{ number_format($stats['views']) }} + {{ __('panel::messages.total_views') }} +
+
+ {{ number_format($stats['offers']) }} + {{ __('offer::messages.offers') }} +
+
+ {{ $stats['reviews']['total'] > 0 ? number_format($stats['reviews']['average'], 1) : '—' }} + {{ __('review::messages.reviews') }} +
+
+ +
+
+
+

{{ __('panel::messages.my_listings') }}

+ {{ __('site::messages.view_all') }} +
+ + @if($recentListings->isNotEmpty()) +
+ @foreach($recentListings as $listing) +
+
+ @if($listing->panelPrimaryImageUrl()) + + @endif +
+
+

{{ $listing->getAttribute('title') }}

+
+ {{ $listing->panelPriceLabel() }} + {{ (int) $listing->getAttribute('view_count') }} + {{ $listing->statusLabel() }} +
+
+ +
+ @endforeach +
+ @else + + @endif +
+ + +
+@endsection diff --git a/Modules/Panel/resources/views/edit-listing.blade.php b/Modules/Panel/resources/views/edit-listing.blade.php index a06b5430d3d..02def68b08c 100644 --- a/Modules/Panel/resources/views/edit-listing.blade.php +++ b/Modules/Panel/resources/views/edit-listing.blade.php @@ -1,165 +1,119 @@ -@extends('app::layouts.app') +@extends('panel::layouts.panel', ['panelSection' => 'listings']) -@section('title', 'Edit Listing') +@section('title', __('panel::messages.edit')) -@section('content') -
-
- @include('panel::partials.sidebar', ['activeMenu' => 'listings']) +@section('panel_content') +
+
+

{{ __('panel::messages.edit') }}

+

{{ $listing->getAttribute('title') }}

+
+ + + {{ __('panel::messages.back_to_listings') }} + +
+ +
+ @csrf + @method('PUT') + +
+
+

{{ __('site::messages.details') }}

+
+
+ + + + @error('title')

{{ $message }}

@enderror +
-
-
-
-

Edit Listing

-

Update the core listing details without leaving the frontend panel.

+
+ + + + @error('description')

{{ $message }}

@enderror
- @if (session('success')) -
- {{ session('success') }} +
+
+ + + + {{ $listing->getAttribute('currency') }} + + @error('price')

{{ $message }}

@enderror
- @endif - - - @csrf - @method('PUT') - - - - - - - - - - - - - - - - - - -
- - - Manage videos - + @error('status')

{{ $message }}

@enderror
- +
+
-
-
-

Photos

-
- @forelse($listing->getMedia('listing-images') as $media) - {{ $listing->title }} - @empty -
- No photos on this listing. + @if(! empty($customFieldValues)) +
+

{{ __('site::messages.details') }}

+
+
+ @foreach($customFieldValues as $field) +
+
{{ $field['label'] }}
+
{{ $field['value'] }}
- @endforelse -
+ @endforeach +
+
+ @endif +
-
-
-

Listing info

-
-
-
Category
-
{{ $listing->category?->name ?? '-' }}
-
-
-
Status
-
{{ $listing->statusLabel() }}
-
-
-
Videos
-
{{ $listing->videos->count() }}
-
-
-
+
-
+ +
+
+ + {{ __('panel::messages.view') }} +
+
+ + @endsection diff --git a/Modules/Panel/resources/views/layouts/panel.blade.php b/Modules/Panel/resources/views/layouts/panel.blade.php new file mode 100644 index 00000000000..c6254292fa2 --- /dev/null +++ b/Modules/Panel/resources/views/layouts/panel.blade.php @@ -0,0 +1,39 @@ +@extends('site::layouts.app') + +@php + $panelUserId = (int) auth()->id(); + $panelSection = $panelSection ?? ''; + $panelItems = [ + ['key' => 'dashboard', 'label' => __('panel::messages.dashboard'), 'icon' => 'chart', 'url' => route('panel.index'), 'count' => null], + ['key' => 'listings', 'label' => __('panel::messages.my_listings'), 'icon' => 'tag', 'url' => route('panel.listings.index'), 'count' => \Modules\Listing\Models\Listing::query()->ownedByUser($panelUserId)->count()], + ['key' => 'offers', 'label' => __('offer::messages.offers'), 'icon' => 'sort', 'url' => route('panel.offers.index'), 'count' => \Modules\Offer\Models\Offer::pendingCountForSeller($panelUserId)], + ['key' => 'inbox', 'label' => __('panel::messages.inbox'), 'icon' => 'mail', 'url' => route('panel.inbox.index'), 'count' => auth()->user()->unreadInboxCount()], + ['key' => 'notifications', 'label' => __('notification::messages.notifications'), 'icon' => 'bell', 'url' => route('panel.notifications.index'), 'count' => \Modules\Notification\Models\UserNotification::unreadCountForUser($panelUserId)], + ['key' => 'promotions', 'label' => __('promotion::messages.promotions'), 'icon' => 'sparkle', 'url' => route('panel.promotions.index'), 'count' => \Modules\Promotion\Models\PromotionOrder::activeCountForUser($panelUserId)], + ['key' => 'videos', 'label' => __('panel::messages.videos'), 'icon' => 'video', 'url' => route('panel.videos.index'), 'count' => null], + ['key' => 'favorites', 'label' => __('panel::messages.favorites'), 'icon' => 'heart', 'url' => route('favorites.index'), 'count' => null], + ['key' => 'profile', 'label' => __('panel::messages.profile'), 'icon' => 'user', 'url' => route('panel.profile.edit'), 'count' => null], + ]; +@endphp + +@section('content') +
+
+ + +
+ @yield('panel_content') +
+
+
+@endsection diff --git a/Modules/Panel/resources/views/listings.blade.php b/Modules/Panel/resources/views/listings.blade.php index d3c4bebc693..e50c017b366 100644 --- a/Modules/Panel/resources/views/listings.blade.php +++ b/Modules/Panel/resources/views/listings.blade.php @@ -1,285 +1,105 @@ -@extends('app::layouts.app') +@extends('panel::layouts.panel', ['panelSection' => 'listings']) -@section('title', 'My Listings') +@section('title', __('panel::messages.my_listings')) -@section('content') @php $statusTabs = [ - [ - 'key' => 'all', - 'label' => 'All Listings', - 'count' => (int) ($counts['all'] ?? 0), - 'description' => 'All listings in your account', - ], - [ - 'key' => 'sold', - 'label' => 'Sold', - 'count' => (int) ($counts['sold'] ?? 0), - 'description' => 'Closed sales', - ], - [ - 'key' => 'expired', - 'label' => 'Expired', - 'count' => (int) ($counts['expired'] ?? 0), - 'description' => 'Waiting to be republished', - ], + 'all' => __('panel::messages.all'), + 'sold' => __('panel::messages.sold'), + 'expired' => __('panel::messages.expired'), ]; - $overviewCards = [ - [ - 'label' => 'Total Listings', - 'value' => (int) ($counts['all'] ?? 0), - 'hint' => 'Every listing in your panel', - ], - [ - 'label' => 'Live', - 'value' => (int) ($counts['active'] ?? 0), - 'hint' => 'Visible to visitors right now', - ], - [ - 'label' => 'Sold', - 'value' => (int) ($counts['sold'] ?? 0), - 'hint' => 'Listings closed by sale', - ], - [ - 'label' => 'Expired', - 'value' => (int) ($counts['expired'] ?? 0), - 'hint' => 'Listings waiting to be republished', - ], - ]; - $hasFilters = $search !== '' || $status !== 'all'; @endphp -
-
- - -
-
-
- -

My Listings

-

- Track all your listings from one screen. Dates, status, and engagement are clearer, while search and filters stay compact. -

-
- -
- @if ($hasFilters) - - @endif - - -
-
- -
- @foreach ($overviewCards as $card) -
-

{{ $card['label'] }}

-

{{ number_format($card['value']) }}

-

{{ $card['hint'] }}

-
- @endforeach -
- -
-
-
- -

Search and status

-

- Search by title or narrow the view within {{ number_format($listings->total()) }} results. -

+@section('panel_content') +
+
+

{{ __('panel::messages.my_listings') }}

+

{{ trans_choice('site::messages.results_count', $counts['all'], ['count' => $counts['all']]) }}

+
+ + + {{ __('panel::messages.new_listing') }} + +
+ +
+ + + + + + + +
+ + + +@if($listings->isNotEmpty()) +
+
+ @foreach($listings as $listing) + @php $meta = $listing->panelStatusMeta(); @endphp +
+
+ @if($listing->panelPrimaryImageUrl()) + + @else + + @endif
- -
- - -
- -
- @forelse ($listings as $listing) - @php - $statusMeta = $listing->panelStatusMeta(); - $listingImage = $listing->primaryImageData('card'); - $priceLabel = $listing->panelPriceLabel(); - $favoriteCount = (int) ($listing->favorited_by_users_count ?? 0); - $viewCount = (int) ($listing->view_count ?? 0); - $publishedAt = $listing->panelPublishedAt(); - $publishedLabel = $publishedAt?->format('d.m.Y') ?? '-'; - $expiresLabel = $listing->expires_at?->format('M j, Y') ?? 'No expiry'; - $videoCount = (int) ($listing->videos_count ?? 0); - $readyVideoCount = (int) ($listing->ready_videos_count ?? 0); - $pendingVideoCount = (int) ($listing->pending_videos_count ?? 0); - $videoSummary = $listing->panelVideoSummary($videoCount, $readyVideoCount, $pendingVideoCount); - @endphp - -
- - @if ($listingImage) - @include('listing::partials.responsive-image', [ - 'image' => $listingImage, - 'alt' => $listing->title, - 'class' => 'h-full w-full object-cover', - ]) - @else -
- No image -
- @endif -
- -
-
- {{ $statusMeta['label'] }} - - @if ($listing->category) - {{ $listing->category->name }} - @endif - - {{ $listing->panelLocationLabel() }} -
- -
-
-
-

{{ $listing->title }}

-

{{ $statusMeta['hint'] }}

-
- -

{{ $priceLabel }}

-
- -
-
- Published - {{ $publishedLabel }} - First visible date -
- -
- {{ $listing->expires_at ? 'End date' : 'Listing duration' }} - {{ $expiresLabel }} - {{ $listing->panelExpirySummary() }} -
- -
- Engagement - {{ number_format($viewCount) }} views - {{ number_format($favoriteCount) }} saved -
-
-
- - @if ($videoSummary) -
- {{ $videoSummary['label'] }} - {{ $videoSummary['detail'] }} -
- @endif - - @if ($listing->statusValue() === 'expired') -
- This listing has expired. If it is sold you can keep it closed, otherwise republish it. -
- @elseif ($listing->statusValue() === 'pending') -
- This listing is in moderation review. It will go live automatically once approved. -
- @endif - -
- - -
- @if ($listing->statusValue() === 'expired') -
- @csrf - -
- @elseif ($listing->statusValue() !== 'sold') -
- @csrf - -
- @endif - -
- @csrf - -
-
-
-
- - -
- @empty -
- -

No listings match this filter

-

- Clear the search term, pick another status, or create a new listing to fill this space. -

-
- @if ($hasFilters) - - @endif - - +
+

{{ $listing->getAttribute('title') }}

+
+ {{ $listing->panelPriceLabel() }} + {{ $meta['label'] }} + {{ (int) $listing->getAttribute('view_count') }} + {{ (int) $listing->getAttribute('favorited_by_users_count') }} + {{ $listing->panelLocationLabel() }} + {{ $listing->panelExpirySummary() }}
- @endforelse -
- @if ($listings->hasPages()) -
- {{ $listings->links() }} -
- @endif -
-
-
+
+ {{ __('panel::messages.view') }} + {{ __('panel::messages.edit') }} + + @if($listing->statusValue() !== 'sold') +
+ @csrf + +
+ @endif + + @if($listing->statusValue() === 'expired') +
+ @csrf + +
+ @endif + +
+ @csrf + +
+
+ + @endforeach +
+ + + {{ $listings->links('components.pagination') }} +@else + + {{ __('panel::messages.new_listing') }} + +@endif @endsection diff --git a/Modules/Panel/resources/views/partials/page-header.blade.php b/Modules/Panel/resources/views/partials/page-header.blade.php deleted file mode 100644 index e736e1d196b..00000000000 --- a/Modules/Panel/resources/views/partials/page-header.blade.php +++ /dev/null @@ -1,24 +0,0 @@ -@props([ - 'kicker' => 'Panel', - 'title', - 'description' => null, - 'actions' => null, -]) - -
-
-
-

{{ $kicker }}

-

{{ $title }}

- @if (filled($description)) -

{{ $description }}

- @endif -
- - @if ($actions) -
- {{ $actions }} -
- @endif -
-
diff --git a/Modules/Panel/resources/views/partials/quick-create/form.blade.php b/Modules/Panel/resources/views/partials/quick-create/form.blade.php index d1fe0af72c0..71e55321d18 100644 --- a/Modules/Panel/resources/views/partials/quick-create/form.blade.php +++ b/Modules/Panel/resources/views/partials/quick-create/form.blade.php @@ -1,536 +1,294 @@ @php - $maxPhotoCount = (int) config('quick-listing.max_photo_count', 20); - $visiblePhotoSlotCount = min($maxPhotoCount, 8); - $maxVideoCount = (int) config('video.max_listing_videos', 5); - $currency = \Modules\Listing\Support\ListingPanelHelper::defaultCurrency(); - $displayPrice = is_numeric($price) ? number_format((float) $price, 0, ',', '.') : $price; + $steps = [ + 1 => __('panel::messages.photos'), + 2 => __('site::messages.category'), + 3 => __('site::messages.details'), + 4 => __('panel::messages.overview'), + ]; @endphp -
-
-
- Step {{ $currentStep }} of 5 -

{{ $this->currentStepTitle }}

-