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:
365
temp_files/fix2/ScheduledGivingCampaignResource.php
Normal file
365
temp_files/fix2/ScheduledGivingCampaignResource.php
Normal 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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user