Telepathic Collect: link-first, flattened hierarchy, embedded leaderboard

Core insight: The primary object is the LINK, not the appeal.
Aaisha doesn't think 'manage appeals' — she thinks 'share my link'.

## Collect page (/dashboard/collect) — complete rewrite
- Flattened hierarchy: single-appeal orgs see links directly (no card to click)
- Multi-appeal orgs: quiet appeal switcher at top, links below
- Inline link creation: just type a name + press Enter (no dialog)
- Quick preset buttons: 'Table 1', 'WhatsApp Group', 'Instagram', etc.
- Share buttons are THE primary CTA on every link card (Copy, WhatsApp, Email, Share)
- Each link shows: clicks, pledges, amount raised, conversion rate
- Embedded mini-leaderboard when 3+ links have pledges
- Contextual tips when pledges < 5 ('give each volunteer their own link')
- New appeal creation is inline, auto-creates 'Main link'

## Appeal detail page (/dashboard/events/[id]) — brand redesign
- Sharp edges, gap-px grids, typography-as-hero
- Same link card component with share-first design
- Embedded leaderboard section
- Inline link creation (same as Collect)
- Clone appeal button
- Appeal details in collapsed <details> (context, not hero)
- Download all QR codes link
- Public progress page link

## Leaderboard page — brand redesign
- Total raised as hero number (dark section)
- Progress bars relative to leader
- Medal badges for top 3
- Conversion rate badges
- Auto-refresh every 10 seconds (live event mode)

## Route cleanup
- /dashboard/events re-exports /dashboard/collect (backward compat)
- Old events/page.tsx removed (was duplicate)

5 files changed, 3 pages redesigned
This commit is contained in:
2026-03-04 21:13:32 +08:00
parent 6fb97e1461
commit a9b3b70dfc
14 changed files with 1680 additions and 556 deletions

View File

@@ -0,0 +1,50 @@
<?php
namespace App\Filament\Widgets;
use App\Models\Donation;
use Filament\Widgets\ChartWidget;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Carbon;
class DonationChartWidget extends ChartWidget
{
protected static ?string $heading = 'Daily Donations — Last 30 Days';
protected static ?int $sort = 2;
protected int | string | array $columnSpan = 'full';
protected static ?string $maxHeight = '220px';
protected function getData(): array
{
$confirmedScope = fn (Builder $q) => $q->whereNotNull('confirmed_at');
$days = collect(range(29, 0))->map(fn ($i) => now()->subDays($i)->format('Y-m-d'));
$donations = Donation::whereHas('donationConfirmation', $confirmedScope)
->where('created_at', '>=', now()->subDays(30)->startOfDay())
->selectRaw('DATE(created_at) as date, SUM(amount) / 100 as total')
->groupByRaw('DATE(created_at)')
->pluck('total', 'date');
return [
'datasets' => [
[
'label' => 'Revenue (£)',
'data' => $days->map(fn ($d) => round($donations[$d] ?? 0, 2))->toArray(),
'borderColor' => '#10b981',
'backgroundColor' => 'rgba(16, 185, 129, 0.1)',
'fill' => true,
'tension' => 0.3,
],
],
'labels' => $days->map(fn ($d) => Carbon::parse($d)->format('d M'))->toArray(),
];
}
protected function getType(): string
{
return 'line';
}
}

View File

@@ -0,0 +1,85 @@
<?php
namespace App\Filament\Widgets;
use App\Models\Donation;
use App\Models\ScheduledGivingDonation;
use Filament\Widgets\StatsOverviewWidget;
use Filament\Widgets\StatsOverviewWidget\Stat;
use Illuminate\Database\Eloquent\Builder;
class DonationStatsWidget extends StatsOverviewWidget
{
protected static ?int $sort = 1;
protected int | string | array $columnSpan = 'full';
protected function getStats(): array
{
$confirmedScope = fn (Builder $q) => $q->whereNotNull('confirmed_at');
// Today
$todayQuery = Donation::whereHas('donationConfirmation', $confirmedScope)
->whereDate('created_at', today());
$todayCount = $todayQuery->count();
$todayRevenue = $todayQuery->sum('amount') / 100;
// This week
$weekQuery = Donation::whereHas('donationConfirmation', $confirmedScope)
->where('created_at', '>=', now()->startOfWeek());
$weekRevenue = $weekQuery->sum('amount') / 100;
// This month
$monthQuery = Donation::whereHas('donationConfirmation', $confirmedScope)
->where('created_at', '>=', now()->startOfMonth());
$monthCount = $monthQuery->count();
$monthRevenue = $monthQuery->sum('amount') / 100;
// Last month for trend
$lastMonthRevenue = Donation::whereHas('donationConfirmation', $confirmedScope)
->whereBetween('created_at', [now()->subMonth()->startOfMonth(), now()->subMonth()->endOfMonth()])
->sum('amount') / 100;
$monthTrend = $lastMonthRevenue > 0
? round(($monthRevenue - $lastMonthRevenue) / $lastMonthRevenue * 100, 1)
: null;
// Failed/incomplete donations today (people who tried but didn't complete)
$incompleteToday = Donation::whereDoesntHave('donationConfirmation', $confirmedScope)
->whereDate('created_at', today())
->count();
// Monthly supporters
$activeSG = ScheduledGivingDonation::where('is_active', true)->count();
// Zakat this month
$zakatMonth = Donation::whereHas('donationConfirmation', $confirmedScope)
->where('created_at', '>=', now()->startOfMonth())
->whereHas('donationPreferences', fn ($q) => $q->where('is_zakat', true))
->sum('amount') / 100;
return [
Stat::make("Today's Donations", '£' . number_format($todayRevenue, 0))
->description($todayCount . ' donations received' . ($incompleteToday > 0 ? " · {$incompleteToday} incomplete" : ''))
->descriptionIcon($incompleteToday > 0 ? 'heroicon-m-exclamation-triangle' : 'heroicon-m-check-circle')
->color($incompleteToday > 5 ? 'warning' : 'success'),
Stat::make('This Month', '£' . number_format($monthRevenue, 0))
->description(
$monthCount . ' donations' .
($monthTrend !== null ? ' · ' . ($monthTrend >= 0 ? '↑' : '↓') . abs($monthTrend) . '% vs last month' : '')
)
->descriptionIcon('heroicon-m-arrow-trending-up')
->color($monthTrend !== null && $monthTrend >= 0 ? 'success' : 'warning'),
Stat::make('Monthly Supporters', number_format($activeSG))
->description('People giving every month')
->descriptionIcon('heroicon-m-heart')
->color('success'),
Stat::make('Zakat This Month', '£' . number_format($zakatMonth, 0))
->description('Zakat-eligible donations')
->descriptionIcon('heroicon-m-star')
->color('info'),
];
}
}

127
temp_files/EditCustomer.php Normal file
View File

@@ -0,0 +1,127 @@
<?php
namespace App\Filament\Resources\CustomerResource\Pages;
use App\Filament\Resources\CustomerResource;
use App\Helpers;
use App\Models\Customer;
use App\Models\Donation;
use Filament\Actions\Action;
use Filament\Forms\Components\Textarea;
use Filament\Notifications\Notification;
use Filament\Resources\Pages\EditRecord;
use Filament\Infolists\Components\Grid;
use Filament\Infolists\Components\Section;
use Filament\Infolists\Components\TextEntry;
use Filament\Infolists\Components\RepeatableEntry;
use Illuminate\Support\HtmlString;
class EditCustomer extends EditRecord
{
protected static string $resource = CustomerResource::class;
public function getHeading(): string|HtmlString
{
$customer = $this->record;
$total = $customer->donations()
->whereHas('donationConfirmation', fn ($q) => $q->whereNotNull('confirmed_at'))
->sum('amount') / 100;
$giftAid = $customer->donations()
->whereHas('donationPreferences', fn ($q) => $q->where('is_gift_aid', true))
->exists();
$badges = '';
if ($total >= 1000) {
$badges .= ' <span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-amber-100 text-amber-800 ml-2">⭐ Major Donor</span>';
}
if ($giftAid) {
$badges .= ' <span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800 ml-2">Gift Aid</span>';
}
$sg = $customer->scheduledGivingDonations()->where('is_active', true)->count();
if ($sg > 0) {
$badges .= ' <span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800 ml-2">Monthly Supporter</span>';
}
return new HtmlString($customer->name . $badges);
}
public function getSubheading(): ?string
{
$customer = $this->record;
$total = $customer->donations()
->whereHas('donationConfirmation', fn ($q) => $q->whereNotNull('confirmed_at'))
->sum('amount') / 100;
$count = $customer->donations()
->whereHas('donationConfirmation', fn ($q) => $q->whereNotNull('confirmed_at'))
->count();
$first = $customer->donations()->oldest()->first();
$since = $first ? $first->created_at->format('M Y') : 'N/A';
return "£" . number_format($total, 2) . " donated across {$count} donations · Supporter since {$since}";
}
protected function getHeaderActions(): array
{
$customer = $this->record;
return [
Action::make('add_note')
->label('Add Note')
->icon('heroicon-o-chat-bubble-left-ellipsis')
->color('gray')
->form([
Textarea::make('body')
->label('Note')
->placeholder("e.g. Called on " . now()->format('d M') . " — wants to update their address")
->required()
->rows(3),
])
->action(function (array $data) use ($customer) {
$customer->internalNotes()->create([
'user_id' => auth()->id(),
'body' => $data['body'],
]);
Notification::make()->title('Note added')->success()->send();
}),
Action::make('resend_last_receipt')
->label('Resend Last Receipt')
->icon('heroicon-o-envelope')
->color('info')
->requiresConfirmation()
->modalDescription('This will email the most recent donation receipt to ' . $customer->email)
->visible(fn () => $customer->donations()->whereHas('donationConfirmation', fn ($q) => $q->whereNotNull('confirmed_at'))->exists())
->action(function () use ($customer) {
$donation = $customer->donations()
->whereHas('donationConfirmation', fn ($q) => $q->whereNotNull('confirmed_at'))
->latest()
->first();
if ($donation) {
try {
\Illuminate\Support\Facades\Mail::to($customer->email)
->send(new \App\Mail\DonationConfirmed($donation));
Notification::make()->title('Receipt sent to ' . $customer->email)->success()->send();
} catch (\Throwable $e) {
Notification::make()->title('Failed to send receipt')->body($e->getMessage())->danger()->send();
}
}
}),
Action::make('view_in_stripe')
->label('View in Stripe')
->icon('heroicon-o-arrow-top-right-on-square')
->color('gray')
->url(function () use ($customer) {
$donation = $customer->donations()->whereNotNull('provider_reference')->latest()->first();
if ($donation && $donation->provider_reference) {
return 'https://dashboard.stripe.com/search?query=' . urlencode($customer->email);
}
return 'https://dashboard.stripe.com/search?query=' . urlencode($customer->email);
})
->openUrlInNewTab(),
];
}
}

View File

@@ -0,0 +1,21 @@
<?php
namespace App\Filament\Resources\AppealResource\Pages;
use App\Filament\Resources\AppealResource;
use Filament\Resources\Pages\ListRecords;
class ListAppeals extends ListRecords
{
protected static string $resource = AppealResource::class;
public function getHeading(): string
{
return 'All Fundraisers';
}
public function getSubheading(): string
{
return 'Every fundraising page on the site — both public supporter pages and charity campaigns. Search by name to find one.';
}
}

View File

@@ -0,0 +1,21 @@
<?php
namespace App\Filament\Resources\ApprovalQueueResource\Pages;
use App\Filament\Resources\ApprovalQueueResource;
use Filament\Resources\Pages\ListRecords;
class ListApprovalQueues extends ListRecords
{
protected static string $resource = ApprovalQueueResource::class;
public function getHeading(): string
{
return 'Fundraiser Review Queue';
}
public function getSubheading(): string
{
return 'New fundraising pages that need your approval before going live. Use "AI Review All" to process in bulk, then approve or reject.';
}
}

View File

@@ -0,0 +1,26 @@
<?php
namespace App\Filament\Resources\CustomerResource\Pages;
use App\Filament\Resources\CustomerResource;
use Filament\Resources\Pages\ListRecords;
class ListCustomers extends ListRecords
{
protected static string $resource = CustomerResource::class;
public function getHeading(): string
{
return 'Donor Lookup';
}
public function getSubheading(): string
{
return 'Search by name, email, or phone to find a donor. Click a donor to see their full history, send receipts, or add notes.';
}
protected function getHeaderActions(): array
{
return [];
}
}

View File

@@ -0,0 +1,26 @@
<?php
namespace App\Filament\Resources\DonationResource\Pages;
use App\Filament\Resources\DonationResource;
use Filament\Resources\Pages\ListRecords;
class ListDonations extends ListRecords
{
protected static string $resource = DonationResource::class;
public function getHeading(): string
{
return 'All Donations';
}
public function getSubheading(): string
{
return 'Every one-off and monthly donation received. Use filters to narrow by date, cause, or status.';
}
protected function getHeaderActions(): array
{
return [];
}
}

View File

@@ -0,0 +1,62 @@
<?php
namespace App\Filament\Widgets;
use App\Models\ApprovalQueue;
use App\Models\Donation;
use App\Models\EventLog;
use App\Models\ScheduledGivingDonation;
use Filament\Widgets\StatsOverviewWidget;
use Filament\Widgets\StatsOverviewWidget\Stat;
class OperationalHealthWidget extends StatsOverviewWidget
{
protected static ?int $sort = 3;
protected int | string | array $columnSpan = 'full';
protected function getStats(): array
{
// Fundraisers waiting for review
$pendingReview = ApprovalQueue::where('status', 'pending')->count();
// Incomplete donations in last 7 days (people who tried to give but something went wrong)
$incomplete7d = Donation::whereDoesntHave('donationConfirmation', fn ($q) => $q->whereNotNull('confirmed_at'))
->where('created_at', '>=', now()->subDays(7))
->count();
// Regular giving that recently stopped
$cancelledSG = ScheduledGivingDonation::where('is_active', false)
->where('updated_at', '>=', now()->subDays(30))
->count();
// System errors
$errorsToday = EventLog::whereDate('created_at', today())
->whereIn('status', ['failed', 'exception'])
->count();
return [
Stat::make('Fundraisers to Review', $pendingReview)
->description($pendingReview > 0 ? 'People are waiting for approval' : 'All caught up!')
->descriptionIcon($pendingReview > 0 ? 'heroicon-m-clock' : 'heroicon-m-check-circle')
->color($pendingReview > 20 ? 'danger' : ($pendingReview > 0 ? 'warning' : 'success'))
->url(route('filament.admin.resources.approval-queues.index')),
Stat::make('Incomplete Donations', $incomplete7d)
->description('Last 7 days — people tried but payment didn\'t complete')
->descriptionIcon('heroicon-m-exclamation-triangle')
->color($incomplete7d > 50 ? 'danger' : ($incomplete7d > 0 ? 'warning' : 'success')),
Stat::make('Cancelled Subscriptions', $cancelledSG)
->description('Monthly supporters who stopped in last 30 days')
->descriptionIcon($cancelledSG > 0 ? 'heroicon-m-arrow-down' : 'heroicon-m-check-circle')
->color($cancelledSG > 5 ? 'warning' : 'success'),
Stat::make('System Problems', $errorsToday)
->description($errorsToday > 0 ? 'Errors today — check activity log' : 'No problems today')
->descriptionIcon($errorsToday > 0 ? 'heroicon-m-x-circle' : 'heroicon-m-check-circle')
->color($errorsToday > 0 ? 'danger' : 'success')
->url(route('filament.admin.resources.event-logs.index')),
];
}
}

View File

@@ -0,0 +1,86 @@
<?php
namespace App\Filament\Widgets;
use App\Filament\Resources\CustomerResource;
use App\Models\Donation;
use Filament\Tables\Actions\Action;
use Filament\Tables\Columns\IconColumn;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table;
use Filament\Widgets\TableWidget as BaseWidget;
class RecentDonationsWidget extends BaseWidget
{
protected static ?int $sort = 4;
protected int | string | array $columnSpan = 'full';
protected static ?string $heading = 'Latest Donations';
protected int $defaultPaginationPageOption = 5;
public function table(Table $table): Table
{
return $table
->query(
Donation::query()
->with(['customer', 'donationType', 'donationConfirmation', 'appeal'])
->latest('created_at')
)
->columns([
IconColumn::make('is_confirmed')
->label('')
->boolean()
->getStateUsing(fn (Donation $r) => $r->isConfirmed())
->trueIcon('heroicon-o-check-circle')
->falseIcon('heroicon-o-x-circle')
->trueColor('success')
->falseColor('danger'),
TextColumn::make('customer.name')
->label('Donor')
->description(fn (Donation $d) => $d->customer?->email)
->searchable(query: function (\Illuminate\Database\Eloquent\Builder $query, string $search) {
$query->whereHas('customer', fn ($q) => $q
->where('first_name', 'like', "%{$search}%")
->orWhere('last_name', 'like', "%{$search}%")
->orWhere('email', 'like', "%{$search}%")
);
}),
TextColumn::make('amount')
->label('Amount')
->money('gbp', divideBy: 100)
->sortable()
->weight('bold'),
TextColumn::make('donationType.display_name')
->label('Cause')
->badge()
->color('success')
->limit(20),
TextColumn::make('appeal.name')
->label('Fundraiser')
->limit(20)
->placeholder('Direct donation'),
TextColumn::make('created_at')
->label('When')
->since()
->sortable(),
])
->actions([
Action::make('view_donor')
->label('View Donor')
->icon('heroicon-o-user')
->url(fn (Donation $d) => $d->customer_id
? CustomerResource::getUrl('edit', ['record' => $d->customer_id])
: null)
->visible(fn (Donation $d) => (bool) $d->customer_id),
])
->paginated([5, 10])
->defaultSort('created_at', 'desc');
}
}