libra/docs/stories/story-5.4-public-posts-disp...

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, 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

// 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') }} &rarr;
                </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">
            &larr; {{ __('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, excerpt accessors - 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:navigate for 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

Ready for Done - All acceptance criteria met, comprehensive test coverage, no issues identified. Implementation follows all project conventions and coding standards.