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:
2026-03-04 21:01:16 +08:00
parent 170a2e7c68
commit 6fb97e1461
6 changed files with 1254 additions and 293 deletions

View 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
}
}