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)
278 lines
13 KiB
PHP
278 lines
13 KiB
PHP
<?php
|
|
|
|
namespace App\Filament\Resources\CustomerResource\Pages;
|
|
|
|
use App\Definitions\PaymentProviders;
|
|
use App\Filament\Resources\CustomerResource;
|
|
use App\Filament\Resources\DonationResource;
|
|
use App\Models\Customer;
|
|
use App\Services\StripeRefundService;
|
|
use Filament\Actions\Action;
|
|
use Filament\Forms\Components\Select;
|
|
use Filament\Forms\Components\Textarea;
|
|
use Filament\Notifications\Notification;
|
|
use Filament\Resources\Pages\EditRecord;
|
|
use Illuminate\Support\Facades\Mail;
|
|
use Illuminate\Support\HtmlString;
|
|
|
|
class EditCustomer extends EditRecord
|
|
{
|
|
protected static string $resource = CustomerResource::class;
|
|
|
|
// ─── Heading: Show who this person IS, not just a name ───────
|
|
|
|
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)->first();
|
|
if ($sg) {
|
|
$amt = '£' . number_format($sg->total_amount / 100, 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">💙 ' . $amt . '/night</span>';
|
|
}
|
|
|
|
// Monthly donations (reoccurrence=2)
|
|
$monthly = $customer->donations()
|
|
->where('reoccurrence', 2)
|
|
->whereHas('donationConfirmation', fn ($q) => $q->whereNotNull('confirmed_at'))
|
|
->first();
|
|
if ($monthly) {
|
|
$mAmt = '£' . number_format($monthly->amount / 100, 0);
|
|
$badges .= ' <span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-indigo-100 text-indigo-800 ml-2">🔄 ' . $mAmt . '/month</span>';
|
|
}
|
|
|
|
// Recent incomplete donations
|
|
$incompleteRecent = $customer->donations()
|
|
->whereDoesntHave('donationConfirmation', fn ($q) => $q->whereNotNull('confirmed_at'))
|
|
->where('created_at', '>=', now()->subDays(7))
|
|
->count();
|
|
if ($incompleteRecent > 0) {
|
|
$badges .= ' <span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-800 ml-2">⚠ ' . $incompleteRecent . ' incomplete</span>';
|
|
}
|
|
|
|
return new HtmlString(e($customer->name) . $badges);
|
|
}
|
|
|
|
// ─── Subheading: The one-line story of this donor ────────────
|
|
|
|
public function getSubheading(): ?string
|
|
{
|
|
$customer = $this->record;
|
|
$confirmed = $customer->donations()
|
|
->whereHas('donationConfirmation', fn ($q) => $q->whereNotNull('confirmed_at'));
|
|
$total = $confirmed->sum('amount') / 100;
|
|
$count = $confirmed->count();
|
|
$first = $customer->donations()->oldest()->first();
|
|
$since = $first ? $first->created_at->format('M Y') : null;
|
|
|
|
$parts = [];
|
|
if ($total > 0) {
|
|
$parts[] = '£' . number_format($total, 2) . ' donated across ' . $count . ' donations';
|
|
} else {
|
|
$parts[] = 'No confirmed donations yet';
|
|
}
|
|
if ($since) {
|
|
$parts[] = 'Supporter since ' . $since;
|
|
}
|
|
|
|
return implode(' · ', $parts);
|
|
}
|
|
|
|
// ─── Header Actions ─────────────────────────────────────────
|
|
|
|
protected function getHeaderActions(): array
|
|
{
|
|
$customer = $this->record;
|
|
|
|
// Pre-compute recurring counts for visibility checks
|
|
$activeScheduled = $customer->scheduledGivingDonations()
|
|
->where('is_active', true)->count();
|
|
|
|
$activeMonthly = $customer->donations()
|
|
->where('reoccurrence', 2)
|
|
->where('provider_type', PaymentProviders::STRIPE)
|
|
->whereHas('donationConfirmation', fn ($q) => $q->whereNotNull('confirmed_at'))
|
|
->count();
|
|
|
|
$hasRecurring = $activeScheduled > 0 || $activeMonthly > 0;
|
|
|
|
return array_values(array_filter([
|
|
|
|
// ── CANCEL ALL RECURRING ────────────────────────────
|
|
$hasRecurring ? Action::make('cancel_all_recurring')
|
|
->label('Cancel All Recurring')
|
|
->icon('heroicon-o-x-circle')
|
|
->color('danger')
|
|
->requiresConfirmation()
|
|
->modalIcon('heroicon-o-exclamation-triangle')
|
|
->modalHeading('Cancel All Recurring Giving')
|
|
->modalDescription(function () use ($customer, $activeScheduled, $activeMonthly) {
|
|
$parts = [];
|
|
if ($activeScheduled > 0) {
|
|
$sgTotal = $customer->scheduledGivingDonations()
|
|
->where('is_active', true)->sum('total_amount') / 100;
|
|
$parts[] = $activeScheduled . ' regular giving '
|
|
. ($activeScheduled === 1 ? 'subscription' : 'subscriptions')
|
|
. ' (£' . number_format($sgTotal, 2) . ' total)';
|
|
}
|
|
if ($activeMonthly > 0) {
|
|
$parts[] = $activeMonthly . ' monthly '
|
|
. ($activeMonthly === 1 ? 'donation' : 'donations');
|
|
}
|
|
|
|
return "This will cancel:\n"
|
|
. implode("\n", array_map(fn ($p) => "• {$p}", $parts))
|
|
. "\n\nAll Stripe payment methods will be detached. "
|
|
. "Previously collected payments will NOT be refunded.";
|
|
})
|
|
->modalSubmitActionLabel('Cancel All Recurring')
|
|
->action(function () use ($customer) {
|
|
$service = app(StripeRefundService::class);
|
|
$cancelled = 0;
|
|
|
|
// Cancel active scheduled giving
|
|
$activeGiving = $customer->scheduledGivingDonations()
|
|
->where('is_active', true)->get();
|
|
foreach ($activeGiving as $sg) {
|
|
$sg->update(['is_active' => false]);
|
|
if ($sg->stripe_setup_intent_id) {
|
|
$service->detachPaymentMethod($sg->stripe_setup_intent_id);
|
|
}
|
|
if (method_exists($sg, 'internalNotes')) {
|
|
$sg->internalNotes()->create([
|
|
'user_id' => auth()->id(),
|
|
'body' => 'Cancelled via donor profile "Cancel All Recurring".',
|
|
]);
|
|
}
|
|
$cancelled++;
|
|
}
|
|
|
|
// Cancel monthly Stripe donations
|
|
$monthlyDonations = $customer->donations()
|
|
->where('reoccurrence', 2)
|
|
->where('provider_type', PaymentProviders::STRIPE)
|
|
->whereHas('donationConfirmation', fn ($q) => $q->whereNotNull('confirmed_at'))
|
|
->get();
|
|
foreach ($monthlyDonations as $d) {
|
|
$ref = $d->provider_reference ?? '';
|
|
if (str_starts_with($ref, 'seti_')) {
|
|
$service->detachPaymentMethod($ref);
|
|
}
|
|
$d->donationConfirmation?->update(['confirmed_at' => null]);
|
|
$d->internalNotes()->create([
|
|
'user_id' => auth()->id(),
|
|
'body' => 'Monthly giving cancelled via donor profile "Cancel All Recurring".',
|
|
]);
|
|
$cancelled++;
|
|
}
|
|
|
|
// Log on the customer too
|
|
$customer->internalNotes()->create([
|
|
'user_id' => auth()->id(),
|
|
'body' => "All recurring giving cancelled ({$cancelled} items). Payment methods detached from Stripe.",
|
|
]);
|
|
|
|
Notification::make()
|
|
->title("Cancelled {$cancelled} recurring " . ($cancelled === 1 ? 'item' : 'items'))
|
|
->success()
|
|
->send();
|
|
}) : null,
|
|
|
|
// ── ADD NOTE ────────────────────────────────────────
|
|
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();
|
|
}),
|
|
|
|
// ── RESEND RECEIPT ──────────────────────────────────
|
|
$customer->donations()
|
|
->whereHas('donationConfirmation', fn ($q) => $q->whereNotNull('confirmed_at'))
|
|
->exists()
|
|
? Action::make('resend_receipt')
|
|
->label('Resend Receipt')
|
|
->icon('heroicon-o-envelope')
|
|
->color('info')
|
|
->form([
|
|
Select::make('donation_id')
|
|
->label('Which donation?')
|
|
->options(function () use ($customer) {
|
|
return $customer->donations()
|
|
->whereHas('donationConfirmation', fn ($q) => $q->whereNotNull('confirmed_at'))
|
|
->latest()
|
|
->take(10)
|
|
->get()
|
|
->mapWithKeys(function ($d) {
|
|
$label = '£' . number_format($d->amount / 100, 2)
|
|
. ' on ' . $d->created_at->format('d M Y')
|
|
. ' — ' . ($d->donationType?->display_name ?? 'Unknown');
|
|
return [$d->id => $label];
|
|
});
|
|
})
|
|
->required()
|
|
->helperText('Select the donation to resend the receipt for'),
|
|
])
|
|
->action(function (array $data) use ($customer) {
|
|
$donation = $customer->donations()->find($data['donation_id']);
|
|
if ($donation) {
|
|
try {
|
|
Mail::to($customer->email)
|
|
->send(new \App\Mail\DonationConfirmed($donation));
|
|
Notification::make()
|
|
->title('Receipt sent to ' . $customer->email)
|
|
->body('For £' . number_format($donation->amount / 100, 2) . ' on ' . $donation->created_at->format('d M Y'))
|
|
->success()
|
|
->send();
|
|
} catch (\Throwable $e) {
|
|
Notification::make()->title('Failed to send receipt')->body($e->getMessage())->danger()->send();
|
|
}
|
|
}
|
|
}) : null,
|
|
|
|
// ── VIEW IN STRIPE ──────────────────────────────────
|
|
Action::make('view_in_stripe')
|
|
->label('Stripe')
|
|
->icon('heroicon-o-arrow-top-right-on-square')
|
|
->color('gray')
|
|
->url('https://dashboard.stripe.com/search?query=' . urlencode($customer->email))
|
|
->openUrlInNewTab(),
|
|
|
|
// ── EMAIL ───────────────────────────────────────────
|
|
$customer->email ? Action::make('email_donor')
|
|
->label('Email')
|
|
->icon('heroicon-o-at-symbol')
|
|
->color('gray')
|
|
->url('mailto:' . $customer->email)
|
|
->openUrlInNewTab() : null,
|
|
]));
|
|
}
|
|
}
|