13 KiB
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, andexcerptaccessors (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
// 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
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
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
/* 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
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,excerptaccessors - see Database Schema and Post Model sections) - Epic 1: Base UI, navigation, bilingual infrastructure
Estimation
Complexity: Medium Estimated Effort: 3-4 hours
Dev Agent Record
Status
Ready for Review
Agent Model Used
Claude Opus 4.5
Tasks Completed
- Add public posts routes to web.php (posts.index, posts.show)
- Create Posts Index Volt component with pagination
- Create Posts Show Volt component with 404 for unpublished
- Add prose-navy styling to app.css
- Add translation strings (read_more) to en/ar
- Write feature tests for public posts (13 tests, 32 assertions)
- Run Pint and verify all tests pass
File List
| File | Action |
|---|---|
routes/web.php |
Modified - Added Volt routes for posts.index and posts.show |
resources/views/livewire/pages/posts/index.blade.php |
Created - Posts listing Volt component |
resources/views/livewire/pages/posts/show.blade.php |
Created - Individual post Volt component |
resources/css/app.css |
Modified - Added prose-navy styling |
lang/en/posts.php |
Modified - Added read_more translation |
lang/ar/posts.php |
Modified - Added read_more translation |
tests/Feature/Public/PostsTest.php |
Created - 13 feature tests |
resources/views/pages/posts/index.blade.php |
Deleted - Replaced by Volt component |
Change Log
- Used
#[Layout('components.layouts.public')]attribute for full-page Volt components - Used
Post::published()->latest()for reverse chronological order - Used
$post->getTitle(),$post->getBody(),$post->getExcerpt()methods for locale-aware content - Used
translatedFormat()for locale-aware date formatting - Added
wire:navigatefor SPA-like navigation between pages
Debug Log References
None - implementation completed without issues.
Completion Notes
- All 13 public posts tests passing
- Full test suite (362 tests, 883 assertions) passing
- Code formatted with Pint
QA Results
Review Date: 2025-12-27
Reviewed By: Quinn (Test Architect)
Code Quality Assessment
Overall: Excellent - Implementation is clean, well-structured, and follows all project conventions. The class-based Volt components are properly implemented with appropriate use of Flux UI components, wire:navigate for SPA navigation, and wire:key for list items. The Post model's locale-aware methods (getTitle, getBody, getExcerpt) are used correctly throughout.
Strengths:
- Clean separation using published() scope
- Proper 404 handling for unpublished posts using PostStatus enum
- Locale-aware content rendering with fallback to Arabic
- Professional prose-navy typography styling
- Comprehensive test coverage (13 tests, 32 assertions)
Refactoring Performed
None required - implementation is solid and follows all coding standards.
Compliance Check
- Coding Standards: ✓ Class-based Volt pattern, Flux UI components, proper naming
- Project Structure: ✓ Files in correct locations per source-tree.md
- Testing Strategy: ✓ Pest tests with comprehensive coverage
- All ACs Met: ✓ All 15 acceptance criteria verified
Requirements Traceability
| Acceptance Criteria | Test Coverage |
|---|---|
| Public access (no login required) | ✓ posts listing page is accessible without authentication |
| Reverse chronological order | ✓ posts listing page displays in reverse chronological order |
| Post cards show title, date, excerpt, read more | ✓ posts listing page shows post excerpt |
| Pagination | ✓ posts listing page paginates results |
| Full post content displayed | ✓ individual post page shows full content |
| Publication date shown | ✓ Verified in template |
| Back to posts link | ✓ individual post page shows back to posts link |
| Content in current locale | ✓ posts listing page displays content in current locale |
| Individual post locale support | ✓ individual post page displays content in current locale |
| Only published posts visible | ✓ posts listing page shows only published posts |
| 404 for unpublished posts | ✓ individual post page returns 404 for unpublished posts |
| 404 for non-existent posts | ✓ individual post page returns 404 for non-existent posts |
| No posts message | ✓ posts listing page shows no posts message when empty |
Improvements Checklist
- All acceptance criteria implemented and tested
- Bilingual support with locale-aware methods
- RTL support via public layout
- Professional typography with prose-navy
- Responsive design with max-width constraints
- SPA-like navigation with wire:navigate
- Proper pagination
- Code formatted with Pint
Security Review
Status: PASS - No security concerns identified.
- Public read-only feature with no sensitive data exposure
- Proper status check prevents accessing unpublished posts
- No user input processed beyond route parameters
- Route model binding handles non-existent posts safely
Performance Considerations
Status: PASS - No performance issues identified.
- Simple queries with pagination (10 per page)
- No N+1 query issues
- Uses eager loading pattern via published() scope
- CSS optimized with Tailwind
Files Modified During Review
None - no modifications required.
Gate Status
Gate: PASS → docs/qa/gates/5.4-public-posts-display.yml
Recommended Status
✓ Ready for Done - All acceptance criteria met, comprehensive test coverage, no issues identified. Implementation follows all project conventions and coding standards.