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
128 lines
5.4 KiB
PHP
128 lines
5.4 KiB
PHP
<?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(),
|
|
];
|
|
}
|
|
}
|