Files
libra/docs/stories/story-5.4-public-posts-display.md
T
2025-12-27 02:02:37 +02:00

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.