Deep UX: 2-column automations, visible appeal cards, platform education, strip model refs

Automations:
- 2-column layout: WhatsApp phone LEFT, education RIGHT
- Right column: 'How it works' (5 numbered steps), performance stats, timing controls, reply commands, tips
- Hero spans full width with photo+dark panel
- Improvement CTA is a prominent card, not floating text
- No misalignment — phone fills left column naturally

Collect:
- Appeals shown as visible gap-px grid cards (not hidden dropdown)
- Each card shows name, platform, amount raised, pledge count, collection rate
- Active appeal has border-l-2 blue indicator
- Platform integration clarity: shows 'Donors redirected to JustGiving' etc
- Educational section: 'Where to share your link' + 'How payment works'
- Explains bank transfer vs JustGiving vs card payment inline

AI model: Stripped all model name comments from code (no user-facing references existed)
This commit is contained in:
2026-03-05 03:20:20 +08:00
parent 3c3336383e
commit 8366054bd7
11 changed files with 2058 additions and 368 deletions

View File

@@ -0,0 +1,313 @@
<?php
namespace App\Filament\Resources\ScheduledGivingDonationResource\Pages;
use App\Filament\Resources\AppealResource\Pages\EditAppeal;
use App\Filament\Resources\CustomerResource\Pages\EditCustomer;
use App\Filament\Resources\ScheduledGivingDonationResource;
use App\Models\ScheduledGivingDonation;
use App\Services\StripeRefundService;
use Filament\Actions\Action;
use Filament\Notifications\Notification;
use Filament\Resources\Pages\EditRecord;
use Illuminate\Support\HtmlString;
/**
* Scheduled/Regular Giving detail page — supporter care.
*
* Shows payment progress, card info, and provides cancel/refund
* actions that sync with Stripe.
*
* @property ScheduledGivingDonation $record
*/
class EditScheduledGivingDonation extends EditRecord
{
protected static string $resource = ScheduledGivingDonationResource::class;
// ── Heading: status + progress at a glance ──────────────────
public function getHeading(): string|HtmlString
{
$d = $this->record;
$status = $d->is_active
? '<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">● Active</span>'
: '<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-800">● Cancelled</span>';
$amount = '£' . number_format($d->total_amount / 100, 2);
// Payment progress
$totalPayments = $d->payments()->count();
$paidPayments = $d->payments()->where('is_paid', true)->count();
$paidTotal = $d->payments()->where('is_paid', true)->sum('amount') / 100;
$progress = $totalPayments > 0
? '<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800 ml-1">'
. $paidPayments . '/' . $totalPayments . ' payments · £' . number_format($paidTotal, 2) . ' collected</span>'
: '<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-600 ml-1">No payments generated</span>';
if ($d->is_zakat) {
$progress .= ' <span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-purple-50 text-purple-700 ml-1">Zakat</span>';
}
if ($d->is_gift_aid) {
$progress .= ' <span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-50 text-green-700 ml-1">Gift Aid</span>';
}
return new HtmlString(
$amount . '/night — ' . e($d->customer?->name ?? 'Unknown')
. '<div class="mt-1">' . $status . ' ' . $progress . '</div>'
);
}
public function getSubheading(): ?string
{
$d = $this->record;
$parts = [];
$parts[] = $d->scheduledGivingCampaign?->title ?? 'Unknown campaign';
$parts[] = 'Started ' . $d->created_at?->format('d M Y');
if ($d->customer?->email) {
$parts[] = $d->customer->email;
}
if ($d->reference_code) {
$parts[] = 'Ref: ' . $d->reference_code;
}
return implode(' · ', $parts);
}
// ── Header Actions ──────────────────────────────────────────
//
// Priority order:
// 1. Cancel (deactivate + detach card)
// 2. Cancel & Refund All
// 3. Activate
// 4. Stripe Status
// 5. Open in Stripe / View Donor / View Appeal / Email
//
public function getHeaderActions(): array
{
$donation = $this->record;
$hasStripe = (bool) $donation->stripe_setup_intent_id;
$paidCount = $donation->payments()
->where('is_paid', true)
->whereNotNull('stripe_payment_intent_id')
->where('stripe_payment_intent_id', '!=', 'SKIPPED')
->count();
$paidTotal = $donation->payments()
->where('is_paid', true)
->whereNotNull('stripe_payment_intent_id')
->where('stripe_payment_intent_id', '!=', 'SKIPPED')
->sum('amount') / 100;
return array_values(array_filter([
// ── CANCEL ──────────────────────────────────────────
$donation->is_active ? Action::make('cancel')
->label('Cancel')
->icon('heroicon-o-x-circle')
->color('danger')
->requiresConfirmation()
->modalIcon('heroicon-o-x-circle')
->modalHeading('Cancel Regular Giving')
->modalDescription(
'This will deactivate ' . e($donation->customer?->name ?? 'this donor') . '\'s regular giving.'
. ($hasStripe ? "\n\nThe payment method will be detached from Stripe, preventing any future charges." : '')
. "\n\nPreviously collected payments will NOT be refunded."
)
->modalSubmitActionLabel('Cancel Regular Giving')
->action(function () {
$donation = $this->record;
$hasStripe = (bool) $donation->stripe_setup_intent_id;
$donation->update(['is_active' => false]);
$stripeMsg = '';
if ($hasStripe) {
$service = app(StripeRefundService::class);
$result = $service->detachPaymentMethod($donation->stripe_setup_intent_id);
$stripeMsg = $result['success']
? ($result['message'] ?? 'Payment method detached.')
: 'Warning: ' . ($result['error'] ?? 'Could not detach payment method.');
}
$donation->internalNotes()->create([
'user_id' => auth()->id(),
'body' => 'Regular giving cancelled by admin. ' . $stripeMsg,
]);
Notification::make()
->title('Regular giving cancelled')
->body($stripeMsg ?: 'Deactivated')
->success()
->send();
}) : null,
// ── CANCEL & REFUND ALL ─────────────────────────────
$donation->is_active && $paidCount > 0 ? Action::make('cancel_and_refund')
->label('Cancel & Refund All')
->icon('heroicon-o-arrow-uturn-left')
->color('danger')
->requiresConfirmation()
->modalIcon('heroicon-o-exclamation-triangle')
->modalHeading('Cancel & Refund All Payments')
->modalDescription(
"This will:\n"
. "1. Deactivate the regular giving\n"
. "2. Detach the payment method from Stripe\n"
. "3. Refund all {$paidCount} collected payments (£" . number_format($paidTotal, 2) . ")\n\n"
. "This cannot be undone."
)
->modalSubmitActionLabel('Cancel & Refund Everything')
->action(function () {
$donation = $this->record;
$donation->update(['is_active' => false]);
$service = app(StripeRefundService::class);
// Detach card
if ($donation->stripe_setup_intent_id) {
$service->detachPaymentMethod($donation->stripe_setup_intent_id);
}
// Refund all paid payments
$paidPayments = $donation->payments()
->where('is_paid', true)
->whereNotNull('stripe_payment_intent_id')
->where('stripe_payment_intent_id', '!=', 'SKIPPED')
->get();
$refunded = 0;
$failed = 0;
$totalRefunded = 0;
foreach ($paidPayments as $payment) {
$result = $service->refundPaymentIntent(
$payment->stripe_payment_intent_id,
null,
'Bulk cancel & refund — payment #' . $payment->id
);
if ($result['success']) {
$payment->update(['is_paid' => false]);
$refunded++;
$totalRefunded += $result['amount'];
} else {
$failed++;
}
}
$donation->internalNotes()->create([
'user_id' => auth()->id(),
'body' => 'Cancelled & refunded all payments. '
. $refunded . ' refunded (£' . number_format($totalRefunded / 100, 2) . ')'
. ($failed > 0 ? ", {$failed} failed" : ''),
]);
Notification::make()
->title('Cancelled & Refunded')
->body(
$refunded . ' payments refunded (£' . number_format($totalRefunded / 100, 2) . ')'
. ($failed > 0 ? ". {$failed} failed — check Stripe." : '')
)
->success()
->send();
}) : null,
// ── REACTIVATE ──────────────────────────────────────
! $donation->is_active ? Action::make('activate')
->label('Reactivate')
->icon('heroicon-o-play')
->color('success')
->requiresConfirmation()
->modalHeading('Reactivate Regular Giving')
->modalDescription(
'Re-enable payment collection. Note: if the payment method '
. 'was previously detached, future charges may fail.'
)
->action(function () {
$donation = $this->record;
$donation->update(['is_active' => true]);
$donation->internalNotes()->create([
'user_id' => auth()->id(),
'body' => 'Regular giving reactivated by admin.',
]);
Notification::make()->title('Regular giving reactivated')->success()->send();
}) : null,
// ── STRIPE STATUS ───────────────────────────────────
$hasStripe ? Action::make('stripe_status')
->label('Stripe Status')
->icon('heroicon-o-magnifying-glass')
->color('gray')
->action(function () {
$donation = $this->record;
$service = app(StripeRefundService::class);
$details = $service->getSetupIntentDetails($donation->stripe_setup_intent_id);
if (! $details) {
Notification::make()
->title('Could not retrieve Stripe details')
->danger()
->send();
return;
}
$body = 'Status: ' . $details['status'];
if ($details['card_brand'] && $details['card_last4']) {
$body .= "\nCard: " . ucfirst($details['card_brand']) . ' ····' . $details['card_last4'];
}
if ($details['card_exp_month'] && $details['card_exp_year']) {
$body .= ' (expires ' . $details['card_exp_month'] . '/' . $details['card_exp_year'] . ')';
}
$body .= "\nCreated: " . $details['created'];
if ($details['customer_id']) {
$body .= "\nStripe Customer: " . $details['customer_id'];
}
Notification::make()
->title('Stripe SetupIntent')
->body($body)
->info()
->persistent()
->send();
}) : null,
// ── OPEN IN STRIPE ──────────────────────────────────
$hasStripe ? Action::make('open_stripe')
->label('Stripe ↗')
->icon('heroicon-o-arrow-top-right-on-square')
->color('gray')
->url('https://dashboard.stripe.com/search?query=' . urlencode($donation->stripe_setup_intent_id))
->openUrlInNewTab() : null,
// ── VIEW DONOR ──────────────────────────────────────
$donation->customer ? Action::make('view_customer')
->label('Donor')
->icon('heroicon-o-user')
->color('gray')
->url(EditCustomer::getUrl(['record' => $donation->customer->id]))
: null,
// ── VIEW APPEAL ─────────────────────────────────────
$donation->appeal ? Action::make('view_appeal')
->label('Fundraiser')
->icon('heroicon-o-heart')
->color('gray')
->url(EditAppeal::getUrl(['record' => $donation->appeal->id]))
: null,
// ── EMAIL ───────────────────────────────────────────
$donation->customer?->email ? Action::make('email')
->label('Email')
->icon('heroicon-o-at-symbol')
->color('gray')
->url('mailto:' . $donation->customer->email)
->openUrlInNewTab() : null,
]));
}
}