complet story 6.9 with qa tests
This commit is contained in:
parent
102f8553f4
commit
50542e1eb0
|
|
@ -0,0 +1,39 @@
|
|||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class Page extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'slug',
|
||||
'title_ar',
|
||||
'title_en',
|
||||
'content_ar',
|
||||
'content_en',
|
||||
];
|
||||
|
||||
/**
|
||||
* Get the title in the specified locale, with fallback to Arabic.
|
||||
*/
|
||||
public function getTitle(?string $locale = null): string
|
||||
{
|
||||
$locale ??= app()->getLocale();
|
||||
|
||||
return $locale === 'en' ? ($this->title_en ?? $this->title_ar) : $this->title_ar;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the content in the specified locale, with fallback to Arabic.
|
||||
*/
|
||||
public function getContent(?string $locale = null): string
|
||||
{
|
||||
$locale ??= app()->getLocale();
|
||||
|
||||
return $locale === 'en' ? ($this->content_en ?? $this->content_ar ?? '') : ($this->content_ar ?? '');
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,45 @@
|
|||
<?php
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
||||
/**
|
||||
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\Page>
|
||||
*/
|
||||
class PageFactory extends Factory
|
||||
{
|
||||
/**
|
||||
* Define the model's default state.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function definition(): array
|
||||
{
|
||||
return [
|
||||
'slug' => fake()->unique()->slug(2),
|
||||
'title_ar' => fake()->sentence(3),
|
||||
'title_en' => fake()->sentence(3),
|
||||
'content_ar' => '<p>'.fake()->paragraphs(3, true).'</p>',
|
||||
'content_en' => '<p>'.fake()->paragraphs(3, true).'</p>',
|
||||
];
|
||||
}
|
||||
|
||||
public function terms(): static
|
||||
{
|
||||
return $this->state(fn (array $attributes) => [
|
||||
'slug' => 'terms',
|
||||
'title_ar' => 'شروط الخدمة',
|
||||
'title_en' => 'Terms of Service',
|
||||
]);
|
||||
}
|
||||
|
||||
public function privacy(): static
|
||||
{
|
||||
return $this->state(fn (array $attributes) => [
|
||||
'slug' => 'privacy',
|
||||
'title_ar' => 'سياسة الخصوصية',
|
||||
'title_en' => 'Privacy Policy',
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('pages', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('slug')->unique();
|
||||
$table->string('title_ar');
|
||||
$table->string('title_en');
|
||||
$table->longText('content_ar')->nullable();
|
||||
$table->longText('content_en')->nullable();
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('pages');
|
||||
}
|
||||
};
|
||||
|
|
@ -23,5 +23,9 @@ class DatabaseSeeder extends Seeder
|
|||
'email_verified_at' => now(),
|
||||
]
|
||||
);
|
||||
|
||||
$this->call([
|
||||
PageSeeder::class,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,35 @@
|
|||
<?php
|
||||
|
||||
namespace Database\Seeders;
|
||||
|
||||
use App\Models\Page;
|
||||
use Illuminate\Database\Seeder;
|
||||
|
||||
class PageSeeder extends Seeder
|
||||
{
|
||||
/**
|
||||
* Run the database seeds.
|
||||
*/
|
||||
public function run(): void
|
||||
{
|
||||
Page::firstOrCreate(
|
||||
['slug' => 'terms'],
|
||||
[
|
||||
'title_ar' => 'شروط الخدمة',
|
||||
'title_en' => 'Terms of Service',
|
||||
'content_ar' => '',
|
||||
'content_en' => '',
|
||||
]
|
||||
);
|
||||
|
||||
Page::firstOrCreate(
|
||||
['slug' => 'privacy'],
|
||||
[
|
||||
'title_ar' => 'سياسة الخصوصية',
|
||||
'title_en' => 'Privacy Policy',
|
||||
'content_ar' => '',
|
||||
'content_en' => '',
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,69 @@
|
|||
# Quality Gate: Story 6.9 Legal Pages Editor
|
||||
schema: 1
|
||||
story: "6.9"
|
||||
story_title: "Legal Pages Editor"
|
||||
gate: PASS
|
||||
status_reason: "All 26 acceptance criteria met with comprehensive test coverage (29 tests). Security, performance, and code quality all pass."
|
||||
reviewer: "Quinn (Test Architect)"
|
||||
updated: "2025-12-28T22:30:00Z"
|
||||
|
||||
waiver: { active: false }
|
||||
|
||||
top_issues: []
|
||||
|
||||
quality_score: 100
|
||||
|
||||
evidence:
|
||||
tests_reviewed: 29
|
||||
tests_passing: 29
|
||||
risks_identified: 0
|
||||
trace:
|
||||
ac_covered:
|
||||
- 1 # Terms page editable
|
||||
- 2 # Privacy page editable
|
||||
- 3 # Rich text editor (Quill.js)
|
||||
- 4 # Bilingual content tabs
|
||||
- 5 # Save and publish button
|
||||
- 6 # Preview modal
|
||||
- 7 # HTML sanitization
|
||||
- 8 # Admin Dashboard location
|
||||
- 9 # Sidebar Legal Pages item
|
||||
- 10 # List view with edit action
|
||||
- 11 # Language tabs in editor
|
||||
- 12 # Footer links accessible
|
||||
- 13 # Public route /page/{slug}
|
||||
- 14 # User language preference
|
||||
- 15 # Last updated timestamp
|
||||
- 16 # Professional layout
|
||||
ac_gaps: []
|
||||
|
||||
nfr_validation:
|
||||
security:
|
||||
status: PASS
|
||||
notes: "HTML sanitized via mews/purifier clean() helper; admin middleware protects routes; slug constrained in route"
|
||||
performance:
|
||||
status: PASS
|
||||
notes: "Simple queries with unique slug index; longText columns appropriate for content"
|
||||
reliability:
|
||||
status: PASS
|
||||
notes: "Error handling via Laravel; proper fallback for empty/null content"
|
||||
maintainability:
|
||||
status: PASS
|
||||
notes: "Clean code following project conventions; comprehensive translations; well-structured Volt components"
|
||||
|
||||
recommendations:
|
||||
immediate: []
|
||||
future:
|
||||
- action: "Consider bundling Quill.js assets instead of CDN for production"
|
||||
refs: ["resources/views/livewire/admin/pages/edit.blade.php:174,227"]
|
||||
|
||||
risk_summary:
|
||||
totals: { critical: 0, high: 0, medium: 0, low: 0 }
|
||||
recommendations:
|
||||
must_fix: []
|
||||
monitor: []
|
||||
|
||||
history:
|
||||
- at: "2025-12-28T22:30:00Z"
|
||||
gate: PASS
|
||||
note: "Initial QA review - all acceptance criteria met, comprehensive test coverage"
|
||||
|
|
@ -21,28 +21,28 @@ So that **I can maintain legal compliance and update policies**.
|
|||
## Acceptance Criteria
|
||||
|
||||
### Pages to Edit
|
||||
- [ ] Terms of Service (`/page/terms`)
|
||||
- [ ] Privacy Policy (`/page/privacy`)
|
||||
- [x] Terms of Service (`/page/terms`)
|
||||
- [x] 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)
|
||||
- [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
|
||||
- [ ] 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
|
||||
- [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
|
||||
- [ ] 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)
|
||||
- [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
|
||||
|
||||
|
|
@ -282,25 +282,25 @@ test('client cannot access admin pages editor', function () {
|
|||
```
|
||||
|
||||
## 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
|
||||
- [x] Page model and migration created
|
||||
- [x] Seeder creates terms and privacy pages
|
||||
- [x] Admin can access legal pages list from settings/sidebar
|
||||
- [x] Admin can edit Terms of Service content (Arabic)
|
||||
- [x] Admin can edit Terms of Service content (English)
|
||||
- [x] Admin can edit Privacy Policy content (Arabic)
|
||||
- [x] Admin can edit Privacy Policy content (English)
|
||||
- [x] Language tabs switch between Arabic/English editor
|
||||
- [x] Rich text editor (Quill) works with RTL Arabic text
|
||||
- [x] Preview modal displays rendered content
|
||||
- [x] Save button updates database and shows success notification
|
||||
- [x] HTML content sanitized before save
|
||||
- [x] Public `/page/terms` route displays Terms of Service
|
||||
- [x] Public `/page/privacy` route displays Privacy Policy
|
||||
- [x] Public pages show content in user's language preference
|
||||
- [x] Last updated timestamp displayed on public pages
|
||||
- [x] Footer links to legal pages work
|
||||
- [x] All feature tests pass
|
||||
- [x] Code formatted with Pint
|
||||
|
||||
## Estimation
|
||||
**Complexity:** Medium | **Effort:** 4-5 hours
|
||||
|
|
@ -310,3 +310,140 @@ test('client cannot access admin pages editor', function () {
|
|||
- Scheduled publishing
|
||||
- Multiple admin approval workflow
|
||||
- PDF export of legal pages
|
||||
|
||||
---
|
||||
|
||||
## Dev Agent Record
|
||||
|
||||
### Status
|
||||
Ready for Review
|
||||
|
||||
### Agent Model Used
|
||||
Claude Opus 4.5 (claude-opus-4-5-20251101)
|
||||
|
||||
### Completion Notes
|
||||
- Page model with localized title/content accessors created
|
||||
- PageSeeder creates terms and privacy pages with Arabic/English titles
|
||||
- Installed mews/purifier for HTML sanitization via clean() helper
|
||||
- Admin pages index shows both legal pages with edit actions
|
||||
- Admin pages edit uses Quill.js rich text editor with RTL support for Arabic
|
||||
- Language tabs (Arabic/English) switch editor content
|
||||
- Preview modal displays sanitized content in both languages
|
||||
- Public route `/page/{slug}` displays content based on user's locale
|
||||
- Legacy `/terms` and `/privacy` routes redirect to new dynamic routes
|
||||
- Footer links already pointed to correct routes (via redirect)
|
||||
- 29 feature tests covering all acceptance criteria
|
||||
- All tests pass, code formatted with Pint
|
||||
|
||||
### File List
|
||||
**New Files:**
|
||||
- `app/Models/Page.php` - Page model with localized accessors
|
||||
- `database/factories/PageFactory.php` - Factory with terms/privacy states
|
||||
- `database/migrations/2025_12_28_203026_create_pages_table.php` - Pages table migration
|
||||
- `database/seeders/PageSeeder.php` - Seeds terms and privacy pages
|
||||
- `resources/views/livewire/admin/pages/index.blade.php` - Admin pages list
|
||||
- `resources/views/livewire/admin/pages/edit.blade.php` - Admin page editor with Quill
|
||||
- `resources/views/pages/legal.blade.php` - Public legal page view
|
||||
- `lang/en/pages.php` - English translations for pages feature
|
||||
- `lang/ar/pages.php` - Arabic translations for pages feature
|
||||
- `tests/Feature/Admin/LegalPagesTest.php` - Feature tests (29 tests)
|
||||
|
||||
**Modified Files:**
|
||||
- `routes/web.php` - Added admin pages routes and public `/page/{slug}` route
|
||||
- `resources/views/components/layouts/app/sidebar.blade.php` - Added Legal Pages nav item
|
||||
- `database/seeders/DatabaseSeeder.php` - Added PageSeeder call
|
||||
- `lang/en/navigation.php` - Added legal_pages key
|
||||
- `lang/ar/navigation.php` - Added legal_pages key
|
||||
|
||||
### Change Log
|
||||
- 2025-12-28: Initial implementation of Story 6.9 Legal Pages Editor
|
||||
|
||||
---
|
||||
|
||||
## QA Results
|
||||
|
||||
### Review Date: 2025-12-28
|
||||
|
||||
### Reviewed By: Quinn (Test Architect)
|
||||
|
||||
### Code Quality Assessment
|
||||
|
||||
The implementation demonstrates **excellent code quality** overall. The developer has:
|
||||
|
||||
1. **Followed existing patterns** - Class-based Volt components match the project architecture
|
||||
2. **Proper separation of concerns** - Model with localized accessors, clean Livewire components
|
||||
3. **Security-first approach** - HTML sanitization via `mews/purifier` package with `clean()` helper
|
||||
4. **Comprehensive test coverage** - 29 tests covering all acceptance criteria
|
||||
5. **Full bilingual support** - Arabic/English translations complete in both `pages.php` and `navigation.php`
|
||||
6. **AdminLog integration** - Page updates are properly audited
|
||||
|
||||
**Highlights:**
|
||||
- The `Page` model's `getTitle()` and `getContent()` methods with locale fallback are well-designed
|
||||
- Quill.js integration with RTL support and dark mode styling is thorough
|
||||
- Legacy route redirects maintain backward compatibility
|
||||
|
||||
### Refactoring Performed
|
||||
|
||||
No refactoring was required. The code meets all quality standards.
|
||||
|
||||
### Compliance Check
|
||||
|
||||
- Coding Standards: ✓ All code passes Pint formatting
|
||||
- Project Structure: ✓ Files in correct locations following project conventions
|
||||
- Testing Strategy: ✓ Feature tests cover all acceptance criteria with appropriate Volt::test() usage
|
||||
- All ACs Met: ✓ All 26 acceptance criteria verified as implemented
|
||||
|
||||
### Improvements Checklist
|
||||
|
||||
All items are satisfied - no changes required:
|
||||
|
||||
- [x] Page model with localized accessors
|
||||
- [x] Migration with proper schema (longText for content)
|
||||
- [x] PageSeeder using firstOrCreate for idempotency
|
||||
- [x] PageFactory with terms() and privacy() states
|
||||
- [x] Admin index page lists both legal pages
|
||||
- [x] Admin edit page with Quill.js rich text editor
|
||||
- [x] Language tabs (Arabic/English) working
|
||||
- [x] Preview modal shows sanitized content
|
||||
- [x] RTL support for Arabic content in editor
|
||||
- [x] Dark mode styling for Quill editor
|
||||
- [x] Public legal page view with locale-based content
|
||||
- [x] Last updated timestamp displayed
|
||||
- [x] HTML sanitization via mews/purifier
|
||||
- [x] AdminLog created on page save
|
||||
- [x] Legacy routes redirect to new dynamic routes
|
||||
- [x] Sidebar navigation includes Legal Pages link
|
||||
- [x] All translations complete (en/ar)
|
||||
- [x] 29 comprehensive feature tests
|
||||
|
||||
### Security Review
|
||||
|
||||
**Status: PASS**
|
||||
|
||||
- XSS Prevention: HTML content is sanitized using `clean()` (mews/purifier) both on save and on display
|
||||
- Authorization: Admin-only routes properly protected by `admin` middleware
|
||||
- CSRF: Standard Laravel/Livewire CSRF protection in place
|
||||
- Input Validation: Slug constrained to `terms|privacy` pattern in routes
|
||||
|
||||
### Performance Considerations
|
||||
|
||||
**Status: PASS**
|
||||
|
||||
- Database: Uses `longText` for content columns (appropriate for legal documents)
|
||||
- Queries: Simple single-row queries by slug with unique index
|
||||
- CDN Assets: Quill.js loaded from CDN (consider bundling for production)
|
||||
- No N+1 issues detected
|
||||
|
||||
### Files Modified During Review
|
||||
|
||||
None - no modifications were required.
|
||||
|
||||
### Gate Status
|
||||
|
||||
Gate: PASS → docs/qa/gates/6.9-legal-pages-editor.yml
|
||||
|
||||
### Recommended Status
|
||||
|
||||
✓ Ready for Done
|
||||
|
||||
The implementation fully satisfies all acceptance criteria with comprehensive test coverage, proper security measures, and clean code following project conventions
|
||||
|
|
|
|||
|
|
@ -39,4 +39,5 @@ return [
|
|||
'export_timelines' => 'تصدير الجداول الزمنية',
|
||||
'working_hours' => 'ساعات العمل',
|
||||
'blocked_times' => 'الأوقات المحظورة',
|
||||
'legal_pages' => 'الصفحات القانونية',
|
||||
];
|
||||
|
|
|
|||
|
|
@ -0,0 +1,21 @@
|
|||
<?php
|
||||
|
||||
return [
|
||||
'legal_pages' => 'الصفحات القانونية',
|
||||
'legal_pages_description' => 'إدارة صفحات شروط الخدمة وسياسة الخصوصية.',
|
||||
'edit_page' => 'تعديل الصفحة',
|
||||
'back_to_pages' => 'العودة إلى الصفحات القانونية',
|
||||
'last_updated' => 'آخر تحديث',
|
||||
'has_content' => 'يوجد محتوى',
|
||||
'no_content' => 'لا يوجد محتوى',
|
||||
'no_pages' => 'لا توجد صفحات.',
|
||||
'arabic' => 'العربية',
|
||||
'english' => 'الإنجليزية',
|
||||
'content' => 'المحتوى',
|
||||
'preview' => 'معاينة',
|
||||
'save_publish' => 'حفظ ونشر',
|
||||
'page_saved' => 'تم حفظ الصفحة بنجاح.',
|
||||
'enter_content_ar' => 'أدخل المحتوى بالعربية...',
|
||||
'enter_content_en' => 'أدخل المحتوى بالإنجليزية...',
|
||||
'content_coming_soon' => 'المحتوى قادم قريباً.',
|
||||
];
|
||||
|
|
@ -39,4 +39,5 @@ return [
|
|||
'export_timelines' => 'Export Timelines',
|
||||
'working_hours' => 'Working Hours',
|
||||
'blocked_times' => 'Blocked Times',
|
||||
'legal_pages' => 'Legal Pages',
|
||||
];
|
||||
|
|
|
|||
|
|
@ -0,0 +1,21 @@
|
|||
<?php
|
||||
|
||||
return [
|
||||
'legal_pages' => 'Legal Pages',
|
||||
'legal_pages_description' => 'Manage your Terms of Service and Privacy Policy pages.',
|
||||
'edit_page' => 'Edit Page',
|
||||
'back_to_pages' => 'Back to Legal Pages',
|
||||
'last_updated' => 'Last updated',
|
||||
'has_content' => 'Has Content',
|
||||
'no_content' => 'No Content',
|
||||
'no_pages' => 'No pages found.',
|
||||
'arabic' => 'Arabic',
|
||||
'english' => 'English',
|
||||
'content' => 'Content',
|
||||
'preview' => 'Preview',
|
||||
'save_publish' => 'Save & Publish',
|
||||
'page_saved' => 'Page saved successfully.',
|
||||
'enter_content_ar' => 'Enter Arabic content...',
|
||||
'enter_content_en' => 'Enter English content...',
|
||||
'content_coming_soon' => 'Content coming soon.',
|
||||
];
|
||||
|
|
@ -125,6 +125,14 @@
|
|||
>
|
||||
{{ __('navigation.blocked_times') }}
|
||||
</flux:navlist.item>
|
||||
<flux:navlist.item
|
||||
icon="scale"
|
||||
:href="route('admin.pages.index')"
|
||||
:current="request()->routeIs('admin.pages.*')"
|
||||
wire:navigate
|
||||
>
|
||||
{{ __('navigation.legal_pages') }}
|
||||
</flux:navlist.item>
|
||||
</flux:navlist.group>
|
||||
@endif
|
||||
</flux:navlist>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,272 @@
|
|||
<?php
|
||||
|
||||
use App\Models\AdminLog;
|
||||
use App\Models\Page;
|
||||
use Livewire\Volt\Component;
|
||||
|
||||
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::query()->where('slug', $slug)->firstOrFail();
|
||||
$this->content_ar = $this->page->content_ar ?? '';
|
||||
$this->content_en = $this->page->content_en ?? '';
|
||||
}
|
||||
|
||||
public function setTab(string $tab): void
|
||||
{
|
||||
$this->activeTab = $tab;
|
||||
}
|
||||
|
||||
public function save(): void
|
||||
{
|
||||
$oldValues = $this->page->toArray();
|
||||
|
||||
$this->page->update([
|
||||
'content_ar' => clean($this->content_ar),
|
||||
'content_en' => clean($this->content_en),
|
||||
]);
|
||||
|
||||
AdminLog::create([
|
||||
'admin_id' => auth()->id(),
|
||||
'action' => 'update',
|
||||
'target_type' => 'page',
|
||||
'target_id' => $this->page->id,
|
||||
'old_values' => $oldValues,
|
||||
'new_values' => $this->page->fresh()->toArray(),
|
||||
'ip_address' => request()->ip(),
|
||||
'created_at' => now(),
|
||||
]);
|
||||
|
||||
session()->flash('success', __('pages.page_saved'));
|
||||
}
|
||||
|
||||
public function togglePreview(): void
|
||||
{
|
||||
$this->showPreview = ! $this->showPreview;
|
||||
}
|
||||
|
||||
public function closePreview(): void
|
||||
{
|
||||
$this->showPreview = false;
|
||||
}
|
||||
}; ?>
|
||||
|
||||
<div>
|
||||
<div class="mb-6">
|
||||
<flux:button variant="ghost" :href="route('admin.pages.index')" wire:navigate icon="arrow-left">
|
||||
{{ __('pages.back_to_pages') }}
|
||||
</flux:button>
|
||||
</div>
|
||||
|
||||
<div class="mb-6 flex flex-col items-start justify-between gap-4 sm:flex-row sm:items-center">
|
||||
<div>
|
||||
<flux:heading size="xl">{{ __('pages.edit_page') }}: {{ $page->title_en }}</flux:heading>
|
||||
<p class="mt-1 text-sm text-zinc-500 dark:text-zinc-400">
|
||||
{{ __('pages.last_updated') }}: {{ $page->updated_at->diffForHumans() }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (session('success'))
|
||||
<flux:callout variant="success" class="mb-6">
|
||||
{{ session('success') }}
|
||||
</flux:callout>
|
||||
@endif
|
||||
|
||||
<div class="rounded-lg border border-zinc-200 bg-white p-6 dark:border-zinc-700 dark:bg-zinc-800">
|
||||
<!-- Language Tabs -->
|
||||
<div class="mb-6 border-b border-zinc-200 dark:border-zinc-700">
|
||||
<nav class="-mb-px flex gap-4">
|
||||
<button
|
||||
wire:click="setTab('ar')"
|
||||
class="{{ $activeTab === 'ar' ? 'border-blue-500 text-blue-600 dark:text-blue-400' : 'border-transparent text-zinc-500 hover:border-zinc-300 hover:text-zinc-700 dark:text-zinc-400' }} border-b-2 px-1 pb-3 text-sm font-medium transition-colors"
|
||||
>
|
||||
{{ __('pages.arabic') }}
|
||||
</button>
|
||||
<button
|
||||
wire:click="setTab('en')"
|
||||
class="{{ $activeTab === 'en' ? 'border-blue-500 text-blue-600 dark:text-blue-400' : 'border-transparent text-zinc-500 hover:border-zinc-300 hover:text-zinc-700 dark:text-zinc-400' }} border-b-2 px-1 pb-3 text-sm font-medium transition-colors"
|
||||
>
|
||||
{{ __('pages.english') }}
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<form wire:submit="save" class="space-y-6">
|
||||
<!-- Arabic Content -->
|
||||
<div x-show="$wire.activeTab === 'ar'" x-cloak>
|
||||
<flux:field>
|
||||
<flux:label>{{ __('pages.content') }} ({{ __('pages.arabic') }})</flux:label>
|
||||
<div wire:ignore>
|
||||
<div
|
||||
id="editor-ar"
|
||||
class="min-h-[300px] rounded-lg border border-zinc-200 bg-white dark:border-zinc-700 dark:bg-zinc-900"
|
||||
dir="rtl"
|
||||
></div>
|
||||
<textarea id="content_ar" wire:model="content_ar" class="hidden">{{ $content_ar }}</textarea>
|
||||
</div>
|
||||
</flux:field>
|
||||
</div>
|
||||
|
||||
<!-- English Content -->
|
||||
<div x-show="$wire.activeTab === 'en'" x-cloak>
|
||||
<flux:field>
|
||||
<flux:label>{{ __('pages.content') }} ({{ __('pages.english') }})</flux:label>
|
||||
<div wire:ignore>
|
||||
<div
|
||||
id="editor-en"
|
||||
class="min-h-[300px] rounded-lg border border-zinc-200 bg-white dark:border-zinc-700 dark:bg-zinc-900"
|
||||
></div>
|
||||
<textarea id="content_en" wire:model="content_en" class="hidden">{{ $content_en }}</textarea>
|
||||
</div>
|
||||
</flux:field>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-end gap-4 border-t border-zinc-200 pt-6 dark:border-zinc-700">
|
||||
<flux:button variant="ghost" :href="route('admin.pages.index')" wire:navigate>
|
||||
{{ __('common.cancel') }}
|
||||
</flux:button>
|
||||
<flux:button type="button" wire:click="togglePreview">
|
||||
{{ __('pages.preview') }}
|
||||
</flux:button>
|
||||
<flux:button variant="primary" type="submit">
|
||||
{{ __('pages.save_publish') }}
|
||||
</flux:button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{{-- Preview Modal --}}
|
||||
<flux:modal wire:model="showPreview" class="max-w-4xl">
|
||||
<div class="p-6">
|
||||
<flux:heading size="lg" class="mb-4">{{ __('pages.preview') }}</flux:heading>
|
||||
|
||||
<div class="space-y-6">
|
||||
<div class="border-b border-zinc-200 pb-4 dark:border-zinc-700">
|
||||
<h3 class="mb-2 text-lg font-semibold text-zinc-600 dark:text-zinc-400">{{ __('pages.arabic') }}</h3>
|
||||
<h2 class="text-xl font-bold text-zinc-900 dark:text-zinc-100" dir="rtl">{{ $page->title_ar }}</h2>
|
||||
<div class="prose prose-sm mt-2 max-w-none dark:prose-invert" dir="rtl">{!! clean($content_ar) !!}</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 class="mb-2 text-lg font-semibold text-zinc-600 dark:text-zinc-400">{{ __('pages.english') }}</h3>
|
||||
<h2 class="text-xl font-bold text-zinc-900 dark:text-zinc-100">{{ $page->title_en }}</h2>
|
||||
<div class="prose prose-sm mt-2 max-w-none dark:prose-invert">{!! clean($content_en) !!}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 flex justify-end">
|
||||
<flux:button wire:click="closePreview">{{ __('common.close') }}</flux:button>
|
||||
</div>
|
||||
</div>
|
||||
</flux:modal>
|
||||
</div>
|
||||
|
||||
@push('styles')
|
||||
<link href="https://cdn.jsdelivr.net/npm/quill@2.0.3/dist/quill.snow.css" rel="stylesheet" />
|
||||
<style>
|
||||
.ql-editor {
|
||||
min-height: 250px;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.ql-container {
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.dark .ql-toolbar {
|
||||
border-color: rgb(63 63 70);
|
||||
background: rgb(24 24 27);
|
||||
}
|
||||
|
||||
.dark .ql-container {
|
||||
border-color: rgb(63 63 70);
|
||||
background: rgb(24 24 27);
|
||||
}
|
||||
|
||||
.dark .ql-editor {
|
||||
color: rgb(244 244 245);
|
||||
}
|
||||
|
||||
.dark .ql-stroke {
|
||||
stroke: rgb(161 161 170);
|
||||
}
|
||||
|
||||
.dark .ql-fill {
|
||||
fill: rgb(161 161 170);
|
||||
}
|
||||
|
||||
.dark .ql-picker-label {
|
||||
color: rgb(161 161 170);
|
||||
}
|
||||
|
||||
.dark .ql-picker-options {
|
||||
background: rgb(39 39 42);
|
||||
border-color: rgb(63 63 70);
|
||||
}
|
||||
|
||||
.dark .ql-picker-item {
|
||||
color: rgb(244 244 245);
|
||||
}
|
||||
|
||||
.dark .ql-picker-item:hover {
|
||||
color: rgb(59 130 246);
|
||||
}
|
||||
</style>
|
||||
@endpush
|
||||
|
||||
@push('scripts')
|
||||
<script src="https://cdn.jsdelivr.net/npm/quill@2.0.3/dist/quill.js"></script>
|
||||
<script>
|
||||
document.addEventListener('livewire:navigated', initQuillEditors)
|
||||
document.addEventListener('DOMContentLoaded', initQuillEditors)
|
||||
|
||||
function initQuillEditors() {
|
||||
if (document.querySelector('#editor-ar') && !document.querySelector('#editor-ar .ql-editor')) {
|
||||
const toolbarOptions = [
|
||||
[{ header: [1, 2, 3, false] }],
|
||||
['bold', 'italic', 'underline'],
|
||||
[{ list: 'ordered' }, { list: 'bullet' }],
|
||||
['link'],
|
||||
['clean'],
|
||||
]
|
||||
|
||||
// Arabic Editor
|
||||
const quillAr = new Quill('#editor-ar', {
|
||||
theme: 'snow',
|
||||
modules: {
|
||||
toolbar: toolbarOptions,
|
||||
},
|
||||
placeholder: '{{ __('pages.enter_content_ar') }}',
|
||||
})
|
||||
|
||||
quillAr.root.innerHTML = document.getElementById('content_ar').value
|
||||
quillAr.on('text-change', function () {
|
||||
@this.set('content_ar', quillAr.root.innerHTML)
|
||||
})
|
||||
|
||||
// English Editor
|
||||
const quillEn = new Quill('#editor-en', {
|
||||
theme: 'snow',
|
||||
modules: {
|
||||
toolbar: toolbarOptions,
|
||||
},
|
||||
placeholder: '{{ __('pages.enter_content_en') }}',
|
||||
})
|
||||
|
||||
quillEn.root.innerHTML = document.getElementById('content_en').value
|
||||
quillEn.on('text-change', function () {
|
||||
@this.set('content_en', quillEn.root.innerHTML)
|
||||
})
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@endpush
|
||||
|
|
@ -0,0 +1,85 @@
|
|||
<?php
|
||||
|
||||
use App\Models\Page;
|
||||
use Livewire\Volt\Component;
|
||||
|
||||
new class extends Component
|
||||
{
|
||||
public function with(): array
|
||||
{
|
||||
return [
|
||||
'pages' => Page::query()
|
||||
->whereIn('slug', ['terms', 'privacy'])
|
||||
->orderBy('slug')
|
||||
->get(),
|
||||
];
|
||||
}
|
||||
}; ?>
|
||||
|
||||
<div class="mx-auto max-w-7xl">
|
||||
<div class="mb-6 flex flex-col items-start justify-between gap-4 sm:flex-row sm:items-center">
|
||||
<div>
|
||||
<flux:heading size="xl">{{ __('pages.legal_pages') }}</flux:heading>
|
||||
<p class="mt-1 text-sm text-zinc-500 dark:text-zinc-400">{{ __('pages.legal_pages_description') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (session('success'))
|
||||
<flux:callout variant="success" class="mb-6">
|
||||
{{ session('success') }}
|
||||
</flux:callout>
|
||||
@endif
|
||||
|
||||
<div class="space-y-0">
|
||||
@forelse ($pages as $page)
|
||||
<div
|
||||
wire:key="page-{{ $page->id }}"
|
||||
class="{{ $loop->first ? 'rounded-t-lg' : '' }} {{ $loop->last ? 'rounded-b-lg' : '' }} {{ ! $loop->first ? 'border-t-0' : '' }} border border-zinc-200 bg-white p-4 dark:border-zinc-700 dark:bg-zinc-800"
|
||||
>
|
||||
<div class="flex flex-col gap-4 lg:flex-row lg:items-center">
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center gap-3">
|
||||
<flux:icon name="document-text" class="h-5 w-5 text-zinc-400" />
|
||||
<div>
|
||||
<h3 class="font-semibold text-zinc-900 dark:text-zinc-100">
|
||||
{{ $page->title_en }}
|
||||
</h3>
|
||||
<p class="text-sm text-zinc-500 dark:text-zinc-400" dir="rtl">
|
||||
{{ $page->title_ar }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="lg:w-32">
|
||||
<flux:badge color="{{ $page->content_en || $page->content_ar ? 'green' : 'amber' }}" size="sm">
|
||||
{{ $page->content_en || $page->content_ar ? __('pages.has_content') : __('pages.no_content') }}
|
||||
</flux:badge>
|
||||
</div>
|
||||
|
||||
<div class="lg:w-40">
|
||||
<div class="text-sm text-zinc-500 dark:text-zinc-400">
|
||||
{{ __('pages.last_updated') }}: {{ $page->updated_at->diffForHumans() }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="lg:w-32">
|
||||
<flux:button
|
||||
href="{{ route('admin.pages.edit', $page->slug) }}"
|
||||
variant="primary"
|
||||
size="sm"
|
||||
wire:navigate
|
||||
>
|
||||
{{ __('common.edit') }}
|
||||
</flux:button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@empty
|
||||
<div class="rounded-lg border border-zinc-200 bg-white py-12 text-center text-zinc-500 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-400">
|
||||
<flux:icon name="document-text" class="mx-auto mb-4 h-12 w-12" />
|
||||
<p>{{ __('pages.no_pages') }}</p>
|
||||
</div>
|
||||
@endforelse
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
<x-layouts.public>
|
||||
<div class="py-8">
|
||||
<h1 class="mb-6 text-3xl font-bold text-navy dark:text-gold">{{ $page->getTitle() }}</h1>
|
||||
<div class="prose max-w-none rounded-lg bg-white p-8 shadow-md dark:bg-zinc-800 dark:prose-invert" dir="{{ app()->getLocale() === 'ar' ? 'rtl' : 'ltr' }}">
|
||||
@if ($page->getContent())
|
||||
{!! clean($page->getContent()) !!}
|
||||
@else
|
||||
<p class="text-charcoal dark:text-zinc-400">{{ __('pages.content_coming_soon') }}</p>
|
||||
@endif
|
||||
</div>
|
||||
<p class="mt-4 text-sm text-zinc-500 dark:text-zinc-400">
|
||||
{{ __('pages.last_updated') }}: {{ $page->updated_at->format('M d, Y') }}
|
||||
</p>
|
||||
</div>
|
||||
</x-layouts.public>
|
||||
|
|
@ -18,13 +18,15 @@ Route::get('/booking', function () {
|
|||
Volt::route('/posts', 'pages.posts.index')->name('posts.index');
|
||||
Volt::route('/posts/{post}', 'pages.posts.show')->name('posts.show');
|
||||
|
||||
Route::get('/terms', function () {
|
||||
return view('pages.terms');
|
||||
})->name('terms');
|
||||
Route::get('/page/{slug}', function (string $slug) {
|
||||
$page = \App\Models\Page::query()->where('slug', $slug)->firstOrFail();
|
||||
|
||||
Route::get('/privacy', function () {
|
||||
return view('pages.privacy');
|
||||
})->name('privacy');
|
||||
return view('pages.legal', ['page' => $page]);
|
||||
})->name('page.show')->where('slug', 'terms|privacy');
|
||||
|
||||
// Legacy routes for backward compatibility
|
||||
Route::redirect('/terms', '/page/terms')->name('terms');
|
||||
Route::redirect('/privacy', '/page/privacy')->name('privacy');
|
||||
|
||||
Route::get('/language/{locale}', function (string $locale) {
|
||||
if (! in_array($locale, ['ar', 'en'])) {
|
||||
|
|
@ -93,6 +95,14 @@ Route::middleware(['auth', 'active'])->group(function () {
|
|||
Volt::route('/blocked-times', 'admin.settings.blocked-times')->name('blocked-times');
|
||||
});
|
||||
|
||||
// Legal Pages Management
|
||||
Route::prefix('pages')->name('admin.pages.')->group(function () {
|
||||
Volt::route('/', 'admin.pages.index')->name('index');
|
||||
Volt::route('/{slug}/edit', 'admin.pages.edit')
|
||||
->name('edit')
|
||||
->where('slug', 'terms|privacy');
|
||||
});
|
||||
|
||||
// Posts Management
|
||||
Route::prefix('posts')->name('admin.posts.')->group(function () {
|
||||
Volt::route('/', 'admin.posts.index')->name('index');
|
||||
|
|
|
|||
|
|
@ -0,0 +1,321 @@
|
|||
<?php
|
||||
|
||||
use App\Models\AdminLog;
|
||||
use App\Models\Page;
|
||||
use App\Models\User;
|
||||
use Livewire\Volt\Volt;
|
||||
|
||||
beforeEach(function () {
|
||||
$this->admin = User::factory()->admin()->create();
|
||||
// Ensure test pages exist
|
||||
Page::factory()->terms()->create();
|
||||
Page::factory()->privacy()->create();
|
||||
});
|
||||
|
||||
// ===========================================
|
||||
// Admin Index Page Tests
|
||||
// ===========================================
|
||||
|
||||
test('admin can view legal pages list', function () {
|
||||
$this->actingAs($this->admin)
|
||||
->get(route('admin.pages.index'))
|
||||
->assertOk()
|
||||
->assertSee('Terms of Service')
|
||||
->assertSee('Privacy Policy');
|
||||
});
|
||||
|
||||
test('legal pages list shows both pages', function () {
|
||||
$this->actingAs($this->admin);
|
||||
|
||||
$component = Volt::test('admin.pages.index');
|
||||
|
||||
expect($component->viewData('pages')->count())->toBe(2);
|
||||
});
|
||||
|
||||
// ===========================================
|
||||
// Admin Edit Page Tests
|
||||
// ===========================================
|
||||
|
||||
test('admin can view terms edit page', function () {
|
||||
$this->actingAs($this->admin)
|
||||
->get(route('admin.pages.edit', 'terms'))
|
||||
->assertOk();
|
||||
});
|
||||
|
||||
test('admin can view privacy edit page', function () {
|
||||
$this->actingAs($this->admin)
|
||||
->get(route('admin.pages.edit', 'privacy'))
|
||||
->assertOk();
|
||||
});
|
||||
|
||||
test('admin can edit terms of service in Arabic', function () {
|
||||
$page = Page::where('slug', 'terms')->first();
|
||||
|
||||
$this->actingAs($this->admin);
|
||||
|
||||
Volt::test('admin.pages.edit', ['slug' => 'terms'])
|
||||
->set('content_ar', '<p>شروط الخدمة الجديدة</p>')
|
||||
->call('save')
|
||||
->assertHasNoErrors();
|
||||
|
||||
expect($page->fresh()->content_ar)->toContain('شروط الخدمة الجديدة');
|
||||
});
|
||||
|
||||
test('admin can edit terms of service in English', function () {
|
||||
$page = Page::where('slug', 'terms')->first();
|
||||
|
||||
$this->actingAs($this->admin);
|
||||
|
||||
Volt::test('admin.pages.edit', ['slug' => 'terms'])
|
||||
->set('content_en', '<p>New terms content</p>')
|
||||
->call('save')
|
||||
->assertHasNoErrors();
|
||||
|
||||
expect($page->fresh()->content_en)->toContain('New terms content');
|
||||
});
|
||||
|
||||
test('admin can edit privacy policy in Arabic', function () {
|
||||
$page = Page::where('slug', 'privacy')->first();
|
||||
|
||||
$this->actingAs($this->admin);
|
||||
|
||||
Volt::test('admin.pages.edit', ['slug' => 'privacy'])
|
||||
->set('content_ar', '<p>سياسة الخصوصية الجديدة</p>')
|
||||
->call('save')
|
||||
->assertHasNoErrors();
|
||||
|
||||
expect($page->fresh()->content_ar)->toContain('سياسة الخصوصية الجديدة');
|
||||
});
|
||||
|
||||
test('admin can edit privacy policy in English', function () {
|
||||
$page = Page::where('slug', 'privacy')->first();
|
||||
|
||||
$this->actingAs($this->admin);
|
||||
|
||||
Volt::test('admin.pages.edit', ['slug' => 'privacy'])
|
||||
->set('content_en', '<p>New privacy policy</p>')
|
||||
->call('save')
|
||||
->assertHasNoErrors();
|
||||
|
||||
expect($page->fresh()->content_en)->toContain('New privacy policy');
|
||||
});
|
||||
|
||||
test('admin can preview page content', function () {
|
||||
$this->actingAs($this->admin);
|
||||
|
||||
Volt::test('admin.pages.edit', ['slug' => 'terms'])
|
||||
->assertSet('showPreview', false)
|
||||
->call('togglePreview')
|
||||
->assertSet('showPreview', true);
|
||||
});
|
||||
|
||||
test('preview modal can be closed', function () {
|
||||
$this->actingAs($this->admin);
|
||||
|
||||
Volt::test('admin.pages.edit', ['slug' => 'terms'])
|
||||
->call('togglePreview')
|
||||
->assertSet('showPreview', true)
|
||||
->call('closePreview')
|
||||
->assertSet('showPreview', false);
|
||||
});
|
||||
|
||||
test('updated_at timestamp changes on save', function () {
|
||||
$page = Page::where('slug', 'terms')->first();
|
||||
$originalTimestamp = $page->updated_at;
|
||||
|
||||
$this->travel(1)->minute();
|
||||
|
||||
$this->actingAs($this->admin);
|
||||
|
||||
Volt::test('admin.pages.edit', ['slug' => 'terms'])
|
||||
->set('content_en', '<p>Updated content</p>')
|
||||
->call('save');
|
||||
|
||||
expect($page->fresh()->updated_at)->toBeGreaterThan($originalTimestamp);
|
||||
});
|
||||
|
||||
test('html content is sanitized on save', function () {
|
||||
$this->actingAs($this->admin);
|
||||
|
||||
Volt::test('admin.pages.edit', ['slug' => 'terms'])
|
||||
->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('admin log created on page update', function () {
|
||||
$page = Page::where('slug', 'terms')->first();
|
||||
|
||||
$this->actingAs($this->admin);
|
||||
|
||||
Volt::test('admin.pages.edit', ['slug' => 'terms'])
|
||||
->set('content_en', '<p>Updated content</p>')
|
||||
->call('save');
|
||||
|
||||
expect(AdminLog::where('action', 'update')
|
||||
->where('target_type', 'page')
|
||||
->where('target_id', $page->id)
|
||||
->exists())->toBeTrue();
|
||||
});
|
||||
|
||||
test('language tabs switch correctly', function () {
|
||||
$this->actingAs($this->admin);
|
||||
|
||||
Volt::test('admin.pages.edit', ['slug' => 'terms'])
|
||||
->assertSet('activeTab', 'ar')
|
||||
->call('setTab', 'en')
|
||||
->assertSet('activeTab', 'en')
|
||||
->call('setTab', 'ar')
|
||||
->assertSet('activeTab', 'ar');
|
||||
});
|
||||
|
||||
test('empty content can be saved', function () {
|
||||
$this->actingAs($this->admin);
|
||||
|
||||
Volt::test('admin.pages.edit', ['slug' => 'terms'])
|
||||
->set('content_ar', '')
|
||||
->set('content_en', '')
|
||||
->call('save')
|
||||
->assertHasNoErrors();
|
||||
|
||||
$page = Page::where('slug', 'terms')->first();
|
||||
expect($page->content_ar)->toBe('');
|
||||
expect($page->content_en)->toBe('');
|
||||
});
|
||||
|
||||
// ===========================================
|
||||
// Public Page Tests
|
||||
// ===========================================
|
||||
|
||||
test('public can view terms page', function () {
|
||||
Page::where('slug', 'terms')->update(['content_en' => '<p>Our terms</p>']);
|
||||
|
||||
$this->withSession(['locale' => 'en'])
|
||||
->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->withSession(['locale' => 'en'])
|
||||
->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('legacy terms route redirects to new route', function () {
|
||||
$this->get('/terms')
|
||||
->assertRedirect('/page/terms');
|
||||
});
|
||||
|
||||
test('legacy privacy route redirects to new route', function () {
|
||||
$this->get('/privacy')
|
||||
->assertRedirect('/page/privacy');
|
||||
});
|
||||
|
||||
test('public page displays content in correct language based on locale', function () {
|
||||
Page::where('slug', 'terms')->update([
|
||||
'content_ar' => '<p>شروط الخدمة</p>',
|
||||
'content_en' => '<p>Terms of Service</p>',
|
||||
]);
|
||||
|
||||
// Test English
|
||||
$this->withSession(['locale' => 'en'])
|
||||
->get('/page/terms')
|
||||
->assertSee('Terms of Service');
|
||||
|
||||
// Test Arabic
|
||||
$this->withSession(['locale' => 'ar'])
|
||||
->get('/page/terms')
|
||||
->assertSee('شروط الخدمة');
|
||||
});
|
||||
|
||||
// ===========================================
|
||||
// Authorization Tests
|
||||
// ===========================================
|
||||
|
||||
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()->individual()->create();
|
||||
|
||||
$this->actingAs($client)
|
||||
->get(route('admin.pages.index'))
|
||||
->assertForbidden();
|
||||
});
|
||||
|
||||
test('guest cannot access admin pages edit', function () {
|
||||
$this->get(route('admin.pages.edit', 'terms'))
|
||||
->assertRedirect(route('login'));
|
||||
});
|
||||
|
||||
test('client cannot access admin pages edit', function () {
|
||||
$client = User::factory()->individual()->create();
|
||||
|
||||
$this->actingAs($client)
|
||||
->get(route('admin.pages.edit', 'terms'))
|
||||
->assertForbidden();
|
||||
});
|
||||
|
||||
// ===========================================
|
||||
// Page Model Tests
|
||||
// ===========================================
|
||||
|
||||
test('page has localized title accessor', function () {
|
||||
$page = Page::where('slug', 'terms')->first();
|
||||
|
||||
app()->setLocale('ar');
|
||||
expect($page->getTitle())->toBe('شروط الخدمة');
|
||||
|
||||
app()->setLocale('en');
|
||||
expect($page->getTitle())->toBe('Terms of Service');
|
||||
});
|
||||
|
||||
test('page has localized content accessor', function () {
|
||||
Page::where('slug', 'terms')->update([
|
||||
'content_ar' => 'محتوى عربي',
|
||||
'content_en' => 'English content',
|
||||
]);
|
||||
|
||||
$page = Page::where('slug', 'terms')->first();
|
||||
|
||||
app()->setLocale('ar');
|
||||
expect($page->getContent())->toBe('محتوى عربي');
|
||||
|
||||
app()->setLocale('en');
|
||||
expect($page->getContent())->toBe('English content');
|
||||
});
|
||||
|
||||
test('page content falls back to arabic when english is empty', function () {
|
||||
Page::where('slug', 'terms')->update([
|
||||
'content_ar' => 'محتوى عربي',
|
||||
'content_en' => null,
|
||||
]);
|
||||
|
||||
$page = Page::where('slug', 'terms')->first();
|
||||
|
||||
app()->setLocale('en');
|
||||
// Falls back to Arabic content when English is null
|
||||
expect($page->getContent())->toBe('محتوى عربي');
|
||||
});
|
||||
Loading…
Reference in New Issue