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

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(),
];
}
}