# 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 - [x] Terms of Service (`/page/terms`) - [x] Privacy Policy (`/page/privacy`) ### Editor Features - [x] Rich text editor using Quill.js (lightweight, RTL-friendly) - [x] Bilingual content with Arabic/English tabs in editor UI - [x] Save and publish button updates database immediately - [x] Preview opens modal showing rendered content in selected language - [x] HTML content sanitized before save (prevent XSS) ### Admin UI Location - [x] Accessible under Admin Dashboard > Settings section - [x] Sidebar item: "Legal Pages" or integrate into Settings page - [x] List view showing both pages with "Edit" action - [x] Edit page shows language tabs (Arabic | English) above editor ### Public Display - [x] Pages accessible from footer links (no auth required) - [x] Route: `/page/{slug}` where slug is `terms` or `privacy` - [x] Content displayed in user's current language preference - [x] Last updated timestamp displayed at bottom of page - [x] 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 ```php // 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 ```php // 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 ```php // 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 ```php // resources/views/livewire/admin/pages/edit.blade.php 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: ```bash 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 ```php // 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', '

شروط الخدمة الجديدة

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

New terms content

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

Updated content

') ->call('save'); expect($page->fresh()->updated_at)->toBeGreaterThan($originalTimestamp); }); test('public can view terms page', function () { Page::where('slug', 'terms')->update(['content_en' => '

Our terms

']); $this->get('/page/terms') ->assertOk() ->assertSee('Our terms'); }); test('public can view privacy page', function () { Page::where('slug', 'privacy')->update(['content_en' => '

Our privacy policy

']); $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', '

Safe content

') ->call('save'); $page = Page::where('slug', 'terms')->first(); expect($page->content_en)->not->toContain('