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:
2026-03-05 04:00:14 +08:00
parent dc8e593849
commit c11bf4bea7
13 changed files with 2203 additions and 567 deletions

View File

@@ -0,0 +1,365 @@
<?php
namespace App\Filament\Resources;
use App\Definitions\DefaultCalendarSchedule;
use App\Definitions\HijriCalendar;
use App\Definitions\ScheduledGivingSplitTypeFlag;
use App\Definitions\ScheduledGivingTime;
use App\Filament\Resources\ScheduledGivingCampaignResource\Pages\CreateScheduledGivingCampaign;
use App\Filament\Resources\ScheduledGivingCampaignResource\Pages\EditScheduledGivingCampaign;
use App\Filament\Resources\ScheduledGivingCampaignResource\Pages\ListScheduledGivingCampaigns;
use App\Filament\Resources\ScheduledGivingCampaignResource\RelationManagers\ScheduledGivingDonationsRelationManager;
use App\Models\DonationCountry;
use App\Models\DonationType;
use App\Models\ScheduledGivingCampaign;
use App\Services\ScheduleGeneratorService;
use Filament\Forms\Components\ColorPicker;
use Filament\Forms\Components\FileUpload;
use Filament\Forms\Components\Grid;
use Filament\Forms\Components\Repeater;
use Filament\Forms\Components\Section;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\TimePicker;
use Filament\Forms\Components\Toggle;
use Filament\Forms\Form;
use Filament\Resources\Resource;
use Filament\Tables\Actions\EditAction;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Columns\ToggleColumn;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Str;
class ScheduledGivingCampaignResource extends Resource
{
protected static ?string $model = ScheduledGivingCampaign::class;
protected static ?string $navigationIcon = 'heroicon-o-calendar';
protected static ?string $navigationGroup = 'Giving';
protected static ?string $navigationLabel = 'Campaign Config';
protected static ?int $navigationSort = 2;
public static function form(Form $form): Form
{
return $form
->schema([
Toggle::make('active'),
Section::make('Info')->schema([
TextInput::make('title')
->required()
->maxLength(255)
->live()
->debounce(1000)
->afterStateUpdated(function (\Filament\Forms\Get $get, \Filament\Forms\Set $set) {
$set('slug', Str::slug($get('title')));
})
->placeholder('30 Nights of Giving'),
TextInput::make('slug')
->required()
->maxLength(255)
->placeholder('30-nights-of-giving'),
FileUpload::make('logo_image')
->image()
->helperText('Leave blank to use Charity Right logo.'),
Grid::make()->schema([
ColorPicker::make('primary_colour')
->label('Primary Colour')
->hex()
->placeholder('#E42281'),
ColorPicker::make('text_colour')
->label('Text Colour')
->hex()
->placeholder('#000000'),
])->columns(1)->columnSpan(1),
TextInput::make('minimum_donation')
->required()
->label('Minimum Donation Amount')
->prefix('£')
->numeric()
->default(30.0),
Toggle::make('catch_up_payments')
->required()
->label('Should we attempt to take out missed payments if a subscription is made during the dates?'),
Toggle::make('accepts_appeals')
->required()
->label('Can donors set up subscriptions against appeals via this campaign?'),
Select::make('split_types')
->label('Use-able Split Options')
->required()
->multiple()
->options([
ScheduledGivingSplitTypeFlag::EQUAL => 'Equal',
ScheduledGivingSplitTypeFlag::DOUBLE_ON_ODD => 'Double on odd',
ScheduledGivingSplitTypeFlag::DOUBLE_ON_EVEN => 'Double on even',
ScheduledGivingSplitTypeFlag::DOUBLE_ON_FRIDAYS => 'Double on Fridays',
ScheduledGivingSplitTypeFlag::DOUBLE_ON_LAST_10_NIGHTS => 'Double on last 10',
ScheduledGivingSplitTypeFlag::DOUBLE_ON27TH_NIGHT => 'Double on 27th',
ScheduledGivingSplitTypeFlag::DOUBLE_ON_DAY_OF_ARAFAH => 'Double on 9th',
]),
])
->columns(),
Section::make('Date Configuration')->collapsible()->schema([
Select::make('date_config_type')
->options([
-1 => 'Custom Configuration',
DefaultCalendarSchedule::RAMADAN_DAYS => 'Ramadan Days',
DefaultCalendarSchedule::RAMADAN_NIGHTS => 'Ramadan Nights',
DefaultCalendarSchedule::DHUL_HIJJAH_DAYS => 'Dhul Hijjah Days',
DefaultCalendarSchedule::DHUL_HIJJAH_NIGHTS => 'Dhul Hijjah Nights',
DefaultCalendarSchedule::RAMADAN_NIGHTS_LAST_10 => 'Ramadan Nights (Last 10)',
DefaultCalendarSchedule::DHUL_HIJJAH_DAYS_FIRST_10 => 'Dhul Hijjah Days (First 10)',
])
->label('Builtin Date Configuration')
->helperText('Select the built-in date configuration - based on previous campaigns - or choose \'Custom Configuration\' to set up the timings manually. Please note it may take some time to generate the dates when selecting anything other than Custom.')
->live()
->afterStateUpdated(function ($old, \Filament\Forms\Get $get, \Filament\Forms\Set $set) {
$configType = (int) $get('date_config_type');
if ($configType === -1) {
return;
}
$data = app(ScheduleGeneratorService::class)->generate($configType);
$set('dates', $data); // This becomes VERY heavy when we are doing it reactively.
// I don't know why, but it seems to fry the server when it passes the data into the repeater.
// Let's just use it when creating the resource and indicate to the user to wait.
})
->visibleOn('create'),
Repeater::make('dates')
->label('Dates')
->schema([
Select::make('month')
->options(self::formatHijriMonths())
->live(),
Select::make('day')
->visible(fn (\Filament\Forms\Get $get) => $get('month'))
->live()
->options(fn (\Filament\Forms\Get $get) => static::formatHijriDays($get('month'))),
Select::make('timing')
->options([
ScheduledGivingTime::DAYTIME => 'Daytime (GMT) / 10:00am',
ScheduledGivingTime::NIGHTTIME => 'Nighttime (GMT) / 10:00pm',
ScheduledGivingTime::CUSTOM => 'Custom',
])
->required()
->live()
->visible(fn (\Filament\Forms\Get $get) => $get('day'))
->required(fn (\Filament\Forms\Get $get) => $get('day')),
TimePicker::make('timing_custom')
->visible(fn (\Filament\Forms\Get $get) => $get('timing') == 2)
->required(fn (\Filament\Forms\Get $get) => $get('timing') == 2)
->live()
->columnSpanFull(),
])
->grid(2)
->columns(2)
->columnSpanFull()
->visible(fn (\Filament\Forms\Get $get) => (bool) $get('date_config_type'))
->live()
->helperText('Select the dates to use for this campaign. Ordering does not matter as it will be chronologically ordered once you save this page.'),
])
->columns(1),
Section::make('Allocations')->collapsible()->schema([
Repeater::make('allocation_types')->schema([
Select::make('donation_type_id')
->label('Donation Item')
->options(function (\Filament\Forms\Get $get) {
$donationTypes = [];
$firstDonationCountryId = static::donationCountries()->first()->id;
if (($countryTypeId = $get('country_type_id')) && ($countryTypeId != $firstDonationCountryId)) {
$donationTypes = DonationCountry::find($countryTypeId)
?->donationTypes()
->where('is_scheduling', true)
->get() ?? collect([]);
} else {
$donationTypes = static::donationTypes();
}
return $donationTypes->mapWithKeys(
fn ($record) => [$record->id => $record->display_name]
)->toArray();
})
->required()
->live(),
Select::make('country_type_id')
->label('Country')
->options(function (\Filament\Forms\Get $get) {
$donationCountries = [];
$firstDonationTypeId = static::donationTypes()->first()->id;
if (($donationTypeId = $get('donation_type_id')) && ($donationTypeId != $firstDonationTypeId)) {
$donationCountries = DonationType::find($donationTypeId)
?->donationCountries ?? collect([]);
} else {
$donationCountries = DonationCountry::all();
}
return $donationCountries->mapWithKeys(
fn ($record) => [$record->id => $record->name]
)->toArray();
})
->required()
->live(),
TextInput::make('title')
->placeholder('Hunger After Eid')
->required()
->columnSpanFull(),
TextInput::make('description')
->placeholder('Make a contribution towards causes with the greatest need, including emergencies.')
->required()
->columnSpanFull(),
])
->columns()
->helperText('Set up the possible allocation types for this donation.'),
]),
Section::make('Page Metadata')->schema([
TextInput::make('meta_title')
->label('Title')
->maxLength(512)
->placeholder(fn (\Filament\Forms\Get $get) => $get('title')),
Textarea::make('meta_description')
->label('Description')
->columnSpanFull(),
TextInput::make('meta_keywords')
->label('Keywords')
->maxLength(512),
])
->collapsible(),
]);
}
public static function table(Table $table): Table
{
return $table
->columns([
ToggleColumn::make('active'),
TextColumn::make('user.name')
->label('Creator')
->numeric()
->sortable()
->toggleable(isToggledHiddenByDefault: false),
TextColumn::make('title')
->description(fn ($record) => $record->slug)
->searchable(),
TextColumn::make('split_types_formatted')
->label('Split Types'),
TextColumn::make('created_at')
->dateTime()
->sortable()
->toggleable(isToggledHiddenByDefault: true),
TextColumn::make('updated_at')
->dateTime()
->sortable()
->toggleable(isToggledHiddenByDefault: true),
])
->actions([
EditAction::make()
->visible(fn () => Auth::user()?->hasRole('Superadmin')), // On Asim's request, this is only available to top-level administrators.
]);
}
public static function getRelations(): array
{
return [
ScheduledGivingDonationsRelationManager::class,
];
}
public static function getPages(): array
{
return [
'index' => ListScheduledGivingCampaigns::route('/'),
'create' => CreateScheduledGivingCampaign::route('/create'),
'edit' => EditScheduledGivingCampaign::route('/{record}/edit'),
];
}
protected static function formatHijriMonths(): array
{
$c = new HijriCalendar;
$islamicMonths = config('calendar.hijri.months');
$monthOffset = $c->getCurrentIslamicMonth();
$hijriYear = $c->getCurrentIslamicYear();
$rotatedMonths = array_merge(
array_slice($islamicMonths, $monthOffset - 1, null, true), // From current month to the end
array_slice($islamicMonths, 0, $monthOffset - 1, true) // From start to just before current month
);
$output = [];
foreach ($rotatedMonths as $index => $monthInfo) {
$englishName = $monthInfo['en'];
$arabicName = $monthInfo['ar'];
$output[$monthInfo['number']] = "{$arabicName} ({$englishName}) {$hijriYear}";
if ($monthInfo['number'] == 12) {
$hijriYear++;
}
}
return $output;
}
/**
* Will always return 30 days. This formats the days of the week to show.
*/
protected static function formatHijriDays(int $monthIndex): array
{
$c = new HijriCalendar;
$monthIndex = Str::padLeft($monthIndex, 2, '0');
$currentYear = $c->getCurrentIslamicYear();
$currentDate = "01-{$monthIndex}-{$currentYear}";
$dates = [];
for ($i = 1; $i <= 30; $i++) {
$dates[$i] = $currentDate;
$currentDate = $c->adjustHijriDate($currentDate, 1);
}
return $dates;
}
private static function donationTypes(): Collection
{
return DonationType::where('is_scheduling', true)->orderBy('display_name')->get();
}
private static function donationCountries(): Collection
{
return DonationCountry::orderBy('name')->get();
}
}