Fundamental Collect redesign: unified creation, payment clarity, widget embed

THE CORE PROBLEM:
Users didn't understand the appeal→link hierarchy.
Payment method was hidden inside appeal creation.
The widget was a separate, undiscoverable concept.
External platforms (JustGiving, LaunchGood) felt disconnected.

THE FIX:

1. ONE CREATION FLOW for everything:
   Step 1: 'What are you raising for?' → creates the appeal
   Step 2: 'How will donors pay?' → 3 big clear cards:
     - Bank transfer (most popular, free)
     - External platform (JustGiving, LaunchGood, etc.)
     - Card payment (Stripe)
   Step 3: 'Name your link' → shows summary, creates both

2. PAYMENT METHOD VISIBLE ON EVERY LINK:
   Each link card shows a badge: 'Bank' or 'JustGiving' etc.
   External links show 'After pledging, donors are sent to...'
   No confusion about how money flows.

3. WIDGET IS A SHARING TAB, NOT A SEPARATE CONCEPT:
   Every link card expands to show 3 tabs:
     - Link (copy URL, WhatsApp, email, share)
     - QR Code (download PNG for printing)
     - Website Widget (iframe embed code with copy button)
   The widget is just another way to share the same link.

4. FLAT LINK LIST (not appeal→link hierarchy):
   All links shown in one flat list
   Appeal name shown as subtitle when multiple appeals exist
   'New link' adds to existing appeal
   'New appeal' uses the full 3-step wizard

5. EDUCATIONAL RIGHT COLUMN:
   'How it works' 5-step guide
   'Which payment method should I choose?' comparison
   'Can I mix payment methods?' FAQ
   'What's an appeal?' explanation (demystifies the concept)
   Leaderboard when 3+ links have pledges
This commit is contained in:
2026-03-05 04:00:14 +08:00
parent dc8e593849
commit c11bf4bea7
13 changed files with 2203 additions and 567 deletions

View File

@@ -0,0 +1,164 @@
<?php
namespace App\Filament\Resources\ScheduledGivingDonationResource\Pages;
use App\Filament\Resources\ScheduledGivingDonationResource;
use App\Models\ScheduledGivingCampaign;
use App\Models\ScheduledGivingDonation;
use Filament\Resources\Components\Tab;
use Filament\Resources\Pages\ListRecords;
use Illuminate\Database\Eloquent\Builder;
class ListScheduledGivingDonations extends ListRecords
{
protected static string $resource = ScheduledGivingDonationResource::class;
public function getHeading(): string
{
return 'Scheduled Giving';
}
public function getSubheading(): string
{
$current = $this->currentSeasonScope()->count();
return "{$current} subscribers this season.";
}
/** Real subscriber: has customer, has payments, amount > 0, not soft-deleted */
private function realScope(): Builder
{
return ScheduledGivingDonation::query()
->whereNotNull('customer_id')
->where('total_amount', '>', 0)
->whereNull('scheduled_giving_donations.deleted_at')
->whereHas('payments', fn ($q) => $q->whereNull('deleted_at'));
}
/** Current season: real + has at least one future payment */
private function currentSeasonScope(): Builder
{
return $this->realScope()
->whereHas('payments', fn ($q) => $q
->whereNull('deleted_at')
->where('expected_at', '>', now()));
}
/** Applies real + current season filters to the query */
private function applyCurrentSeason(Builder $q): Builder
{
return $q
->whereNotNull('customer_id')
->where('total_amount', '>', 0)
->whereNull('scheduled_giving_donations.deleted_at')
->whereHas('payments', fn ($sub) => $sub->whereNull('deleted_at'))
->whereHas('payments', fn ($sub) => $sub
->whereNull('deleted_at')
->where('expected_at', '>', now()));
}
/** Applies real + expired (no future payments) filters */
private function applyExpired(Builder $q): Builder
{
return $q
->whereNotNull('customer_id')
->where('total_amount', '>', 0)
->whereNull('scheduled_giving_donations.deleted_at')
->whereHas('payments', fn ($sub) => $sub->whereNull('deleted_at'))
->whereDoesntHave('payments', fn ($sub) => $sub
->whereNull('deleted_at')
->where('expected_at', '>', now()));
}
/** Applies real subscriber filters */
private function applyReal(Builder $q): Builder
{
return $q
->whereNotNull('customer_id')
->where('total_amount', '>', 0)
->whereNull('scheduled_giving_donations.deleted_at')
->whereHas('payments', fn ($sub) => $sub->whereNull('deleted_at'));
}
public function getTabs(): array
{
$campaigns = ScheduledGivingCampaign::all();
$currentCount = $this->currentSeasonScope()->count();
$tabs = [];
// Current season — the primary tab
$tabs['current'] = Tab::make('This Season')
->icon('heroicon-o-sun')
->badge($currentCount)
->badgeColor('success')
->modifyQueryUsing(fn (Builder $q) => $this->applyCurrentSeason($q));
// Per-campaign tabs for current season
foreach ($campaigns as $c) {
$slug = str($c->title)->slug()->toString();
$count = $this->currentSeasonScope()
->where('scheduled_giving_campaign_id', $c->id)
->count();
if ($count === 0) continue; // Skip campaigns with no current subscribers
$tabs[$slug] = Tab::make($c->title)
->icon('heroicon-o-calendar')
->badge($count)
->badgeColor('primary')
->modifyQueryUsing(fn (Builder $q) => $this->applyCurrentSeason($q)
->where('scheduled_giving_campaign_id', $c->id));
}
// Failed (current season only)
$failedCount = $this->currentSeasonScope()
->whereHas('payments', fn ($q) => $q
->where('is_paid', false)
->where('attempts', '>', 0)
->whereNull('deleted_at'))
->count();
if ($failedCount > 0) {
$tabs['failed'] = Tab::make('Failed')
->icon('heroicon-o-exclamation-triangle')
->badge($failedCount)
->badgeColor('danger')
->modifyQueryUsing(fn (Builder $q) => $this->applyCurrentSeason($q)
->whereHas('payments', fn ($sub) => $sub
->where('is_paid', false)
->where('attempts', '>', 0)
->whereNull('deleted_at')));
}
// Past seasons
$expiredCount = $this->realScope()
->whereDoesntHave('payments', fn ($q) => $q
->whereNull('deleted_at')
->where('expected_at', '>', now()))
->count();
$tabs['past'] = Tab::make('Past Seasons')
->icon('heroicon-o-archive-box')
->badge($expiredCount > 0 ? $expiredCount : null)
->badgeColor('gray')
->modifyQueryUsing(fn (Builder $q) => $this->applyExpired($q));
// All real
$tabs['all'] = Tab::make('All')
->icon('heroicon-o-squares-2x2')
->modifyQueryUsing(fn (Builder $q) => $this->applyReal($q));
return $tabs;
}
protected function getHeaderActions(): array
{
return [];
}
public function getDefaultActiveTab(): string|int|null
{
return 'current';
}
}