Telepathic onboarding: welcome flow + context-aware dashboard
New: /dashboard/welcome — guided first-time setup - Step 1: 'What are you raising for?' (starts with what excites them) - Step 2: 'Where should donors send money?' (natural follow-up) - Step 3: 'Want auto-reminders?' (WhatsApp as bonus, skippable) - Step 4: 'Here's your link!' (dark section with copy/WhatsApp/share) - Auto-creates event + first pledge link during flow - User holds a shareable link within 90 seconds of signing up Updated: /dashboard (context-aware home) - State 1 (no events): auto-redirects to /dashboard/welcome - State 2 (0 pledges): shows pledge link + share buttons prominently - State 3 (has pledges): shows stats + feed - State 4 (has 'said paid'): amber prompt to upload bank statement - State 5 (100% collected): celebration banner - No more onboarding checklist — dashboard adapts instead - Event name as page header (not generic 'Home') - Event switcher for multi-event orgs Updated: /signup → redirects to /dashboard/welcome (not /dashboard) Persona spec: docs/PERSONA_JOURNEY_SPEC.md
This commit is contained in:
@@ -61,7 +61,7 @@ class AIAppealReviewService
|
||||
} elseif ($result['decision'] === 'reject') {
|
||||
$item->update([
|
||||
'status' => 'change_requested',
|
||||
'message' => 'Auto-flagged: ' . implode('; ', $result['reasons']),
|
||||
'message' => substr('Auto-flagged: ' . implode('; ', $result['reasons']), 0, 250),
|
||||
]);
|
||||
|
||||
$appeal = $item->appeal;
|
||||
|
||||
339
temp_files/EditApprovalQueue.php
Normal file
339
temp_files/EditApprovalQueue.php
Normal file
@@ -0,0 +1,339 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\ApprovalQueueResource\Pages;
|
||||
|
||||
use App\Filament\Resources\AppealResource;
|
||||
use App\Filament\Resources\ApprovalQueueResource;
|
||||
use App\Services\AIAppealReviewService;
|
||||
use App\Services\ApprovalQueueService;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Forms\Components\Fieldset;
|
||||
use Filament\Forms\Components\Placeholder;
|
||||
use Filament\Forms\Components\RichEditor;
|
||||
use Filament\Forms\Components\Section;
|
||||
use Filament\Forms\Components\Textarea;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Forms\Components\Toggle;
|
||||
use Filament\Forms\Form;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Resources\Pages\EditRecord;
|
||||
use Illuminate\Support\HtmlString;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class EditApprovalQueue extends EditRecord
|
||||
{
|
||||
protected static string $resource = ApprovalQueueResource::class;
|
||||
|
||||
public function form(Form $form): Form
|
||||
{
|
||||
return $form
|
||||
->schema([
|
||||
// AI Review Summary (if available)
|
||||
Section::make('AI Review')
|
||||
->icon('heroicon-o-sparkles')
|
||||
->description('Automated compliance check results')
|
||||
->schema([
|
||||
Placeholder::make('ai_summary')
|
||||
->label('')
|
||||
->content(function () {
|
||||
$extra = json_decode($this->record->extra_data ?? '{}', true);
|
||||
$ai = $extra['ai_review'] ?? null;
|
||||
|
||||
if (!$ai) {
|
||||
return new HtmlString(
|
||||
'<div class="rounded-lg bg-gray-50 p-4 text-gray-500 text-sm">' .
|
||||
'<p>No AI review has been run yet. Click "Run AI Review" above to check this fundraiser.</p>' .
|
||||
'</div>'
|
||||
);
|
||||
}
|
||||
|
||||
$decision = $ai['decision'] ?? 'unknown';
|
||||
$confidence = round(($ai['confidence'] ?? 0) * 100);
|
||||
$summary = $ai['summary'] ?? '';
|
||||
$reasons = $ai['reasons'] ?? [];
|
||||
$flags = $ai['flags'] ?? [];
|
||||
|
||||
$colorMap = ['approve' => 'green', 'reject' => 'red', 'review' => 'amber'];
|
||||
$iconMap = ['approve' => '✓', 'reject' => '✗', 'review' => '?'];
|
||||
$labelMap = ['approve' => 'Safe to Approve', 'reject' => 'Should Be Rejected', 'review' => 'Needs Your Judgment'];
|
||||
|
||||
$color = $colorMap[$decision] ?? 'gray';
|
||||
$icon = $iconMap[$decision] ?? '?';
|
||||
$label = $labelMap[$decision] ?? 'Unknown';
|
||||
|
||||
$html = "<div class='rounded-lg bg-{$color}-50 border border-{$color}-200 p-4'>";
|
||||
$html .= "<div class='flex items-center gap-3 mb-2'>";
|
||||
$html .= "<span class='text-2xl'>{$icon}</span>";
|
||||
$html .= "<div>";
|
||||
$html .= "<p class='font-semibold text-{$color}-800 text-lg'>{$label}</p>";
|
||||
$html .= "<p class='text-{$color}-600 text-sm'>Confidence: {$confidence}%</p>";
|
||||
$html .= "</div></div>";
|
||||
$html .= "<p class='text-gray-700 mt-2'>{$summary}</p>";
|
||||
|
||||
if (!empty($reasons)) {
|
||||
$html .= "<ul class='mt-2 space-y-1'>";
|
||||
foreach ($reasons as $reason) {
|
||||
$html .= "<li class='text-sm text-gray-600'>• {$reason}</li>";
|
||||
}
|
||||
$html .= "</ul>";
|
||||
}
|
||||
|
||||
if (!empty($flags)) {
|
||||
$html .= "<div class='mt-3 flex gap-2 flex-wrap'>";
|
||||
foreach ($flags as $flag) {
|
||||
$html .= "<span class='inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-{$color}-100 text-{$color}-700'>{$flag}</span>";
|
||||
}
|
||||
$html .= "</div>";
|
||||
}
|
||||
|
||||
$html .= "</div>";
|
||||
|
||||
return new HtmlString($html);
|
||||
}),
|
||||
])
|
||||
->collapsible(),
|
||||
|
||||
// Fundraiser Details
|
||||
Section::make('Fundraiser Details')
|
||||
->icon('heroicon-o-document-text')
|
||||
->description('What the supporter submitted')
|
||||
->schema([
|
||||
Fieldset::make('Basic Info')->schema([
|
||||
Placeholder::make('appeal_name')
|
||||
->label('Fundraiser Name')
|
||||
->content(fn () => $this->record->appeal?->name ?? '—'),
|
||||
|
||||
Placeholder::make('appeal_owner')
|
||||
->label('Created By')
|
||||
->content(fn () => ($this->record->appeal?->user?->name ?? '—') . ' (' . ($this->record->appeal?->user?->email ?? '') . ')'),
|
||||
|
||||
Placeholder::make('appeal_type')
|
||||
->label('Cause')
|
||||
->content(fn () => $this->record->appeal?->donationType?->display_name ?? '—'),
|
||||
|
||||
Placeholder::make('appeal_target')
|
||||
->label('Fundraising Goal')
|
||||
->content(fn () => '£' . number_format($this->record->appeal?->amount_to_raise ?? 0, 0)),
|
||||
|
||||
Placeholder::make('appeal_status')
|
||||
->label('Current Status')
|
||||
->content(function () {
|
||||
$status = $this->record->status;
|
||||
return match ($status) {
|
||||
'pending' => new HtmlString('<span class="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium bg-amber-100 text-amber-800">⏳ Waiting for Review</span>'),
|
||||
'confirmed' => new HtmlString('<span class="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium bg-green-100 text-green-800">✓ Approved</span>'),
|
||||
'change_requested' => new HtmlString('<span class="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium bg-red-100 text-red-800">✗ Changes Requested</span>'),
|
||||
default => ucfirst($status),
|
||||
};
|
||||
}),
|
||||
|
||||
Placeholder::make('submission_type')
|
||||
->label('Submission Type')
|
||||
->content(function () {
|
||||
return match ($this->record->action) {
|
||||
'Create' => 'Brand new fundraiser',
|
||||
'Update' => 'Editing an existing fundraiser',
|
||||
default => $this->record->action,
|
||||
};
|
||||
}),
|
||||
])->columns(3),
|
||||
|
||||
Placeholder::make('appeal_description')
|
||||
->label('Description')
|
||||
->content(fn () => $this->record->appeal?->description ?? '—')
|
||||
->columnSpanFull(),
|
||||
|
||||
Placeholder::make('appeal_story')
|
||||
->label('Story')
|
||||
->content(fn () => new HtmlString(
|
||||
'<div class="prose max-w-none">' . ($this->record->appeal?->story ?? '<em>No story provided</em>') . '</div>'
|
||||
))
|
||||
->columnSpanFull(),
|
||||
|
||||
Placeholder::make('appeal_image')
|
||||
->label('Cover Image')
|
||||
->content(function () {
|
||||
$url = $this->record->appeal?->getPictureUrl();
|
||||
if (!$url) return new HtmlString('<em>No image</em>');
|
||||
return new HtmlString(
|
||||
"<img src='{$url}' style='max-width: 400px; border-radius: 8px; border: 1px solid #e5e7eb;' />"
|
||||
);
|
||||
})
|
||||
->columnSpanFull(),
|
||||
])
|
||||
->collapsible(),
|
||||
|
||||
// What Changed (for updates only)
|
||||
Section::make('What Changed')
|
||||
->icon('heroicon-o-pencil-square')
|
||||
->description('Fields modified since the last approved version')
|
||||
->visible(fn () => $this->record->action === 'Update')
|
||||
->schema([
|
||||
Placeholder::make('changes_list')
|
||||
->label('')
|
||||
->content(function () {
|
||||
$changes = json_decode($this->record->extra_data ?? '[]', true);
|
||||
// Handle nested ai_review structure
|
||||
if (isset($changes['previous_extra_data'])) {
|
||||
$changes = json_decode($changes['previous_extra_data'] ?? '[]', true);
|
||||
}
|
||||
if (!is_array($changes) || empty($changes)) {
|
||||
return 'No specific changes recorded.';
|
||||
}
|
||||
|
||||
$friendlyNames = [
|
||||
'name' => 'Fundraiser name',
|
||||
'description' => 'Description',
|
||||
'story' => 'Story content',
|
||||
'picture' => 'Cover image',
|
||||
'amount_to_raise' => 'Fundraising goal',
|
||||
'donation_type_id' => 'Cause',
|
||||
'donation_country_id' => 'Country',
|
||||
'is_in_memory' => 'In memory setting',
|
||||
'in_memory_name' => 'Memorial name',
|
||||
'is_visible' => 'Visibility',
|
||||
'is_team_campaign' => 'Team campaign setting',
|
||||
'is_accepting_members' => 'Member acceptance',
|
||||
'is_custom_story' => 'Custom story setting',
|
||||
'expires_at' => 'End date',
|
||||
'parent_appeal_id' => 'Parent fundraiser',
|
||||
];
|
||||
|
||||
$html = '<ul class="space-y-1">';
|
||||
foreach ($changes as $field) {
|
||||
if (is_string($field)) {
|
||||
$label = $friendlyNames[$field] ?? ucfirst(str_replace('_', ' ', $field));
|
||||
$html .= "<li class='text-sm'>• <strong>{$label}</strong> was modified</li>";
|
||||
}
|
||||
}
|
||||
$html .= '</ul>';
|
||||
|
||||
return new HtmlString($html);
|
||||
}),
|
||||
])
|
||||
->collapsible()
|
||||
->collapsed(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function getHeading(): string
|
||||
{
|
||||
return 'Review: ' . ($this->record->appeal?->name ?? 'Unknown Fundraiser');
|
||||
}
|
||||
|
||||
public function getSubheading(): string
|
||||
{
|
||||
return match ($this->record->status) {
|
||||
'pending' => 'This fundraiser is waiting for your review. Check the details below and approve or request changes.',
|
||||
'confirmed' => 'This fundraiser has been approved and is live on the website.',
|
||||
'change_requested' => 'Changes have been requested. The fundraiser creator has been notified.',
|
||||
default => '',
|
||||
};
|
||||
}
|
||||
|
||||
protected function mutateFormDataBeforeFill(array $data): array
|
||||
{
|
||||
return $data;
|
||||
}
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
Action::make('ai_review')
|
||||
->label('Run AI Review')
|
||||
->icon('heroicon-o-sparkles')
|
||||
->color('info')
|
||||
->visible(fn () => $this->record->status === 'pending')
|
||||
->action(function () {
|
||||
$service = app(AIAppealReviewService::class);
|
||||
$result = $service->review($this->record->appeal);
|
||||
|
||||
$this->record->update([
|
||||
'extra_data' => json_encode([
|
||||
'ai_review' => $result,
|
||||
'previous_extra_data' => $this->record->extra_data,
|
||||
]),
|
||||
]);
|
||||
|
||||
$decisionLabels = [
|
||||
'approve' => 'AI recommends APPROVING',
|
||||
'reject' => 'AI recommends REJECTING',
|
||||
'review' => 'AI is UNCERTAIN — needs your judgment',
|
||||
];
|
||||
|
||||
Notification::make()
|
||||
->title($decisionLabels[$result['decision']] ?? 'Review complete')
|
||||
->body($result['summary'])
|
||||
->color(match ($result['decision']) {
|
||||
'approve' => 'success',
|
||||
'reject' => 'danger',
|
||||
default => 'warning',
|
||||
})
|
||||
->persistent()
|
||||
->send();
|
||||
|
||||
$this->fillForm();
|
||||
}),
|
||||
|
||||
Action::make('approve')
|
||||
->label('Approve Fundraiser')
|
||||
->icon('heroicon-o-check-circle')
|
||||
->color('success')
|
||||
->requiresConfirmation()
|
||||
->modalHeading('Approve this fundraiser?')
|
||||
->modalDescription(fn () => "This will make \"{$this->record->appeal?->name}\" visible on the CharityRight website. The fundraiser creator will be notified by email.")
|
||||
->modalSubmitActionLabel('Yes, Approve')
|
||||
->visible(fn () => $this->record->status === 'pending')
|
||||
->action(function () {
|
||||
app(ApprovalQueueService::class)->approveAppeal($this->record);
|
||||
|
||||
Notification::make()
|
||||
->title('Fundraiser approved! 🎉')
|
||||
->body('The fundraiser is now live and the creator has been notified.')
|
||||
->success()
|
||||
->send();
|
||||
|
||||
return redirect()->route('filament.admin.resources.approval-queues.index');
|
||||
}),
|
||||
|
||||
Action::make('request_changes')
|
||||
->label('Request Changes')
|
||||
->icon('heroicon-o-pencil-square')
|
||||
->color('warning')
|
||||
->visible(fn () => $this->record->status === 'pending')
|
||||
->form([
|
||||
Textarea::make('message')
|
||||
->label('What needs to change?')
|
||||
->placeholder("Tell the fundraiser creator what to fix. Be specific and friendly.\n\nExample: \"Your story mentions a travel company — fundraisers must be about charitable causes only. Please rewrite your story to focus on the people you're helping.\"")
|
||||
->required()
|
||||
->rows(4),
|
||||
])
|
||||
->action(function (array $data) {
|
||||
$this->record->update(['message' => $data['message']]);
|
||||
app(ApprovalQueueService::class)->requestChange($this->record);
|
||||
|
||||
Notification::make()
|
||||
->title('Change request sent')
|
||||
->body('The fundraiser creator has been notified and asked to make changes.')
|
||||
->warning()
|
||||
->send();
|
||||
|
||||
return redirect()->route('filament.admin.resources.approval-queues.index');
|
||||
}),
|
||||
|
||||
Action::make('view_appeal')
|
||||
->label('Open Fundraiser')
|
||||
->icon('heroicon-o-arrow-top-right-on-square')
|
||||
->color('gray')
|
||||
->url(fn () => $this->record->appeal_id
|
||||
? AppealResource::getUrl('edit', ['record' => $this->record->appeal_id])
|
||||
: null)
|
||||
->openUrlInNewTab(),
|
||||
];
|
||||
}
|
||||
|
||||
protected function getSavedNotification(): ?Notification
|
||||
{
|
||||
return null; // We handle notifications in our custom actions
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user