281 lines
7.7 KiB
Markdown
281 lines
7.7 KiB
Markdown
# Story 5.4: Public Posts Display
|
|
|
|
## Epic Reference
|
|
**Epic 5:** Posts/Blog System
|
|
|
|
## User Story
|
|
As a **website visitor**,
|
|
I want **to view published blog posts**,
|
|
So that **I can read legal updates and articles from the firm**.
|
|
|
|
## Story Context
|
|
|
|
### Existing System Integration
|
|
- **Integrates with:** posts table (published only)
|
|
- **Technology:** Livewire Volt (public routes)
|
|
- **Follows pattern:** Public content display
|
|
- **Touch points:** Navigation, homepage
|
|
|
|
### Assumptions
|
|
- Post model exists with `published()` scope (from Story 5.1)
|
|
- Post model has locale-aware `title`, `body`, and `excerpt` accessors (from Story 5.1)
|
|
- Bilingual infrastructure is in place (from Epic 1)
|
|
- Base navigation includes link to posts section (from Epic 1)
|
|
|
|
## Acceptance Criteria
|
|
|
|
### Posts Listing Page
|
|
- [ ] Public access (no login required)
|
|
- [ ] Display in reverse chronological order
|
|
- [ ] Each post card shows:
|
|
- Title
|
|
- Publication date
|
|
- Excerpt (first ~150 characters)
|
|
- Read more link
|
|
- [ ] Pagination for many posts
|
|
|
|
### Individual Post Page
|
|
- [ ] Full post content displayed
|
|
- [ ] Clean, readable typography
|
|
- [ ] Publication date shown
|
|
- [ ] Back to posts list link
|
|
|
|
### Language Support
|
|
- [ ] Content displayed based on current site language
|
|
- [ ] Title and body in selected language
|
|
- [ ] Date formatting per locale
|
|
|
|
### Design Requirements
|
|
- [ ] Responsive design (mobile-friendly)
|
|
- [ ] Professional typography
|
|
- [ ] Consistent with brand colors
|
|
|
|
### Quality Requirements
|
|
- [ ] Only published posts visible
|
|
- [ ] Fast loading
|
|
- [ ] SEO-friendly URLs (optional per PRD)
|
|
- [ ] Tests for display logic
|
|
|
|
## Technical Notes
|
|
|
|
### Routes
|
|
```php
|
|
// routes/web.php (public)
|
|
Route::get('/posts', \App\Livewire\Posts\Index::class)->name('posts.index');
|
|
Route::get('/posts/{post}', \App\Livewire\Posts\Show::class)->name('posts.show');
|
|
```
|
|
|
|
### Posts Index Component
|
|
```php
|
|
<?php
|
|
|
|
use App\Models\Post;
|
|
use Livewire\Volt\Component;
|
|
use Livewire\WithPagination;
|
|
|
|
new class extends Component {
|
|
use WithPagination;
|
|
|
|
public function with(): array
|
|
{
|
|
return [
|
|
'posts' => Post::published()
|
|
->latest()
|
|
->paginate(10),
|
|
];
|
|
}
|
|
}; ?>
|
|
|
|
<div class="max-w-4xl mx-auto">
|
|
<flux:heading>{{ __('posts.title') }}</flux:heading>
|
|
|
|
<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">
|
|
{{ $post->title }}
|
|
</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">
|
|
{{ $post->excerpt }}
|
|
</p>
|
|
|
|
<a href="{{ route('posts.show', $post) }}" class="text-gold hover:underline mt-4 inline-block">
|
|
{{ __('posts.read_more') }} →
|
|
</a>
|
|
</article>
|
|
@empty
|
|
<p class="text-center text-charcoal/70 py-12">
|
|
{{ __('posts.no_posts') }}
|
|
</p>
|
|
@endforelse
|
|
</div>
|
|
|
|
{{ $posts->links() }}
|
|
</div>
|
|
```
|
|
|
|
### Post Show Component
|
|
```php
|
|
<?php
|
|
|
|
use App\Models\Post;
|
|
use Livewire\Volt\Component;
|
|
|
|
new class extends Component {
|
|
public Post $post;
|
|
|
|
public function mount(Post $post): void
|
|
{
|
|
// Only show published posts
|
|
abort_unless($post->status === 'published', 404);
|
|
|
|
$this->post = $post;
|
|
}
|
|
}; ?>
|
|
|
|
<article class="max-w-3xl mx-auto">
|
|
<header class="mb-8">
|
|
<flux:heading>{{ $post->title }}</flux:heading>
|
|
|
|
<time class="text-charcoal/70 mt-2 block">
|
|
{{ $post->created_at->translatedFormat('l, d F Y') }}
|
|
</time>
|
|
</header>
|
|
|
|
<div class="prose prose-lg prose-navy max-w-none">
|
|
{!! $post->body !!}
|
|
</div>
|
|
|
|
<footer class="mt-12 pt-6 border-t border-charcoal/20">
|
|
<a href="{{ route('posts.index') }}" class="text-gold hover:underline">
|
|
← {{ __('posts.back_to_posts') }}
|
|
</a>
|
|
</footer>
|
|
</article>
|
|
```
|
|
|
|
### Prose Styling
|
|
```css
|
|
/* In app.css */
|
|
.prose-navy {
|
|
--tw-prose-headings: theme('colors.navy');
|
|
--tw-prose-links: theme('colors.gold');
|
|
--tw-prose-bold: theme('colors.navy');
|
|
}
|
|
|
|
.prose-navy a {
|
|
text-decoration: underline;
|
|
}
|
|
|
|
.prose-navy a:hover {
|
|
color: theme('colors.gold-light');
|
|
}
|
|
```
|
|
|
|
## Testing Requirements
|
|
|
|
### Feature Tests
|
|
```php
|
|
use App\Models\Post;
|
|
use Livewire\Volt\Volt;
|
|
|
|
test('posts listing page shows only published posts', function () {
|
|
Post::factory()->create(['status' => 'published', 'title_en' => 'Published Post']);
|
|
Post::factory()->create(['status' => 'draft', 'title_en' => 'Draft Post']);
|
|
|
|
$this->get(route('posts.index'))
|
|
->assertOk()
|
|
->assertSee('Published Post')
|
|
->assertDontSee('Draft Post');
|
|
});
|
|
|
|
test('posts displayed in reverse chronological order', function () {
|
|
$older = Post::factory()->create(['status' => 'published', 'created_at' => now()->subDays(5)]);
|
|
$newer = Post::factory()->create(['status' => 'published', 'created_at' => now()]);
|
|
|
|
$this->get(route('posts.index'))
|
|
->assertOk()
|
|
->assertSeeInOrder([$newer->title, $older->title]);
|
|
});
|
|
|
|
test('posts listing page paginates results', function () {
|
|
Post::factory()->count(15)->create(['status' => 'published']);
|
|
|
|
$this->get(route('posts.index'))
|
|
->assertOk()
|
|
->assertSee('Next'); // pagination link
|
|
});
|
|
|
|
test('individual post page shows full content', function () {
|
|
$post = Post::factory()->create(['status' => 'published']);
|
|
|
|
$this->get(route('posts.show', $post))
|
|
->assertOk()
|
|
->assertSee($post->title)
|
|
->assertSee($post->body);
|
|
});
|
|
|
|
test('individual post page returns 404 for unpublished posts', function () {
|
|
$post = Post::factory()->create(['status' => 'draft']);
|
|
|
|
$this->get(route('posts.show', $post))
|
|
->assertNotFound();
|
|
});
|
|
|
|
test('individual post page returns 404 for non-existent posts', function () {
|
|
$this->get(route('posts.show', 999))
|
|
->assertNotFound();
|
|
});
|
|
```
|
|
|
|
### Language/RTL Tests
|
|
- [ ] Arabic content displays correctly with RTL direction
|
|
- [ ] English content displays correctly with LTR direction
|
|
- [ ] Date formatting respects locale (`translatedFormat`)
|
|
- [ ] Back link text displays in current language
|
|
|
|
### Responsive Tests
|
|
- [ ] Mobile layout renders correctly (single column, touch-friendly)
|
|
- [ ] Tablet layout renders correctly
|
|
- [ ] Typography remains readable at all breakpoints
|
|
|
|
### Key Test Scenarios
|
|
| Scenario | Expected Result |
|
|
|----------|-----------------|
|
|
| Visit /posts as guest | Shows published posts list |
|
|
| Visit /posts with no posts | Shows "no posts" message |
|
|
| Visit /posts/{id} for published post | Shows full post content |
|
|
| Visit /posts/{id} for draft post | Returns 404 |
|
|
| Switch language on posts page | Content displays in selected language |
|
|
|
|
## Definition of Done
|
|
|
|
- [ ] Posts listing page works
|
|
- [ ] Individual post page works
|
|
- [ ] Only published posts visible
|
|
- [ ] Reverse chronological order
|
|
- [ ] Excerpts display correctly
|
|
- [ ] Full content renders properly
|
|
- [ ] Language-appropriate content shown
|
|
- [ ] Mobile responsive
|
|
- [ ] RTL support
|
|
- [ ] Tests pass
|
|
- [ ] Code formatted with Pint
|
|
|
|
## Dependencies
|
|
|
|
- **Story 5.1:** Post creation (provides Post model with `published()` scope, `title`, `body`, `excerpt` accessors - see Database Schema and Post Model sections)
|
|
- **Epic 1:** Base UI, navigation, bilingual infrastructure
|
|
|
|
## Estimation
|
|
|
|
**Complexity:** Medium
|
|
**Estimated Effort:** 3-4 hours
|