reviewed epic 5 & 6 stories
This commit is contained in:
@@ -8,39 +8,73 @@ 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
|
||||
- [ ] Privacy Policy
|
||||
- [ ] Terms of Service (`/page/terms`)
|
||||
- [ ] Privacy Policy (`/page/privacy`)
|
||||
|
||||
### Editor Features
|
||||
- [ ] Rich text editor
|
||||
- [ ] Bilingual content (Arabic/English)
|
||||
- [ ] Save and publish
|
||||
- [ ] Preview before publishing
|
||||
- [ ] 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 (public)
|
||||
- [ ] Last updated timestamp displayed
|
||||
- [ ] 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
|
||||
|
||||
Store in database settings table or dedicated pages table.
|
||||
### 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
|
||||
// Migration: create_pages_table.php
|
||||
Schema::create('pages', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('slug')->unique();
|
||||
$table->string('slug')->unique(); // 'terms', 'privacy'
|
||||
$table->string('title_ar');
|
||||
$table->string('title_en');
|
||||
$table->text('content_ar');
|
||||
$table->text('content_en');
|
||||
$table->timestamps();
|
||||
$table->longText('content_ar')->nullable();
|
||||
$table->longText('content_en')->nullable();
|
||||
$table->timestamps(); // updated_at used for "Last updated"
|
||||
});
|
||||
```
|
||||
|
||||
// Seeder
|
||||
### Seeder
|
||||
|
||||
```php
|
||||
// PageSeeder.php
|
||||
Page::create([
|
||||
'slug' => 'terms',
|
||||
'title_ar' => 'شروط الخدمة',
|
||||
@@ -58,22 +92,221 @@ Page::create([
|
||||
]);
|
||||
```
|
||||
|
||||
### Public Route
|
||||
### Routes
|
||||
|
||||
```php
|
||||
Route::get('/page/{slug}', function (string $slug) {
|
||||
$page = Page::where('slug', $slug)->firstOrFail();
|
||||
return view('pages.show', compact('page'));
|
||||
})->name('page.show');
|
||||
// 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
|
||||
<?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:
|
||||
```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', '<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
|
||||
- [ ] Can edit Terms of Service
|
||||
- [ ] Can edit Privacy Policy
|
||||
- [ ] Bilingual content works
|
||||
- [ ] Preview works
|
||||
- [ ] Public pages accessible
|
||||
- [ ] Last updated shows
|
||||
- [ ] Tests pass
|
||||
- [ ] 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:** 3-4 hours
|
||||
**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
|
||||
|
||||
Reference in New Issue
Block a user