Compare commits

...

6 Commits

Author SHA1 Message Date
fatihalp
de09a50893 Refactor modules for mobile UI 2026-03-09 00:08:58 +03:00
fatihalp
e601c3dd9f Refactor home mobile layout 2026-03-08 23:42:52 +03:00
fatihalp
5ce2b15d3d Fix chat modal flicker 2026-03-08 23:17:02 +03:00
fatihalp
4f0b6d0ef2 Fix modal chat toggling issue 2026-03-08 22:29:11 +03:00
fatihalp
4e0ed5ca36 Implement realtime listing chat 2026-03-08 21:49:26 +03:00
fatihalp
8c0365e710 Refactor home layout and seed data 2026-03-08 20:18:56 +03:00
70 changed files with 4972 additions and 1176 deletions

View File

@ -0,0 +1,413 @@
---
name: developing-with-ai-sdk
description: 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).
---
# Developing with the Laravel AI SDK
The Laravel AI SDK (`laravel/ai`) is the official AI package for Laravel, providing a unified API for agents, images, audio, transcription, embeddings, reranking, vector stores, and file management across multiple AI providers.
## Searching the Documentation
This package is new. Always search the documentation before implementing any feature. Never guess at APIs — the documentation is the single source of truth.
- Use broad, simple queries that match the documentation section headings below.
- Do not add package names to queries — package information is shared automatically. Use `test agent fake`, not `laravel ai test agent fake`.
- Run multiple queries at once — the most relevant results are returned first.
### Documentation Sections
Use these section headings as query terms for accurate results:
- Introduction, Installation, Configuration, Provider Support
- Agents: Prompting, Conversation Context, Structured Output, Attachments, Streaming, Broadcasting, Queueing, Tools, Provider Tools, Middleware, Anonymous Agents, Agent Configuration
- Images
- Audio (TTS)
- Transcription (STT)
- Embeddings: Querying Embeddings, Caching Embeddings
- Reranking
- Files
- Vector Stores: Adding Files to Stores
- Failover
- Testing: Agents, Images, Audio, Transcriptions, Embeddings, Reranking, Files, Vector Stores
- Events
## Decision Workflow
Determine the right entry point before writing code:
Text generation or chat? → Agent class with `Promptable` trait
Chat with conversation history? → Agent + `Conversational` interface (manual) or `RemembersConversations` trait (automatic)
Structured JSON output? → Agent + `HasStructuredOutput` interface
Image generation? → `Image::of()->generate()`
Audio synthesis? → `Audio::of()->generate()`
Transcription? → `Transcription::fromPath()->generate()`
Embeddings? → `Embeddings::for()->generate()`
Reranking? → `Reranking::of()->rerank()`
File storage? → `Document::fromPath()->put()`
Vector stores? → `Stores::create()`
## Basic Usage Examples
### Agents
```php
use Laravel\Ai\Contracts\Agent;
use Laravel\Ai\Promptable;
class SalesCoach implements Agent
{
use Promptable;
public function instructions(): string
{
return 'You are a sales coach.';
}
}
// Prompting
$response = (new SalesCoach)->prompt('Analyze this transcript...');
echo $response->text;
// Streaming (returns SSE response from a route)
return (new SalesCoach)->stream('Analyze this transcript...');
// Queueing
(new SalesCoach)->queue('Analyze this transcript...')
->then(fn ($response) => /* ... */);
// Anonymous agents
use function Laravel\Ai\{agent};
$response = agent(instructions: 'You are a helpful assistant.')->prompt('Hello');
```
### Conversation Context
Manual conversation history via the `Conversational` interface:
```php
use Laravel\Ai\Contracts\Agent;
use Laravel\Ai\Contracts\Conversational;
use Laravel\Ai\Messages\Message;
use Laravel\Ai\Promptable;
class SalesCoach implements Agent, Conversational
{
use Promptable;
public function __construct(public User $user) {}
public function instructions(): string { return 'You are a sales coach.'; }
public function messages(): iterable
{
return History::where('user_id', $this->user->id)
->latest()->limit(50)->get()->reverse()
->map(fn ($m) => new Message($m->role, $m->content))
->all();
}
}
```
Automatic conversation persistence via the `RemembersConversations` trait:
```php
use Laravel\Ai\Concerns\RemembersConversations;
use Laravel\Ai\Contracts\Agent;
use Laravel\Ai\Contracts\Conversational;
use Laravel\Ai\Promptable;
class SalesCoach implements Agent, Conversational
{
use Promptable, RemembersConversations;
public function instructions(): string { return 'You are a sales coach.'; }
}
// Start a new conversation
$response = (new SalesCoach)->forUser($user)->prompt('Hello!');
$conversationId = $response->conversationId;
// Continue an existing conversation
$response = (new SalesCoach)->continue($conversationId, as: $user)->prompt('Tell me more.');
```
### Structured Output
```php
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Laravel\Ai\Contracts\Agent;
use Laravel\Ai\Contracts\HasStructuredOutput;
use Laravel\Ai\Promptable;
class Reviewer implements Agent, HasStructuredOutput
{
use Promptable;
public function instructions(): string { return 'Review and score content.'; }
public function schema(JsonSchema $schema): array
{
return [
'feedback' => $schema->string()->required(),
'score' => $schema->integer()->min(1)->max(10)->required(),
];
}
}
$response = (new Reviewer)->prompt('Review this...');
echo $response['score']; // Access like an array
```
### Images
```php
use Laravel\Ai\Image;
$image = Image::of('A sunset over mountains')
->landscape()
->quality('high')
->generate();
$path = $image->store(); // Store to default disk
```
### Audio
```php
use Laravel\Ai\Audio;
$audio = Audio::of('Hello from Laravel.')
->female()
->instructions('Speak warmly')
->generate();
$path = $audio->store();
```
### Transcription
```php
use Laravel\Ai\Transcription;
$transcript = Transcription::fromStorage('audio.mp3')
->diarize()
->generate();
echo (string) $transcript;
```
### Embeddings
```php
use Laravel\Ai\Embeddings;
use Illuminate\Support\Str;
$response = Embeddings::for(['Text one', 'Text two'])
->dimensions(1536)
->cache()
->generate();
// Single string via Stringable
$embedding = Str::of('Napa Valley has great wine.')->toEmbeddings();
```
### Reranking
```php
use Laravel\Ai\Reranking;
$response = Reranking::of(['Django is Python.', 'Laravel is PHP.', 'React is JS.'])
->limit(5)
->rerank('PHP frameworks');
$response->first()->document; // "Laravel is PHP."
```
### Files and Vector Stores
```php
use Laravel\Ai\Files\Document;
use Laravel\Ai\Stores;
// Store a file with the provider
$file = Document::fromPath('/path/to/doc.pdf')->put();
// Create a vector store and add files
$store = Stores::create('Knowledge Base');
$store->add($file->id);
$store->add(Document::fromStorage('manual.pdf')); // Store + add in one step
```
## Agent Configuration
### PHP Attributes
```php
use Laravel\Ai\Attributes\{Provider, MaxSteps, MaxTokens, Temperature, Timeout};
#[Provider('anthropic')]
#[MaxSteps(10)]
#[MaxTokens(4096)]
#[Temperature(0.7)]
#[Timeout(120)]
class MyAgent implements Agent
{
use Promptable;
// ...
}
```
The `#[UseCheapestModel]` and `#[UseSmartestModel]` attributes are also available for automatic model selection.
### Tools
Implement the `HasTools` interface and scaffold tools with `php artisan make:tool`:
```php
use Laravel\Ai\Contracts\HasTools;
class MyAgent implements Agent, HasTools
{
use Promptable;
public function tools(): iterable
{
return [new MyCustomTool];
}
}
```
### Provider Tools
```php
use Laravel\Ai\Providers\Tools\{WebSearch, WebFetch, FileSearch};
public function tools(): iterable
{
return [
(new WebSearch)->max(5)->allow(['laravel.com']),
new WebFetch,
new FileSearch(stores: ['store_id']),
];
}
```
### Conversation Memory
```php
use Laravel\Ai\Concerns\RemembersConversations;
use Laravel\Ai\Contracts\Conversational;
class ChatBot implements Agent, Conversational
{
use Promptable, RemembersConversations;
// ...
}
$response = (new ChatBot)->forUser($user)->prompt('Hello!');
$response = (new ChatBot)->continue($conversationId, as: $user)->prompt('More...');
```
### Failover
```php
$response = (new MyAgent)->prompt('Hello', provider: ['openai', 'anthropic']);
```
## Testing and Faking
Each capability supports `fake()` with assertions:
```php
use App\Ai\Agents\SalesCoach;
use Laravel\Ai\{Image, Audio, Transcription, Embeddings, Reranking, Files, Stores};
// Agents
SalesCoach::fake(['Response 1', 'Response 2']);
SalesCoach::assertPrompted('query');
SalesCoach::assertNotPrompted('query');
SalesCoach::assertNeverPrompted();
SalesCoach::fake()->preventStrayPrompts();
// Images
Image::fake();
Image::assertGenerated(fn ($prompt) => $prompt->contains('sunset'));
Image::assertNothingGenerated();
// Audio
Audio::fake();
Audio::assertGenerated(fn ($prompt) => $prompt->contains('Hello'));
// Transcription
Transcription::fake(['Transcribed text.']);
Transcription::assertGenerated(fn ($prompt) => $prompt->isDiarized());
// Embeddings
Embeddings::fake();
Embeddings::assertGenerated(fn ($prompt) => $prompt->contains('Laravel'));
// Reranking
Reranking::fake();
Reranking::assertReranked(fn ($prompt) => $prompt->contains('PHP'));
// Files
Files::fake();
Files::assertStored(fn ($file) => $file->mimeType() === 'text/plain');
// Stores
Stores::fake();
Stores::assertCreated('Knowledge Base');
$store = Stores::get('id');
$store->assertAdded('file_id');
```
## Key Patterns
- Namespace: `Laravel\Ai\`
- Package: `composer require laravel/ai`
- Agent pattern: Implement the `Agent` interface and use the `Promptable` trait
- Optional interfaces: `HasTools`, `HasMiddleware`, `HasStructuredOutput`, `Conversational`
- Entry-point classes: `Image`, `Audio`, `Transcription`, `Embeddings`, `Reranking`, `Stores`
- Artisan commands: `php artisan make:agent`, `php artisan make:tool`
- Global helper: `agent()` for anonymous agents
## Common Pitfalls
### Wrong Namespace
The namespace is `Laravel\Ai`, not `Illuminate\Ai` or `Laravel\AI`.
```php
// Correct
use Laravel\Ai\Image;
use Laravel\Ai\Contracts\Agent;
use Laravel\Ai\Promptable;
// Wrong — these do not exist
use Illuminate\Ai\Image;
use Laravel\AI\Agent;
```
### Unsupported Provider Capability
Calling a capability not supported by a provider throws a `LogicException`. Refer to the provider support table below.
### Never Use Prism Directly
Use agents and entry-point classes (`Image`, `Audio`, etc.) — not `Prism::text()` directly. The AI SDK wraps Prism internally.
## Provider Support
| Provider | Text | Image | Audio | STT | Embeddings | Reranking | Files | Stores |
| ---------- | ---- | ----- | ----- | --- | ---------- | --------- | ----- | ------ |
| OpenAI | Y | Y | Y | Y | Y | - | Y | Y |
| Anthropic | Y | - | - | - | - | - | Y | - |
| Gemini | Y | Y | - | - | Y | - | Y | Y |
| xAI | Y | Y | - | - | - | - | - | - |
| Groq | Y | - | - | - | - | - | - | - |
| OpenRouter | Y | - | - | - | - | - | - | - |
| ElevenLabs | - | - | Y | Y | - | - | - | - |
| Cohere | - | - | - | - | Y | Y | - | - |
| Jina | - | - | - | - | Y | Y | - | - |

View File

@ -0,0 +1,100 @@
---
name: tailwindcss-development
description: "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."
license: MIT
metadata:
author: laravel
---
# Tailwind CSS Development
## When to Apply
Activate this skill when:
- Adding styles to components or pages
- Working with responsive design
- Implementing dark mode
- Extracting repeated patterns into components
- Debugging spacing or layout issues
## Documentation
Use `search-docs` for detailed Tailwind CSS v3 patterns and documentation.
## Basic Usage
- Use Tailwind CSS classes to style HTML. Check and follow existing Tailwind conventions in the project before introducing new patterns.
- Offer to extract repeated patterns into components that match the project's conventions (e.g., Blade, JSX, Vue).
- Consider class placement, order, priority, and defaults. Remove redundant classes, add classes to parent or child elements carefully to reduce repetition, and group elements logically.
## Tailwind CSS v3 Specifics
- Always use Tailwind CSS v3 and verify you're using only classes it supports.
- Configuration is done in the `tailwind.config.js` file.
- Import using `@tailwind` directives:
<!-- v3 Import Syntax -->
```css
@tailwind base;
@tailwind components;
@tailwind utilities;
```
## Spacing
When listing items, use gap utilities for spacing; don't use margins.
<!-- Gap Utilities -->
```html
<div class="flex gap-8">
<div>Item 1</div>
<div>Item 2</div>
</div>
```
## Dark Mode
If existing pages and components support dark mode, new pages and components must support it the same way, typically using the `dark:` variant:
<!-- Dark Mode -->
```html
<div class="bg-white dark:bg-gray-900 text-gray-900 dark:text-white">
Content adapts to color scheme
</div>
```
## Common Patterns
### Flexbox Layout
<!-- Flexbox Layout -->
```html
<div class="flex items-center justify-between gap-4">
<div>Left content</div>
<div>Right content</div>
</div>
```
### Grid Layout
<!-- Grid Layout -->
```html
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<div>Card 1</div>
<div>Card 2</div>
<div>Card 3</div>
</div>
```
## Verification
1. Check browser for visual rendering
2. Test responsive breakpoints
3. Verify dark mode if project uses it
## Common Pitfalls
- Using margins for spacing between siblings instead of gap utilities
- Forgetting to add dark mode variants when the project uses dark mode
- Not checking existing project conventions before adding new utilities
- Overusing inline styles when Tailwind classes would suffice

View File

@ -5,3 +5,4 @@ Act as a Senior Laravel & FilamentPHP Architect. Refactor the attached code as a
4. Database: Consolidate migrations into a single file per table or topic (e.g., users, cache, jobs) to reduce the overall number of migration files.
5. Modularity: Use the `laravel-modules` package to encapsulate all features, routing, and Filament resources strictly inside their respective modules.
6. Frontend: Optimize and reduce the CSS footprint while maintaining the exact same visual output.
7. Tooling: Use Laravel Boost as the default framework intelligence layer. Search Laravel Boost documentation before framework-level changes and prefer Boost MCP tools for artisan, browser logs, database inspection, and debugging.

View File

@ -33,7 +33,7 @@ SESSION_ENCRYPT=false
SESSION_PATH=/
SESSION_DOMAIN=null
BROADCAST_CONNECTION=log
BROADCAST_CONNECTION=reverb
FILESYSTEM_DISK=s3
MEDIA_DISK=s3
LOCAL_MEDIA_DISK=public
@ -64,6 +64,20 @@ AWS_URL=
AWS_ENDPOINT=https://hel1.your-objectstorage.com
AWS_USE_PATH_STYLE_ENDPOINT=false
REVERB_APP_ID=
REVERB_APP_KEY=
REVERB_APP_SECRET=
REVERB_HOST=localhost
REVERB_PORT=8080
REVERB_SCHEME=http
REVERB_SERVER_HOST=0.0.0.0
REVERB_SERVER_PORT=8080
VITE_REVERB_APP_KEY="${REVERB_APP_KEY}"
VITE_REVERB_HOST="${REVERB_HOST}"
VITE_REVERB_PORT="${REVERB_PORT}"
VITE_REVERB_SCHEME="${REVERB_SCHEME}"
VITE_APP_NAME="${APP_NAME}"
OPENAI_API_KEY=
@ -73,4 +87,4 @@ QUICK_LISTING_AI_MODEL=gpt-5.2
DEMO=0
DEMO_TTL_MINUTES=360
DEMO_TTL_MINUTES=360

View File

@ -4,4 +4,5 @@ Act as a Senior Laravel & FilamentPHP Architect. Refactor the attached code as a
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.
6. Frontend: Optimize and reduce the CSS footprint while maintaining the exact same visual output.
7. Tooling: Use Laravel Boost as the default framework intelligence layer. Search Laravel Boost documentation before framework-level changes and prefer Boost MCP tools for artisan, browser logs, database inspection, and debugging.

View File

@ -0,0 +1,413 @@
---
name: developing-with-ai-sdk
description: 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).
---
# Developing with the Laravel AI SDK
The Laravel AI SDK (`laravel/ai`) is the official AI package for Laravel, providing a unified API for agents, images, audio, transcription, embeddings, reranking, vector stores, and file management across multiple AI providers.
## Searching the Documentation
This package is new. Always search the documentation before implementing any feature. Never guess at APIs — the documentation is the single source of truth.
- Use broad, simple queries that match the documentation section headings below.
- Do not add package names to queries — package information is shared automatically. Use `test agent fake`, not `laravel ai test agent fake`.
- Run multiple queries at once — the most relevant results are returned first.
### Documentation Sections
Use these section headings as query terms for accurate results:
- Introduction, Installation, Configuration, Provider Support
- Agents: Prompting, Conversation Context, Structured Output, Attachments, Streaming, Broadcasting, Queueing, Tools, Provider Tools, Middleware, Anonymous Agents, Agent Configuration
- Images
- Audio (TTS)
- Transcription (STT)
- Embeddings: Querying Embeddings, Caching Embeddings
- Reranking
- Files
- Vector Stores: Adding Files to Stores
- Failover
- Testing: Agents, Images, Audio, Transcriptions, Embeddings, Reranking, Files, Vector Stores
- Events
## Decision Workflow
Determine the right entry point before writing code:
Text generation or chat? → Agent class with `Promptable` trait
Chat with conversation history? → Agent + `Conversational` interface (manual) or `RemembersConversations` trait (automatic)
Structured JSON output? → Agent + `HasStructuredOutput` interface
Image generation? → `Image::of()->generate()`
Audio synthesis? → `Audio::of()->generate()`
Transcription? → `Transcription::fromPath()->generate()`
Embeddings? → `Embeddings::for()->generate()`
Reranking? → `Reranking::of()->rerank()`
File storage? → `Document::fromPath()->put()`
Vector stores? → `Stores::create()`
## Basic Usage Examples
### Agents
```php
use Laravel\Ai\Contracts\Agent;
use Laravel\Ai\Promptable;
class SalesCoach implements Agent
{
use Promptable;
public function instructions(): string
{
return 'You are a sales coach.';
}
}
// Prompting
$response = (new SalesCoach)->prompt('Analyze this transcript...');
echo $response->text;
// Streaming (returns SSE response from a route)
return (new SalesCoach)->stream('Analyze this transcript...');
// Queueing
(new SalesCoach)->queue('Analyze this transcript...')
->then(fn ($response) => /* ... */);
// Anonymous agents
use function Laravel\Ai\{agent};
$response = agent(instructions: 'You are a helpful assistant.')->prompt('Hello');
```
### Conversation Context
Manual conversation history via the `Conversational` interface:
```php
use Laravel\Ai\Contracts\Agent;
use Laravel\Ai\Contracts\Conversational;
use Laravel\Ai\Messages\Message;
use Laravel\Ai\Promptable;
class SalesCoach implements Agent, Conversational
{
use Promptable;
public function __construct(public User $user) {}
public function instructions(): string { return 'You are a sales coach.'; }
public function messages(): iterable
{
return History::where('user_id', $this->user->id)
->latest()->limit(50)->get()->reverse()
->map(fn ($m) => new Message($m->role, $m->content))
->all();
}
}
```
Automatic conversation persistence via the `RemembersConversations` trait:
```php
use Laravel\Ai\Concerns\RemembersConversations;
use Laravel\Ai\Contracts\Agent;
use Laravel\Ai\Contracts\Conversational;
use Laravel\Ai\Promptable;
class SalesCoach implements Agent, Conversational
{
use Promptable, RemembersConversations;
public function instructions(): string { return 'You are a sales coach.'; }
}
// Start a new conversation
$response = (new SalesCoach)->forUser($user)->prompt('Hello!');
$conversationId = $response->conversationId;
// Continue an existing conversation
$response = (new SalesCoach)->continue($conversationId, as: $user)->prompt('Tell me more.');
```
### Structured Output
```php
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Laravel\Ai\Contracts\Agent;
use Laravel\Ai\Contracts\HasStructuredOutput;
use Laravel\Ai\Promptable;
class Reviewer implements Agent, HasStructuredOutput
{
use Promptable;
public function instructions(): string { return 'Review and score content.'; }
public function schema(JsonSchema $schema): array
{
return [
'feedback' => $schema->string()->required(),
'score' => $schema->integer()->min(1)->max(10)->required(),
];
}
}
$response = (new Reviewer)->prompt('Review this...');
echo $response['score']; // Access like an array
```
### Images
```php
use Laravel\Ai\Image;
$image = Image::of('A sunset over mountains')
->landscape()
->quality('high')
->generate();
$path = $image->store(); // Store to default disk
```
### Audio
```php
use Laravel\Ai\Audio;
$audio = Audio::of('Hello from Laravel.')
->female()
->instructions('Speak warmly')
->generate();
$path = $audio->store();
```
### Transcription
```php
use Laravel\Ai\Transcription;
$transcript = Transcription::fromStorage('audio.mp3')
->diarize()
->generate();
echo (string) $transcript;
```
### Embeddings
```php
use Laravel\Ai\Embeddings;
use Illuminate\Support\Str;
$response = Embeddings::for(['Text one', 'Text two'])
->dimensions(1536)
->cache()
->generate();
// Single string via Stringable
$embedding = Str::of('Napa Valley has great wine.')->toEmbeddings();
```
### Reranking
```php
use Laravel\Ai\Reranking;
$response = Reranking::of(['Django is Python.', 'Laravel is PHP.', 'React is JS.'])
->limit(5)
->rerank('PHP frameworks');
$response->first()->document; // "Laravel is PHP."
```
### Files and Vector Stores
```php
use Laravel\Ai\Files\Document;
use Laravel\Ai\Stores;
// Store a file with the provider
$file = Document::fromPath('/path/to/doc.pdf')->put();
// Create a vector store and add files
$store = Stores::create('Knowledge Base');
$store->add($file->id);
$store->add(Document::fromStorage('manual.pdf')); // Store + add in one step
```
## Agent Configuration
### PHP Attributes
```php
use Laravel\Ai\Attributes\{Provider, MaxSteps, MaxTokens, Temperature, Timeout};
#[Provider('anthropic')]
#[MaxSteps(10)]
#[MaxTokens(4096)]
#[Temperature(0.7)]
#[Timeout(120)]
class MyAgent implements Agent
{
use Promptable;
// ...
}
```
The `#[UseCheapestModel]` and `#[UseSmartestModel]` attributes are also available for automatic model selection.
### Tools
Implement the `HasTools` interface and scaffold tools with `php artisan make:tool`:
```php
use Laravel\Ai\Contracts\HasTools;
class MyAgent implements Agent, HasTools
{
use Promptable;
public function tools(): iterable
{
return [new MyCustomTool];
}
}
```
### Provider Tools
```php
use Laravel\Ai\Providers\Tools\{WebSearch, WebFetch, FileSearch};
public function tools(): iterable
{
return [
(new WebSearch)->max(5)->allow(['laravel.com']),
new WebFetch,
new FileSearch(stores: ['store_id']),
];
}
```
### Conversation Memory
```php
use Laravel\Ai\Concerns\RemembersConversations;
use Laravel\Ai\Contracts\Conversational;
class ChatBot implements Agent, Conversational
{
use Promptable, RemembersConversations;
// ...
}
$response = (new ChatBot)->forUser($user)->prompt('Hello!');
$response = (new ChatBot)->continue($conversationId, as: $user)->prompt('More...');
```
### Failover
```php
$response = (new MyAgent)->prompt('Hello', provider: ['openai', 'anthropic']);
```
## Testing and Faking
Each capability supports `fake()` with assertions:
```php
use App\Ai\Agents\SalesCoach;
use Laravel\Ai\{Image, Audio, Transcription, Embeddings, Reranking, Files, Stores};
// Agents
SalesCoach::fake(['Response 1', 'Response 2']);
SalesCoach::assertPrompted('query');
SalesCoach::assertNotPrompted('query');
SalesCoach::assertNeverPrompted();
SalesCoach::fake()->preventStrayPrompts();
// Images
Image::fake();
Image::assertGenerated(fn ($prompt) => $prompt->contains('sunset'));
Image::assertNothingGenerated();
// Audio
Audio::fake();
Audio::assertGenerated(fn ($prompt) => $prompt->contains('Hello'));
// Transcription
Transcription::fake(['Transcribed text.']);
Transcription::assertGenerated(fn ($prompt) => $prompt->isDiarized());
// Embeddings
Embeddings::fake();
Embeddings::assertGenerated(fn ($prompt) => $prompt->contains('Laravel'));
// Reranking
Reranking::fake();
Reranking::assertReranked(fn ($prompt) => $prompt->contains('PHP'));
// Files
Files::fake();
Files::assertStored(fn ($file) => $file->mimeType() === 'text/plain');
// Stores
Stores::fake();
Stores::assertCreated('Knowledge Base');
$store = Stores::get('id');
$store->assertAdded('file_id');
```
## Key Patterns
- Namespace: `Laravel\Ai\`
- Package: `composer require laravel/ai`
- Agent pattern: Implement the `Agent` interface and use the `Promptable` trait
- Optional interfaces: `HasTools`, `HasMiddleware`, `HasStructuredOutput`, `Conversational`
- Entry-point classes: `Image`, `Audio`, `Transcription`, `Embeddings`, `Reranking`, `Stores`
- Artisan commands: `php artisan make:agent`, `php artisan make:tool`
- Global helper: `agent()` for anonymous agents
## Common Pitfalls
### Wrong Namespace
The namespace is `Laravel\Ai`, not `Illuminate\Ai` or `Laravel\AI`.
```php
// Correct
use Laravel\Ai\Image;
use Laravel\Ai\Contracts\Agent;
use Laravel\Ai\Promptable;
// Wrong — these do not exist
use Illuminate\Ai\Image;
use Laravel\AI\Agent;
```
### Unsupported Provider Capability
Calling a capability not supported by a provider throws a `LogicException`. Refer to the provider support table below.
### Never Use Prism Directly
Use agents and entry-point classes (`Image`, `Audio`, etc.) — not `Prism::text()` directly. The AI SDK wraps Prism internally.
## Provider Support
| Provider | Text | Image | Audio | STT | Embeddings | Reranking | Files | Stores |
| ---------- | ---- | ----- | ----- | --- | ---------- | --------- | ----- | ------ |
| OpenAI | Y | Y | Y | Y | Y | - | Y | Y |
| Anthropic | Y | - | - | - | - | - | Y | - |
| Gemini | Y | Y | - | - | Y | - | Y | Y |
| xAI | Y | Y | - | - | - | - | - | - |
| Groq | Y | - | - | - | - | - | - | - |
| OpenRouter | Y | - | - | - | - | - | - | - |
| ElevenLabs | - | - | Y | Y | - | - | - | - |
| Cohere | - | - | - | - | Y | Y | - | - |
| Jina | - | - | - | - | Y | Y | - | - |

View File

@ -0,0 +1,100 @@
---
name: tailwindcss-development
description: "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."
license: MIT
metadata:
author: laravel
---
# Tailwind CSS Development
## When to Apply
Activate this skill when:
- Adding styles to components or pages
- Working with responsive design
- Implementing dark mode
- Extracting repeated patterns into components
- Debugging spacing or layout issues
## Documentation
Use `search-docs` for detailed Tailwind CSS v3 patterns and documentation.
## Basic Usage
- Use Tailwind CSS classes to style HTML. Check and follow existing Tailwind conventions in the project before introducing new patterns.
- Offer to extract repeated patterns into components that match the project's conventions (e.g., Blade, JSX, Vue).
- Consider class placement, order, priority, and defaults. Remove redundant classes, add classes to parent or child elements carefully to reduce repetition, and group elements logically.
## Tailwind CSS v3 Specifics
- Always use Tailwind CSS v3 and verify you're using only classes it supports.
- Configuration is done in the `tailwind.config.js` file.
- Import using `@tailwind` directives:
<!-- v3 Import Syntax -->
```css
@tailwind base;
@tailwind components;
@tailwind utilities;
```
## Spacing
When listing items, use gap utilities for spacing; don't use margins.
<!-- Gap Utilities -->
```html
<div class="flex gap-8">
<div>Item 1</div>
<div>Item 2</div>
</div>
```
## Dark Mode
If existing pages and components support dark mode, new pages and components must support it the same way, typically using the `dark:` variant:
<!-- Dark Mode -->
```html
<div class="bg-white dark:bg-gray-900 text-gray-900 dark:text-white">
Content adapts to color scheme
</div>
```
## Common Patterns
### Flexbox Layout
<!-- Flexbox Layout -->
```html
<div class="flex items-center justify-between gap-4">
<div>Left content</div>
<div>Right content</div>
</div>
```
### Grid Layout
<!-- Grid Layout -->
```html
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<div>Card 1</div>
<div>Card 2</div>
<div>Card 3</div>
</div>
```
## Verification
1. Check browser for visual rendering
2. Test responsive breakpoints
3. Verify dark mode if project uses it
## Common Pitfalls
- Using margins for spacing between siblings instead of gap utilities
- Forgetting to add dark mode variants when the project uses dark mode
- Not checking existing project conventions before adding new utilities
- Overusing inline styles when Tailwind classes would suffice

415
AGENTS.md
View File

@ -5,3 +5,418 @@ Act as a Senior Laravel & FilamentPHP Architect. Refactor the attached code as a
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.
===
<laravel-boost-guidelines>
=== 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.
<!-- Explicit Return Types and Method Params -->
```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:
<code-snippet name="Conditional form field visibility" lang="php">
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'),
</code-snippet>
Use `state()` with a `Closure` to compute derived column values:
<code-snippet name="Computed table column value" lang="php">
use Filament\Tables\Columns\TextColumn;
TextColumn::make('full_name')
->state(fn (User $record): string => "{$record->first_name} {$record->last_name}"),
</code-snippet>
Actions encapsulate a button with an optional modal form and logic:
<code-snippet name="Action with modal form" lang="php">
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))
</code-snippet>
### 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`):
<code-snippet name="Table test" lang="php">
use function Pest\Livewire\livewire;
livewire(ListUsers::class)
->assertCanSeeTableRecords($users)
->searchTable($users->first()->name)
->assertCanSeeTableRecords($users->take(1))
->assertCanNotSeeTableRecords($users->skip(1));
</code-snippet>
<code-snippet name="Create resource test" lang="php">
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',
]);
</code-snippet>
<code-snippet name="Testing validation" lang="php">
use function Pest\Livewire\livewire;
livewire(CreateUser::class)
->fillForm([
'name' => null,
'email' => 'invalid-email',
])
->call('create')
->assertHasFormErrors([
'name' => 'required',
'email' => 'email',
])
->assertNotNotified();
</code-snippet>
<code-snippet name="Calling actions in pages" lang="php">
use Filament\Actions\DeleteAction;
use function Pest\Livewire\livewire;
livewire(EditUser::class, ['record' => $user->id])
->callAction(DeleteAction::class)
->assertNotified()
->assertRedirect();
</code-snippet>
<code-snippet name="Calling actions in tables" lang="php">
use Filament\Actions\Testing\TestAction;
use function Pest\Livewire\livewire;
livewire(ListUsers::class)
->callAction(TestAction::make('promote')->table($user), [
'role' => 'admin',
])
->assertNotified();
</code-snippet>
### 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).
</laravel-boost-guidelines>

423
GEMINI.md Normal file
View File

@ -0,0 +1,423 @@
Act as a Senior Laravel & FilamentPHP Architect. Refactor the attached code as a greenfield project adhering to the following strict constraints:
1. Architecture: Enforce strict SOLID principles, prioritize brevity, and completely ignore backward compatibility.
2. Cleanup: Remove all legacy code, comments, tests, and PHPDocs.
3. Refactoring: Move all database logic into Models and extract repetitive Filament code into dedicated Helper classes. Identify and fix any existing logical errors.
4. Database: Consolidate migrations into a single file per table or topic (e.g., users, cache, jobs) to reduce the overall number of migration files.
5. Modularity: Use the `laravel-modules` package to encapsulate all features, routing, and Filament resources strictly inside their respective modules.
6. Frontend: Optimize and reduce the CSS footprint while maintaining the exact same visual output.
7. Tooling: Use Laravel Boost as the default framework intelligence layer. Search Laravel Boost documentation before framework-level changes and prefer Boost MCP tools for artisan, browser logs, database inspection, and debugging.
===
<laravel-boost-guidelines>
=== 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.
<!-- Explicit Return Types and Method Params -->
```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:
<code-snippet name="Conditional form field visibility" lang="php">
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'),
</code-snippet>
Use `state()` with a `Closure` to compute derived column values:
<code-snippet name="Computed table column value" lang="php">
use Filament\Tables\Columns\TextColumn;
TextColumn::make('full_name')
->state(fn (User $record): string => "{$record->first_name} {$record->last_name}"),
</code-snippet>
Actions encapsulate a button with an optional modal form and logic:
<code-snippet name="Action with modal form" lang="php">
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))
</code-snippet>
### 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`):
<code-snippet name="Table test" lang="php">
use function Pest\Livewire\livewire;
livewire(ListUsers::class)
->assertCanSeeTableRecords($users)
->searchTable($users->first()->name)
->assertCanSeeTableRecords($users->take(1))
->assertCanNotSeeTableRecords($users->skip(1));
</code-snippet>
<code-snippet name="Create resource test" lang="php">
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',
]);
</code-snippet>
<code-snippet name="Testing validation" lang="php">
use function Pest\Livewire\livewire;
livewire(CreateUser::class)
->fillForm([
'name' => null,
'email' => 'invalid-email',
])
->call('create')
->assertHasFormErrors([
'name' => 'required',
'email' => 'email',
])
->assertNotNotified();
</code-snippet>
<code-snippet name="Calling actions in pages" lang="php">
use Filament\Actions\DeleteAction;
use function Pest\Livewire\livewire;
livewire(EditUser::class, ['record' => $user->id])
->callAction(DeleteAction::class)
->assertNotified()
->assertRedirect();
</code-snippet>
<code-snippet name="Calling actions in tables" lang="php">
use Filament\Actions\Testing\TestAction;
use function Pest\Livewire\livewire;
livewire(ListUsers::class)
->callAction(TestAction::make('promote')->table($user), [
'role' => 'admin',
])
->assertNotified();
</code-snippet>
### 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).
</laravel-boost-guidelines>

View File

@ -13,7 +13,7 @@ class CategorySeeder extends Seeder
['name' => 'Vehicles', 'slug' => 'vehicles', 'icon' => 'img/category/car.png', 'children' => ['Cars', 'Motorcycles', 'Trucks', 'Boats']],
['name' => 'Real Estate', 'slug' => 'real-estate', 'icon' => 'img/category/home_garden.png', 'children' => ['For Sale', 'For Rent', 'Commercial']],
['name' => 'Fashion', 'slug' => 'fashion', 'icon' => 'img/category/phone.png', 'children' => ['Men', 'Women', 'Kids', 'Shoes']],
['name' => 'Home & Garden', 'slug' => 'home-garden', 'icon' => 'img/category/home_tools.png', 'children' => ['Furniture', 'Garden', 'Appliances']],
['name' => 'Home', 'slug' => 'home-garden', 'icon' => 'img/category/home_tools.png', 'children' => ['Furniture', 'Garden', 'Appliances']],
['name' => 'Sports', 'slug' => 'sports', 'icon' => 'img/category/sports.png', 'children' => ['Outdoor', 'Fitness', 'Team Sports']],
['name' => 'Jobs', 'slug' => 'jobs', 'icon' => 'img/category/education.png', 'children' => ['Full Time', 'Part Time', 'Freelance']],
['name' => 'Services', 'slug' => 'services', 'icon' => 'img/category/home_tools.png', 'children' => ['Cleaning', 'Repair', 'Education']],

View File

@ -0,0 +1,37 @@
<?php
namespace Modules\Conversation\App\Events;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Broadcasting\PrivateChannel;
use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class ConversationReadUpdated implements ShouldBroadcastNow
{
use Dispatchable;
use InteractsWithSockets;
use SerializesModels;
public function __construct(
public int $userId,
public array $payload,
) {
}
public function broadcastOn(): PrivateChannel
{
return new PrivateChannel('users.'.$this->userId.'.inbox');
}
public function broadcastAs(): string
{
return 'inbox.read.updated';
}
public function broadcastWith(): array
{
return $this->payload;
}
}

View File

@ -0,0 +1,37 @@
<?php
namespace Modules\Conversation\App\Events;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Broadcasting\PrivateChannel;
use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class InboxMessageCreated implements ShouldBroadcastNow
{
use Dispatchable;
use InteractsWithSockets;
use SerializesModels;
public function __construct(
public int $userId,
public array $payload,
) {
}
public function broadcastOn(): PrivateChannel
{
return new PrivateChannel('users.'.$this->userId.'.inbox');
}
public function broadcastAs(): string
{
return 'inbox.message.created';
}
public function broadcastWith(): array
{
return $this->payload;
}
}

View File

@ -8,6 +8,8 @@ use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Schema;
use Illuminate\View\View;
use Modules\Conversation\App\Events\ConversationReadUpdated;
use Modules\Conversation\App\Events\InboxMessageCreated;
use Modules\Conversation\App\Models\Conversation;
use Modules\Conversation\App\Models\ConversationMessage;
use Modules\Conversation\App\Support\QuickMessageCatalog;
@ -28,20 +30,22 @@ class ConversationController extends Controller
if ($userId && $this->messagingTablesReady()) {
try {
$conversations = Conversation::inboxForUser($userId, $messageFilter);
$selectedConversation = Conversation::resolveSelected($conversations, $request->integer('conversation'));
[
'conversations' => $conversations,
'selectedConversation' => $selectedConversation,
'markedRead' => $markedRead,
] = $this->resolveInboxState(
$userId,
$messageFilter,
$request->integer('conversation'),
true,
);
if ($selectedConversation) {
$selectedConversation->loadThread();
$selectedConversation->markAsReadFor($userId);
$conversations = $conversations->map(function (Conversation $conversation) use ($selectedConversation): Conversation {
if ((int) $conversation->getKey() === (int) $selectedConversation->getKey()) {
$conversation->unread_count = 0;
}
return $conversation;
});
if ($selectedConversation && $markedRead) {
broadcast(new ConversationReadUpdated(
$userId,
$selectedConversation->readPayloadFor($userId),
));
}
} catch (Throwable) {
$conversations = collect();
@ -58,6 +62,33 @@ class ConversationController extends Controller
]);
}
public function state(Request $request): JsonResponse
{
abort_unless($this->messagingTablesReady(), 503, 'Messaging is not available yet.');
$userId = (int) $request->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
{
if (! $this->messagingTablesReady()) {
@ -98,12 +129,8 @@ class ConversationController extends Controller
$message = null;
if ($messageBody !== '') {
$message = $conversation->messages()->create([
'sender_id' => $user->getKey(),
'body' => $messageBody,
]);
$conversation->forceFill(['last_message_at' => $message->created_at])->save();
$message = $conversation->createMessageFor((int) $user->getKey(), $messageBody);
$this->broadcastMessageCreated($conversation, $message, (int) $user->getKey());
}
if ($request->expectsJson()) {
@ -146,12 +173,8 @@ class ConversationController extends Controller
return back()->with('error', 'Message cannot be empty.');
}
$message = $conversation->messages()->create([
'sender_id' => $userId,
'body' => $messageBody,
]);
$conversation->forceFill(['last_message_at' => $message->created_at])->save();
$message = $conversation->createMessageFor($userId, $messageBody);
$this->broadcastMessageCreated($conversation, $message, $userId);
if ($request->expectsJson()) {
return $this->conversationJsonResponse($conversation, $message, $userId);
@ -162,23 +185,43 @@ class ConversationController extends Controller
->with('success', 'Message sent.');
}
public function read(Request $request, Conversation $conversation): JsonResponse
{
abort_unless($this->messagingTablesReady(), 503, 'Messaging is not available yet.');
$userId = (int) $request->user()->getKey();
abort_unless($conversation->hasParticipant($userId), 403);
$updated = $conversation->markAsReadFor($userId);
$payload = $conversation->readPayloadFor($userId);
if ($updated > 0) {
broadcast(new ConversationReadUpdated($userId, $payload))->toOthers();
}
return response()->json($payload);
}
private function conversationJsonResponse(Conversation $conversation, ?ConversationMessage $message, int $userId): JsonResponse
{
return response()->json([
'conversation_id' => (int) $conversation->getKey(),
'send_url' => route('conversations.messages.send', $conversation),
'read_url' => route('conversations.read', $conversation),
'conversation' => [
'id' => (int) $conversation->getKey(),
'unread_count' => $conversation->unreadCountForParticipant($userId),
],
'counts' => [
'unread_messages_total' => Conversation::unreadCountForUser($userId),
],
'message' => $message ? $this->messagePayload($message, $userId) : null,
]);
}
private function messagePayload(ConversationMessage $message, int $userId): array
{
return [
'id' => (int) $message->getKey(),
'body' => (string) $message->body,
'time' => $message->created_at?->format('H:i') ?? now()->format('H:i'),
'is_mine' => (int) $message->sender_id === $userId,
];
return $message->toRealtimePayloadFor($userId);
}
private function inboxFilters(Request $request): array
@ -195,6 +238,78 @@ class ConversationController extends Controller
return in_array($messageFilter, ['all', 'unread', 'important'], true) ? $messageFilter : 'all';
}
private function resolveInboxState(
int $userId,
string $messageFilter,
?int $conversationId,
bool $markSelectedRead,
): array {
$conversations = Conversation::inboxForUser($userId, $messageFilter);
$selectedConversation = Conversation::resolveSelected($conversations, $conversationId);
$markedRead = false;
if ($selectedConversation) {
$selectedConversation->loadThread();
if ($markSelectedRead) {
$markedRead = $selectedConversation->markAsReadFor($userId) > 0;
$selectedConversation->unread_count = 0;
$conversations = $conversations->map(function (Conversation $conversation) use ($selectedConversation): Conversation {
if ((int) $conversation->getKey() === (int) $selectedConversation->getKey()) {
$conversation->unread_count = 0;
}
return $conversation;
});
}
}
return [
'conversations' => $conversations,
'selectedConversation' => $selectedConversation,
'markedRead' => $markedRead,
];
}
private function renderInboxList($conversations, string $messageFilter, ?Conversation $selectedConversation): string
{
return view('conversation::partials.inbox-list-pane', [
'conversations' => $conversations,
'messageFilter' => $messageFilter,
'selectedConversation' => $selectedConversation,
])->render();
}
private function renderInboxThread(?Conversation $selectedConversation, string $messageFilter): string
{
return view('conversation::partials.inbox-thread-pane', [
'selectedConversation' => $selectedConversation,
'messageFilter' => $messageFilter,
'quickMessages' => QuickMessageCatalog::all(),
])->render();
}
private function broadcastMessageCreated(
Conversation $conversation,
ConversationMessage $message,
int $senderId,
): void {
foreach ($conversation->participantIds() as $participantId) {
$event = new InboxMessageCreated(
$participantId,
$conversation->realtimePayloadFor($participantId, $message),
);
if ($participantId === $senderId) {
broadcast($event)->toOthers();
continue;
}
broadcast($event);
}
}
private function messagingTablesReady(): bool
{
try {

View File

@ -121,13 +121,59 @@ class Conversation extends Model
{
$this->load([
'listing:id,title,price,currency,user_id',
'messages' => fn ($query) => $query->with('sender:id,name')->orderBy('created_at'),
'messages' => fn ($query) => $query->with('sender:id,name')->ordered(),
]);
}
public function markAsReadFor(int $userId): void
public function hasParticipant(int $userId): bool
{
ConversationMessage::query()
return (int) $this->buyer_id === $userId || (int) $this->seller_id === $userId;
}
public function participantIds(): array
{
return collect([$this->buyer_id, $this->seller_id])
->filter()
->map(fn ($id): int => (int) $id)
->unique()
->values()
->all();
}
public function partnerFor(int $userId): ?User
{
$this->loadMissing([
'buyer:id,name,email',
'seller:id,name,email',
]);
return (int) $this->buyer_id === $userId ? $this->seller : $this->buyer;
}
public function createMessageFor(int $senderId, string $body): ConversationMessage
{
$message = $this->messages()->create([
'sender_id' => $senderId,
'body' => $body,
]);
$this->forceFill(['last_message_at' => $message->created_at])->save();
return $message->loadMissing('sender:id,name');
}
public function unreadCountForParticipant(int $userId): int
{
return (int) ConversationMessage::query()
->where('conversation_id', $this->getKey())
->where('sender_id', '!=', $userId)
->whereNull('read_at')
->count();
}
public function markAsReadFor(int $userId): int
{
return ConversationMessage::query()
->where('conversation_id', $this->getKey())
->where('sender_id', '!=', $userId)
->whereNull('read_at')
@ -137,6 +183,78 @@ class Conversation extends Model
]);
}
public function listingImageUrl(): ?string
{
$this->loadMissing('listing');
$url = $this->listing?->getFirstMediaUrl('listing-images');
return is_string($url) && trim($url) !== '' ? $url : null;
}
public function summaryPayloadFor(int $viewerId): array
{
$this->loadMissing([
'listing:id,title,price,currency,user_id',
'buyer:id,name,email',
'seller:id,name,email',
'lastMessage',
'lastMessage.sender:id,name',
]);
$lastMessage = $this->lastMessage;
$partner = $this->partnerFor($viewerId);
return [
'id' => (int) $this->getKey(),
'unread_count' => $this->unread_count ?? $this->unreadCountForParticipant($viewerId),
'listing' => [
'id' => (int) ($this->listing?->getKey() ?? 0),
'title' => (string) ($this->listing?->title ?? 'Listing removed'),
'image_url' => $this->listingImageUrl(),
],
'partner' => [
'id' => (int) ($partner?->getKey() ?? 0),
'name' => (string) ($partner?->name ?? 'User'),
],
'last_message' => $lastMessage
? $lastMessage->toRealtimePayloadFor($viewerId)
: null,
'last_message_at' => $this->last_message_at?->toIso8601String(),
];
}
public function realtimePayloadFor(int $viewerId, ConversationMessage $message): array
{
$summary = $this->summaryPayloadFor($viewerId);
return [
'conversation' => [
'id' => (int) $this->getKey(),
'unread_count' => $this->unreadCountForParticipant($viewerId),
],
'listing' => $summary['listing'],
'partner' => $summary['partner'],
'message' => $message->toRealtimePayloadFor($viewerId),
'counts' => [
'unread_messages_total' => static::unreadCountForUser($viewerId),
],
];
}
public function readPayloadFor(int $viewerId): array
{
return [
'conversation' => [
'id' => (int) $this->getKey(),
'unread_count' => $this->unreadCountForParticipant($viewerId),
],
'counts' => [
'unread_messages_total' => static::unreadCountForUser($viewerId),
],
];
}
public static function openForListingBuyer(Listing $listing, int $buyerId): self
{
$conversation = static::query()->firstOrCreate(
@ -165,4 +283,15 @@ class Conversation extends Model
return is_null($value) ? null : (int) $value;
}
public static function unreadCountForUser(int $userId): int
{
return (int) ConversationMessage::query()
->whereHas('conversation', function (Builder $query) use ($userId): void {
$query->forUser($userId);
})
->where('sender_id', '!=', $userId)
->whereNull('read_at')
->count();
}
}

View File

@ -4,6 +4,7 @@ namespace Modules\Conversation\App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Builder;
use Modules\User\App\Models\User;
class ConversationMessage extends Model
@ -23,4 +24,23 @@ class ConversationMessage extends Model
{
return $this->belongsTo(User::class, 'sender_id');
}
public function scopeOrdered(Builder $query): Builder
{
return $query->orderBy('created_at');
}
public function toRealtimePayloadFor(int $viewerId): array
{
$this->loadMissing('sender:id,name');
return [
'id' => (int) $this->getKey(),
'body' => (string) $this->body,
'time' => $this->created_at?->format('H:i') ?? now()->format('H:i'),
'sender_id' => (int) $this->sender_id,
'sender_name' => (string) ($this->sender?->name ?? 'User'),
'is_mine' => (int) $this->sender_id === $viewerId,
];
}
}

View File

@ -2,6 +2,7 @@
namespace Modules\Conversation\App\Providers;
use Illuminate\Support\Facades\Broadcast;
use Illuminate\Support\ServiceProvider;
class ConversationServiceProvider extends ServiceProvider
@ -11,6 +12,10 @@ class ConversationServiceProvider extends ServiceProvider
$this->loadMigrationsFrom(module_path('Conversation', 'database/migrations'));
$this->loadRoutesFrom(module_path('Conversation', 'routes/web.php'));
$this->loadViewsFrom(module_path('Conversation', 'resources/views'), 'conversation');
Broadcast::channel('users.{id}.inbox', function ($user, $id): bool {
return (int) $user->getKey() === (int) $id;
});
}
public function register(): void

View File

@ -3,12 +3,12 @@
namespace Modules\Conversation\Database\Seeders;
use Illuminate\Database\Seeder;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Schema;
use Modules\Conversation\App\Models\Conversation;
use Modules\Conversation\App\Models\ConversationMessage;
use Modules\Listing\Models\Listing;
use Modules\User\App\Models\User;
use Modules\User\App\Support\DemoUserCatalog;
class ConversationDemoSeeder extends Seeder
{
@ -18,44 +18,59 @@ class ConversationDemoSeeder extends Seeder
return;
}
$admin = User::query()->where('email', 'a@a.com')->first();
$partner = User::query()->where('email', 'b@b.com')->first();
$users = User::query()
->whereIn('email', DemoUserCatalog::emails())
->orderBy('email')
->get()
->values();
if (! $admin || ! $partner) {
if ($users->count() < 2) {
return;
}
$listings = Listing::query()
->where('user_id', $admin->getKey())
->where('status', 'active')
->orderBy('id')
->take(2)
->get();
ConversationMessage::query()
->whereHas('conversation', fn ($query) => $query->whereIn('buyer_id', $users->pluck('id'))->orWhereIn('seller_id', $users->pluck('id')))
->delete();
if ($listings->count() < 2) {
return;
Conversation::query()
->whereIn('buyer_id', $users->pluck('id'))
->orWhereIn('seller_id', $users->pluck('id'))
->delete();
foreach ($users as $index => $buyer) {
$primarySeller = $users->get(($index + 1) % $users->count());
$secondarySeller = $users->get(($index + 2) % $users->count());
if (! $primarySeller instanceof User || ! $secondarySeller instanceof User) {
continue;
}
$primaryListing = Listing::query()
->where('user_id', $primarySeller->getKey())
->where('status', 'active')
->orderBy('id')
->first();
$secondaryListing = Listing::query()
->where('user_id', $secondarySeller->getKey())
->where('status', 'active')
->orderBy('id')
->first();
$this->seedConversationThread(
$primarySeller,
$buyer,
$primaryListing,
$this->messagePayloads($index, false)
);
$this->seedConversationThread(
$secondarySeller,
$buyer,
$secondaryListing,
$this->messagePayloads($index, true)
);
}
$this->seedConversationThread(
$listings->get(0),
$admin,
$partner,
[
['sender' => 'partner', 'body' => 'Hi, is this still available?', 'hours_ago' => 30, 'read_after_minutes' => 4],
['sender' => 'admin', 'body' => 'Yes, it is available. I can share more photos.', 'hours_ago' => 29, 'read_after_minutes' => 7],
['sender' => 'partner', 'body' => 'Perfect. Can we meet tomorrow afternoon?', 'hours_ago' => 4, 'read_after_minutes' => null],
]
);
$this->seedConversationThread(
$listings->get(1),
$admin,
$partner,
[
['sender' => 'partner', 'body' => 'Can you confirm the final price?', 'hours_ago' => 20, 'read_after_minutes' => 8],
['sender' => 'admin', 'body' => 'I can do a small discount if you pick it up today.', 'hours_ago' => 18, 'read_after_minutes' => null],
]
);
}
private function conversationTablesExist(): bool
@ -64,35 +79,31 @@ class ConversationDemoSeeder extends Seeder
}
private function seedConversationThread(
User $seller,
User $buyer,
?Listing $listing,
User $admin,
User $partner,
array $messages
): void {
if (! $listing) {
if (! $listing || (int) $seller->getKey() === (int) $buyer->getKey()) {
return;
}
$conversation = Conversation::updateOrCreate(
[
'listing_id' => $listing->getKey(),
'buyer_id' => $partner->getKey(),
'buyer_id' => $buyer->getKey(),
],
[
'seller_id' => $admin->getKey(),
'seller_id' => $seller->getKey(),
'last_message_at' => now(),
]
);
ConversationMessage::query()
->where('conversation_id', $conversation->getKey())
->delete();
$lastMessageAt = null;
foreach ($messages as $payload) {
$createdAt = now()->subHours((int) $payload['hours_ago']);
$sender = ($payload['sender'] ?? 'partner') === 'admin' ? $admin : $partner;
$sender = ($payload['sender'] ?? 'buyer') === 'seller' ? $seller : $buyer;
$readAfterMinutes = $payload['read_after_minutes'];
$readAt = is_numeric($readAfterMinutes) ? $createdAt->copy()->addMinutes((int) $readAfterMinutes) : null;
@ -109,10 +120,47 @@ class ConversationDemoSeeder extends Seeder
$lastMessageAt = $createdAt;
}
$conversation->forceFill([
'seller_id' => $admin->getKey(),
'last_message_at' => $lastMessageAt,
'updated_at' => $lastMessageAt,
])->saveQuietly();
if ($lastMessageAt) {
$conversation->forceFill([
'seller_id' => $seller->getKey(),
'last_message_at' => $lastMessageAt,
'updated_at' => $lastMessageAt,
])->saveQuietly();
}
}
private function messagePayloads(int $index, bool $secondary): array
{
$openingMessages = [
'Is this listing still available?',
'Can you share the best price?',
'Would pickup this evening work for you?',
'Can you confirm the condition details?',
'Do you have any more photos?',
];
$sellerReplies = [
'Yes, it is available.',
'I can offer a small discount.',
'This evening works for me.',
'Everything is in clean condition.',
'I can send more photos in a minute.',
];
$closingMessages = [
'Great, I will message again before I leave.',
'Perfect. I can arrange pickup.',
'Thanks. That sounds good to me.',
'Understood. I am interested.',
'Nice. I will keep this saved.',
];
$offset = ($index + ($secondary ? 2 : 0)) % count($openingMessages);
return [
['sender' => 'buyer', 'body' => $openingMessages[$offset], 'hours_ago' => 30 - $index, 'read_after_minutes' => 5],
['sender' => 'seller', 'body' => $sellerReplies[$offset], 'hours_ago' => 28 - $index, 'read_after_minutes' => 8],
['sender' => 'buyer', 'body' => $closingMessages[$offset], 'hours_ago' => 4 + $index, 'read_after_minutes' => $secondary ? 6 : null],
];
}
}

View File

@ -0,0 +1,605 @@
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();
});

View File

@ -16,7 +16,15 @@
: null,
])
<div class="panel-surface overflow-hidden p-0">
<div
class="panel-surface overflow-hidden p-0"
@auth
data-inbox-root
data-state-url="{{ route('panel.inbox.state') }}"
data-read-url-template="{{ route('conversations.read', ['conversation' => '__CONVERSATION__']) }}"
data-selected-conversation-id="{{ $selectedConversation?->id ?? '' }}"
@endauth
>
@if($requiresLogin ?? false)
<div class="border-b border-slate-200 px-5 py-4 bg-slate-50">
<div>
@ -27,146 +35,20 @@
@endif
<div class="grid grid-cols-1 xl:grid-cols-[420px,1fr] min-h-[620px]">
<div class="border-b xl:border-b-0 xl:border-r border-slate-200">
<div class="px-6 py-4 border-b border-slate-200">
<p class="mb-2 text-sm font-semibold text-slate-600">Filters</p>
<div class="flex flex-wrap items-center gap-2">
<a href="{{ route('panel.inbox.index', ['message_filter' => 'all']) }}" class="inline-flex items-center px-4 py-2 rounded-full text-sm font-semibold border {{ $messageFilter === 'all' ? 'border-rose-400 bg-rose-50 text-rose-600' : 'border-slate-300 text-slate-600 hover:bg-slate-100' }}">
All
</a>
<a href="{{ route('panel.inbox.index', ['message_filter' => 'unread']) }}" class="inline-flex items-center px-4 py-2 rounded-full text-sm font-semibold border {{ $messageFilter === 'unread' ? 'border-rose-400 bg-rose-50 text-rose-600' : 'border-slate-300 text-slate-600 hover:bg-slate-100' }}">
Unread
</a>
<a href="{{ route('panel.inbox.index', ['message_filter' => 'important']) }}" class="inline-flex items-center px-4 py-2 rounded-full text-sm font-semibold border {{ $messageFilter === 'important' ? 'border-rose-400 bg-rose-50 text-rose-600' : 'border-slate-300 text-slate-600 hover:bg-slate-100' }}">
Important
</a>
</div>
</div>
<div class="max-h-[480px] overflow-y-auto divide-y divide-slate-200">
@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?->getFirstMediaUrl('listing-images');
$lastMessage = trim((string) ($conversation->lastMessage?->body ?? ''));
@endphp
<a href="{{ route('panel.inbox.index', ['message_filter' => $messageFilter, 'conversation' => $conversation->id]) }}" class="block px-6 py-4 transition {{ $isSelected ? 'bg-rose-50' : 'hover:bg-slate-50' }}">
<div class="flex gap-3">
<div class="w-14 h-14 rounded-xl bg-slate-100 border border-slate-200 overflow-hidden shrink-0">
@if($conversationImage)
<img src="{{ $conversationImage }}" alt="{{ $conversationListing?->title }}" class="w-full h-full object-cover">
@else
<div class="w-full h-full grid place-items-center text-slate-400 text-xs">Listing</div>
@endif
</div>
<div class="min-w-0 flex-1">
<div class="flex items-start gap-2">
<p class="font-semibold text-2xl text-slate-900 truncate">{{ $partner?->name ?? 'User' }}</p>
<p class="text-xs text-slate-500 whitespace-nowrap ml-auto">{{ $conversation->last_message_at?->format('d.m.Y') }}</p>
</div>
<p class="text-sm text-slate-500 truncate mt-1">{{ $conversationListing?->title ?? 'Listing removed' }}</p>
<p class="text-sm {{ $conversation->unread_count > 0 ? 'text-slate-900 font-semibold' : 'text-slate-500' }} truncate mt-1">
{{ $lastMessage !== '' ? $lastMessage : 'No messages yet' }}
</p>
</div>
@if($conversation->unread_count > 0)
<span class="inline-flex items-center justify-center min-w-6 h-6 px-2 rounded-full bg-rose-500 text-white text-xs font-semibold">
{{ $conversation->unread_count }}
</span>
@endif
</div>
</a>
@empty
<div class="px-6 py-16 text-center text-slate-500">
No conversations yet.
</div>
@endforelse
</div>
<div data-inbox-list-container>
@include('conversation::partials.inbox-list-pane', [
'conversations' => $conversations,
'messageFilter' => $messageFilter,
'selectedConversation' => $selectedConversation,
])
</div>
<div class="flex flex-col min-h-[620px]">
@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
<div class="h-24 px-6 border-b border-slate-200 flex items-center gap-4">
<div class="w-12 h-12 rounded-full bg-slate-600 text-white grid place-items-center font-semibold text-lg">
{{ strtoupper(substr((string) ($activePartner?->name ?? 'K'), 0, 1)) }}
</div>
<div class="min-w-0">
<p class="text-3xl font-bold text-slate-900 truncate">{{ $activePartner?->name ?? 'User' }}</p>
<p class="text-sm text-slate-500 truncate">{{ $activeListing?->title ?? 'Listing removed' }}</p>
</div>
@if($activePriceLabel)
<div class="ml-auto text-3xl font-semibold text-slate-800 whitespace-nowrap">{{ $activePriceLabel }}</div>
@endif
</div>
<div class="flex-1 px-6 py-6 bg-slate-100/60 overflow-y-auto max-h-[390px]">
@forelse($selectedConversation->messages as $message)
@php $isMine = (int) $message->sender_id === (int) auth()->id(); @endphp
<div class="mb-4 flex {{ $isMine ? 'justify-end' : 'justify-start' }}">
<div class="max-w-[80%]">
<div class="{{ $isMine ? '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">
{{ $message->body }}
</div>
<p class="text-xs text-slate-500 mt-1 {{ $isMine ? 'text-right' : 'text-left' }}">
{{ $message->created_at?->format('H:i') }}
</p>
</div>
</div>
@empty
<div class="h-full grid place-items-center text-slate-500 text-center px-8">
<div>
<p class="font-semibold text-slate-700">No messages yet.</p>
<p class="mt-1 text-sm">Use a quick reply or send the first message below.</p>
</div>
</div>
@endforelse
</div>
<div class="px-4 py-3 border-t border-slate-200 bg-white">
<div class="flex items-center gap-2 overflow-x-auto pb-2">
@foreach($quickMessages as $quickMessage)
<form method="POST" action="{{ route('conversations.messages.send', $selectedConversation) }}" class="shrink-0">
@csrf
<input type="hidden" name="message_filter" value="{{ $messageFilter }}">
<input type="hidden" name="message" value="{{ $quickMessage }}">
<button type="submit" class="inline-flex items-center h-11 px-5 rounded-full border border-rose-300 text-rose-600 font-semibold text-sm hover:bg-rose-50 transition">
{{ $quickMessage }}
</button>
</form>
@endforeach
</div>
<form method="POST" action="{{ route('conversations.messages.send', $selectedConversation) }}" class="flex items-center gap-2 border-t border-slate-200 pt-3 mt-1">
@csrf
<input type="hidden" name="message_filter" value="{{ $messageFilter }}">
<input type="text" name="message" value="{{ old('message') }}" placeholder="Write a message" maxlength="2000" class="h-12 flex-1 rounded-full border border-slate-300 px-5 text-sm focus:outline-none focus:ring-2 focus:ring-rose-300" required>
<button type="submit" class="h-12 w-12 rounded-full bg-black text-white grid place-items-center hover:bg-slate-800 transition" aria-label="Send">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 12h13m0 0l-5-5m5 5l-5 5"/>
</svg>
</button>
</form>
@error('message')
<p class="text-xs text-rose-600 mt-2 px-2">{{ $message }}</p>
@enderror
</div>
@else
<div class="h-full min-h-[620px] grid place-items-center px-8 text-center text-slate-500">
<div>
<p class="text-2xl font-semibold text-slate-700">Choose a conversation to start messaging.</p>
<p class="mt-2 text-sm">Start a new chat from a listing detail page or continue an existing thread here.</p>
</div>
</div>
@endif
<div data-inbox-thread-container>
@include('conversation::partials.inbox-thread-pane', [
'selectedConversation' => $selectedConversation,
'messageFilter' => $messageFilter,
'quickMessages' => $quickMessages,
])
</div>
</div>
</div>

View File

@ -0,0 +1,57 @@
<div class="border-b xl:border-b-0 xl:border-r border-slate-200">
<div class="px-6 py-4 border-b border-slate-200">
<p class="mb-2 text-sm font-semibold text-slate-600">Filters</p>
<div class="flex flex-wrap items-center gap-2">
<a href="{{ route('panel.inbox.index', ['message_filter' => 'all']) }}" class="inline-flex items-center px-4 py-2 rounded-full text-sm font-semibold border {{ $messageFilter === 'all' ? 'border-rose-400 bg-rose-50 text-rose-600' : 'border-slate-300 text-slate-600 hover:bg-slate-100' }}">
All
</a>
<a href="{{ route('panel.inbox.index', ['message_filter' => 'unread']) }}" class="inline-flex items-center px-4 py-2 rounded-full text-sm font-semibold border {{ $messageFilter === 'unread' ? 'border-rose-400 bg-rose-50 text-rose-600' : 'border-slate-300 text-slate-600 hover:bg-slate-100' }}">
Unread
</a>
<a href="{{ route('panel.inbox.index', ['message_filter' => 'important']) }}" class="inline-flex items-center px-4 py-2 rounded-full text-sm font-semibold border {{ $messageFilter === 'important' ? 'border-rose-400 bg-rose-50 text-rose-600' : 'border-slate-300 text-slate-600 hover:bg-slate-100' }}">
Important
</a>
</div>
</div>
<div class="max-h-[480px] overflow-y-auto divide-y divide-slate-200">
@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?->getFirstMediaUrl('listing-images');
$lastMessage = trim((string) ($conversation->lastMessage?->body ?? ''));
@endphp
<a href="{{ route('panel.inbox.index', ['message_filter' => $messageFilter, 'conversation' => $conversation->id]) }}" class="block px-6 py-4 transition {{ $isSelected ? 'bg-rose-50' : 'hover:bg-slate-50' }}">
<div class="flex gap-3">
<div class="w-14 h-14 rounded-xl bg-slate-100 border border-slate-200 overflow-hidden shrink-0">
@if($conversationImage)
<img src="{{ $conversationImage }}" alt="{{ $conversationListing?->title }}" class="w-full h-full object-cover">
@else
<div class="w-full h-full grid place-items-center text-slate-400 text-xs">Listing</div>
@endif
</div>
<div class="min-w-0 flex-1">
<div class="flex items-start gap-2">
<p class="font-semibold text-2xl text-slate-900 truncate">{{ $partner?->name ?? 'User' }}</p>
<p class="text-xs text-slate-500 whitespace-nowrap ml-auto">{{ $conversation->last_message_at?->format('d.m.Y') }}</p>
</div>
<p class="text-sm text-slate-500 truncate mt-1">{{ $conversationListing?->title ?? 'Listing removed' }}</p>
<p class="text-sm {{ $conversation->unread_count > 0 ? 'text-slate-900 font-semibold' : 'text-slate-500' }} truncate mt-1">
{{ $lastMessage !== '' ? $lastMessage : 'No messages yet' }}
</p>
</div>
@if($conversation->unread_count > 0)
<span class="inline-flex items-center justify-center min-w-6 h-6 px-2 rounded-full bg-rose-500 text-white text-xs font-semibold">
{{ $conversation->unread_count }}
</span>
@endif
</div>
</a>
@empty
<div class="px-6 py-16 text-center text-slate-500">
No conversations yet.
</div>
@endforelse
</div>
</div>

View File

@ -0,0 +1,81 @@
<div class="flex flex-col min-h-[620px]" data-inbox-thread-panel data-selected-conversation-id="{{ $selectedConversation?->id ?? '' }}">
@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
<div class="h-24 px-6 border-b border-slate-200 flex items-center gap-4">
<div class="w-12 h-12 rounded-full bg-slate-600 text-white grid place-items-center font-semibold text-lg">
{{ strtoupper(substr((string) ($activePartner?->name ?? 'K'), 0, 1)) }}
</div>
<div class="min-w-0">
<p class="text-3xl font-bold text-slate-900 truncate">{{ $activePartner?->name ?? 'User' }}</p>
<p class="text-sm text-slate-500 truncate">{{ $activeListing?->title ?? 'Listing removed' }}</p>
</div>
@if($activePriceLabel)
<div class="ml-auto text-3xl font-semibold text-slate-800 whitespace-nowrap">{{ $activePriceLabel }}</div>
@endif
</div>
<div class="flex-1 px-6 py-6 bg-slate-100/60 overflow-y-auto max-h-[390px]" data-inbox-thread>
@forelse($selectedConversation->messages as $message)
@php $isMine = (int) $message->sender_id === (int) auth()->id(); @endphp
<div class="mb-4 flex {{ $isMine ? 'justify-end' : 'justify-start' }}" data-message-id="{{ $message->id }}">
<div class="max-w-[80%]">
<div class="{{ $isMine ? '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">
{{ $message->body }}
</div>
<p class="text-xs text-slate-500 mt-1 {{ $isMine ? 'text-right' : 'text-left' }}">
{{ $message->created_at?->format('H:i') }}
</p>
</div>
</div>
@empty
<div class="h-full grid place-items-center text-slate-500 text-center px-8" data-inbox-empty>
<div>
<p class="font-semibold text-slate-700">No messages yet.</p>
<p class="mt-1 text-sm">Use a quick reply or send the first message below.</p>
</div>
</div>
@endforelse
</div>
<div class="px-4 py-3 border-t border-slate-200 bg-white">
<div class="flex items-center gap-2 overflow-x-auto pb-2">
@foreach($quickMessages as $quickMessage)
<form method="POST" action="{{ route('conversations.messages.send', $selectedConversation) }}" class="shrink-0" data-inbox-send-form>
@csrf
<input type="hidden" name="message_filter" value="{{ $messageFilter }}">
<input type="hidden" name="message" value="{{ $quickMessage }}">
<button type="submit" class="inline-flex items-center h-11 px-5 rounded-full border border-rose-300 text-rose-600 font-semibold text-sm hover:bg-rose-50 transition">
{{ $quickMessage }}
</button>
</form>
@endforeach
</div>
<form method="POST" action="{{ route('conversations.messages.send', $selectedConversation) }}" class="flex items-center gap-2 border-t border-slate-200 pt-3 mt-1" data-inbox-send-form>
@csrf
<input type="hidden" name="message_filter" value="{{ $messageFilter }}">
<input type="text" name="message" value="{{ old('message') }}" placeholder="Write a message" maxlength="2000" class="h-12 flex-1 rounded-full border border-slate-300 px-5 text-sm focus:outline-none focus:ring-2 focus:ring-rose-300" required data-inbox-message-input>
<button type="submit" class="h-12 w-12 rounded-full bg-black text-white grid place-items-center hover:bg-slate-800 transition" aria-label="Send" data-inbox-send-button>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 12h13m0 0l-5-5m5 5l-5 5"/>
</svg>
</button>
</form>
<p class="text-xs text-rose-600 mt-2 px-2 hidden" data-inbox-send-error></p>
</div>
@else
<div class="h-full min-h-[620px] grid place-items-center px-8 text-center text-slate-500">
<div>
<p class="text-2xl font-semibold text-slate-700">Choose a conversation to start messaging.</p>
<p class="mt-2 text-sm">Start a new chat from a listing detail page or continue an existing thread here.</p>
</div>
</div>
@endif
</div>

View File

@ -6,10 +6,12 @@ use Modules\Conversation\App\Http\Controllers\ConversationController;
Route::middleware('web')->group(function () {
Route::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 () {
Route::post('/listings/{listing}/conversation', [ConversationController::class, 'start'])->name('start');
Route::post('/conversations/{conversation}/messages', [ConversationController::class, 'send'])->name('messages.send');
Route::post('/conversations/{conversation}/read', [ConversationController::class, 'read'])->name('read');
});
});

View File

@ -9,10 +9,7 @@ class DemoContentSeeder extends Seeder
public function run(): void
{
$this->call([
\Modules\User\Database\Seeders\AuthUserSeeder::class,
\Modules\Listing\Database\Seeders\ListingPanelDemoSeeder::class,
\Modules\Favorite\Database\Seeders\FavoriteDemoSeeder::class,
\Modules\Conversation\Database\Seeders\ConversationDemoSeeder::class,
\Modules\User\Database\Seeders\UserWorkspaceSeeder::class,
]);
}
}

View File

@ -10,6 +10,7 @@ use Modules\Category\Models\Category;
use Modules\Favorite\App\Models\FavoriteSearch;
use Modules\Listing\Models\Listing;
use Modules\User\App\Models\User;
use Modules\User\App\Support\DemoUserCatalog;
class FavoriteDemoSeeder extends Seeder
{
@ -19,40 +20,40 @@ class FavoriteDemoSeeder extends Seeder
return;
}
$admin = User::query()->where('email', 'a@a.com')->first();
$partner = User::query()->where('email', 'b@b.com')->first();
$users = User::query()
->whereIn('email', DemoUserCatalog::emails())
->orderBy('email')
->get()
->values();
if (! $admin || ! $partner) {
if ($users->count() < 2) {
return;
}
$adminListings = Listing::query()
->where('user_id', $admin->getKey())
->orderByDesc('is_featured')
->orderBy('id')
->get();
DB::table('favorite_listings')->whereIn('user_id', $users->pluck('id'))->delete();
DB::table('favorite_sellers')->whereIn('user_id', $users->pluck('id'))->delete();
FavoriteSearch::query()->whereIn('user_id', $users->pluck('id'))->delete();
if ($adminListings->isEmpty()) {
return;
foreach ($users as $index => $user) {
$favoriteSeller = $users->get(($index + 1) % $users->count());
$secondarySeller = $users->get(($index + 2) % $users->count());
if (! $favoriteSeller instanceof User || ! $secondarySeller instanceof User) {
continue;
}
$favoriteListings = Listing::query()
->whereIn('user_id', [$favoriteSeller->getKey(), $secondarySeller->getKey()])
->where('status', 'active')
->orderByDesc('is_featured')
->orderBy('id')
->take(4)
->get();
$this->seedFavoriteListings($user, $favoriteListings);
$this->seedFavoriteSeller($user, $favoriteSeller, now()->subDays($index + 1));
$this->seedFavoriteSearches($user, $this->searchPayloadsForUser($index));
}
$activeAdminListings = $adminListings->where('status', 'active')->values();
$this->seedFavoriteListings(
$partner,
$activeAdminListings->take(6)
);
$this->seedFavoriteListings(
$admin,
$adminListings->take(3)->values()
);
$this->seedFavoriteSeller($partner, $admin, now()->subDays(2));
$this->seedFavoriteSeller($admin, $partner, now()->subDays(1));
$this->seedFavoriteSearches($partner, $this->partnerSearchPayloads());
$this->seedFavoriteSearches($admin, $this->adminSearchPayloads());
}
private function favoriteTablesExist(): bool
@ -67,7 +68,7 @@ class FavoriteDemoSeeder extends Seeder
$rows = $listings
->values()
->map(function (Listing $listing, int $index) use ($user): array {
$timestamp = now()->subHours(12 + ($index * 5));
$timestamp = now()->subHours(8 + ($index * 3));
return [
'user_id' => $user->getKey(),
@ -147,27 +148,28 @@ class FavoriteDemoSeeder extends Seeder
}
}
private function partnerSearchPayloads(): array
private function searchPayloadsForUser(int $index): array
{
$electronicsId = Category::query()->where('name', 'Electronics')->value('id');
$vehiclesId = Category::query()->where('name', 'Vehicles')->value('id');
$realEstateId = Category::query()->where('name', 'Real Estate')->value('id');
return [
['search' => 'iphone', 'category_id' => $electronicsId],
['search' => 'sedan', 'category_id' => $vehiclesId],
['search' => 'apartment', 'category_id' => $realEstateId],
$blueprints = [
['search' => 'phone', 'slug' => 'electronics'],
['search' => 'car', 'slug' => 'vehicles'],
['search' => 'apartment', 'slug' => 'real-estate'],
['search' => 'style', 'slug' => 'fashion'],
['search' => 'furniture', 'slug' => 'home-garden'],
['search' => 'fitness', 'slug' => 'sports'],
['search' => 'remote', 'slug' => 'jobs'],
['search' => 'cleaning', 'slug' => 'services'],
];
}
private function adminSearchPayloads(): array
{
$fashionId = Category::query()->where('name', 'Fashion')->value('id');
$homeGardenId = Category::query()->where('name', 'Home & Garden')->value('id');
return collect(range(0, 2))
->map(function (int $offset) use ($blueprints, $index): array {
$blueprint = $blueprints[($index + $offset) % count($blueprints)];
return [
['search' => 'vintage', 'category_id' => $fashionId],
['search' => 'garden', 'category_id' => $homeGardenId],
];
return [
'search' => $blueprint['search'],
'category_id' => Category::query()->where('slug', $blueprint['slug'])->value('id'),
];
})
->all();
}
}

View File

@ -3,173 +3,79 @@
namespace Modules\Listing\Database\Seeders;
use Illuminate\Database\Seeder;
use Illuminate\Support\Collection;
use Illuminate\Support\Str;
use Modules\Category\Models\Category;
use Modules\Listing\Models\Listing;
use Modules\User\App\Models\User;
use Modules\User\App\Support\DemoUserCatalog;
class ListingPanelDemoSeeder extends Seeder
{
private const PANEL_LISTINGS = [
[
'slug' => 'admin-demo-sold-camera',
'title' => 'Admin Demo Camera Bundle',
'description' => 'Sample sold listing for the panel filters and activity cards.',
'price' => 18450,
'status' => 'sold',
'city' => 'Istanbul',
'country' => 'Turkey',
'image' => 'sample_image/macbook.jpg',
'expires_offset_days' => 12,
'is_featured' => false,
],
[
'slug' => 'admin-demo-expired-sofa',
'title' => 'Admin Demo Sofa Set',
'description' => 'Sample expired listing for the panel filters and republish flow.',
'price' => 9800,
'status' => 'expired',
'city' => 'Ankara',
'country' => 'Turkey',
'image' => 'sample_image/cup.jpg',
'expires_offset_days' => -5,
'is_featured' => false,
],
[
'slug' => 'admin-demo-expired-bike',
'title' => 'Admin Demo City Bike',
'description' => 'Extra expired sample listing so My Listings is not empty in filtered views.',
'price' => 6200,
'status' => 'expired',
'city' => 'Izmir',
'country' => 'Turkey',
'image' => 'sample_image/car2.jpeg',
'expires_offset_days' => -11,
'is_featured' => false,
],
private const LEGACY_SLUGS = [
'admin-demo-pro-workstation',
'admin-demo-sold-camera',
'admin-demo-expired-sofa',
'member-demo-iphone',
'member-demo-city-bike',
'member-demo-vintage-chair',
'member-demo-garden-tools',
];
public function run(): void
{
$admin = $this->resolveAdminUser();
Listing::query()
->whereIn('slug', self::LEGACY_SLUGS)
->get()
->each(function (Listing $listing): void {
$listing->clearMediaCollection('listing-images');
$listing->delete();
});
if (! $admin) {
return;
}
foreach (DemoUserCatalog::emails() as $email) {
$user = User::query()->where('email', $email)->first();
$this->claimAllListingsForAdmin($admin);
$categories = $this->resolveCategories();
if ($categories->isEmpty()) {
return;
}
foreach (self::PANEL_LISTINGS as $index => $payload) {
$category = $categories->get($index % $categories->count());
if (! $category instanceof Category) {
if (! $user) {
continue;
}
$listing = Listing::updateOrCreate(
['slug' => $payload['slug']],
[
'slug' => $payload['slug'],
'title' => $payload['title'],
'description' => $payload['description'],
'price' => $payload['price'],
'currency' => 'TRY',
'city' => $payload['city'],
'country' => $payload['country'],
'category_id' => $category->getKey(),
'user_id' => $admin->getKey(),
'status' => $payload['status'],
'contact_email' => $admin->email,
'contact_phone' => '+905551112233',
'is_featured' => $payload['is_featured'],
'expires_at' => now()->addDays((int) $payload['expires_offset_days']),
]
);
$this->syncListingImage($listing, (string) $payload['image']);
$this->applyPanelStates($user);
}
}
private function resolveAdminUser(): ?User
private function applyPanelStates(User $user): void
{
return User::query()->where('email', 'a@a.com')->first()
?? User::query()->whereHas('roles', fn ($query) => $query->where('name', 'admin'))->first()
?? User::query()->first();
}
private function claimAllListingsForAdmin(User $admin): void
{
Listing::query()
->where(function ($query) use ($admin): void {
$query
->whereNull('user_id')
->orWhere('user_id', '!=', $admin->getKey());
})
->update([
'user_id' => $admin->getKey(),
'contact_email' => $admin->email,
'updated_at' => now(),
]);
}
private function resolveCategories(): Collection
{
$leafCategories = Category::query()
->where('is_active', true)
->whereDoesntHave('children')
->orderBy('sort_order')
->orderBy('name')
->get();
if ($leafCategories->isNotEmpty()) {
return $leafCategories->values();
}
return Category::query()
->where('is_active', true)
->orderBy('sort_order')
->orderBy('name')
$listings = Listing::query()
->where('user_id', $user->getKey())
->where('slug', 'like', 'demo-%')
->orderBy('created_at')
->get()
->values();
foreach ($listings as $index => $listing) {
$listing->forceFill([
'status' => $this->statusForIndex($index),
'is_featured' => $index < 2,
'expires_at' => $this->expiresAtForIndex($index),
'updated_at' => now()->subHours($index),
])->saveQuietly();
}
}
private function syncListingImage(Listing $listing, string $imageRelativePath): void
private function statusForIndex(int $index): string
{
$imageAbsolutePath = public_path($imageRelativePath);
return match ($index % 9) {
2 => 'sold',
3 => 'expired',
4 => 'pending',
default => 'active',
};
}
if (! is_file($imageAbsolutePath)) {
return;
}
$targetFileName = basename($imageAbsolutePath);
$existingMedia = $listing->getMedia('listing-images')->first();
if (
$existingMedia
&& (string) $existingMedia->file_name === $targetFileName
&& (string) $existingMedia->disk === 'public'
) {
try {
if (is_file($existingMedia->getPath())) {
return;
}
} catch (\Throwable) {
}
}
$listing->clearMediaCollection('listing-images');
$listing
->addMedia($imageAbsolutePath)
->usingFileName(Str::slug($listing->slug).'-'.basename($imageAbsolutePath))
->preservingOriginal()
->toMediaCollection('listing-images', 'public');
private function expiresAtForIndex(int $index): \Illuminate\Support\Carbon
{
return match ($this->statusForIndex($index)) {
'expired' => now()->subDays(4 + ($index % 5)),
'sold' => now()->addDays(8 + ($index % 4)),
'pending' => now()->addDays(5 + ($index % 4)),
default => now()->addDays(20 + ($index % 9)),
};
}
}

View File

@ -8,22 +8,14 @@ use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Str;
use Modules\Category\Models\Category;
use Modules\Listing\Models\Listing;
use Modules\Listing\Support\DemoListingImageFactory;
use Modules\Location\Models\City;
use Modules\Location\Models\Country;
use Modules\User\App\Models\User;
use Modules\User\App\Support\DemoUserCatalog;
class ListingSeeder extends Seeder
{
private const SAMPLE_IMAGES = [
'sample_image/phone.jpeg',
'sample_image/macbook.jpg',
'sample_image/car.jpeg',
'sample_image/headphones.jpg',
'sample_image/laptop.jpg',
'sample_image/cup.jpg',
'sample_image/car2.jpeg',
];
private const TITLE_PREFIXES = [
'Clean',
'Lightly used',
@ -36,31 +28,45 @@ class ListingSeeder extends Seeder
public function run(): void
{
$user = $this->resolveSeederUser();
$users = $this->resolveSeederUsers();
$categories = $this->resolveSeedableCategories();
if (! $user || $categories->isEmpty()) {
if ($users->isEmpty() || $categories->isEmpty()) {
return;
}
$countries = $this->resolveCountries();
$turkeyCities = $this->resolveTurkeyCities();
$plannedSlugs = [];
foreach ($categories as $index => $category) {
$listingData = $this->buildListingData($category, $index, $countries, $turkeyCities);
$listing = $this->upsertListing($index, $listingData, $category, $user);
$this->syncListingImage($listing, $listingData['image']);
foreach ($users as $userIndex => $user) {
foreach ($categories as $categoryIndex => $category) {
$listingIndex = ($userIndex * max(1, $categories->count())) + $categoryIndex;
$listingData = $this->buildListingData($category, $listingIndex, $countries, $turkeyCities, $user);
$listing = $this->upsertListing($listingData, $category, $user);
$plannedSlugs[] = $listing->slug;
$this->syncListingImage($listing, $listingData['image_path']);
}
}
Listing::query()
->whereIn('user_id', $users->pluck('id'))
->where('slug', 'like', 'demo-%')
->whereNotIn('slug', $plannedSlugs)
->get()
->each(function (Listing $listing): void {
$listing->clearMediaCollection('listing-images');
$listing->delete();
});
}
private function resolveSeederUser(): ?User
private function resolveSeederUsers(): Collection
{
return User::query()->where('email', 'a@a.com')->first()
?? User::query()->where('email', 'admin@openclassify.com')->first()
?? User::query()
->whereHas('roles', fn ($query) => $query->where('name', 'admin'))
->first()
?? User::query()->first();
return User::query()
->whereIn('email', DemoUserCatalog::emails())
->orderBy('email')
->get()
->values();
}
private function resolveSeedableCategories(): Collection
@ -68,6 +74,7 @@ class ListingSeeder extends Seeder
$leafCategories = Category::query()
->where('is_active', true)
->whereDoesntHave('children')
->with('parent:id,name')
->orderBy('sort_order')
->orderBy('name')
->get();
@ -78,6 +85,7 @@ class ListingSeeder extends Seeder
return Category::query()
->where('is_active', true)
->with('parent:id,name')
->orderBy('sort_order')
->orderBy('name')
->get()
@ -129,18 +137,32 @@ class ListingSeeder extends Seeder
Category $category,
int $index,
Collection $countries,
Collection $turkeyCities
Collection $turkeyCities,
User $user
): array {
$location = $this->resolveLocation($index, $countries, $turkeyCities);
$image = self::SAMPLE_IMAGES[$index % count(self::SAMPLE_IMAGES)];
$title = $this->buildTitle($category, $index, $user);
$slug = 'demo-'.Str::slug($user->email).'-'.$category->slug;
$familyName = trim((string) ($category->parent?->name ?? $category->name));
return [
'title' => $this->buildTitle($category, $index),
'description' => $this->buildDescription($category, $location['city'], $location['country']),
'slug' => $slug,
'title' => $title,
'description' => $this->buildDescription($category, $location['city'], $location['country'], $user),
'price' => $this->priceForIndex($index),
'city' => $location['city'],
'country' => $location['country'],
'image' => $image,
'contact_phone' => DemoUserCatalog::phoneFor($user->email),
'is_featured' => $index % 7 === 0,
'expires_at' => now()->addDays(21 + ($index % 9)),
'created_at' => now()->subHours(6 + $index),
'image_path' => DemoListingImageFactory::ensure(
$slug,
$title,
$familyName,
$user->name,
$index
),
];
}
@ -148,7 +170,6 @@ class ListingSeeder extends Seeder
{
$turkeyCountry = $countries->first(fn ($country): bool => strtoupper((string) $country->code) === 'TR');
$turkeyName = trim((string) ($turkeyCountry->name ?? 'Turkey')) ?: 'Turkey';
$useForeignCountry = $countries->count() > 1 && $index % 4 === 0;
if ($useForeignCountry) {
@ -175,22 +196,29 @@ class ListingSeeder extends Seeder
];
}
private function buildTitle(Category $category, int $index): string
private function buildTitle(Category $category, int $index, User $user): string
{
$prefix = self::TITLE_PREFIXES[$index % count(self::TITLE_PREFIXES)];
$categoryName = trim((string) $category->name);
$ownerFragment = trim(Str::before($user->name, ' '));
return sprintf('%s %s listing', $prefix, $categoryName !== '' ? $categoryName : 'item');
return sprintf(
'%s %s for %s',
$prefix,
$categoryName !== '' ? $categoryName : 'item',
$ownerFragment !== '' ? $ownerFragment : 'demo'
);
}
private function buildDescription(Category $category, string $city, string $country): string
private function buildDescription(Category $category, string $city, string $country, User $user): string
{
$categoryName = trim((string) $category->name);
$location = trim(collect([$city, $country])->filter()->join(', '));
return sprintf(
'Listed in %s, in clean condition and ready to use. Pickup area: %s. Message for more details.',
'%s listed by %s. Clean demo condition, unique seeded media, and ready for browsing, favorites, inbox, and panel testing. Pickup area: %s.',
$categoryName !== '' ? $categoryName : 'Item',
trim((string) $user->name) !== '' ? trim((string) $user->name) : 'a marketplace user',
$location !== '' ? $location : 'Turkey'
);
}
@ -214,14 +242,12 @@ class ListingSeeder extends Seeder
return $base + $step;
}
private function upsertListing(int $index, array $data, Category $category, User $user): Listing
private function upsertListing(array $data, Category $category, User $user): Listing
{
$slug = Str::slug($category->slug.'-'.$data['title']).'-'.($index + 1);
return Listing::updateOrCreate(
['slug' => $slug],
$listing = Listing::updateOrCreate(
['slug' => $data['slug']],
[
'slug' => $slug,
'slug' => $data['slug'],
'title' => $data['title'],
'description' => $data['description'],
'price' => $data['price'],
@ -232,57 +258,22 @@ class ListingSeeder extends Seeder
'user_id' => $user->id,
'status' => 'active',
'contact_email' => $user->email,
'contact_phone' => '+905551112233',
'is_featured' => $index < 8,
'contact_phone' => $data['contact_phone'],
'is_featured' => $data['is_featured'],
'expires_at' => $data['expires_at'],
]
);
$listing->forceFill([
'created_at' => $data['created_at'],
'updated_at' => $data['created_at'],
])->saveQuietly();
return $listing;
}
private function syncListingImage(Listing $listing, string $imageRelativePath): void
private function syncListingImage(Listing $listing, string $imageAbsolutePath): void
{
$imageAbsolutePath = public_path($imageRelativePath);
if (! is_file($imageAbsolutePath)) {
if ($this->command) {
$this->command->warn("Image not found: {$imageRelativePath}");
}
return;
}
$targetFileName = basename($imageAbsolutePath);
$mediaItems = $listing->getMedia('listing-images');
if (! $this->hasSingleHealthyTargetMedia($mediaItems, $targetFileName)) {
$listing->clearMediaCollection('listing-images');
$listing
->addMedia($imageAbsolutePath)
->preservingOriginal()
->toMediaCollection('listing-images', 'public');
}
}
private function hasSingleHealthyTargetMedia(Collection $mediaItems, string $targetFileName): bool
{
if ($mediaItems->count() !== 1) {
return false;
}
$media = $mediaItems->first();
if (
! $media
|| (string) $media->file_name !== $targetFileName
|| (string) $media->disk !== 'public'
) {
return false;
}
try {
return is_file($media->getPath());
} catch (\Throwable) {
return false;
}
$listing->replacePublicImage($imageAbsolutePath, $listing->slug.'.svg');
}
}

View File

@ -188,7 +188,6 @@ class ListingController extends Controller
$isListingFavorited = false;
$isSellerFavorited = false;
$existingConversationId = null;
$detailConversation = null;
if (auth()->check()) {
@ -219,7 +218,11 @@ class ListingController extends Controller
if ($detailConversation) {
$detailConversation->loadThread();
$detailConversation->markAsReadFor($userId);
$detailConversation->loadCount([
'messages as unread_count' => fn ($query) => $query
->where('sender_id', '!=', $userId)
->whereNull('read_at'),
]);
}
}
}
@ -230,7 +233,6 @@ class ListingController extends Controller
'isListingFavorited',
'isSellerFavorited',
'presentableCustomFields',
'existingConversationId',
'detailConversation',
'gallery',
'listingVideos',

View File

@ -18,6 +18,7 @@ use Spatie\Activitylog\Traits\LogsActivity;
use Spatie\MediaLibrary\HasMedia;
use Spatie\MediaLibrary\InteractsWithMedia;
use Spatie\ModelStates\HasStates;
use Throwable;
class Listing extends Model implements HasMedia
{
@ -347,6 +348,41 @@ class Listing extends Model implements HasMedia
];
}
public function replacePublicImage(string $absolutePath, ?string $fileName = null): void
{
if (! is_file($absolutePath)) {
return;
}
$targetFileName = trim((string) ($fileName ?: basename($absolutePath)));
$existingMediaItems = $this->getMedia('listing-images');
if ($existingMediaItems->count() === 1) {
$existingMedia = $existingMediaItems->first();
if (
$existingMedia
&& (string) $existingMedia->file_name === $targetFileName
&& (string) $existingMedia->disk === 'public'
) {
try {
if (is_file($existingMedia->getPath())) {
return;
}
} catch (Throwable) {
}
}
}
$this->clearMediaCollection('listing-images');
$this
->addMedia($absolutePath)
->usingFileName($targetFileName)
->preservingOriginal()
->toMediaCollection('listing-images', 'public');
}
public function statusValue(): string
{
return $this->status instanceof ListingStatus

View File

@ -0,0 +1,84 @@
<?php
namespace Modules\Listing\Support;
use Illuminate\Support\Str;
final class DemoListingImageFactory
{
private const PALETTES = [
['#0f172a', '#1d4ed8', '#dbeafe'],
['#172554', '#2563eb', '#dbeafe'],
['#0f3b2e', '#059669', '#d1fae5'],
['#3f2200', '#ea580c', '#ffedd5'],
['#3b0764', '#9333ea', '#f3e8ff'],
['#3f3f46', '#e11d48', '#ffe4e6'],
['#0b3b66', '#0891b2', '#cffafe'],
['#422006', '#ca8a04', '#fef3c7'],
];
public static function ensure(
string $slug,
string $title,
string $categoryName,
string $ownerName,
int $seed
): string {
$directory = public_path('generated/demo-listings');
if (! is_dir($directory)) {
mkdir($directory, 0755, true);
}
$filePath = $directory.'/'.Str::slug($slug).'.svg';
$palette = self::PALETTES[$seed % count(self::PALETTES)];
[$baseColor, $accentColor, $surfaceColor] = $palette;
$shortTitle = self::escape(Str::limit($title, 36, ''));
$shortCategory = self::escape(Str::limit($categoryName, 18, ''));
$shortOwner = self::escape(Str::limit($ownerName, 18, ''));
$code = self::escape(strtoupper(Str::substr(md5($slug), 0, 6)));
$svg = <<<SVG
<svg xmlns="http://www.w3.org/2000/svg" width="1600" height="1200" viewBox="0 0 1600 1200" fill="none">
<defs>
<linearGradient id="bg" x1="120" y1="80" x2="1480" y2="1120" gradientUnits="userSpaceOnUse">
<stop stop-color="{$baseColor}"/>
<stop offset="1" stop-color="{$accentColor}"/>
</linearGradient>
<linearGradient id="glass" x1="320" y1="220" x2="1200" y2="920" gradientUnits="userSpaceOnUse">
<stop stop-color="white" stop-opacity="0.95"/>
<stop offset="1" stop-color="{$surfaceColor}" stop-opacity="0.86"/>
</linearGradient>
</defs>
<rect width="1600" height="1200" rx="64" fill="url(#bg)"/>
<circle cx="1320" cy="230" r="170" fill="white" fill-opacity="0.08"/>
<circle cx="240" cy="1010" r="220" fill="white" fill-opacity="0.06"/>
<rect x="170" y="146" width="1260" height="908" rx="58" fill="url(#glass)" stroke="white" stroke-opacity="0.22" stroke-width="6"/>
<rect x="260" y="248" width="420" height="700" rx="44" fill="{$baseColor}" fill-opacity="0.94"/>
<rect x="740" y="248" width="520" height="200" rx="34" fill="white" fill-opacity="0.72"/>
<rect x="740" y="490" width="520" height="210" rx="34" fill="white" fill-opacity="0.52"/>
<rect x="740" y="742" width="240" height="180" rx="30" fill="white" fill-opacity="0.58"/>
<rect x="1020" y="742" width="240" height="180" rx="30" fill="white" fill-opacity="0.32"/>
<rect x="340" y="338" width="260" height="260" rx="130" fill="white" fill-opacity="0.12"/>
<rect x="830" y="310" width="230" height="38" rx="19" fill="{$accentColor}" fill-opacity="0.16"/>
<rect x="830" y="548" width="340" height="26" rx="13" fill="{$baseColor}" fill-opacity="0.12"/>
<rect x="830" y="596" width="260" height="26" rx="13" fill="{$baseColor}" fill-opacity="0.08"/>
<text x="262" y="214" fill="white" fill-opacity="0.92" font-family="Arial, Helvetica, sans-serif" font-size="40" font-weight="700" letter-spacing="10">OPENCLASSIFY DEMO</text>
<text x="258" y="760" fill="white" font-family="Arial, Helvetica, sans-serif" font-size="86" font-weight="700">{$shortCategory}</text>
<text x="258" y="840" fill="white" fill-opacity="0.78" font-family="Arial, Helvetica, sans-serif" font-size="44" font-weight="500">{$shortOwner}</text>
<text x="818" y="390" fill="{$baseColor}" font-family="Arial, Helvetica, sans-serif" font-size="72" font-weight="700">{$shortTitle}</text>
<text x="818" y="474" fill="{$accentColor}" font-family="Arial, Helvetica, sans-serif" font-size="34" font-weight="700" letter-spacing="8">{$code}</text>
</svg>
SVG;
file_put_contents($filePath, $svg);
return $filePath;
}
private static function escape(string $value): string
{
return htmlspecialchars($value, ENT_QUOTES | ENT_XML1, 'UTF-8');
}
}

View File

@ -45,7 +45,9 @@
$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));
$primaryContactHref = null;
$primaryContactLabel = 'Call';
@ -61,12 +63,11 @@
$reportUrl = 'mailto:'.$reportEmail.'?subject='.rawurlencode('Report listing '.$referenceCode);
$shareUrl = route('listings.show', $listing);
$specRows = collect([
['label' => 'Price', 'value' => $priceLabel],
['label' => 'Published', 'value' => $publishedAt],
$detailRows = collect([
['label' => 'Listing ID', 'value' => $referenceCode],
['label' => 'Published', 'value' => $publishedAt],
['label' => 'Category', 'value' => $listing->category?->name ?? 'General'],
['label' => 'Location', 'value' => $locationLabel !== '' ? str_replace(' / ', ' / ', $locationLabel) : 'Not specified'],
['label' => 'Location', 'value' => $locationLabel !== '' ? $locationLabel : 'Not specified'],
])
->merge(
collect($presentableCustomFields ?? [])->map(fn (array $field) => [
@ -77,6 +78,9 @@
->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
<div class="lt-wrap">
@ -90,321 +94,248 @@
<span>{{ $displayTitle }}</span>
</nav>
<section class="lt-card lt-hero-card">
<div class="lt-hero-main">
<div class="lt-hero-copy">
<p class="lt-overline">{{ $listing->category?->name ?? 'Marketplace listing' }}</p>
<h1 class="lt-hero-title">{{ $displayTitle }}</h1>
<div class="lt-hero-meta">
<span>{{ $referenceCode }}</span>
<span>{{ $sellerName }}</span>
<span>{{ $postedAgo }}</span>
</div>
<section class="lt-card ld-header-card">
<div class="ld-header-copy">
<p class="ld-header-ref">{{ $referenceCode }}</p>
<h1 class="ld-header-title">{{ $displayTitle }}</h1>
<div class="ld-header-meta">
<span>{{ $listing->category?->name ?? 'Marketplace listing' }}</span>
<span>{{ $sellerName }}</span>
<span>{{ $postedAgo }}</span>
</div>
</div>
<div class="lt-hero-side">
<div class="lt-hero-price">{{ $priceLabel }}</div>
@if($locationLabel !== '')
<div class="lt-address-chip">{{ $locationLabel }}</div>
@endif
<div class="ld-header-side">
<div class="ld-header-actions" aria-label="Listing actions">
<button
type="button"
class="ld-header-action"
data-listing-share
data-share-url="{{ $shareUrl }}"
data-share-title="{{ $displayTitle }}"
>
Share
</button>
<div class="lt-hero-tools">
<button
type="button"
class="lt-link-action"
data-listing-share
data-share-url="{{ $shareUrl }}"
data-share-title="{{ $displayTitle }}"
>
Share
</button>
@auth
<form method="POST" action="{{ route('favorites.listings.toggle', $listing) }}" class="ld-inline-form">
@csrf
<button type="submit" class="ld-header-action {{ $isListingFavorited ? 'is-active' : '' }}">
{{ $isListingFavorited ? 'Saved' : 'Save listing' }}
</button>
</form>
@else
<a href="{{ route('login') }}" class="ld-header-action">Save listing</a>
@endauth
@auth
<form method="POST" action="{{ route('favorites.listings.toggle', $listing) }}" class="lt-inline-form">
@csrf
<button type="submit" class="lt-link-action">
{{ $isListingFavorited ? 'Saved' : 'Save listing' }}
</button>
</form>
@else
<a href="{{ route('login') }}" class="lt-link-action">Save listing</a>
@endauth
</div>
<button type="button" class="ld-header-action" onclick="window.print()">
Print
</button>
</div>
</div>
</section>
<div class="lt-grid">
<div class="lt-main-column">
<div class="lt-media-spec-grid">
<section class="lt-card lt-media-card" data-gallery>
<div class="lt-gallery-main">
<div class="lt-gallery-top">
<div class="lt-gallery-spacer"></div>
<div class="ld-stage">
<section class="lt-card ld-gallery-card" data-gallery>
<div class="lt-gallery-main">
<div class="lt-gallery-top">
<div class="ld-gallery-chip">Photo gallery</div>
</div>
<div class="lt-gallery-utility">
<button
type="button"
class="lt-icon-btn"
data-listing-share
data-share-url="{{ $shareUrl }}"
data-share-title="{{ $displayTitle }}"
aria-label="Share listing"
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9">
<path d="M15 8a3 3 0 1 0-2.83-4H12a3 3 0 0 0 .17 1L8.91 6.94a3 3 0 0 0-1.91-.69 3 3 0 1 0 1.91 5.31l3.27 1.94A3 3 0 0 0 12 15a3 3 0 1 0 2.82 4H15a3 3 0 0 0-.17-1l-3.26-1.94a3 3 0 0 0 0-3.12L14.83 10A3 3 0 0 0 15 10h0a3 3 0 0 0 0-2Z"/>
</svg>
</button>
@if($initialGalleryImage)
<img src="{{ $initialGalleryImage }}" alt="{{ $displayTitle }}" data-gallery-main>
@else
<div class="lt-gallery-main-empty">No photos uploaded yet.</div>
@endif
@auth
<form method="POST" action="{{ route('favorites.listings.toggle', $listing) }}">
@csrf
<button
type="submit"
class="lt-icon-btn {{ $isListingFavorited ? 'is-active' : '' }}"
aria-label="{{ $isListingFavorited ? 'Remove from saved listings' : 'Save listing' }}"
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9">
<path d="M12 21l-1.45-1.32C5.4 15.03 2 12.01 2 8.31 2 5.3 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.08A6.04 6.04 0 0116.5 3C19.58 3 22 5.3 22 8.31c0 3.7-3.4 6.72-8.55 11.39L12 21z"/>
</svg>
</button>
</form>
@else
<a href="{{ route('login') }}" class="lt-icon-btn" aria-label="Sign in to save this listing">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9">
<path d="M12 21l-1.45-1.32C5.4 15.03 2 12.01 2 8.31 2 5.3 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.08A6.04 6.04 0 0116.5 3C19.58 3 22 5.3 22 8.31c0 3.7-3.4 6.72-8.55 11.39L12 21z"/>
</svg>
</a>
@endauth
</div>
</div>
@if($galleryCount > 1)
<button type="button" class="lt-gallery-nav" data-gallery-prev aria-label="Previous photo">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="m15 18-6-6 6-6"/>
</svg>
</button>
<button type="button" class="lt-gallery-nav" data-gallery-next aria-label="Next photo">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="m9 18 6-6-6-6"/>
</svg>
</button>
@endif
@if($initialGalleryImage)
<img src="{{ $initialGalleryImage }}" alt="{{ $displayTitle }}" data-gallery-main>
@else
<div class="lt-gallery-main-empty">No photos uploaded yet.</div>
@endif
@if($galleryCount > 1)
<button type="button" class="lt-gallery-nav" data-gallery-prev aria-label="Previous photo">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="m15 18-6-6 6-6"/>
</svg>
</button>
<button type="button" class="lt-gallery-nav" data-gallery-next aria-label="Next photo">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="m9 18 6-6-6-6"/>
</svg>
</button>
@endif
@if($galleryCount > 0)
<div class="lt-gallery-count">
<span data-gallery-current>1</span> / <span>{{ $galleryCount }}</span>
</div>
@endif
@if($galleryCount > 0)
<div class="lt-gallery-count">
<span data-gallery-current>1</span> / <span>{{ $galleryCount }}</span>
</div>
@endif
</div>
@if($galleryImages !== [])
<div class="lt-thumbs" data-gallery-thumbs>
@foreach($galleryImages as $index => $image)
<button
type="button"
class="lt-thumb {{ $index === 0 ? 'is-active' : '' }}"
data-gallery-thumb
data-gallery-index="{{ $index }}"
data-gallery-src="{{ $image }}"
aria-label="Open photo {{ $index + 1 }}"
>
<img src="{{ $image }}" alt="{{ $displayTitle }} {{ $index + 1 }}">
</button>
@endforeach
</div>
@endif
</section>
@if($galleryImages !== [])
<div class="lt-thumbs" data-gallery-thumbs>
@foreach($galleryImages as $index => $image)
<button
type="button"
class="lt-thumb {{ $index === 0 ? 'is-active' : '' }}"
data-gallery-thumb
data-gallery-index="{{ $index }}"
data-gallery-src="{{ $image }}"
aria-label="Open photo {{ $index + 1 }}"
>
<img src="{{ $image }}" alt="{{ $displayTitle }} {{ $index + 1 }}">
</button>
@endforeach
</div>
@endif
</section>
<div class="lt-info-column">
<section class="lt-card lt-spec-card lt-desktop-only">
<div class="lt-card-head">
<div>
<h2 class="lt-section-title">Listing details</h2>
<p class="lt-section-copy">Everything important, laid out cleanly.</p>
</div>
</div>
<section class="lt-card ld-summary-card">
<div class="ld-summary-head">
<div class="ld-summary-price">{{ $priceLabel }}</div>
<div class="ld-summary-date">{{ $publishedAt }}</div>
</div>
@if($locationLabel !== '')
<div class="lt-address-chip lt-address-chip-soft">{{ $locationLabel }}</div>
@endif
<div class="ld-summary-location">{{ $locationText }}</div>
<div class="lt-spec-table">
@foreach($specRows as $row)
<div class="lt-spec-row">
<span>{{ $row['label'] }}</span>
<strong>{{ $row['value'] }}</strong>
</div>
@endforeach
</div>
<div class="lt-spec-table">
@foreach($summaryRows as $row)
<div class="lt-spec-row">
<span>{{ $row['label'] }}</span>
<strong>{{ $row['value'] }}</strong>
</div>
@endforeach
</div>
<a href="{{ $reportUrl }}" class="lt-inline-link">Report this listing</a>
</section>
<a href="{{ $reportUrl }}" class="lt-inline-link">Report this listing</a>
</section>
<section class="lt-card lt-description-card lt-desktop-only">
<div class="lt-card-head">
<div>
<h2 class="lt-section-title">Description</h2>
<p class="lt-section-copy">Seller notes, condition, and extra context.</p>
</div>
</div>
<div class="lt-description">
{!! nl2br(e($displayDescription)) !!}
</div>
</section>
<aside class="lt-card ld-seller-card">
<div class="lt-seller-head">
<div class="lt-avatar">{{ $sellerInitial !== '' ? $sellerInitial : 'S' }}</div>
<div>
<p class="lt-seller-kicker">Seller</p>
<p class="lt-seller-name">{{ $sellerName }}</p>
<div class="lt-seller-meta">{{ $sellerMemberText }}</div>
</div>
</div>
<section class="lt-card lt-mobile-seller-card lt-mobile-only">
<div class="lt-mobile-seller-row">
<div class="lt-avatar">{{ $sellerInitial !== '' ? $sellerInitial : 'S' }}</div>
<div>
<p class="lt-mobile-seller-name">{{ $sellerName }}</p>
<p class="lt-mobile-seller-meta">{{ $sellerMemberText }}</p>
</div>
</div>
</section>
<section class="lt-card lt-mobile-tabs lt-mobile-only" data-detail-tabs>
<div class="lt-tab-list" role="tablist" aria-label="Listing sections">
<button type="button" class="lt-tab-button is-active" data-detail-tab-button data-tab="details" role="tab" aria-selected="true">
Listing details
</button>
<button type="button" class="lt-tab-button" data-detail-tab-button data-tab="description" role="tab" aria-selected="false">
Description
</button>
</div>
<div class="lt-tab-panel is-active" data-detail-tab-panel data-panel="details" role="tabpanel">
@if($locationLabel !== '')
<div class="lt-address-chip lt-address-chip-soft">{{ $locationLabel }}</div>
@endif
<div class="lt-spec-table">
@foreach($specRows as $row)
<div class="lt-spec-row">
<span>{{ $row['label'] }}</span>
<strong>{{ $row['value'] }}</strong>
</div>
@endforeach
</div>
</div>
<div class="lt-tab-panel" data-detail-tab-panel data-panel="description" role="tabpanel">
<div class="lt-description">
{!! nl2br(e($displayDescription)) !!}
</div>
</div>
</section>
@if(($listingVideos ?? collect())->isNotEmpty())
<section class="lt-card lt-video-section">
<div class="lt-card-head">
<div>
<h2 class="lt-section-title">Videos</h2>
<p class="lt-section-copy">Additional media attached to the listing.</p>
</div>
</div>
<div class="lt-video-grid">
@foreach($listingVideos as $video)
<div class="lt-video-card">
<video class="lt-video-player" controls preload="metadata" src="{{ $video->playableUrl() }}"></video>
<p class="lt-video-title">{{ $video->titleLabel() }}</p>
</div>
@endforeach
</div>
</section>
@endif
</div>
<aside class="lt-side-rail">
<section class="lt-card lt-side-card">
<div class="lt-seller-head">
<div class="lt-avatar">{{ $sellerInitial !== '' ? $sellerInitial : 'S' }}</div>
<div>
<p class="lt-seller-kicker">Seller</p>
<p class="lt-seller-name">{{ $sellerName }}</p>
<div class="lt-seller-meta">{{ $sellerMemberText }}</div>
</div>
</div>
@if(filled($listing->contact_phone) || filled($listing->contact_email))
<div class="lt-contact-panel">
@if(filled($listing->contact_phone))
<a href="tel:{{ preg_replace('/\s+/', '', (string) $listing->contact_phone) }}" class="lt-contact-primary">
{{ $listing->contact_phone }}
</a>
@endif
@if(filled($listing->contact_email))
<a href="mailto:{{ $listing->contact_email }}" class="lt-contact-secondary">
{{ $listing->contact_email }}
</a>
@endif
</div>
@endif
<div class="lt-actions">
<div class="lt-row-2">
@if(! $listing->user)
<button type="button" class="lt-btn" disabled>Unavailable</button>
@elseif($canStartConversation)
<button type="button" class="lt-btn" data-inline-chat-open>Message</button>
@elseif($isOwnListing)
<button type="button" class="lt-btn" disabled>Your listing</button>
@else
<a href="{{ $loginRedirectRoute }}" class="lt-btn">Message</a>
@endif
@if($primaryContactHref)
<a href="{{ $primaryContactHref }}" class="lt-btn lt-btn-outline">{{ $primaryContactLabel }}</a>
@else
<button type="button" class="lt-btn lt-btn-outline" disabled>No contact</button>
@endif
</div>
@if($listing->user)
<div class="ld-seller-links">
<a href="{{ $sellerListingsUrl }}" class="ld-seller-link">All listings</a>
@if($listing->user && ! $isOwnListing)
@auth
<form method="POST" action="{{ route('favorites.sellers.toggle', $listing->user) }}" class="lt-action-form">
<form method="POST" action="{{ route('favorites.sellers.toggle', $listing->user) }}" class="ld-inline-form">
@csrf
<input type="hidden" name="redirect_to" value="{{ request()->fullUrl() }}">
<button type="submit" class="lt-btn lt-btn-outline">
<button type="submit" class="ld-seller-link">
{{ $isSellerFavorited ? 'Saved seller' : 'Save seller' }}
</button>
</form>
@else
<a href="{{ $loginRedirectRoute }}" class="lt-btn lt-btn-outline">Save seller</a>
<a href="{{ $loginRedirectRoute }}" class="ld-seller-link">Save seller</a>
@endauth
@elseif($isOwnListing)
<button type="button" class="lt-btn lt-btn-outline" disabled>Your account</button>
@endif
</div>
</section>
@endif
<section class="lt-card lt-safety-card">
<h3 class="lt-safety-title">Safety tips</h3>
<p class="lt-safety-copy">Inspect the item in person, avoid sending money in advance, and confirm the seller identity before closing the deal.</p>
<a href="{{ $reportUrl }}" class="lt-inline-link">Report this listing</a>
</section>
@if(filled($listing->contact_phone) || filled($listing->contact_email))
<div class="lt-contact-panel">
@if(filled($listing->contact_phone))
<a href="tel:{{ preg_replace('/\s+/', '', (string) $listing->contact_phone) }}" class="lt-contact-primary">
{{ $listing->contact_phone }}
</a>
@endif
@if(filled($listing->contact_email))
<a href="mailto:{{ $listing->contact_email }}" class="lt-contact-secondary">
{{ $listing->contact_email }}
</a>
@endif
</div>
@else
<div class="ld-empty-contact">No contact details provided.</div>
@endif
<div class="lt-actions">
<div class="lt-row-2">
@if(! $listing->user)
<button type="button" class="lt-btn" disabled>Unavailable</button>
@elseif($canStartConversation)
<button type="button" class="lt-btn" data-inline-chat-trigger>Message</button>
@elseif($isOwnListing)
<button type="button" class="lt-btn" disabled>Your listing</button>
@else
<a href="{{ $loginRedirectRoute }}" class="lt-btn">Message</a>
@endif
@if($primaryContactHref)
<a href="{{ $primaryContactHref }}" class="lt-btn lt-btn-outline">{{ $primaryContactLabel }}</a>
@else
<button type="button" class="lt-btn lt-btn-outline" disabled>No contact</button>
@endif
</div>
@if($listing->user && ! $isOwnListing)
<a href="{{ $sellerListingsUrl }}" class="lt-btn lt-btn-outline">View listings</a>
@elseif($isOwnListing)
<button type="button" class="lt-btn lt-btn-outline" disabled>Your account</button>
@endif
</div>
</aside>
</div>
<section class="lt-card ld-tab-card" data-detail-tabs>
<div class="lt-tab-list" role="tablist" aria-label="Listing sections">
<button type="button" class="lt-tab-button is-active" data-detail-tab-button data-tab="details" role="tab" aria-selected="true">
Listing details
</button>
<button type="button" class="lt-tab-button" data-detail-tab-button data-tab="description" role="tab" aria-selected="false">
Description
</button>
</div>
<div class="lt-tab-panel is-active" data-detail-tab-panel data-panel="details" role="tabpanel">
<div class="lt-spec-table">
@foreach($detailRows as $row)
<div class="lt-spec-row">
<span>{{ $row['label'] }}</span>
<strong>{{ $row['value'] }}</strong>
</div>
@endforeach
</div>
</div>
<div class="lt-tab-panel" data-detail-tab-panel data-panel="description" role="tabpanel">
<div class="lt-description">
{!! nl2br(e($displayDescription)) !!}
</div>
</div>
</section>
@if(($listingVideos ?? collect())->isNotEmpty())
<section class="lt-card lt-video-section">
<div class="lt-card-head">
<div>
<h2 class="lt-section-title">Videos</h2>
<p class="lt-section-copy">Additional media attached to the listing.</p>
</div>
</div>
<div class="lt-video-grid">
@foreach($listingVideos as $video)
<div class="lt-video-card">
<video class="lt-video-player" controls preload="metadata" src="{{ $video->playableUrl() }}"></video>
<p class="lt-video-title">{{ $video->titleLabel() }}</p>
</div>
@endforeach
</div>
</section>
@endif
<div class="lt-mobile-actions">
<div class="lt-mobile-actions-shell">
<div class="lt-mobile-actions-row">
@if(! $listing->user)
<button type="button" class="lt-btn" disabled>Unavailable</button>
@elseif($canStartConversation)
<button type="button" class="lt-btn" data-inline-chat-open>Message</button>
<button type="button" class="lt-btn" data-inline-chat-trigger>Message</button>
@elseif($isOwnListing)
<button type="button" class="lt-btn" disabled>Your listing</button>
@else
@ -417,27 +348,27 @@
<button type="button" class="lt-btn lt-btn-outline" disabled>No contact</button>
@endif
</div>
@if($listing->user && ! $isOwnListing)
@auth
<form method="POST" action="{{ route('favorites.sellers.toggle', $listing->user) }}" class="lt-action-form">
@csrf
<input type="hidden" name="redirect_to" value="{{ request()->fullUrl() }}">
<button type="submit" class="lt-btn lt-btn-outline">
{{ $isSellerFavorited ? 'Saved seller' : 'Save seller' }}
</button>
</form>
@else
<a href="{{ $loginRedirectRoute }}" class="lt-btn lt-btn-outline">Save seller</a>
@endauth
@elseif($isOwnListing)
<button type="button" class="lt-btn lt-btn-outline" disabled>Your account</button>
@endif
</div>
</div>
@if($canStartConversation)
<div class="lt-chat-widget" data-inline-chat data-start-url="{{ $chatStartUrl }}" data-send-url="{{ $chatSendUrl }}">
<div
class="lt-chat-widget is-collapsed"
data-inline-chat
data-state="collapsed"
data-conversation-id="{{ $chatConversation?->id ?? '' }}"
data-start-url="{{ $chatStartUrl }}"
data-send-url="{{ $chatSendUrl }}"
data-read-url="{{ $chatReadUrl }}"
data-read-url-template="{{ route('conversations.read', ['conversation' => '__CONVERSATION__']) }}"
>
<button type="button" class="lt-chat-launcher" data-inline-chat-launcher aria-label="Open chat">
<span class="lt-chat-launcher-copy">
<span class="lt-chat-launcher-kicker">Chat</span>
<span class="lt-chat-launcher-name">{{ $sellerName }}</span>
</span>
<span class="lt-chat-launcher-badge {{ $chatUnreadCount > 0 ? '' : 'hidden' }}" data-inline-chat-badge>{{ $chatUnreadCount }}</span>
</button>
<section class="lt-chat-panel" data-inline-chat-panel hidden>
<div class="lt-chat-head">
<div>
@ -454,7 +385,7 @@
<div class="lt-chat-thread" data-inline-chat-thread>
@foreach($chatMessages as $message)
<div class="lt-chat-item {{ (int) $message->sender_id === (int) auth()->id() ? 'is-mine' : '' }}">
<div class="lt-chat-item {{ (int) $message->sender_id === (int) auth()->id() ? 'is-mine' : '' }}" data-message-id="{{ $message->id }}">
<div class="lt-chat-bubble">{{ $message->body }}</div>
<span class="lt-chat-time">{{ $message->created_at?->format('H:i') }}</span>
</div>
@ -680,137 +611,6 @@
});
});
const chatRoot = document.querySelector('[data-inline-chat]');
if (chatRoot) {
const panel = chatRoot.querySelector('[data-inline-chat-panel]');
const thread = chatRoot.querySelector('[data-inline-chat-thread]');
const emptyState = chatRoot.querySelector('[data-inline-chat-empty]');
const form = chatRoot.querySelector('[data-inline-chat-form]');
const input = chatRoot.querySelector('[data-inline-chat-input]');
const error = chatRoot.querySelector('[data-inline-chat-error]');
const submitButton = chatRoot.querySelector('[data-inline-chat-submit]');
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || '';
const togglePanel = (open) => {
if (!panel) {
return;
}
panel.hidden = !open;
chatRoot.classList.toggle('is-open', open);
if (open) {
window.requestAnimationFrame(() => 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 appendMessage = (message) => {
if (!thread || !message?.body) {
return;
}
const item = document.createElement('div');
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.appendChild(bubble);
item.appendChild(time);
thread.appendChild(item);
thread.scrollTop = thread.scrollHeight;
emptyState?.classList.add('is-hidden');
};
document.querySelectorAll('[data-inline-chat-open]').forEach((button) => {
button.addEventListener('click', () => {
showError('');
togglePanel(true);
});
});
chatRoot.querySelector('[data-inline-chat-close]')?.addEventListener('click', () => {
togglePanel(false);
});
form?.addEventListener('submit', async (event) => {
event.preventDefault();
if (!input || !submitButton) {
return;
}
const message = input.value.trim();
if (message === '') {
showError('Message cannot be empty.');
input.focus();
return;
}
const targetUrl = chatRoot.dataset.sendUrl || chatRoot.dataset.startUrl;
if (!targetUrl) {
showError('Messaging is not available right now.');
return;
}
showError('');
submitButton.disabled = true;
try {
const response = await fetch(targetUrl, {
method: 'POST',
headers: {
'Accept': 'application/json',
'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8',
'X-CSRF-TOKEN': csrfToken,
'X-Requested-With': 'XMLHttpRequest',
},
body: new URLSearchParams({ message }).toString(),
});
const payload = await response.json().catch(() => ({}));
if (!response.ok) {
const responseMessage = payload?.message || payload?.errors?.message?.[0] || 'Message could not be sent.';
throw new Error(responseMessage);
}
if (payload.send_url) {
chatRoot.dataset.sendUrl = payload.send_url;
}
if (payload.message) {
appendMessage(payload.message);
}
input.value = '';
input.focus();
} catch (requestError) {
showError(requestError instanceof Error ? requestError.message : 'Message could not be sent.');
} finally {
submitButton.disabled = false;
}
});
}
})();
</script>
@endsection

View File

@ -22,6 +22,7 @@ use Spatie\Activitylog\LogOptions;
use Spatie\Activitylog\Traits\LogsActivity;
use Spatie\ModelStates\HasStates;
use Spatie\Permission\Traits\HasRoles;
use Throwable;
class User extends Authenticatable implements FilamentUser, HasAvatar
{
@ -186,4 +187,36 @@ class User extends Authenticatable implements FilamentUser, HasAvatar
return true;
}
public function unreadInboxCount(): int
{
return Conversation::unreadCountForUser((int) $this->getKey());
}
public function unreadNotificationCount(): int
{
try {
return (int) $this->unreadNotifications()->count();
} catch (Throwable) {
return 0;
}
}
public function savedListingsCount(): int
{
try {
return (int) $this->favoriteListings()->count();
} catch (Throwable) {
return 0;
}
}
public function headerBadgeCounts(): array
{
return [
'messages' => $this->unreadInboxCount(),
'notifications' => $this->unreadNotificationCount(),
'favorites' => $this->savedListingsCount(),
];
}
}

View File

@ -0,0 +1,74 @@
<?php
namespace Modules\User\App\Support;
final class DemoUserCatalog
{
public static function records(): array
{
return [
[
'email' => 'a@a.com',
'name' => 'Admin',
'password' => '236330',
'phone' => '+905551112233',
'is_admin' => true,
],
[
'email' => 'b@b.com',
'name' => 'Member',
'password' => '236330',
'phone' => '+905551112244',
'is_admin' => false,
],
[
'email' => 'c@c.com',
'name' => 'Ava Carter',
'password' => '236330',
'phone' => '+905551112255',
'is_admin' => false,
],
[
'email' => 'd@d.com',
'name' => 'Liam Stone',
'password' => '236330',
'phone' => '+905551112266',
'is_admin' => false,
],
[
'email' => 'e@e.com',
'name' => 'Mila Reed',
'password' => '236330',
'phone' => '+905551112277',
'is_admin' => false,
],
];
}
public static function emails(): array
{
return array_column(self::records(), 'email');
}
public static function phoneFor(string $email): string
{
foreach (self::records() as $record) {
if ($record['email'] === $email) {
return $record['phone'];
}
}
return '+905551110000';
}
public static function isAdmin(string $email): bool
{
foreach (self::records() as $record) {
if ($record['email'] === $email) {
return (bool) $record['is_admin'];
}
}
return false;
}
}

View File

@ -5,29 +5,22 @@ namespace Modules\User\Database\Seeders;
use Illuminate\Database\Seeder;
use Illuminate\Support\Facades\Schema;
use Modules\User\App\Models\User;
use Modules\User\App\Support\DemoUserCatalog;
use Spatie\Permission\Models\Role;
class AuthUserSeeder extends Seeder
{
public function run(): void
{
$admin = User::query()->updateOrCreate(
['email' => 'a@a.com'],
[
'name' => 'Admin',
'password' => '236330',
'status' => 'active',
],
);
User::query()->updateOrCreate(
['email' => 'b@b.com'],
[
'name' => 'Member',
'password' => '236330',
'status' => 'active',
],
);
$users = collect(DemoUserCatalog::records())
->map(fn (array $record): User => User::query()->updateOrCreate(
['email' => $record['email']],
[
'name' => $record['name'],
'password' => $record['password'],
'status' => 'active',
],
));
if (! class_exists(Role::class) || ! Schema::hasTable((new Role())->getTable())) {
return;
@ -38,6 +31,14 @@ class AuthUserSeeder extends Seeder
'guard_name' => 'web',
]);
$admin->syncRoles([$adminRole->name]);
$users->each(function (User $user) use ($adminRole): void {
if (DemoUserCatalog::isAdmin($user->email)) {
$user->syncRoles([$adminRole->name]);
return;
}
$user->syncRoles([]);
});
}
}

View File

@ -0,0 +1,18 @@
<?php
namespace Modules\User\Database\Seeders;
use Illuminate\Database\Seeder;
class UserWorkspaceSeeder extends Seeder
{
public function run(): void
{
$this->call([
\Modules\Listing\Database\Seeders\ListingPanelDemoSeeder::class,
\Modules\Favorite\Database\Seeders\FavoriteDemoSeeder::class,
\Modules\Conversation\Database\Seeders\ConversationDemoSeeder::class,
\Modules\Video\Database\Seeders\VideoDemoSeeder::class,
]);
}
}

View File

@ -0,0 +1,85 @@
<?php
namespace Modules\Video\Database\Seeders;
use Illuminate\Database\Seeder;
use Illuminate\Support\Facades\Schema;
use Modules\Listing\Models\Listing;
use Modules\User\App\Models\User;
use Modules\User\App\Support\DemoUserCatalog;
use Modules\Video\Enums\VideoStatus;
use Modules\Video\Models\Video;
class VideoDemoSeeder extends Seeder
{
public function run(): void
{
if (! Schema::hasTable('videos') || ! Schema::hasTable('listings')) {
return;
}
$users = User::query()
->whereIn('email', DemoUserCatalog::emails())
->orderBy('email')
->get()
->values();
foreach ($users as $userIndex => $user) {
$listings = Listing::query()
->where('user_id', $user->getKey())
->where('status', 'active')
->orderBy('id')
->take(2)
->get();
foreach ($listings as $listingIndex => $listing) {
$blueprint = $this->blueprintFor($userIndex, $listingIndex);
$video = Video::query()->firstOrNew([
'listing_id' => $listing->getKey(),
'user_id' => $user->getKey(),
'title' => $blueprint['title'],
]);
$video->forceFill([
'listing_id' => $listing->getKey(),
'user_id' => $user->getKey(),
'title' => $blueprint['title'],
'description' => $blueprint['description'],
'status' => $blueprint['status'],
'disk' => 'public',
'path' => null,
'upload_disk' => 'public',
'upload_path' => null,
'mime_type' => 'video/mp4',
'size' => null,
'sort_order' => $listingIndex + 1,
'is_active' => $blueprint['is_active'],
'processing_error' => $blueprint['processing_error'],
'processed_at' => null,
])->saveQuietly();
}
}
}
private function blueprintFor(int $userIndex, int $listingIndex): array
{
if ($listingIndex === 0) {
return [
'title' => 'Quick walkthrough '.($userIndex + 1),
'description' => 'Pending demo video for uploader and panel testing.',
'status' => VideoStatus::Pending,
'is_active' => true,
'processing_error' => null,
];
}
return [
'title' => 'Condition details '.($userIndex + 1),
'description' => 'Failed demo video for status handling and retry UI testing.',
'status' => VideoStatus::Failed,
'is_active' => false,
'processing_error' => 'Demo processing was skipped intentionally.',
];
}
}

View File

@ -15,13 +15,6 @@ A modern classified ads platform built with Laravel 12, FilamentPHP v5, and Lara
- 🐳 **Docker Ready** — One-command production and development setup
- ☁️ **GitHub Codespaces** — Zero-config cloud development
## AI Custom Instructions
Project-level custom instruction set files are available at:
- `.chatgpt/CUSTOM_INSTRUCTIONS.md` (ChatGPT)
- `.codex/CUSTOM_INSTRUCTIONS.md` (Codex)
- `.gemini/CUSTOM_INSTRUCTIONS.md` (Google Gemini / Antigravity)
## Tech Stack
@ -32,7 +25,7 @@ Project-level custom instruction set files are available at:
| Modules | nWidart/laravel-modules v11 |
| Auth/Roles | Spatie Laravel Permission |
| Frontend | Blade + TailwindCSS + Vite |
| Database | PostgreSQL (required for demo mode), SQLite for minimal local dev |
| Database | PostgreSQL (required for demo mode) |
| Cache/Queue | Database or Redis |
## Quick Start (Docker)
@ -147,50 +140,6 @@ php artisan demo:prepare
php artisan demo:cleanup
```
### Notes
- `php artisan db:seed` only injects demo-heavy listings, favorites, inbox threads, and demo users when demo mode is enabled.
- Public infrastructure tables such as sessions, cache, jobs, and failed jobs remain on the public schema even while visitor requests are switched into demo schemas.
---
## Architecture
### Module Structure
```
Modules/
├── Admin/ # FilamentPHP Admin Panel
│ ├── Filament/
│ │ └── Resources/ # CRUD resources (User, Category, Listing, Location)
│ └── Providers/
│ ├── AdminServiceProvider.php
│ └── AdminPanelProvider.php
├── Category/ # Category management
│ ├── Models/Category.php
│ ├── Http/Controllers/
│ ├── database/migrations/
│ └── database/seeders/
├── Listing/ # Listing management
│ ├── Models/Listing.php
│ ├── Http/Controllers/
│ ├── database/migrations/
│ └── database/seeders/
├── Location/ # Countries & Cities
│ ├── Models/{Country,City,District}.php
│ ├── database/migrations/
│ └── database/seeders/
└── User/ # Users, auth, profile, and account flows
├── App/Http/Controllers/
├── App/Models/
├── Database/Seeders/
└── database/migrations/
```
### Panels
| Panel | URL | Access |
@ -297,20 +246,4 @@ php artisan config:cache
php artisan route:cache
php artisan view:cache
php artisan storage:link
```
---
## Contributing
1. Fork the repository
2. Create a feature branch: `git checkout -b feature/your-feature`
3. Commit your changes: `git commit -m 'Add your feature'`
4. Push to the branch: `git push origin feature/your-feature`
5. Open a Pull Request
---
## License
MIT License. See [LICENSE](LICENSE) for details.
```

View File

@ -48,7 +48,7 @@ final class HomeSlideDefaults
$normalized = collect($source)
->filter(fn ($slide): bool => is_array($slide))
->values()
->map(function (array $slide, int $index) use ($defaults): ?array {
->map(function (array $slide, int $index) use ($defaults, $defaultDisk): ?array {
$fallback = $defaults[$index] ?? $defaults[array_key_last($defaults)];
$badge = trim((string) ($slide['badge'] ?? ''));
$title = trim((string) ($slide['title'] ?? ''));

View File

@ -9,6 +9,7 @@ use Illuminate\Support\Facades\View;
use Modules\Category\Models\Category;
use Modules\Location\Models\Country;
use Modules\S3\Support\MediaStorage;
use Modules\User\App\Models\User;
use Throwable;
final class RequestAppData
@ -22,6 +23,7 @@ final class RequestAppData
View::share('generalSettings', $generalSettings);
View::share('headerLocationCountries', $this->resolveHeaderLocationCountries());
View::share('headerNavCategories', $this->resolveHeaderNavCategories());
View::share('headerAccountMeta', $this->resolveHeaderAccountMeta());
}
private function resolveGeneralSettings(): array
@ -214,6 +216,24 @@ final class RequestAppData
}
}
private function resolveHeaderAccountMeta(): ?array
{
$user = auth()->user();
if (! $user instanceof User) {
return null;
}
$badgeCounts = $user->headerBadgeCounts();
return [
'name' => $user->getDisplayName(),
'messages' => max(0, (int) ($badgeCounts['messages'] ?? 0)),
'notifications' => max(0, (int) ($badgeCounts['notifications'] ?? 0)),
'favorites' => max(0, (int) ($badgeCounts['favorites'] ?? 0)),
];
}
private function normalizeCurrencies(array $currencies): array
{
$normalized = collect($currencies)

View File

@ -4,15 +4,17 @@
"gemini",
"copilot"
],
"guidelines": true,
"herd_mcp": false,
"mcp": true,
"nightwatch_mcp": false,
"packages": [
"filament/filament",
"spatie/laravel-permission"
"laravel/ai"
],
"sail": false,
"skills": [
"laravel-permission-development"
"tailwindcss-development",
"developing-with-ai-sdk"
]
}

View File

@ -11,6 +11,7 @@ return Application::configure(basePath: dirname(__DIR__))
->withRouting(
web: __DIR__.'/../routes/web.php',
commands: __DIR__.'/../routes/console.php',
channels: __DIR__.'/../routes/channels.php',
health: '/up',
)
->withMiddleware(function (Middleware $middleware): void {

View File

@ -20,6 +20,7 @@
"jeffgreco13/filament-breezy": "^3.2",
"laravel/ai": "^0.2.5",
"laravel/framework": "^12.0",
"laravel/reverb": "^1.8",
"laravel/sanctum": "^4.3",
"laravel/tinker": "^2.10.1",
"league/flysystem-aws-s3-v3": "^3.25",
@ -67,7 +68,7 @@
],
"dev": [
"Composer\\Config::disableProcessTimeout",
"npx concurrently -c \"#93c5fd,#c4b5fd,#fb7185,#fdba74\" \"php artisan serve\" \"php artisan queue:listen --tries=1 --timeout=0\" \"php artisan pail --timeout=0\" \"npm run dev\" --names=server,queue,logs,vite --kill-others"
"npx concurrently -c \"#93c5fd,#c4b5fd,#34d399,#fb7185,#fdba74\" \"php artisan serve\" \"php artisan queue:listen --tries=1 --timeout=0\" \"php artisan reverb:start --host=0.0.0.0 --port=8080\" \"php artisan pail --timeout=0\" \"npm run dev\" --names=server,queue,reverb,logs,vite --kill-others"
],
"test": [
"@php artisan config:clear --ansi",

82
config/broadcasting.php Normal file
View File

@ -0,0 +1,82 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Default Broadcaster
|--------------------------------------------------------------------------
|
| This option controls the default broadcaster that will be used by the
| framework when an event needs to be broadcast. You may set this to
| any of the connections defined in the "connections" array below.
|
| Supported: "reverb", "pusher", "ably", "redis", "log", "null"
|
*/
'default' => env('BROADCAST_CONNECTION', 'null'),
/*
|--------------------------------------------------------------------------
| Broadcast Connections
|--------------------------------------------------------------------------
|
| Here you may define all of the broadcast connections that will be used
| to broadcast events to other systems or over WebSockets. Samples of
| each available type of connection are provided inside this array.
|
*/
'connections' => [
'reverb' => [
'driver' => 'reverb',
'key' => env('REVERB_APP_KEY'),
'secret' => env('REVERB_APP_SECRET'),
'app_id' => env('REVERB_APP_ID'),
'options' => [
'host' => env('REVERB_HOST'),
'port' => env('REVERB_PORT', 443),
'scheme' => env('REVERB_SCHEME', 'https'),
'useTLS' => env('REVERB_SCHEME', 'https') === 'https',
],
'client_options' => [
// Guzzle client options: https://docs.guzzlephp.org/en/stable/request-options.html
],
],
'pusher' => [
'driver' => 'pusher',
'key' => env('PUSHER_APP_KEY'),
'secret' => env('PUSHER_APP_SECRET'),
'app_id' => env('PUSHER_APP_ID'),
'options' => [
'cluster' => env('PUSHER_APP_CLUSTER'),
'host' => env('PUSHER_HOST') ?: 'api-'.env('PUSHER_APP_CLUSTER', 'mt1').'.pusher.com',
'port' => env('PUSHER_PORT', 443),
'scheme' => env('PUSHER_SCHEME', 'https'),
'encrypted' => true,
'useTLS' => env('PUSHER_SCHEME', 'https') === 'https',
],
'client_options' => [
// Guzzle client options: https://docs.guzzlephp.org/en/stable/request-options.html
],
],
'ably' => [
'driver' => 'ably',
'key' => env('ABLY_KEY'),
],
'log' => [
'driver' => 'log',
],
'null' => [
'driver' => 'null',
],
],
];

96
config/reverb.php Normal file
View File

@ -0,0 +1,96 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Default Reverb Server
|--------------------------------------------------------------------------
|
| This option controls the default server used by Reverb to handle
| incoming messages as well as broadcasting message to all your
| connected clients. At this time only "reverb" is supported.
|
*/
'default' => env('REVERB_SERVER', 'reverb'),
/*
|--------------------------------------------------------------------------
| Reverb Servers
|--------------------------------------------------------------------------
|
| Here you may define details for each of the supported Reverb servers.
| Each server has its own configuration options that are defined in
| the array below. You should ensure all the options are present.
|
*/
'servers' => [
'reverb' => [
'host' => env('REVERB_SERVER_HOST', '0.0.0.0'),
'port' => env('REVERB_SERVER_PORT', 8080),
'path' => env('REVERB_SERVER_PATH', ''),
'hostname' => env('REVERB_HOST'),
'options' => [
'tls' => [],
],
'max_request_size' => env('REVERB_MAX_REQUEST_SIZE', 10_000),
'scaling' => [
'enabled' => env('REVERB_SCALING_ENABLED', false),
'channel' => env('REVERB_SCALING_CHANNEL', 'reverb'),
'server' => [
'url' => env('REDIS_URL'),
'host' => env('REDIS_HOST', '127.0.0.1'),
'port' => env('REDIS_PORT', '6379'),
'username' => env('REDIS_USERNAME'),
'password' => env('REDIS_PASSWORD'),
'database' => env('REDIS_DB', '0'),
'timeout' => env('REDIS_TIMEOUT', 60),
],
],
'pulse_ingest_interval' => env('REVERB_PULSE_INGEST_INTERVAL', 15),
'telescope_ingest_interval' => env('REVERB_TELESCOPE_INGEST_INTERVAL', 15),
],
],
/*
|--------------------------------------------------------------------------
| Reverb Applications
|--------------------------------------------------------------------------
|
| Here you may define how Reverb applications are managed. If you choose
| to use the "config" provider, you may define an array of apps which
| your server will support, including their connection credentials.
|
*/
'apps' => [
'provider' => 'config',
'apps' => [
[
'key' => env('REVERB_APP_KEY'),
'secret' => env('REVERB_APP_SECRET'),
'app_id' => env('REVERB_APP_ID'),
'options' => [
'host' => env('REVERB_HOST'),
'port' => env('REVERB_PORT', 443),
'scheme' => env('REVERB_SCHEME', 'https'),
'useTLS' => env('REVERB_SCHEME', 'https') === 'https',
],
'allowed_origins' => ['*'],
'ping_interval' => env('REVERB_APP_PING_INTERVAL', 60),
'activity_timeout' => env('REVERB_APP_ACTIVITY_TIMEOUT', 30),
'max_connections' => env('REVERB_APP_MAX_CONNECTIONS'),
'max_message_size' => env('REVERB_APP_MAX_MESSAGE_SIZE', 10_000),
'accept_client_events_from' => env('REVERB_APP_ACCEPT_CLIENT_EVENTS_FROM', 'members'),
],
],
],
];

View File

@ -15,12 +15,7 @@ class DatabaseSeeder extends Seeder
\Modules\Category\Database\Seeders\CategorySeeder::class,
\Modules\Listing\Database\Seeders\ListingCustomFieldSeeder::class,
\Modules\Listing\Database\Seeders\ListingSeeder::class,
\Modules\User\Database\Seeders\UserWorkspaceSeeder::class,
]);
if ((bool) config('demo.enabled') || (bool) config('demo.provisioning')) {
$this->call([
\Modules\Demo\Database\Seeders\DemoContentSeeder::class,
]);
}
}
}

View File

@ -13,8 +13,10 @@
"autoprefixer": "^10.4.2",
"axios": "^1.11.0",
"concurrently": "^9.0.1",
"laravel-echo": "^2.3.0",
"laravel-vite-plugin": "^2.0.0",
"postcss": "^8.4.31",
"pusher-js": "^8.4.0",
"tailwindcss": "^3.1.0",
"vite": "^7.0.7"
},

Binary file not shown.

After

Width:  |  Height:  |  Size: 390 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 499 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 970 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 208 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

View File

@ -192,6 +192,11 @@ h6 {
min-width: 0;
}
.oc-location[open],
.oc-account-menu[open] {
z-index: 90;
}
.oc-location-trigger {
justify-content: space-between;
width: 100%;
@ -435,7 +440,14 @@ h6 {
align-items: center;
gap: 8px;
overflow-x: auto;
overflow-y: hidden;
padding-bottom: 2px;
scrollbar-width: none;
-ms-overflow-style: none;
}
.oc-category-track::-webkit-scrollbar {
display: none;
}
.oc-category-pill {
@ -534,6 +546,116 @@ h6 {
height: 1.125rem;
}
.oc-header-icon {
position: relative;
}
.oc-header-badge {
position: absolute;
top: -0.35rem;
right: -0.2rem;
min-width: 1.2rem;
height: 1.2rem;
padding: 0 0.28rem;
border-radius: 0.45rem;
background: #e66767;
color: #ffffff;
font-size: 0.72rem;
font-weight: 800;
line-height: 1;
display: inline-flex;
align-items: center;
justify-content: center;
box-shadow: 0 6px 14px rgba(230, 103, 103, 0.28);
}
.oc-header-badge.is-neutral {
background: #5f6f89;
box-shadow: 0 6px 14px rgba(95, 111, 137, 0.24);
}
.oc-account-menu {
position: relative;
}
.oc-account-trigger {
min-height: 2.9rem;
padding: 0 0.9rem 0 1rem;
border: 1px solid rgba(29, 29, 31, 0.08);
border-radius: 999px;
background: rgba(61, 69, 97, 0.92);
color: #f8fafc;
display: inline-flex;
align-items: center;
gap: 0.6rem;
box-shadow: 0 10px 24px rgba(15, 23, 42, 0.16);
transition: background 0.2s ease, transform 0.2s ease, box-shadow 0.2s ease;
}
.oc-account-trigger:hover {
transform: translateY(-1px);
background: rgba(55, 63, 90, 0.98);
box-shadow: 0 14px 28px rgba(15, 23, 42, 0.18);
}
.oc-account-name {
max-width: 9rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 0.95rem;
font-weight: 700;
}
.oc-account-chevron {
width: 1rem;
height: 1rem;
flex-shrink: 0;
opacity: 0.84;
}
.oc-account-panel {
position: absolute;
top: calc(100% + 12px);
left: 0;
z-index: 20;
min-width: 13rem;
padding: 0.6rem;
border: 1px solid rgba(29, 29, 31, 0.08);
border-radius: 1rem;
background: rgba(255, 255, 255, 0.98);
box-shadow: 0 24px 48px rgba(15, 23, 42, 0.14);
backdrop-filter: blur(18px);
display: grid;
gap: 0.25rem;
}
.oc-account-link {
min-height: 2.7rem;
padding: 0 0.9rem;
border-radius: 0.8rem;
color: #344054;
font-size: 0.9rem;
font-weight: 600;
text-decoration: none;
display: inline-flex;
align-items: center;
}
.oc-account-link:hover,
.oc-account-link-button:hover {
background: #f3f6fa;
color: var(--oc-text);
}
.oc-account-link-button {
width: 100%;
border: 0;
background: transparent;
text-align: left;
cursor: pointer;
}
.header-utility.oc-compact-menu-trigger,
.header-utility.oc-mobile-menu-close {
display: inline-flex;
@ -547,6 +669,7 @@ h6 {
.location-panel {
width: min(calc(100vw - 32px), 360px);
z-index: 120;
}
.location-panel select {
@ -682,6 +805,10 @@ h6 {
box-shadow: none;
}
.oc-account-trigger {
min-height: 3rem;
}
.oc-text-link {
min-height: 3rem;
display: inline-flex;
@ -707,6 +834,15 @@ h6 {
.oc-category-row {
display: block;
overflow: hidden;
}
.oc-category-track {
flex-wrap: wrap;
row-gap: 10px;
overflow-x: hidden;
overflow-y: visible;
padding-bottom: 0;
}
}
@ -1337,6 +1473,82 @@ summary::-webkit-details-marker {
right: 22px;
bottom: 22px;
z-index: 55;
display: grid;
justify-items: end;
gap: 12px;
}
.lt-chat-launcher[hidden],
.lt-chat-panel[hidden] {
display: none !important;
}
.lt-chat-launcher {
min-width: 176px;
max-width: min(260px, calc(100vw - 28px));
min-height: 62px;
padding: 12px 16px;
border: 1px solid rgba(29, 29, 31, 0.08);
border-radius: 22px;
background: rgba(255, 255, 255, 0.98);
box-shadow: 0 18px 40px rgba(15, 23, 42, 0.16);
backdrop-filter: saturate(180%) blur(18px);
display: inline-flex;
align-items: center;
justify-content: space-between;
gap: 12px;
cursor: pointer;
transition: transform 0.2s ease, box-shadow 0.2s ease, opacity 0.2s ease;
}
.lt-chat-launcher:hover {
transform: translateY(-1px);
box-shadow: 0 24px 48px rgba(15, 23, 42, 0.18);
}
.lt-chat-launcher-copy {
display: grid;
gap: 2px;
min-width: 0;
text-align: left;
}
.lt-chat-launcher-kicker {
color: var(--oc-primary);
font-size: 0.72rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.lt-chat-launcher-name {
color: var(--oc-text);
font-size: 0.94rem;
font-weight: 700;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.lt-chat-launcher-badge {
min-width: 28px;
height: 28px;
padding: 0 8px;
border-radius: 999px;
background: linear-gradient(180deg, #1581eb 0%, #0071e3 100%);
color: #ffffff;
font-size: 0.8rem;
font-weight: 700;
display: inline-flex;
align-items: center;
justify-content: center;
box-shadow: 0 12px 24px rgba(0, 113, 227, 0.24);
}
.lt-chat-widget.is-open .lt-chat-launcher {
opacity: 0;
pointer-events: none;
transform: translateY(8px);
}
.lt-chat-panel {
@ -1515,6 +1727,11 @@ summary::-webkit-details-marker {
cursor: wait;
}
.lt-chat-widget.is-sending .lt-chat-send {
opacity: 0.55;
cursor: wait;
}
.lt-chat-empty.is-hidden,
.lt-chat-error.is-hidden {
display: none;
@ -1674,7 +1891,252 @@ summary::-webkit-details-marker {
font-weight: 600;
}
.ld-header-card {
display: grid;
gap: 24px;
padding: 22px 20px;
margin-bottom: 16px;
align-items: start;
}
.ld-header-ref {
margin: 0 0 8px;
color: #98a2b3;
font-size: 0.8rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.ld-header-title {
margin: 0;
color: var(--oc-text);
font-size: 1.75rem;
line-height: 1.12;
font-weight: 700;
letter-spacing: -0.05em;
}
.ld-header-meta {
display: flex;
flex-wrap: wrap;
gap: 8px 14px;
margin-top: 12px;
color: #667085;
font-size: 0.9rem;
}
.ld-header-actions {
display: flex;
flex-wrap: wrap;
gap: 10px;
align-items: center;
justify-content: flex-start;
}
.ld-header-side {
display: grid;
align-content: start;
justify-items: start;
}
.ld-inline-form {
display: inline-flex;
}
.ld-header-action {
min-height: 42px;
padding: 0 16px;
border: 1px solid rgba(29, 29, 31, 0.08);
border-radius: 999px;
background: rgba(255, 255, 255, 0.88);
color: #344054;
font-size: 0.9rem;
font-weight: 600;
text-decoration: none;
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: transform 0.2s ease, box-shadow 0.2s ease, background 0.2s ease, color 0.2s ease, border-color 0.2s ease;
}
.ld-header-action:hover,
.ld-seller-link:hover {
transform: translateY(-1px);
box-shadow: 0 12px 24px rgba(15, 23, 42, 0.08);
background: #ffffff;
color: var(--oc-text);
}
.ld-header-action.is-active {
border-color: rgba(0, 113, 227, 0.18);
background: rgba(0, 113, 227, 0.08);
color: var(--oc-primary);
}
.ld-stage {
display: grid;
gap: 16px;
grid-template-areas:
"gallery"
"summary"
"seller";
}
.ld-gallery-card {
grid-area: gallery;
padding: 14px;
overflow: hidden;
}
.ld-gallery-card .lt-gallery-main {
min-height: 360px;
border-radius: 20px;
background: linear-gradient(180deg, #edf2f7 0%, #dbe3ee 100%);
}
.ld-gallery-card .lt-gallery-main img {
min-height: 360px;
padding: 14px;
}
.ld-gallery-card .lt-gallery-top {
justify-content: space-between;
align-items: center;
}
.ld-gallery-chip {
display: inline-flex;
align-items: center;
min-height: 34px;
padding: 0 12px;
border: 1px solid rgba(255, 255, 255, 0.4);
border-radius: 999px;
background: rgba(255, 255, 255, 0.78);
color: #0f172a;
font-size: 0.76rem;
font-weight: 700;
letter-spacing: 0.02em;
backdrop-filter: blur(14px);
}
.ld-summary-card,
.ld-seller-card,
.ld-tab-card {
padding: 18px 16px;
}
.ld-summary-card {
grid-area: summary;
display: grid;
gap: 18px;
}
.ld-summary-head {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
align-items: flex-end;
gap: 10px 18px;
}
.ld-summary-price {
color: var(--oc-text);
font-size: clamp(2rem, 4vw, 2.8rem);
line-height: 1;
font-weight: 700;
letter-spacing: -0.06em;
}
.ld-summary-date {
color: #667085;
font-size: 0.92rem;
font-weight: 600;
}
.ld-summary-location {
color: #0b4a8a;
font-size: 1rem;
font-weight: 600;
line-height: 1.5;
}
.ld-summary-card .lt-spec-table,
.ld-tab-card .lt-spec-table {
border-top: 0;
}
.ld-summary-card .lt-spec-row:first-child,
.ld-tab-card .lt-spec-row:first-child {
padding-top: 0;
}
.ld-seller-card {
grid-area: seller;
display: grid;
gap: 16px;
align-self: start;
}
.ld-seller-links {
display: flex;
flex-wrap: wrap;
gap: 10px 14px;
}
.ld-seller-link {
padding: 0;
border: 0;
background: transparent;
color: var(--oc-primary);
font-size: 0.92rem;
font-weight: 600;
text-decoration: none;
display: inline-flex;
align-items: center;
cursor: pointer;
transition: transform 0.2s ease, box-shadow 0.2s ease, color 0.2s ease;
}
.ld-seller-card .lt-contact-panel {
margin-bottom: 0;
}
.ld-empty-contact {
padding: 14px;
border: 1px solid rgba(29, 29, 31, 0.08);
border-radius: 18px;
background: linear-gradient(180deg, #ffffff 0%, #f7f9fc 100%);
color: #667085;
font-size: 0.9rem;
}
.ld-tab-card {
margin-top: 18px;
}
.ld-tab-card .lt-tab-list {
margin-bottom: 18px;
}
.ld-tab-card .lt-description {
max-width: 78ch;
}
@media (min-width: 721px) {
.ld-header-card {
grid-template-columns: minmax(0, 1fr) auto;
}
.ld-header-side {
justify-items: end;
}
.ld-header-actions {
justify-content: flex-end;
}
.lt-wrap {
padding: 22px 16px 56px;
}
@ -1703,6 +2165,34 @@ summary::-webkit-details-marker {
line-height: 1.15;
}
.ld-header-card {
padding: 22px 24px;
}
.ld-header-title {
font-size: 2.4rem;
}
.ld-gallery-card {
padding: 18px;
}
.ld-gallery-card .lt-gallery-main {
min-height: 520px;
border-radius: 24px;
}
.ld-gallery-card .lt-gallery-main img {
min-height: 520px;
padding: 28px;
}
.ld-summary-card,
.ld-seller-card,
.ld-tab-card {
padding: 22px;
}
.lt-hero-side {
justify-items: flex-end;
}
@ -1786,6 +2276,23 @@ summary::-webkit-details-marker {
}
@media (min-width: 900px) {
.ld-header-card {
grid-template-columns: minmax(0, 1fr) minmax(280px, 420px);
}
.ld-stage {
grid-template-columns: minmax(0, 1fr) 360px;
grid-template-areas:
"gallery summary"
"gallery seller";
align-items: start;
}
.ld-seller-card {
position: sticky;
top: 92px;
}
.lt-media-spec-grid {
grid-template-columns: minmax(0, 1fr) minmax(320px, 360px);
align-items: start;
@ -1801,6 +2308,11 @@ summary::-webkit-details-marker {
}
@media (min-width: 1180px) {
.ld-stage {
grid-template-columns: minmax(0, 1fr) 420px 300px;
grid-template-areas: "gallery summary seller";
}
.lt-grid {
grid-template-columns: minmax(0, 1fr) 320px;
align-items: start;
@ -1908,80 +2420,83 @@ summary::-webkit-details-marker {
@media (max-width: 720px) {
.lt-wrap {
padding: 18px 12px calc(130px + env(safe-area-inset-bottom, 0px));
padding: 14px 10px calc(94px + env(safe-area-inset-bottom, 0px));
}
.lt-card {
border-radius: 24px;
border-radius: 22px;
}
.lt-media-card,
.lt-summary-card,
.lt-detail-card {
padding: 14px;
.ld-header-card {
gap: 14px;
padding: 16px 14px;
margin-bottom: 12px;
}
.lt-media-card {
padding: 0;
border: 0;
border-radius: 0;
background: transparent;
box-shadow: none;
backdrop-filter: none;
.ld-header-title {
font-size: 1.28rem;
line-height: 1.14;
letter-spacing: -0.04em;
}
.lt-gallery-main,
.lt-gallery-main img,
.lt-gallery-main-empty {
min-height: 420px;
.ld-header-ref {
margin-bottom: 6px;
font-size: 0.72rem;
}
.lt-gallery-main {
border-radius: 0;
background: #2b2d31;
box-shadow: none;
.ld-header-meta {
gap: 6px 10px;
margin-top: 10px;
font-size: 0.82rem;
}
.lt-gallery-main::after {
display: none;
.ld-header-actions {
gap: 8px;
}
.lt-gallery-main img {
padding: 0;
.ld-header-action {
min-height: 36px;
padding: 0 12px;
font-size: 0.82rem;
}
.ld-gallery-card,
.ld-summary-card,
.ld-seller-card,
.ld-tab-card {
padding: 12px;
}
.ld-stage {
gap: 12px;
}
.ld-gallery-card .lt-gallery-main,
.ld-gallery-card .lt-gallery-main img,
.ld-gallery-card .lt-gallery-main-empty {
min-height: 286px;
}
.ld-gallery-card .lt-gallery-main {
border-radius: 18px;
background: linear-gradient(180deg, #edf2f7 0%, #dbe3ee 100%);
}
.ld-gallery-card .lt-gallery-main img {
padding: 18px;
object-fit: contain;
}
.lt-title {
max-width: none;
font-size: 1.18rem;
line-height: 1.45;
letter-spacing: -0.02em;
}
.lt-price {
margin-top: 0;
font-size: 1.95rem;
}
.lt-overline,
.lt-overview-grid,
.lt-gallery-pills,
.lt-side-card,
.lt-contact-strip,
.lt-report,
.lt-policy {
display: none;
.ld-gallery-chip {
min-height: 30px;
padding: 0 10px;
font-size: 0.68rem;
}
.lt-gallery-top {
align-items: flex-start;
}
.lt-gallery-utility {
margin-left: auto;
}
.lt-icon-btn,
.lt-gallery-nav {
border-color: rgba(255, 255, 255, 0.14);
background: rgba(29, 29, 31, 0.52);
@ -1990,37 +2505,89 @@ summary::-webkit-details-marker {
backdrop-filter: blur(12px);
}
.lt-summary-card {
position: relative;
z-index: 3;
margin-top: -22px;
border-radius: 22px 22px 0 0;
border-bottom-left-radius: 0;
border-bottom-right-radius: 0;
background: rgba(255, 255, 255, 0.98);
.ld-summary-card {
gap: 14px;
}
.lt-summary-card + .lt-detail-card {
margin-top: 0;
border-top-left-radius: 0;
border-top-right-radius: 0;
box-shadow: 0 18px 36px rgba(15, 23, 42, 0.06);
.ld-summary-head {
gap: 8px 14px;
align-items: flex-start;
}
.lt-summary-meta-row {
margin-top: 14px;
padding-top: 14px;
.ld-summary-price {
font-size: clamp(1.95rem, 10vw, 2.5rem);
}
.lt-summary-meta-item {
.ld-summary-date {
font-size: 0.82rem;
}
.ld-summary-location {
font-size: 0.92rem;
line-height: 1.4;
}
.lt-spec-row {
grid-template-columns: minmax(0, 1fr) auto;
gap: 10px;
padding: 10px 0;
}
.lt-spec-row span {
font-size: 0.84rem;
}
.lt-overview-grid,
.lt-feature-grid,
.lt-video-grid,
.lt-row-2 {
grid-template-columns: 1fr;
.lt-spec-row strong {
font-size: 0.86rem;
}
.ld-seller-card {
gap: 12px;
}
.ld-seller-card .lt-actions {
display: none;
}
.ld-seller-links {
gap: 8px 12px;
}
.ld-seller-link {
font-size: 0.86rem;
}
.lt-contact-panel {
gap: 6px;
padding: 12px;
border-radius: 16px;
}
.lt-contact-primary {
font-size: 1rem;
}
.lt-contact-secondary {
font-size: 0.82rem;
}
.ld-tab-card {
margin-top: 14px;
}
.lt-tab-list {
gap: 6px;
margin-bottom: 12px;
}
.lt-tab-button {
min-height: 38px;
font-size: 0.82rem;
}
.lt-description {
font-size: 0.9rem;
line-height: 1.7;
}
.lt-related-head {
@ -2029,12 +2596,44 @@ summary::-webkit-details-marker {
}
.lt-rel-card {
min-width: 220px;
width: 220px;
min-width: 176px;
width: 176px;
border-radius: 20px;
}
.lt-rel-photo {
height: 156px;
height: 132px;
}
.lt-rel-body {
padding: 12px;
}
.lt-rel-price {
font-size: 0.98rem;
}
.lt-rel-title {
font-size: 0.86rem;
min-height: 2.7em;
}
.lt-rel-city {
margin-top: 8px;
font-size: 0.78rem;
}
.lt-pill-wrap {
margin-top: 18px;
}
.lt-pill-title {
font-size: 1rem;
}
.lt-pill {
padding: 8px 12px;
font-size: 0.82rem;
}
.lt-mobile-actions {
@ -2044,19 +2643,18 @@ summary::-webkit-details-marker {
bottom: 0;
left: 0;
z-index: 45;
padding: 10px 10px calc(10px + env(safe-area-inset-bottom, 0px));
padding: 8px 10px calc(8px + env(safe-area-inset-bottom, 0px));
background: linear-gradient(180deg, rgba(245, 245, 247, 0) 0%, rgba(245, 245, 247, 0.88) 28%, rgba(245, 245, 247, 0.98) 100%);
backdrop-filter: blur(14px);
}
.lt-mobile-actions-shell {
display: grid;
gap: 10px;
display: block;
max-width: 28rem;
margin: 0 auto;
padding: 10px;
padding: 8px;
border: 1px solid rgba(29, 29, 31, 0.08);
border-radius: 22px;
border-radius: 20px;
background: rgba(255, 255, 255, 0.96);
box-shadow: 0 18px 40px rgba(15, 23, 42, 0.12);
}
@ -2064,23 +2662,88 @@ summary::-webkit-details-marker {
.lt-mobile-actions-row {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
gap: 8px;
}
.lt-chat-widget {
right: 10px;
bottom: calc(104px + env(safe-area-inset-bottom, 0px));
left: 10px;
right: 12px;
bottom: calc(78px + env(safe-area-inset-bottom, 0px));
left: auto;
justify-items: end;
}
.lt-chat-panel {
width: 100%;
border-radius: 22px;
width: min(360px, calc(100vw - 20px));
border-radius: 20px;
}
.lt-chat-launcher {
min-width: 88px;
max-width: none;
min-height: 48px;
padding: 0 14px;
border-radius: 18px;
width: auto;
box-shadow: 0 14px 30px rgba(15, 23, 42, 0.14);
}
.lt-chat-launcher-copy {
gap: 0;
}
.lt-chat-launcher-kicker {
color: var(--oc-text);
font-size: 0.82rem;
letter-spacing: 0.03em;
}
.lt-chat-launcher-name {
display: none;
}
.lt-chat-launcher-badge {
min-width: 22px;
height: 22px;
padding: 0 6px;
font-size: 0.72rem;
}
.lt-chat-thread {
min-height: 220px;
max-height: 44vh;
min-height: 180px;
max-height: 34vh;
}
.lt-chat-head {
padding: 12px 14px 10px;
}
.lt-chat-name {
font-size: 0.95rem;
}
.lt-chat-meta {
font-size: 0.74rem;
}
.lt-chat-close {
width: 34px;
height: 34px;
}
.lt-chat-form {
grid-template-columns: minmax(0, 1fr) 42px;
gap: 8px;
padding: 10px 12px 12px;
}
.lt-chat-input {
min-height: 42px;
font-size: 0.88rem;
}
.lt-chat-send {
width: 42px;
height: 42px;
}
}

View File

@ -1,4 +1,5 @@
import './bootstrap';
import '../../Modules/Conversation/resources/assets/js/conversation';
import { animate, createTimeline, stagger } from 'animejs';
const prefersReducedMotion = () => window.matchMedia('(prefers-reduced-motion: reduce)').matches;

View File

@ -2,3 +2,11 @@ import axios from 'axios';
window.axios = axios;
window.axios.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest';
/**
* Echo exposes an expressive API for subscribing to channels and listening
* for events that are broadcast by Laravel. Echo and event broadcasting
* allow your team to quickly build robust real-time web applications.
*/
import './echo';

14
resources/js/echo.js Normal file
View File

@ -0,0 +1,14 @@
import Echo from 'laravel-echo';
import Pusher from 'pusher-js';
window.Pusher = Pusher;
window.Echo = new Echo({
broadcaster: 'reverb',
key: import.meta.env.VITE_REVERB_APP_KEY,
wsHost: import.meta.env.VITE_REVERB_HOST,
wsPort: import.meta.env.VITE_REVERB_PORT ?? 80,
wssPort: import.meta.env.VITE_REVERB_PORT ?? 443,
forceTLS: (import.meta.env.VITE_REVERB_SCHEME ?? 'https') === 'https',
enabledTransports: ['ws', 'wss'],
});

View File

@ -78,32 +78,32 @@
</form>
</div>
@else
<div class="max-w-[1320px] mx-auto px-4 py-5 md:py-7 space-y-7">
<section class="relative overflow-hidden rounded-[28px] bg-gradient-to-r from-blue-900 via-blue-700 to-blue-600 text-white shadow-xl" data-home-hero>
<div class="max-w-[1320px] mx-auto px-3 py-4 md:px-4 md:py-7 space-y-5 md:space-y-7">
<section class="relative overflow-hidden rounded-[24px] md:rounded-[28px] bg-gradient-to-r from-blue-900 via-blue-700 to-blue-600 text-white shadow-xl" data-home-hero>
<div class="absolute -top-20 -left-24 w-80 h-80 rounded-full bg-blue-400/20 blur-3xl"></div>
<div class="absolute -bottom-24 right-10 w-80 h-80 rounded-full bg-cyan-300/20 blur-3xl"></div>
<div class="relative grid lg:grid-cols-[1fr,1.1fr] gap-6 items-center px-8 md:px-12 py-12 md:py-14">
<div class="relative grid lg:grid-cols-[1fr,1.1fr] gap-4 md:gap-6 items-center px-5 md:px-12 py-6 md:py-14">
<div data-home-slider data-home-hero-copy>
<div class="relative min-h-[250px]">
<div class="relative min-h-[210px] md:min-h-[250px]">
@foreach($homeSlides as $index => $slide)
<div
data-home-slide
@class(['transition-opacity duration-300', 'hidden' => $index !== 0])
aria-hidden="{{ $index === 0 ? 'false' : 'true' }}"
>
<p class="text-sm uppercase tracking-[0.22em] text-blue-200 font-semibold mb-4">{{ $slide['badge'] }}</p>
<h1 class="text-4xl md:text-5xl leading-tight font-extrabold max-w-xl">{{ $slide['title'] }}</h1>
<p class="mt-4 text-blue-100 text-base md:text-lg max-w-xl">{{ $slide['subtitle'] }}</p>
<div class="mt-8 flex flex-wrap items-center gap-3">
<a href="{{ route('listings.index') }}" class="bg-white text-blue-900 px-8 py-3 rounded-full font-semibold hover:bg-blue-50 transition">
<p class="text-[10px] md:text-sm uppercase tracking-[0.22em] text-blue-200 font-semibold mb-3 md:mb-4">{{ $slide['badge'] }}</p>
<h1 class="text-[1.85rem] md:text-5xl leading-[1.1] font-extrabold max-w-xl">{{ $slide['title'] }}</h1>
<p class="mt-3 md:mt-4 text-blue-100 text-[13px] md:text-lg max-w-xl leading-6 md:leading-8">{{ $slide['subtitle'] }}</p>
<div class="mt-6 md:mt-8 flex flex-wrap items-center gap-2 md:gap-3">
<a href="{{ route('listings.index') }}" class="bg-white text-blue-900 px-5 md:px-8 py-2.5 md:py-3 rounded-full text-sm md:text-base font-semibold hover:bg-blue-50 transition">
{{ $slide['primary_button_text'] }}
</a>
@auth
<a href="{{ route('panel.listings.create') }}" class="border border-blue-200/60 px-8 py-3 rounded-full font-semibold hover:bg-white/10 transition">
<a href="{{ route('panel.listings.create') }}" class="border border-blue-200/60 px-5 md:px-8 py-2.5 md:py-3 rounded-full text-sm md:text-base font-semibold hover:bg-white/10 transition">
{{ $slide['secondary_button_text'] }}
</a>
@else
<a href="{{ route('login') }}" class="border border-blue-200/60 px-8 py-3 rounded-full font-semibold hover:bg-white/10 transition">
<a href="{{ route('login') }}" class="border border-blue-200/60 px-5 md:px-8 py-2.5 md:py-3 rounded-full text-sm md:text-base font-semibold hover:bg-white/10 transition">
{{ $slide['secondary_button_text'] }}
</a>
@endauth
@ -113,11 +113,11 @@
</div>
@if($homeSlides->count() > 1)
<div class="mt-8 flex items-center gap-2">
<div class="mt-5 md:mt-8 flex items-center gap-2">
<button
type="button"
data-home-slide-prev
class="w-8 h-8 rounded-full border border-white/45 text-white grid place-items-center hover:bg-white/15 transition"
class="w-7 h-7 md:w-8 md:h-8 rounded-full border border-white/45 text-white grid place-items-center hover:bg-white/15 transition"
aria-label="Previous slide"
>
<span aria-hidden="true"></span>
@ -137,7 +137,7 @@
<button
type="button"
data-home-slide-next
class="w-8 h-8 rounded-full border border-white/45 text-white grid place-items-center hover:bg-white/15 transition"
class="w-7 h-7 md:w-8 md:h-8 rounded-full border border-white/45 text-white grid place-items-center hover:bg-white/15 transition"
aria-label="Next slide"
>
<span aria-hidden="true"></span>
@ -149,8 +149,8 @@
</div>
@endif
</div>
<div class="relative h-[310px] md:h-[360px]" data-home-hero-visual>
<div class="absolute left-6 md:left-10 bottom-0 w-32 md:w-40 h-[250px] md:h-[300px] bg-slate-950 rounded-[32px] shadow-2xl p-2 rotate-[-8deg]">
<div class="hidden lg:block relative h-[360px]" data-home-hero-visual>
<div class="absolute left-3 md:left-10 bottom-0 w-24 md:w-40 h-[170px] md:h-[300px] bg-slate-950 rounded-[24px] md:rounded-[32px] shadow-2xl p-2 rotate-[-8deg]">
<div class="w-full h-full rounded-[24px] bg-white overflow-hidden">
<div class="px-3 py-2 border-b border-slate-100">
<p class="text-rose-500 text-sm font-bold">OpenClassify</p>
@ -170,11 +170,11 @@
</div>
</div>
</div>
<div class="absolute right-0 bottom-0 w-[78%] h-[88%] rounded-[28px] bg-gradient-to-br from-white/20 to-blue-500/40 border border-white/20 shadow-2xl flex items-end justify-center p-4 overflow-hidden">
<div class="absolute right-0 bottom-0 w-[82%] md:w-[78%] h-[86%] md:h-[88%] rounded-[24px] md:rounded-[28px] bg-gradient-to-br from-white/20 to-blue-500/40 border border-white/20 shadow-2xl flex items-end justify-center p-3 md:p-4 overflow-hidden">
@foreach($homeSlides as $index => $slide)
<div
data-home-slide-visual
@class(['absolute inset-4 transition-opacity duration-300', 'hidden' => $index !== 0])
@class(['absolute inset-3 md:inset-4 transition-opacity duration-300', 'hidden' => $index !== 0])
aria-hidden="{{ $index === 0 ? 'false' : 'true' }}"
>
@if($slide['image_url'])
@ -196,12 +196,30 @@
<section data-home-section>
<div class="flex items-center justify-between mb-3">
<h2 class="text-3xl font-extrabold tracking-tight text-slate-900">Trending Categories</h2>
<a href="{{ route('categories.index') }}" class="hidden sm:inline-flex text-sm font-semibold text-rose-500 hover:text-rose-600 transition">
<h2 class="text-[1.35rem] md:text-3xl font-extrabold tracking-tight text-slate-900">Trending Categories</h2>
<a href="{{ route('categories.index') }}" class="inline-flex text-sm font-semibold text-rose-500 hover:text-rose-600 transition">
View all
</a>
</div>
<div class="relative">
<div class="grid grid-cols-4 gap-3 sm:hidden">
@foreach($menuCategories as $category)
@php
$categoryIconUrl = $category->iconUrl();
$fallbackLabel = strtoupper(\Illuminate\Support\Str::substr($category->name, 0, 1));
@endphp
<a href="{{ route('listings.index', ['category' => $category->id]) }}" class="flex flex-col items-center gap-2 text-center">
<span class="flex h-16 w-16 items-center justify-center rounded-full bg-white border border-slate-200 shadow-sm">
@if($categoryIconUrl)
<img src="{{ $categoryIconUrl }}" alt="{{ $category->name }}" class="h-9 w-9 object-contain">
@else
<span class="inline-flex h-9 w-9 items-center justify-center rounded-full bg-slate-100 text-sm font-semibold text-slate-700">{{ $fallbackLabel }}</span>
@endif
</span>
<span class="text-[11px] leading-4 font-semibold text-slate-800">{{ $category->name }}</span>
</a>
@endforeach
</div>
<div class="relative hidden sm:block">
<button
type="button"
data-trend-prev
@ -247,13 +265,13 @@
<section data-home-section>
<div class="flex items-center justify-between mb-4">
<h2 class="text-2xl font-bold text-slate-900">Popular Listings</h2>
<h2 class="text-[1.35rem] md:text-2xl font-bold text-slate-900">Popular Listings</h2>
<div class="hidden sm:flex items-center gap-2 text-sm text-slate-500">
<span class="w-8 h-8 rounded-full border border-slate-300 grid place-items-center"></span>
<span class="w-8 h-8 rounded-full border border-slate-300 grid place-items-center"></span>
</div>
</div>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4">
<div class="grid grid-cols-1 lg:grid-cols-2 gap-3 md:gap-4">
@forelse($listingCards as $listing)
@php
$listingImage = $listing->getFirstMediaUrl('listing-images');
@ -261,8 +279,8 @@
$locationLabel = trim(collect([$listing->city, $listing->country])->filter()->join(', '));
$isFavorited = in_array($listing->id, $favoriteListingIds ?? [], true);
@endphp
<article class="rounded-2xl border border-slate-200 bg-white overflow-hidden shadow-sm hover:shadow-md transition" data-home-listing-card>
<div class="relative h-64 md:h-[290px] bg-slate-100">
<article class="rounded-[22px] border border-slate-200 bg-white overflow-hidden shadow-sm hover:shadow-md transition" data-home-listing-card>
<div class="relative h-44 sm:h-64 md:h-[290px] bg-slate-100">
<a href="{{ route('listings.show', $listing) }}" class="block h-full w-full" aria-label="{{ $listing->title }}">
@if($listingImage)
<img src="{{ $listingImage }}" alt="{{ $listing->title }}" class="w-full h-full object-cover">
@ -276,9 +294,9 @@
</a>
<div class="absolute top-3 left-3 flex items-center gap-2">
@if($listing->is_featured)
<span class="bg-amber-300 text-amber-950 text-xs font-bold px-2.5 py-1 rounded-full">Featured</span>
<span class="bg-amber-300 text-amber-950 text-[10px] md:text-xs font-bold px-2 py-1 rounded-full">Featured</span>
@endif
<span class="bg-sky-500 text-white text-xs font-semibold px-2.5 py-1 rounded-full">Spotlight</span>
<span class="bg-sky-500 text-white text-[10px] md:text-xs font-semibold px-2 py-1 rounded-full">Spotlight</span>
</div>
<div class="absolute top-3 right-3">
@auth
@ -291,17 +309,17 @@
@endauth
</div>
</div>
<div class="p-4">
<div class="p-3.5 md:p-4">
<div class="flex items-start justify-between gap-3">
<div>
<p class="text-3xl font-extrabold tracking-tight text-slate-900">{{ $priceLabel }}</p>
<h3 class="text-xl font-semibold text-slate-800 mt-1 truncate">{{ $listing->title }}</h3>
<p class="text-[1.7rem] md:text-3xl font-extrabold tracking-tight text-slate-900">{{ $priceLabel }}</p>
<h3 class="text-[15px] md:text-xl font-semibold text-slate-800 mt-1 truncate">{{ $listing->title }}</h3>
</div>
<span class="text-xs text-blue-600 bg-blue-50 px-2 py-1 rounded-full font-semibold">12 installments</span>
<span class="hidden sm:inline-flex text-xs text-blue-600 bg-blue-50 px-2 py-1 rounded-full font-semibold">12 installments</span>
</div>
<div class="mt-5 flex items-center justify-between text-sm text-slate-500">
<div class="mt-3 md:mt-5 flex items-center justify-between gap-3 text-xs md:text-sm text-slate-500">
<span class="truncate">{{ $locationLabel !== '' ? $locationLabel : 'Location not specified' }}</span>
<span>{{ $listing->created_at->diffForHumans() }}</span>
<span class="shrink-0">{{ $listing->created_at->diffForHumans() }}</span>
</div>
</div>
</article>
@ -313,18 +331,18 @@
</div>
</section>
<section class="rounded-3xl bg-slate-900 text-white px-8 py-10 md:p-12" data-home-section>
<section class="rounded-[24px] md:rounded-3xl bg-slate-900 text-white px-5 py-6 md:px-8 md:py-10 md:p-12" data-home-section>
<div class="grid md:grid-cols-[1fr,auto] gap-6 items-center">
<div>
<h2 class="text-3xl md:text-4xl font-extrabold">{{ __('messages.sell_something') }}</h2>
<p class="text-slate-300 mt-3">Create a free listing in minutes and reach thousands of buyers.</p>
<h2 class="text-[1.45rem] md:text-4xl font-extrabold">{{ __('messages.sell_something') }}</h2>
<p class="text-slate-300 mt-2 md:mt-3 text-sm md:text-base">Create a free listing in minutes and reach thousands of buyers.</p>
</div>
@auth
<a href="{{ route('panel.listings.create') }}" class="inline-flex items-center justify-center rounded-full bg-rose-500 hover:bg-rose-600 px-8 py-3 font-semibold transition whitespace-nowrap">
<a href="{{ route('panel.listings.create') }}" class="inline-flex items-center justify-center rounded-full bg-rose-500 hover:bg-rose-600 px-6 md:px-8 py-3 font-semibold transition whitespace-nowrap">
Post listing
</a>
@else
<a href="{{ route('register') }}" class="inline-flex items-center justify-center rounded-full bg-white text-slate-900 hover:bg-slate-100 px-8 py-3 font-semibold transition whitespace-nowrap">
<a href="{{ route('register') }}" class="inline-flex items-center justify-center rounded-full bg-white text-slate-900 hover:bg-slate-100 px-6 md:px-8 py-3 font-semibold transition whitespace-nowrap">
Start free
</a>
@endauth

View File

@ -14,6 +14,8 @@
$panelListingsRoute = auth()->check() ? route('panel.listings.index') : $loginRoute;
$inboxRoute = auth()->check() ? route('panel.inbox.index') : $loginRoute;
$favoritesRoute = auth()->check() ? route('favorites.index') : $loginRoute;
$profileRoute = auth()->check() ? route('panel.profile.edit') : $loginRoute;
$notificationsRoute = auth()->check() ? route('panel.listings.index') : $loginRoute;
$demoEnabled = (bool) config('demo.enabled');
$hasDemoSession = (bool) session('is_demo_session') || filled(session('demo_uuid'));
$demoLandingMode = $demoEnabled && request()->routeIs('home') && !auth()->check() && !$hasDemoSession;
@ -62,6 +64,11 @@
? route('locations.cities', ['country' => '__COUNTRY__'], false)
: '';
$simplePage = trim((string) $__env->yieldContent('simple_page')) === '1';
$headerAccount = is_array($headerAccountMeta ?? null) ? $headerAccountMeta : null;
$headerMessageCount = max(0, (int) ($headerAccount['messages'] ?? 0));
$headerNotificationCount = max(0, (int) ($headerAccount['notifications'] ?? 0));
$headerFavoritesCount = max(0, (int) ($headerAccount['favorites'] ?? 0));
$headerBadgeLabel = static fn (int $count): string => $count > 99 ? '99+' : (string) $count;
@endphp
<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}" dir="{{ in_array(app()->getLocale(), ['ar']) ? 'rtl' : 'ltr' }}">
@ -73,7 +80,10 @@
@vite(['resources/css/app.css', 'resources/js/app.js'])
@livewireStyles
</head>
<body @class([
<body
data-auth-user-id="{{ auth()->id() ?? '' }}"
data-inbox-channel="{{ auth()->check() ? 'users.'.auth()->id().'.inbox' : '' }}"
@class([
'min-h-screen font-sans antialiased',
'bg-slate-50' => $demoLandingMode,
'bg-[#f5f5f7]' => $simplePage && ! $demoLandingMode,
@ -191,29 +201,51 @@
</details>
@auth
<a href="{{ $favoritesRoute }}" class="header-utility oc-desktop-utility" aria-label="Favorites">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.8" d="M12 21l-1.45-1.32C5.4 15.03 2 12.01 2 8.31 2 5.3 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.08A6.04 6.04 0 0116.5 3C19.58 3 22 5.3 22 8.31c0 3.7-3.4 6.72-8.55 11.39L12 21z"/>
</svg>
</a>
<a href="{{ $inboxRoute }}" class="header-utility oc-desktop-utility" aria-label="Inbox">
<details class="oc-account-menu oc-desktop-utility">
<summary class="oc-account-trigger list-none cursor-pointer">
<span class="oc-account-name">{{ $headerAccount['name'] ?? auth()->user()->name }}</span>
<svg class="oc-account-chevron" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.8" d="M6 9l6 6 6-6"/>
</svg>
</summary>
<div class="oc-account-panel">
<a href="{{ $panelListingsRoute }}" class="oc-account-link">My Listings</a>
<a href="{{ $profileRoute }}" class="oc-account-link">My Profile</a>
<a href="{{ $favoritesRoute }}" class="oc-account-link">Favorites</a>
<a href="{{ $inboxRoute }}" class="oc-account-link">Inbox</a>
<form method="POST" action="{{ $logoutRoute }}">
@csrf
<button type="submit" class="oc-account-link oc-account-link-button">Logout</button>
</form>
</div>
</details>
<a href="{{ $inboxRoute }}" class="header-utility oc-desktop-utility oc-header-icon" aria-label="Inbox" data-header-inbox-link>
<span class="oc-header-badge {{ $headerMessageCount > 0 ? '' : 'hidden' }}" data-header-inbox-badge>{{ $headerBadgeLabel($headerMessageCount) }}</span>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.8" d="M4 6h16a1 1 0 011 1v10a1 1 0 01-1 1H4a1 1 0 01-1-1V7a1 1 0 011-1z"/>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.8" d="M3 8l9 6 9-6"/>
</svg>
</a>
<a href="{{ $panelListingsRoute }}" class="header-utility oc-desktop-utility" aria-label="Dashboard">
<a href="{{ $notificationsRoute }}" class="header-utility oc-desktop-utility oc-header-icon" aria-label="Notifications">
@if($headerNotificationCount > 0)
<span class="oc-header-badge">{{ $headerBadgeLabel($headerNotificationCount) }}</span>
@endif
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.8" d="M3 12l9-9 9 9M5 10v10h14V10"/>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.8" d="M15 17h5l-1.4-1.4a2 2 0 0 1-.6-1.4V11a6 6 0 1 0-12 0v3.2a2 2 0 0 1-.6 1.4L4 17h5"/>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.8" d="M10 17a2 2 0 0 0 4 0"/>
</svg>
</a>
<a href="{{ $favoritesRoute }}" class="header-utility oc-desktop-utility oc-header-icon" aria-label="Favorites">
@if($headerFavoritesCount > 0)
<span class="oc-header-badge is-neutral">{{ $headerBadgeLabel($headerFavoritesCount) }}</span>
@endif
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.8" d="m12 3 2.8 5.67 6.2.9-4.5 4.39 1.06 6.2L12 17.21 6.44 20.16 7.5 13.96 3 9.57l6.2-.9L12 3z"/>
</svg>
</a>
<a href="{{ $panelCreateRoute }}" class="btn-primary oc-cta">
Sell
</a>
<form method="POST" action="{{ $logoutRoute }}" class="oc-logout">
@csrf
<button type="submit" class="oc-text-link">{{ __('messages.logout') }}</button>
</form>
@else
<a href="{{ $loginRoute }}" class="oc-text-link oc-auth-link">
{{ __('messages.login') }}
@ -364,9 +396,9 @@
'min-h-screen' => $demoLandingMode,
])>@yield('content')</main>
@if(!$simplePage)
<footer class="mt-14 bg-slate-100 text-slate-600 border-t border-slate-200" data-anim-footer>
<div class="max-w-[1320px] mx-auto px-4 py-12">
<div class="grid grid-cols-1 md:grid-cols-4 gap-8">
<footer class="mt-10 md:mt-14 bg-slate-100 text-slate-600 border-t border-slate-200" data-anim-footer>
<div class="max-w-[1320px] mx-auto px-4 py-8 md:py-12">
<div class="grid grid-cols-1 md:grid-cols-4 gap-6 md:gap-8">
<div data-anim-footer-item>
<h3 class="text-slate-900 font-semibold text-lg mb-3">{{ $siteName }}</h3>
<p class="text-sm text-slate-500 leading-relaxed">{{ $siteDescription }}</p>
@ -388,7 +420,7 @@
</div>
<div data-anim-footer-item>
<h4 class="text-slate-900 font-medium mb-4">Links</h4>
<ul class="space-y-2 text-sm mb-4">
<ul class="space-y-2 text-sm mb-3 md:mb-4">
@if($linkedinUrl)
<li><a href="{{ $linkedinUrl }}" target="_blank" rel="noopener" class="hover:text-slate-900 transition">LinkedIn</a></li>
@endif
@ -410,7 +442,7 @@
</div>
</div>
</div>
<div class="border-t border-slate-300 mt-8 pt-8 text-center text-sm text-slate-500">
<div class="border-t border-slate-300 mt-6 md:mt-8 pt-6 md:pt-8 text-center text-sm text-slate-500">
<p>© {{ date('Y') }} {{ $siteName }}. All rights reserved.</p>
</div>
</div>

1
routes/channels.php Normal file
View File

@ -0,0 +1 @@
<?php