generated sotries
This commit is contained in:
@@ -0,0 +1,275 @@
|
||||
# Story 5.5: Post Search
|
||||
|
||||
## Epic Reference
|
||||
**Epic 5:** Posts/Blog System
|
||||
|
||||
## User Story
|
||||
As a **website visitor**,
|
||||
I want **to search through blog posts**,
|
||||
So that **I can find relevant legal articles and information**.
|
||||
|
||||
## Story Context
|
||||
|
||||
### Existing System Integration
|
||||
- **Integrates with:** posts table, public posts listing
|
||||
- **Technology:** Livewire Volt with real-time search
|
||||
- **Follows pattern:** Search component pattern
|
||||
- **Touch points:** Posts listing page
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
### Search Functionality
|
||||
- [ ] Search input on posts listing page
|
||||
- [ ] Search by title (both languages)
|
||||
- [ ] Search by body content (both languages)
|
||||
- [ ] Real-time search results (debounced)
|
||||
|
||||
### User Experience
|
||||
- [ ] "No results found" message when empty
|
||||
- [ ] Clear search button
|
||||
- [ ] Search works in both Arabic and English
|
||||
- [ ] Only searches published posts
|
||||
|
||||
### Optional Enhancements
|
||||
- [ ] Search highlights in results
|
||||
- [ ] Search suggestions
|
||||
|
||||
### Quality Requirements
|
||||
- [ ] Debounce to reduce server load (300ms)
|
||||
- [ ] Case-insensitive search
|
||||
- [ ] Tests for search functionality
|
||||
|
||||
## Technical Notes
|
||||
|
||||
### Updated Posts Index Component
|
||||
```php
|
||||
<?php
|
||||
|
||||
use App\Models\Post;
|
||||
use Livewire\Volt\Component;
|
||||
use Livewire\WithPagination;
|
||||
|
||||
new class extends Component {
|
||||
use WithPagination;
|
||||
|
||||
public string $search = '';
|
||||
|
||||
public function updatedSearch()
|
||||
{
|
||||
$this->resetPage();
|
||||
}
|
||||
|
||||
public function clearSearch(): void
|
||||
{
|
||||
$this->search = '';
|
||||
$this->resetPage();
|
||||
}
|
||||
|
||||
public function with(): array
|
||||
{
|
||||
return [
|
||||
'posts' => Post::published()
|
||||
->when($this->search, function ($query) {
|
||||
$search = $this->search;
|
||||
$query->where(function ($q) use ($search) {
|
||||
$q->where('title_ar', 'like', "%{$search}%")
|
||||
->orWhere('title_en', 'like', "%{$search}%")
|
||||
->orWhere('body_ar', 'like', "%{$search}%")
|
||||
->orWhere('body_en', 'like', "%{$search}%");
|
||||
});
|
||||
})
|
||||
->latest()
|
||||
->paginate(10),
|
||||
];
|
||||
}
|
||||
}; ?>
|
||||
|
||||
<div class="max-w-4xl mx-auto">
|
||||
<flux:heading>{{ __('posts.title') }}</flux:heading>
|
||||
|
||||
<!-- Search Bar -->
|
||||
<div class="mt-6 relative">
|
||||
<flux:input
|
||||
wire:model.live.debounce.300ms="search"
|
||||
placeholder="{{ __('posts.search_placeholder') }}"
|
||||
class="w-full"
|
||||
>
|
||||
<x-slot:leading>
|
||||
<flux:icon name="magnifying-glass" class="w-5 h-5 text-charcoal/50" />
|
||||
</x-slot:leading>
|
||||
</flux:input>
|
||||
|
||||
@if($search)
|
||||
<button
|
||||
wire:click="clearSearch"
|
||||
class="absolute {{ app()->getLocale() === 'ar' ? 'left-3' : 'right-3' }} top-1/2 -translate-y-1/2 text-charcoal/50 hover:text-charcoal"
|
||||
>
|
||||
<flux:icon name="x-mark" class="w-5 h-5" />
|
||||
</button>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<!-- Search Results Info -->
|
||||
@if($search)
|
||||
<p class="mt-4 text-sm text-charcoal/70">
|
||||
@if($posts->total() > 0)
|
||||
{{ __('posts.search_results', ['count' => $posts->total(), 'query' => $search]) }}
|
||||
@else
|
||||
{{ __('posts.no_results', ['query' => $search]) }}
|
||||
@endif
|
||||
</p>
|
||||
@endif
|
||||
|
||||
<!-- Posts List -->
|
||||
<div class="mt-8 space-y-6">
|
||||
@forelse($posts as $post)
|
||||
<article class="bg-cream p-6 rounded-lg shadow-sm hover:shadow-md transition-shadow">
|
||||
<h2 class="text-xl font-semibold text-navy">
|
||||
<a href="{{ route('posts.show', $post) }}" class="hover:text-gold">
|
||||
@if($search)
|
||||
{!! $this->highlightSearch($post->title, $search) !!}
|
||||
@else
|
||||
{{ $post->title }}
|
||||
@endif
|
||||
</a>
|
||||
</h2>
|
||||
|
||||
<time class="text-sm text-charcoal/70 mt-2 block">
|
||||
{{ $post->created_at->translatedFormat('d F Y') }}
|
||||
</time>
|
||||
|
||||
<p class="mt-3 text-charcoal">
|
||||
@if($search)
|
||||
{!! $this->highlightSearch($post->excerpt, $search) !!}
|
||||
@else
|
||||
{{ $post->excerpt }}
|
||||
@endif
|
||||
</p>
|
||||
|
||||
<a href="{{ route('posts.show', $post) }}" class="text-gold hover:underline mt-4 inline-block">
|
||||
{{ __('posts.read_more') }} →
|
||||
</a>
|
||||
</article>
|
||||
@empty
|
||||
<div class="text-center py-12">
|
||||
@if($search)
|
||||
<flux:icon name="magnifying-glass" class="w-12 h-12 text-charcoal/30 mx-auto mb-4" />
|
||||
<p class="text-charcoal/70">{{ __('posts.no_results', ['query' => $search]) }}</p>
|
||||
<flux:button wire:click="clearSearch" class="mt-4">
|
||||
{{ __('posts.clear_search') }}
|
||||
</flux:button>
|
||||
@else
|
||||
<p class="text-charcoal/70">{{ __('posts.no_posts') }}</p>
|
||||
@endif
|
||||
</div>
|
||||
@endforelse
|
||||
</div>
|
||||
|
||||
{{ $posts->links() }}
|
||||
</div>
|
||||
```
|
||||
|
||||
### Highlight Helper Method
|
||||
```php
|
||||
public function highlightSearch(string $text, string $search): string
|
||||
{
|
||||
if (empty($search)) {
|
||||
return e($text);
|
||||
}
|
||||
|
||||
$escapedText = e($text);
|
||||
$escapedSearch = e($search);
|
||||
|
||||
return preg_replace(
|
||||
'/(' . preg_quote($escapedSearch, '/') . ')/iu',
|
||||
'<mark class="bg-gold/30 rounded px-1">$1</mark>',
|
||||
$escapedText
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Translation Strings
|
||||
```php
|
||||
// resources/lang/en/posts.php
|
||||
return [
|
||||
'search_placeholder' => 'Search articles...',
|
||||
'search_results' => 'Found :count results for ":query"',
|
||||
'no_results' => 'No results found for ":query"',
|
||||
'clear_search' => 'Clear search',
|
||||
];
|
||||
|
||||
// resources/lang/ar/posts.php
|
||||
return [
|
||||
'search_placeholder' => 'البحث في المقالات...',
|
||||
'search_results' => 'تم العثور على :count نتيجة لـ ":query"',
|
||||
'no_results' => 'لم يتم العثور على نتائج لـ ":query"',
|
||||
'clear_search' => 'مسح البحث',
|
||||
];
|
||||
```
|
||||
|
||||
### Testing
|
||||
```php
|
||||
it('searches posts by title', function () {
|
||||
Post::factory()->published()->create(['title_en' => 'Legal Rights Guide']);
|
||||
Post::factory()->published()->create(['title_en' => 'Tax Information']);
|
||||
|
||||
Volt::test('posts.index')
|
||||
->set('search', 'Legal')
|
||||
->assertSee('Legal Rights Guide')
|
||||
->assertDontSee('Tax Information');
|
||||
});
|
||||
|
||||
it('searches posts by body content', function () {
|
||||
Post::factory()->published()->create([
|
||||
'title_en' => 'Post 1',
|
||||
'body_en' => 'This discusses property law topics.',
|
||||
]);
|
||||
Post::factory()->published()->create([
|
||||
'title_en' => 'Post 2',
|
||||
'body_en' => 'This is about family matters.',
|
||||
]);
|
||||
|
||||
Volt::test('posts.index')
|
||||
->set('search', 'property')
|
||||
->assertSee('Post 1')
|
||||
->assertDontSee('Post 2');
|
||||
});
|
||||
|
||||
it('shows no results message', function () {
|
||||
Volt::test('posts.index')
|
||||
->set('search', 'nonexistent')
|
||||
->assertSee('No results found');
|
||||
});
|
||||
|
||||
it('only searches published posts', function () {
|
||||
Post::factory()->create(['status' => 'draft', 'title_en' => 'Draft Post']);
|
||||
Post::factory()->published()->create(['title_en' => 'Published Post']);
|
||||
|
||||
Volt::test('posts.index')
|
||||
->set('search', 'Post')
|
||||
->assertSee('Published Post')
|
||||
->assertDontSee('Draft Post');
|
||||
});
|
||||
```
|
||||
|
||||
## Definition of Done
|
||||
|
||||
- [ ] Search input on posts page
|
||||
- [ ] Searches title in both languages
|
||||
- [ ] Searches body in both languages
|
||||
- [ ] Real-time results with debounce
|
||||
- [ ] Clear search button works
|
||||
- [ ] "No results" message shows
|
||||
- [ ] Only published posts searched
|
||||
- [ ] Search highlighting works
|
||||
- [ ] Tests pass
|
||||
- [ ] Code formatted with Pint
|
||||
|
||||
## Dependencies
|
||||
|
||||
- **Story 5.4:** Public posts display
|
||||
|
||||
## Estimation
|
||||
|
||||
**Complexity:** Low-Medium
|
||||
**Estimated Effort:** 2-3 hours
|
||||
Reference in New Issue
Block a user