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
313 lines
15 KiB
PHP
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();
|
|
}
|
|
}),
|
|
]);
|
|
}
|
|
}
|