Add notification preferences and admin governance

This commit is contained in:
Codex
2026-07-10 18:33:39 -05:00
parent eb9456e1bd
commit de9e473d3b
28 changed files with 734 additions and 20 deletions
+60
View File
@@ -115,5 +115,65 @@
@endforelse
</div>
</section>
<section class="rounded border bg-white p-6">
<h3 class="font-semibold">Moderation reports</h3>
<p class="mt-1 text-sm text-gray-600">Reports are triage records for profiles, jobs, and attachments. They do not delete or hide content by themselves.</p>
<div class="mt-4 space-y-4">
@forelse ($moderationReports as $report)
<div class="rounded border bg-gray-50 p-4 text-sm">
<div class="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
<div>
<p class="font-semibold">{{ class_basename($report->reportable_type) }} #{{ $report->reportable_id }} | {{ \App\Models\ModerationReport::REASON_CODES[$report->reason_code] ?? $report->reason_code }}</p>
<p class="mt-1 text-gray-600">Reported by {{ $report->reporter->name }} | {{ $report->status }} | {{ $report->created_at->diffForHumans() }}</p>
<p class="mt-2 whitespace-pre-line text-gray-700">{{ $report->details }}</p>
</div>
<form method="POST" action="{{ route('moderation-reports.moderate', $report) }}" class="grid min-w-72 gap-2">
@csrf
@method('PATCH')
<label class="text-xs font-semibold text-gray-600" for="report_status_{{ $report->id }}">Report status</label>
<select id="report_status_{{ $report->id }}" name="status" class="rounded-md border-gray-300 text-sm">
@foreach (\App\Models\ModerationReport::STATUSES as $status)
<option value="{{ $status }}" @selected($report->status === $status)>{{ str_replace('_', ' ', ucfirst($status)) }}</option>
@endforeach
</select>
<textarea name="moderation_notes" rows="2" class="rounded-md border-gray-300 text-sm" placeholder="Moderation notes">{{ $report->moderation_notes }}</textarea>
<x-primary-button>Save report</x-primary-button>
</form>
</div>
</div>
@empty
<p class="text-sm text-gray-600">No moderation reports are waiting.</p>
@endforelse
</div>
</section>
<section class="rounded border bg-white p-6">
<h3 class="font-semibold">Recent audit log</h3>
<div class="mt-4 overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200 text-sm">
<thead>
<tr class="text-left text-xs uppercase text-gray-500">
<th class="py-2 pr-4">When</th>
<th class="py-2 pr-4">Actor</th>
<th class="py-2 pr-4">Action</th>
<th class="py-2 pr-4">Target</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100">
@forelse ($auditLogs as $log)
<tr>
<td class="py-2 pr-4">{{ $log->created_at->format('M j, g:i A') }}</td>
<td class="py-2 pr-4">{{ $log->actor?->name ?? 'System' }}</td>
<td class="py-2 pr-4">{{ str_replace('.', ' ', $log->action) }}</td>
<td class="py-2 pr-4">{{ $log->auditable_type ? class_basename($log->auditable_type).' #'.$log->auditable_id : 'n/a' }}</td>
</tr>
@empty
<tr><td colspan="4" class="py-3 text-gray-600">No audit records yet.</td></tr>
@endforelse
</tbody>
</table>
</div>
</section>
</div>
</x-app-layout>
+1
View File
@@ -65,6 +65,7 @@
<x-attachments :attachments="$profile->attachments" />
<x-rating-summary :ratings="$profile->ratings" />
<x-rating-form type="buyer_profile" :id="$profile->id" category="buyer_overall" />
<x-moderation-report-form type="buyer_profile" :id="$profile->id" />
</section>
</div>
</x-app-layout>
@@ -29,6 +29,7 @@
@if ($attachment->caption)
<p class="mt-2 text-slate-600 dark:text-slate-400">{{ $attachment->caption }}</p>
@endif
<x-moderation-report-form type="attachment" :id="$attachment->id" />
</div>
@endforeach
</div>
@@ -0,0 +1,22 @@
@props(['type', 'id'])
@auth
<details class="mt-4 rounded-md border border-slate-200 p-4 text-sm dark:border-slate-800">
<summary class="cursor-pointer font-semibold text-slate-950 dark:text-white">Report for moderation</summary>
<form method="POST" action="{{ route('moderation-reports.store') }}" class="mt-3 space-y-3">
@csrf
<input type="hidden" name="reportable_type" value="{{ $type }}">
<input type="hidden" name="reportable_id" value="{{ $id }}">
<div>
<label class="block text-sm font-medium text-slate-800 dark:text-slate-200">Reason</label>
<select name="reason_code" class="mt-1 block w-full rounded-md border-slate-300 bg-white text-slate-950 shadow-sm focus:border-sky-500 focus:ring-sky-500 dark:border-slate-700 dark:bg-slate-950 dark:text-slate-100">
@foreach (\App\Models\ModerationReport::REASON_CODES as $code => $label)
<option value="{{ $code }}">{{ $label }}</option>
@endforeach
</select>
</div>
<x-field name="details" label="Details" textarea />
<x-secondary-button type="submit">Submit report</x-secondary-button>
</form>
</details>
@endauth
+1
View File
@@ -86,6 +86,7 @@
<x-attachments :attachments="$job->attachments" />
<x-rating-summary :ratings="$job->ratings" />
<x-rating-form type="job_post" :id="$job->id" category="job_quality" />
<x-moderation-report-form type="job_post" :id="$job->id" />
@auth
@if (auth()->id() === $job->buyer_id)
<x-attachment-form type="job_post" :id="$job->id" kind="job" />
+80 -11
View File
@@ -1,27 +1,96 @@
<x-app-layout>
<x-slot name="header"><h2 class="text-xl font-semibold">Notifications</h2></x-slot>
<div class="mx-auto max-w-5xl space-y-4 p-6">
<form method="POST" action="{{ route('notifications.read-all') }}">
@csrf
<x-secondary-button>Mark all read</x-secondary-button>
</form>
<x-slot name="header">
<x-page-header
title="Notifications"
description="Control in-app alert categories now, and store explicit email/push intent for future delivery channels."
/>
</x-slot>
<div class="mx-auto max-w-5xl space-y-6 p-6">
<section class="tse-panel p-6">
<h3 class="font-semibold text-slate-950 dark:text-white">Alert preferences</h3>
<form method="POST" action="{{ route('notifications.preferences') }}" class="mt-4 space-y-5">
@csrf
@method('PATCH')
<div class="grid gap-3 md:grid-cols-3">
<label class="rounded-md border border-slate-200 p-3 text-sm dark:border-slate-800">
<input type="checkbox" name="in_app_enabled" value="1" @checked($preference->in_app_enabled) class="rounded border-slate-300 text-sky-600">
<span class="ml-2 font-semibold">In-app alerts</span>
</label>
<label class="rounded-md border border-slate-200 p-3 text-sm dark:border-slate-800">
<input type="checkbox" name="email_enabled" value="1" @checked($preference->email_enabled) class="rounded border-slate-300 text-sky-600">
<span class="ml-2 font-semibold">Email intent</span>
</label>
<label class="rounded-md border border-slate-200 p-3 text-sm dark:border-slate-800">
<input type="checkbox" name="push_enabled" value="1" @checked($preference->push_enabled) class="rounded border-slate-300 text-sky-600">
<span class="ml-2 font-semibold">Push intent</span>
</label>
</div>
<div class="grid gap-4 md:grid-cols-2">
<div>
<label for="digest_frequency" class="block text-sm font-medium text-slate-800 dark:text-slate-200">Digest preference</label>
<select id="digest_frequency" name="digest_frequency" class="mt-1 block w-full rounded-md border-slate-300 bg-white text-slate-950 shadow-sm focus:border-sky-500 focus:ring-sky-500 dark:border-slate-700 dark:bg-slate-950 dark:text-slate-100">
@foreach (['immediate' => 'Immediate', 'daily' => 'Daily digest', 'weekly' => 'Weekly digest'] as $value => $label)
<option value="{{ $value }}" @selected($preference->digest_frequency === $value)>{{ $label }}</option>
@endforeach
</select>
</div>
<div class="grid gap-3 sm:grid-cols-2">
<x-field name="quiet_hours_start" label="Quiet hours start" type="time" :value="$preference->quiet_hours_start" />
<x-field name="quiet_hours_end" label="Quiet hours end" type="time" :value="$preference->quiet_hours_end" />
</div>
</div>
<div>
<p class="text-sm font-semibold text-slate-800 dark:text-slate-200">Category controls</p>
<div class="mt-2 grid gap-3 md:grid-cols-2">
@foreach ($eventCategories as $field => $label)
<label class="rounded-md border border-slate-200 p-3 text-sm dark:border-slate-800">
<input type="checkbox" name="{{ $field }}" value="1" @checked($preference->{$field}) class="rounded border-slate-300 text-sky-600">
<span class="ml-2">{{ $label }}</span>
</label>
@endforeach
</div>
</div>
<details class="rounded-md border border-slate-200 p-4 dark:border-slate-800">
<summary class="cursor-pointer font-semibold text-slate-950 dark:text-white">Fine tune event types</summary>
<div class="mt-3 grid gap-3 md:grid-cols-2">
@foreach ($eventTypes as $event => $category)
<label class="rounded-md border border-slate-200 p-3 text-sm dark:border-slate-800">
<input type="checkbox" name="event_preferences[{{ $event }}]" value="1" @checked(($preference->event_preferences[$event] ?? $preference->{$category}) === true) class="rounded border-slate-300 text-sky-600">
<span class="ml-2">{{ str_replace('_', ' ', $event) }}</span>
</label>
@endforeach
</div>
</details>
<x-primary-button>Save preferences</x-primary-button>
</form>
</section>
<section class="tse-panel p-6">
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<h3 class="font-semibold text-slate-950 dark:text-white">Inbox</h3>
<form method="POST" action="{{ route('notifications.read-all') }}">
@csrf
<x-secondary-button>Mark all read</x-secondary-button>
</form>
</div>
@foreach ($notifications as $notification)
<div class="rounded border bg-white p-4 {{ $notification->read_at ? '' : 'border-indigo-400' }}">
<p class="font-semibold">{{ $notification->data['title'] ?? 'Notification' }}</p>
<p class="mt-1 text-sm text-gray-600">{{ $notification->data['body'] ?? '' }}</p>
<div class="mt-4 rounded-md border bg-white p-4 text-sm dark:bg-slate-900 {{ $notification->read_at ? 'border-slate-200 dark:border-slate-800' : 'border-sky-400' }}">
<p class="font-semibold text-slate-950 dark:text-white">{{ $notification->data['title'] ?? 'Notification' }}</p>
<p class="mt-1 text-slate-600 dark:text-slate-400">{{ $notification->data['body'] ?? '' }}</p>
<div class="mt-3 flex gap-3 text-sm">
@if (! empty($notification->data['url']))
<a class="text-indigo-600" href="{{ $notification->data['url'] }}">Open</a>
<a class="font-semibold text-sky-700 dark:text-sky-300" href="{{ $notification->data['url'] }}">Open</a>
@endif
@unless ($notification->read_at)
<form method="POST" action="{{ route('notifications.read', $notification->id) }}">
@csrf
<button class="text-gray-600" type="submit">Mark read</button>
<button class="text-slate-600 dark:text-slate-400" type="submit">Mark read</button>
</form>
@endunless
</div>
</div>
@endforeach
{{ $notifications->links() }}
</section>
</div>
</x-app-layout>
+1
View File
@@ -89,6 +89,7 @@
<x-attachments :attachments="$profile->attachments" />
<x-rating-summary :ratings="$profile->ratings" />
<x-rating-form type="provider_profile" :id="$profile->id" category="provider_overall" />
<x-moderation-report-form type="provider_profile" :id="$profile->id" />
</section>
<section class="tse-panel p-6">