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:
288
temp_files/fix2/ScheduledGivingDashboard.php
Normal file
288
temp_files/fix2/ScheduledGivingDashboard.php
Normal file
@@ -0,0 +1,288 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Pages;
|
||||
|
||||
use App\Models\ScheduledGivingCampaign;
|
||||
use App\Models\ScheduledGivingDonation;
|
||||
use Filament\Pages\Page;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* Scheduled Giving Command Centre.
|
||||
*
|
||||
* Campaigns are seasonal (Ramadan). Each subscription belongs to a specific year.
|
||||
* "Current season" = has future payments. "Expired" = all payments in the past.
|
||||
* Only shows REAL subscribers (has customer + payments + amount > 0 + not soft-deleted).
|
||||
*/
|
||||
class ScheduledGivingDashboard extends Page
|
||||
{
|
||||
protected static ?string $navigationIcon = 'heroicon-o-calendar-days';
|
||||
|
||||
protected static ?string $navigationGroup = 'Giving';
|
||||
|
||||
protected static ?int $navigationSort = 0;
|
||||
|
||||
protected static ?string $navigationLabel = 'Dashboard';
|
||||
|
||||
protected static ?string $title = 'Scheduled Giving';
|
||||
|
||||
protected static string $view = 'filament.pages.scheduled-giving-dashboard';
|
||||
|
||||
/** Real subscriber IDs (has customer + payments + amount > 0 + not soft-deleted) */
|
||||
private function realSubscriberIds(?int $campaignId = null)
|
||||
{
|
||||
$q = DB::table('scheduled_giving_donations as d')
|
||||
->whereNotNull('d.customer_id')
|
||||
->where('d.total_amount', '>', 0)
|
||||
->whereNull('d.deleted_at')
|
||||
->whereExists(function ($sub) {
|
||||
$sub->select(DB::raw(1))
|
||||
->from('scheduled_giving_payments as p')
|
||||
->whereColumn('p.scheduled_giving_donation_id', 'd.id')
|
||||
->whereNull('p.deleted_at');
|
||||
});
|
||||
|
||||
if ($campaignId) {
|
||||
$q->where('d.scheduled_giving_campaign_id', $campaignId);
|
||||
}
|
||||
|
||||
return $q->pluck('d.id');
|
||||
}
|
||||
|
||||
/** IDs with at least one future payment = current season */
|
||||
private function currentSeasonIds(?int $campaignId = null)
|
||||
{
|
||||
$realIds = $this->realSubscriberIds($campaignId);
|
||||
if ($realIds->isEmpty()) return collect();
|
||||
|
||||
return DB::table('scheduled_giving_donations as d')
|
||||
->whereIn('d.id', $realIds)
|
||||
->whereExists(function ($sub) {
|
||||
$sub->select(DB::raw(1))
|
||||
->from('scheduled_giving_payments as p')
|
||||
->whereColumn('p.scheduled_giving_donation_id', 'd.id')
|
||||
->whereNull('p.deleted_at')
|
||||
->whereRaw('p.expected_at > NOW()');
|
||||
})
|
||||
->pluck('d.id');
|
||||
}
|
||||
|
||||
public function getCampaignData(): array
|
||||
{
|
||||
$campaigns = ScheduledGivingCampaign::all();
|
||||
$result = [];
|
||||
|
||||
foreach ($campaigns as $c) {
|
||||
$realIds = $this->realSubscriberIds($c->id);
|
||||
if ($realIds->isEmpty()) {
|
||||
$result[] = $this->emptyCampaign($c);
|
||||
continue;
|
||||
}
|
||||
|
||||
$currentIds = $this->currentSeasonIds($c->id);
|
||||
$expiredIds = $realIds->diff($currentIds);
|
||||
|
||||
// Current season payment stats
|
||||
$currentPayments = null;
|
||||
if ($currentIds->isNotEmpty()) {
|
||||
$currentPayments = DB::table('scheduled_giving_payments')
|
||||
->whereIn('scheduled_giving_donation_id', $currentIds)
|
||||
->whereNull('deleted_at')
|
||||
->selectRaw("
|
||||
COUNT(*) as total,
|
||||
SUM(is_paid = 1) as paid,
|
||||
SUM(is_paid = 0) as pending,
|
||||
SUM(is_paid = 0 AND attempts > 0) as failed,
|
||||
SUM(CASE WHEN is_paid = 1 THEN amount ELSE 0 END) as collected,
|
||||
SUM(CASE WHEN is_paid = 0 THEN amount ELSE 0 END) as pending_amount,
|
||||
AVG(CASE WHEN is_paid = 1 THEN amount ELSE NULL END) as avg_amount,
|
||||
MIN(CASE WHEN is_paid = 0 AND expected_at > NOW() THEN expected_at ELSE NULL END) as next_payment
|
||||
")
|
||||
->first();
|
||||
}
|
||||
|
||||
// All-time payment stats (for totals)
|
||||
$allPayments = DB::table('scheduled_giving_payments')
|
||||
->whereIn('scheduled_giving_donation_id', $realIds)
|
||||
->whereNull('deleted_at')
|
||||
->selectRaw("
|
||||
SUM(CASE WHEN is_paid = 1 THEN amount ELSE 0 END) as collected,
|
||||
SUM(CASE WHEN is_paid = 1 THEN 1 ELSE 0 END) as paid,
|
||||
COUNT(*) as total
|
||||
")
|
||||
->first();
|
||||
|
||||
// Completion for current season
|
||||
$totalNights = count($c->dates ?? []);
|
||||
$fullyPaid = 0;
|
||||
if ($totalNights > 0 && $currentIds->isNotEmpty()) {
|
||||
$ids = $currentIds->implode(',');
|
||||
$row = DB::selectOne("SELECT COUNT(*) as cnt FROM (SELECT scheduled_giving_donation_id FROM scheduled_giving_payments WHERE scheduled_giving_donation_id IN ({$ids}) AND deleted_at IS NULL GROUP BY scheduled_giving_donation_id HAVING SUM(is_paid) >= {$totalNights}) sub");
|
||||
$fullyPaid = $row->cnt ?? 0;
|
||||
}
|
||||
|
||||
$result[] = [
|
||||
'campaign' => $c,
|
||||
'all_time_subscribers' => $realIds->count(),
|
||||
'all_time_collected' => ($allPayments->collected ?? 0) / 100,
|
||||
'all_time_payments' => (int) ($allPayments->total ?? 0),
|
||||
'all_time_paid' => (int) ($allPayments->paid ?? 0),
|
||||
// Current season
|
||||
'current_subscribers' => $currentIds->count(),
|
||||
'expired_subscribers' => $expiredIds->count(),
|
||||
'current_payments' => (int) ($currentPayments->total ?? 0),
|
||||
'current_paid' => (int) ($currentPayments->paid ?? 0),
|
||||
'current_pending' => (int) ($currentPayments->pending ?? 0),
|
||||
'current_failed' => (int) ($currentPayments->failed ?? 0),
|
||||
'current_collected' => ($currentPayments->collected ?? 0) / 100,
|
||||
'current_pending_amount' => ($currentPayments->pending_amount ?? 0) / 100,
|
||||
'avg_per_night' => ($currentPayments->avg_amount ?? 0) / 100,
|
||||
'fully_completed' => $fullyPaid,
|
||||
'dates' => $c->dates ?? [],
|
||||
'total_nights' => $totalNights,
|
||||
'next_payment' => $currentPayments->next_payment ?? null,
|
||||
];
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
public function getGlobalStats(): array
|
||||
{
|
||||
$realIds = $this->realSubscriberIds();
|
||||
$currentIds = $this->currentSeasonIds();
|
||||
|
||||
$allTime = DB::table('scheduled_giving_payments')
|
||||
->whereIn('scheduled_giving_donation_id', $realIds)
|
||||
->whereNull('deleted_at')
|
||||
->selectRaw("
|
||||
SUM(CASE WHEN is_paid = 1 THEN amount ELSE 0 END) / 100 as collected,
|
||||
SUM(CASE WHEN is_paid = 1 THEN 1 ELSE 0 END) as paid,
|
||||
COUNT(*) as total
|
||||
")
|
||||
->first();
|
||||
|
||||
$currentStats = null;
|
||||
if ($currentIds->isNotEmpty()) {
|
||||
$currentStats = DB::table('scheduled_giving_payments')
|
||||
->whereIn('scheduled_giving_donation_id', $currentIds)
|
||||
->whereNull('deleted_at')
|
||||
->selectRaw("
|
||||
SUM(is_paid = 1) as paid,
|
||||
SUM(is_paid = 0 AND attempts > 0) as failed,
|
||||
SUM(CASE WHEN is_paid = 1 THEN amount ELSE 0 END) / 100 as collected,
|
||||
SUM(CASE WHEN is_paid = 0 THEN amount ELSE 0 END) / 100 as pending,
|
||||
COUNT(*) as total
|
||||
")
|
||||
->first();
|
||||
}
|
||||
|
||||
return [
|
||||
'total_subscribers' => $realIds->count(),
|
||||
'current_subscribers' => $currentIds->count(),
|
||||
'expired_subscribers' => $realIds->count() - $currentIds->count(),
|
||||
'all_time_collected' => (float) ($allTime->collected ?? 0),
|
||||
'current_collected' => (float) ($currentStats->collected ?? 0),
|
||||
'current_pending' => (float) ($currentStats->pending ?? 0),
|
||||
'current_failed' => (int) ($currentStats->failed ?? 0),
|
||||
'collection_rate' => ($currentStats->total ?? 0) > 0
|
||||
? round($currentStats->paid / $currentStats->total * 100, 1)
|
||||
: 0,
|
||||
];
|
||||
}
|
||||
|
||||
public function getFailedPayments(): array
|
||||
{
|
||||
$currentIds = $this->currentSeasonIds();
|
||||
if ($currentIds->isEmpty()) return [];
|
||||
|
||||
return DB::table('scheduled_giving_payments as p')
|
||||
->join('scheduled_giving_donations as d', 'd.id', '=', 'p.scheduled_giving_donation_id')
|
||||
->join('scheduled_giving_campaigns as c', 'c.id', '=', 'd.scheduled_giving_campaign_id')
|
||||
->leftJoin('customers as cu', 'cu.id', '=', 'd.customer_id')
|
||||
->whereIn('d.id', $currentIds)
|
||||
->where('p.is_paid', false)
|
||||
->where('p.attempts', '>', 0)
|
||||
->whereNull('p.deleted_at')
|
||||
->orderByDesc('p.updated_at')
|
||||
->limit(15)
|
||||
->get([
|
||||
'p.id as payment_id',
|
||||
'p.amount',
|
||||
'p.expected_at',
|
||||
'p.attempts',
|
||||
'd.id as donation_id',
|
||||
'c.title as campaign',
|
||||
DB::raw("CONCAT(cu.first_name, ' ', cu.last_name) as donor_name"),
|
||||
'cu.email as donor_email',
|
||||
])
|
||||
->toArray();
|
||||
}
|
||||
|
||||
public function getUpcomingPayments(): array
|
||||
{
|
||||
$currentIds = $this->currentSeasonIds();
|
||||
if ($currentIds->isEmpty()) return [];
|
||||
|
||||
return DB::table('scheduled_giving_payments as p')
|
||||
->join('scheduled_giving_donations as d', 'd.id', '=', 'p.scheduled_giving_donation_id')
|
||||
->join('scheduled_giving_campaigns as c', 'c.id', '=', 'd.scheduled_giving_campaign_id')
|
||||
->whereIn('d.id', $currentIds)
|
||||
->where('p.is_paid', false)
|
||||
->where('p.attempts', 0)
|
||||
->where('d.is_active', true)
|
||||
->whereNull('p.deleted_at')
|
||||
->whereBetween('p.expected_at', [now(), now()->addHours(48)])
|
||||
->selectRaw("
|
||||
c.title as campaign,
|
||||
COUNT(*) as payment_count,
|
||||
SUM(p.amount) / 100 as total_amount,
|
||||
MIN(p.expected_at) as earliest
|
||||
")
|
||||
->groupBy('c.title')
|
||||
->orderBy('earliest')
|
||||
->get()
|
||||
->toArray();
|
||||
}
|
||||
|
||||
public function getDataQuality(): array
|
||||
{
|
||||
$total = DB::table('scheduled_giving_donations')->count();
|
||||
$softDeleted = DB::table('scheduled_giving_donations')->whereNotNull('deleted_at')->count();
|
||||
$noCustomer = DB::table('scheduled_giving_donations')->whereNull('customer_id')->whereNull('deleted_at')->count();
|
||||
$noPayments = DB::table('scheduled_giving_donations as d')
|
||||
->whereNull('d.deleted_at')
|
||||
->whereNotNull('d.customer_id')
|
||||
->whereNotExists(function ($q) {
|
||||
$q->select(DB::raw(1))
|
||||
->from('scheduled_giving_payments as p')
|
||||
->whereColumn('p.scheduled_giving_donation_id', 'd.id')
|
||||
->whereNull('p.deleted_at');
|
||||
})->count();
|
||||
$zeroAmount = DB::table('scheduled_giving_donations')
|
||||
->whereNull('deleted_at')
|
||||
->where('total_amount', '<=', 0)
|
||||
->count();
|
||||
|
||||
return [
|
||||
'total_records' => $total,
|
||||
'soft_deleted' => $softDeleted,
|
||||
'no_customer' => $noCustomer,
|
||||
'no_payments' => $noPayments,
|
||||
'zero_amount' => $zeroAmount,
|
||||
];
|
||||
}
|
||||
|
||||
private function emptyCampaign($c): array
|
||||
{
|
||||
return [
|
||||
'campaign' => $c,
|
||||
'all_time_subscribers' => 0, 'all_time_collected' => 0, 'all_time_payments' => 0, 'all_time_paid' => 0,
|
||||
'current_subscribers' => 0, 'expired_subscribers' => 0,
|
||||
'current_payments' => 0, 'current_paid' => 0, 'current_pending' => 0, 'current_failed' => 0,
|
||||
'current_collected' => 0, 'current_pending_amount' => 0, 'avg_per_night' => 0,
|
||||
'fully_completed' => 0, 'dates' => $c->dates ?? [], 'total_nights' => count($c->dates ?? []),
|
||||
'next_payment' => null,
|
||||
];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user