libra/docs/stories/story-6.9-legal-pages-edito...

9.6 KiB

Story 6.9: Legal Pages Editor

Epic Reference

Epic 6: Admin Dashboard

User Story

As an admin, I want to edit Terms of Service and Privacy Policy pages, So that I can maintain legal compliance and update policies.

Dependencies

  • Epic 1: Base authentication and admin middleware
  • Story 6.8: System Settings (admin settings UI patterns and navigation)

References

  • PRD Section 10.1: Legal & Compliance - Required pages specification
  • PRD Section 5.7H: Settings - Terms of Service and Privacy Policy editor requirements
  • PRD Section 9.3: User Privacy - Terms and Privacy page requirements
  • PRD Section 16.3: Third-party dependencies - Rich text editor options (TinyMCE or Quill)

Acceptance Criteria

Pages to Edit

  • Terms of Service (/page/terms)
  • Privacy Policy (/page/privacy)

Editor Features

  • Rich text editor using Quill.js (lightweight, RTL-friendly)
  • Bilingual content with Arabic/English tabs in editor UI
  • Save and publish button updates database immediately
  • Preview opens modal showing rendered content in selected language
  • HTML content sanitized before save (prevent XSS)

Admin UI Location

  • Accessible under Admin Dashboard > Settings section
  • Sidebar item: "Legal Pages" or integrate into Settings page
  • List view showing both pages with "Edit" action
  • Edit page shows language tabs (Arabic | English) above editor

Public Display

  • Pages accessible from footer links (no auth required)
  • Route: /page/{slug} where slug is terms or privacy
  • Content displayed in user's current language preference
  • Last updated timestamp displayed at bottom of page
  • Professional layout consistent with site design (navy/gold)

Technical Notes

Architecture

This project uses class-based Volt components for interactivity. Follow existing patterns in resources/views/livewire/.

Rich Text Editor

Use Quill.js for the rich text editor:

  • Lightweight and RTL-compatible
  • Include via CDN or npm
  • Configure toolbar: bold, italic, underline, lists, links, headings
  • Bind to Livewire with wire:model on hidden textarea

Model & Migration

// Migration: create_pages_table.php
Schema::create('pages', function (Blueprint $table) {
    $table->id();
    $table->string('slug')->unique(); // 'terms', 'privacy'
    $table->string('title_ar');
    $table->string('title_en');
    $table->longText('content_ar')->nullable();
    $table->longText('content_en')->nullable();
    $table->timestamps(); // updated_at used for "Last updated"
});

Seeder

// PageSeeder.php
Page::create([
    'slug' => 'terms',
    'title_ar' => 'شروط الخدمة',
    'title_en' => 'Terms of Service',
    'content_ar' => '',
    'content_en' => '',
]);

Page::create([
    'slug' => 'privacy',
    'title_ar' => 'سياسة الخصوصية',
    'title_en' => 'Privacy Policy',
    'content_ar' => '',
    'content_en' => '',
]);

Routes

// Public route (web.php)
Route::get('/page/{slug}', [PageController::class, 'show'])
    ->name('page.show')
    ->where('slug', 'terms|privacy');

// Admin route (protected by admin middleware)
Route::middleware(['auth', 'admin'])->prefix('admin')->group(function () {
    Route::get('/pages', PagesIndex::class)->name('admin.pages.index');
    Route::get('/pages/{slug}/edit', PagesEdit::class)->name('admin.pages.edit');
});

Volt Component Structure

// resources/views/livewire/admin/pages/edit.blade.php
<?php
use Livewire\Volt\Component;
use App\Models\Page;

new class extends Component {
    public Page $page;
    public string $activeTab = 'ar';
    public string $content_ar = '';
    public string $content_en = '';
    public bool $showPreview = false;

    public function mount(string $slug): void
    {
        $this->page = Page::where('slug', $slug)->firstOrFail();
        $this->content_ar = $this->page->content_ar ?? '';
        $this->content_en = $this->page->content_en ?? '';
    }

    public function save(): void
    {
        $this->page->update([
            'content_ar' => clean($this->content_ar), // Sanitize HTML
            'content_en' => clean($this->content_en),
        ]);

        $this->dispatch('notify', message: __('Page saved successfully'));
    }

    public function togglePreview(): void
    {
        $this->showPreview = !$this->showPreview;
    }
}; ?>

HTML Sanitization

Use mews/purifier package or similar to sanitize rich text HTML before saving:

composer require mews/purifier

Edge Cases

  • Empty content: Allow saving empty content (legal pages may be drafted later)
  • Large content: Use longText column type, consider lazy loading for edit
  • Concurrent edits: Single admin system - not a concern
  • RTL in editor: Quill supports RTL via direction: rtl CSS on editor container

Test Scenarios

Feature Tests

// tests/Feature/Admin/LegalPagesTest.php

test('admin can view legal pages list', function () {
    $admin = User::factory()->admin()->create();

    $this->actingAs($admin)
        ->get(route('admin.pages.index'))
        ->assertOk()
        ->assertSee('Terms of Service')
        ->assertSee('Privacy Policy');
});

test('admin can edit terms of service in Arabic', function () {
    $admin = User::factory()->admin()->create();
    $page = Page::where('slug', 'terms')->first();

    Volt::test('admin.pages.edit', ['slug' => 'terms'])
        ->actingAs($admin)
        ->set('content_ar', '<p>شروط الخدمة الجديدة</p>')
        ->call('save')
        ->assertHasNoErrors();

    expect($page->fresh()->content_ar)->toContain('شروط الخدمة الجديدة');
});

test('admin can edit terms of service in English', function () {
    $admin = User::factory()->admin()->create();
    $page = Page::where('slug', 'terms')->first();

    Volt::test('admin.pages.edit', ['slug' => 'terms'])
        ->actingAs($admin)
        ->set('content_en', '<p>New terms content</p>')
        ->call('save')
        ->assertHasNoErrors();

    expect($page->fresh()->content_en)->toContain('New terms content');
});

test('admin can preview page content', function () {
    $admin = User::factory()->admin()->create();

    Volt::test('admin.pages.edit', ['slug' => 'terms'])
        ->actingAs($admin)
        ->call('togglePreview')
        ->assertSet('showPreview', true);
});

test('updated_at timestamp changes on save', function () {
    $admin = User::factory()->admin()->create();
    $page = Page::where('slug', 'terms')->first();
    $originalTimestamp = $page->updated_at;

    $this->travel(1)->minute();

    Volt::test('admin.pages.edit', ['slug' => 'terms'])
        ->actingAs($admin)
        ->set('content_en', '<p>Updated content</p>')
        ->call('save');

    expect($page->fresh()->updated_at)->toBeGreaterThan($originalTimestamp);
});

test('public can view terms page', function () {
    Page::where('slug', 'terms')->update(['content_en' => '<p>Our terms</p>']);

    $this->get('/page/terms')
        ->assertOk()
        ->assertSee('Our terms');
});

test('public can view privacy page', function () {
    Page::where('slug', 'privacy')->update(['content_en' => '<p>Our privacy policy</p>']);

    $this->get('/page/privacy')
        ->assertOk()
        ->assertSee('Our privacy policy');
});

test('public page shows last updated timestamp', function () {
    $page = Page::where('slug', 'terms')->first();

    $this->get('/page/terms')
        ->assertOk()
        ->assertSee($page->updated_at->format('M d, Y'));
});

test('invalid page slug returns 404', function () {
    $this->get('/page/invalid-slug')
        ->assertNotFound();
});

test('html content is sanitized on save', function () {
    $admin = User::factory()->admin()->create();

    Volt::test('admin.pages.edit', ['slug' => 'terms'])
        ->actingAs($admin)
        ->set('content_en', '<script>alert("xss")</script><p>Safe content</p>')
        ->call('save');

    $page = Page::where('slug', 'terms')->first();
    expect($page->content_en)->not->toContain('<script>');
    expect($page->content_en)->toContain('Safe content');
});

test('guest cannot access admin pages editor', function () {
    $this->get(route('admin.pages.index'))
        ->assertRedirect(route('login'));
});

test('client cannot access admin pages editor', function () {
    $client = User::factory()->create();

    $this->actingAs($client)
        ->get(route('admin.pages.index'))
        ->assertForbidden();
});

Definition of Done

  • Page model and migration created
  • Seeder creates terms and privacy pages
  • Admin can access legal pages list from settings/sidebar
  • Admin can edit Terms of Service content (Arabic)
  • Admin can edit Terms of Service content (English)
  • Admin can edit Privacy Policy content (Arabic)
  • Admin can edit Privacy Policy content (English)
  • Language tabs switch between Arabic/English editor
  • Rich text editor (Quill) works with RTL Arabic text
  • Preview modal displays rendered content
  • Save button updates database and shows success notification
  • HTML content sanitized before save
  • Public /page/terms route displays Terms of Service
  • Public /page/privacy route displays Privacy Policy
  • Public pages show content in user's language preference
  • Last updated timestamp displayed on public pages
  • Footer links to legal pages work
  • All feature tests pass
  • Code formatted with Pint

Estimation

Complexity: Medium | Effort: 4-5 hours

Out of Scope

  • Version history for legal pages
  • Scheduled publishing
  • Multiple admin approval workflow
  • PDF export of legal pages