9.6 KiB
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 istermsorprivacy - 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:modelon 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
longTextcolumn type, consider lazy loading for edit - Concurrent edits: Single admin system - not a concern
- RTL in editor: Quill supports RTL via
direction: rtlCSS 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/termsroute displays Terms of Service - Public
/page/privacyroute 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