Files
calvana/temp_files/fix2/ScheduledGivingDonationResource.php
Omair Saleh c11bf4bea7 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
2026-03-05 04:00:14 +08:00

313 lines
15 KiB
PHP

<?php
namespace App\Filament\Resources;
use App\Filament\Resources\ScheduledGivingDonationResource\Pages\EditScheduledGivingDonation;
use App\Filament\Resources\ScheduledGivingDonationResource\Pages\ListScheduledGivingDonations;
use App\Filament\Resources\ScheduledGivingDonationResource\RelationManagers\ScheduledGivingDonationPayments;
use App\Filament\RelationManagers\InternalNotesRelationManager;
use App\Helpers;
use App\Jobs\Zapier\Data\SendCustomer;
use App\Models\DonationType;
use App\Models\ScheduledGivingCampaign;
use App\Models\ScheduledGivingDonation;
use Filament\Forms\Components\Fieldset;
use Filament\Forms\Components\Grid;
use Filament\Forms\Components\Placeholder;
use Filament\Forms\Components\Repeater;
use Filament\Forms\Components\Section;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Form;
use Filament\Notifications\Notification;
use Filament\Resources\Resource;
use Filament\Tables\Actions\Action;
use Filament\Tables\Actions\ActionGroup;
use Filament\Tables\Actions\BulkAction;
use Filament\Tables\Actions\BulkActionGroup;
use Filament\Tables\Actions\ViewAction;
use Filament\Tables\Columns\IconColumn;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Filters\Filter;
use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\HtmlString;
class ScheduledGivingDonationResource extends Resource
{
protected static ?string $model = ScheduledGivingDonation::class;
protected static ?string $navigationIcon = 'heroicon-o-user-group';
protected static ?string $navigationGroup = 'Giving';
protected static ?int $navigationSort = 1;
protected static ?string $modelLabel = 'Scheduled Giving';
protected static ?string $pluralModelLabel = 'Scheduled Giving';
protected static ?string $navigationLabel = 'Subscribers';
public static function form(Form $form): Form
{
return $form
->schema([
Section::make('Donation Details')
->schema([
Grid::make(5)->schema([
Placeholder::make('status')
->label('Status')
->content(function (ScheduledGivingDonation $scheduledGivingDonation) {
if ($scheduledGivingDonation->is_active) {
return new HtmlString('<span class="bg-green-100 text-green-800 text-sm font-medium px-2 py-1 rounded-lg flex-1">Enabled</span>');
} else {
return new HtmlString('<span class="bg-yellow-100 text-yellow-800 text-sm font-medium px-2 py-1 rounded-lg flex-1">Disabled</span>');
}
}),
Placeholder::make('scheduledGivingCampaign.title')
->label('Campaign')
->content(fn (ScheduledGivingDonation $record): HtmlString => new HtmlString('<b>' . $record->scheduledGivingCampaign->title . '</b>')),
Placeholder::make('Amount')
->content(fn (ScheduledGivingDonation $record): HtmlString => new Htmlstring('<b>' . Helpers::formatMoneyGlobal($record->total_amount) . '</b>')),
Placeholder::make('admin_contribution')
->label('Admin Contribution')
->content(fn (ScheduledGivingDonation $record): HtmlString => new Htmlstring('<b>' . Helpers::formatMoneyGlobal($record->amount_admin) . '</b>')),
Placeholder::make('is_zakat')
->label('Zakat?')
->content(fn (ScheduledGivingDonation $record): HtmlString => new Htmlstring('<b>' . ($record->is_zakat ? 'Yes' : 'No') . '</b>')),
Placeholder::make('is_gift_aid')
->label('Gift Aid?')
->content(fn (ScheduledGivingDonation $record): HtmlString => new Htmlstring('<b>' . ($record->is_gift_aid ? 'Yes' : 'No') . '</b>')),
]),
Fieldset::make('Customer Details')
->columns(3)
->schema([
Placeholder::make('name')
->label('Name')
->content(fn (ScheduledGivingDonation $donation) => $donation->customer->name),
Placeholder::make('email')
->label('Email')
->content(fn (ScheduledGivingDonation $donation) => $donation->customer->email),
Placeholder::make('phone')
->label('Phone')
->content(fn (ScheduledGivingDonation $donation) => strlen(trim($phone = $donation->customer->phone)) > 0 ? $phone : new HtmlString('<i>(Not given)</i>')),
])
->visible(fn (ScheduledGivingDonation $donation) => (bool) $donation->customer),
Fieldset::make('Address')
->columns(3)
->schema([
Placeholder::make('house')
->label('House')
->content(fn (ScheduledGivingDonation $donation) => strlen(trim($v = $donation->address->house)) ? $v : new HtmlString('<i>(Not given)</i>')),
Placeholder::make('street')
->label('Street')
->content(fn (ScheduledGivingDonation $donation) => strlen(trim($v = $donation->address->street)) ? $v : new HtmlString('<i>(Not given)</i>')),
Placeholder::make('town')
->label('Town')
->content(fn (ScheduledGivingDonation $donation) => strlen(trim($v = $donation->address->town)) ? $v : new HtmlString('<i>(Not given)</i>')),
Placeholder::make('state')
->label('State')
->content(fn (ScheduledGivingDonation $donation) => strlen(trim($v = $donation->address->state)) ? $v : new HtmlString('<i>(Not given)</i>')),
Placeholder::make('postcode')
->label('Postcode')
->content(fn (ScheduledGivingDonation $donation) => strlen(trim($v = $donation->address->postcode)) ? $v : new HtmlString('<i>(Not given)</i>')),
Placeholder::make('country_code')
->label('Country')
->content(fn (ScheduledGivingDonation $donation) => strlen(trim($v = $donation->address->country_code)) ? $v : new HtmlString('<i>(Not given)</i>')),
])
->visible(fn (ScheduledGivingDonation $donation) => (bool) $donation->address),
Fieldset::make('Appeal')
->columns(2)
->schema([
Placeholder::make('appeal.name')
->label('Name')
->content(fn (ScheduledGivingDonation $donation) => $donation->appeal->name),
Placeholder::make('appeal.url')
->label('URL')
->content(fn (ScheduledGivingDonation $donation): HtmlString => new HtmlString('<a href="' . $donation->appeal->url() . '">' . $donation->appeal->url() . '</a>')),
])
->visible(fn (ScheduledGivingDonation $donation) => (bool) $donation->appeal),
Repeater::make('allocations')
->schema([
TextInput::make('type')
->label('Donation Type')
->required()
->disabled(),
TextInput::make('amount')
->label('Amount')
->numeric()
->required()
->disabled(),
])
->columns()
->formatStateUsing(function ($state) {
return collect($state)
->map(fn ($amount, $type) => ['type' => DonationType::find($type)?->display_name, 'amount' => $amount])
->values()
->toArray();
})
->addable(false)
->deletable(false),
]),
]);
}
public static function getEloquentQuery(): Builder
{
return parent::getEloquentQuery()
->withCount(['payments as paid_payments_count' => fn ($q) => $q->where('is_paid', true)->whereNull('deleted_at')]);
}
public static function table(Table $table): Table
{
return $table
->columns([
IconColumn::make('is_active')
->label('')
->boolean()
->trueIcon('heroicon-o-check-circle')
->falseIcon('heroicon-o-x-circle')
->trueColor('success')
->falseColor('danger')
->tooltip(fn (ScheduledGivingDonation $r) => $r->is_active ? 'Active' : 'Cancelled'),
TextColumn::make('customer.name')
->label('Donor')
->description(fn (ScheduledGivingDonation $d) => $d->customer?->email)
->sortable()
->searchable(query: function (Builder $query, string $search) {
$query->whereHas('customer', fn (Builder $q) => $q
->where('first_name', 'like', "%{$search}%")
->orWhere('last_name', 'like', "%{$search}%")
->orWhere('email', 'like', "%{$search}%"));
}),
TextColumn::make('scheduledGivingCampaign.title')
->label('Campaign')
->badge()
->color(fn ($state) => match ($state) {
'30 Nights of Giving' => 'primary',
'10 Days of Giving' => 'success',
'Night of Power' => 'warning',
default => 'gray',
}),
TextColumn::make('total_amount')
->label('Per Night')
->money('gbp', divideBy: 100)
->sortable(),
TextColumn::make('payments_count')
->label('Progress')
->counts('payments')
->formatStateUsing(function ($state, ScheduledGivingDonation $record) {
$total = (int) $state;
if ($total === 0) return '—';
$paid = (int) ($record->paid_payments_count ?? 0);
$pct = round($paid / $total * 100);
return "{$paid}/{$total} ({$pct}%)";
})
->color(function ($state, ScheduledGivingDonation $record) {
$total = (int) $state;
if ($total === 0) return 'gray';
$paid = (int) ($record->paid_payments_count ?? 0);
$pct = $paid / $total * 100;
return $pct >= 80 ? 'success' : ($pct >= 40 ? 'warning' : 'danger');
})
->badge(),
TextColumn::make('created_at')
->label('Signed Up')
->date('d M Y')
->description(fn (ScheduledGivingDonation $d) => $d->created_at?->diffForHumans())
->sortable(),
TextColumn::make('reference_code')
->label('Ref')
->searchable()
->fontFamily('mono')
->copyable()
->toggleable(isToggledHiddenByDefault: true),
])
->searchable()
->bulkActions(static::getBulkActions())
->actions(static::getTableRowActions())
->defaultSort('created_at', 'desc')
->filters([
Filter::make('confirmed')
->query(fn (Builder $query): Builder => $query->whereHas('donationConfirmation', fn ($q) => $q->whereNotNull('confirmed_at'))),
SelectFilter::make('scheduled_giving_campaign_id')
->options(ScheduledGivingCampaign::get()->pluck('title', 'id')),
]);
}
public static function getRelations(): array
{
return [
ScheduledGivingDonationPayments::class,
InternalNotesRelationManager::class,
];
}
public static function getPages(): array
{
return [
'index' => ListScheduledGivingDonations::route('/'),
'edit' => EditScheduledGivingDonation::route('/{record}/edit'),
];
}
private static function getTableRowActions(): array
{
return [
ViewAction::make(),
ActionGroup::make([
Action::make('view_customer')
->label('View Customer')
->icon('heroicon-s-eye')
->visible(fn () => (Auth::user()?->hasPermissionTo('view-customer') || Auth::user()?->hasRole('Superadmin')) || Auth::user()?->hasRole('Superadmin')),
]),
];
}
private static function getBulkActions()
{
return BulkActionGroup::make([
BulkAction::make('send_to_zapier')
->label('Send to Zapier')
->icon('heroicon-s-envelope')
->visible(fn (ScheduledGivingDonation $donation) => (Auth::user()?->hasPermissionTo('view-donation') || Auth::user()?->hasRole('Superadmin')))
->action(function ($records) {
foreach ($records as $donation) {
dispatch(new SendCustomer($donation->customer));
Notification::make()
->success()
->title($donation->reference_code . ' pushed to Zapier.')
->send();
}
}),
]);
}
}