| 1 |
<?php |
| 2 |
|
| 3 |
namespace Better_Payment\Lite\AI\Layout; |
| 4 |
|
| 5 |
if ( ! defined( 'ABSPATH' ) ) { |
| 6 |
exit; |
| 7 |
} |
| 8 |
|
| 9 |
/** |
| 10 |
* Reusable layout blueprint library for AI campaign generation. |
| 11 |
* |
| 12 |
* The AI used to default to the same 2-column "story + donation sidebar" every |
| 13 |
* time, so generated campaigns felt cloned. This library gives generation a |
| 14 |
* varied palette of professionally-composed page *structures* — different layout |
| 15 |
* presets, column arrangements, widths, section sets, ordering and donation |
| 16 |
* placement — all expressed with the EXISTING element types and layout presets |
| 17 |
* (no new widgets/blocks). When the user hasn't pre-chosen a layout, the |
| 18 |
* generator picks a blueprint (weighted, category-aware, avoiding immediate |
| 19 |
* repeats) and builds the campaign into it. The result is controlled creativity: |
| 20 |
* every campaign varies structurally, but each one still follows sound design. |
| 21 |
* |
| 22 |
* A blueprint is pure configuration, so new patterns can be added — here or via |
| 23 |
* the `better_payment/ai/layout_blueprints` filter — without touching the |
| 24 |
* generation logic. |
| 25 |
* |
| 26 |
* Blueprint shape: |
| 27 |
* [ |
| 28 |
* 'key' => string, // unique |
| 29 |
* 'name' => string, // human name, shown to the model |
| 30 |
* 'weight' => int, // selection weight |
| 31 |
* 'tags' => array<string>, // campaign categories it suits |
| 32 |
* 'preset' => '1-column'|'2-column'|'3-column'|'split', |
| 33 |
* 'columns' => [ [ 'id','label','width','role', |
| 34 |
* 'suggested' => [element types] ], ... ], |
| 35 |
* ] |
| 36 |
* |
| 37 |
* Blueprints name free element types only. They used to carry a per-column `pro` |
| 38 |
* list of Pro widgets, read by a `suggested_for()` helper — but generation stopped |
| 39 |
* prescribing a blueprint, nothing called it any more, and Pro-widget placement |
| 40 |
* is proposed to a Pro install by CampaignGenerator::pro_elements_instruction(). |
| 41 |
* |
| 42 |
* @see \Better_Payment\Lite\AI\Services\CampaignGenerator Consumer. |
| 43 |
*/ |
| 44 |
class LayoutLibrary { |
| 45 |
|
| 46 |
/** |
| 47 |
* Keyword → category map used to infer a campaign's category from its brief. |
| 48 |
* |
| 49 |
* @return array<string, array<int, string>> |
| 50 |
*/ |
| 51 |
private static function category_keywords(): array { |
| 52 |
// Matched as whole words (see infer_categories), so include the inflected |
| 53 |
// forms that matter rather than bare stems. |
| 54 |
return [ |
| 55 |
'medical' => [ 'medical', 'surgery', 'surgeries', 'cancer', 'hospital', 'treatment', 'health', 'illness', 'disease', 'transplant', 'therapy', 'diagnosis', 'diagnosed' ], |
| 56 |
'emergency' => [ 'emergency', 'urgent', 'disaster', 'relief', 'crisis', 'flood', 'fire', 'earthquake', 'hurricane', 'refugee', 'refugees', 'evacuate', 'evacuated' ], |
| 57 |
'memorial' => [ 'memorial', 'funeral', 'memory', 'tribute', 'grief', 'loss', 'passed away' ], |
| 58 |
'animal' => [ 'animal', 'animals', 'dog', 'dogs', 'cat', 'cats', 'shelter', 'wildlife', 'pet', 'pets', 'rescue', 'paws', 'kitten', 'kittens', 'puppy', 'puppies' ], |
| 59 |
'education' => [ 'school', 'schools', 'student', 'students', 'education', 'scholarship', 'college', 'tuition', 'learning', 'library', 'classroom', 'books' ], |
| 60 |
'community' => [ 'community', 'neighborhood', 'neighbourhood', 'local', 'together', 'village', 'town' ], |
| 61 |
'event' => [ 'event', 'marathon', 'gala', 'tournament', 'sports', 'team', 'concert', 'festival', 'challenge' ], |
| 62 |
'nonprofit' => [ 'nonprofit', 'non-profit', 'charity', 'foundation', 'ngo', 'mission', 'humanitarian' ], |
| 63 |
'creative' => [ 'creative', 'art', 'film', 'music', 'album', 'book', 'startup', 'invention', 'documentary' ], |
| 64 |
]; |
| 65 |
} |
| 66 |
|
| 67 |
/** |
| 68 |
* Curated primary-colour palettes with mood tags, for colour variation. |
| 69 |
* |
| 70 |
* @return array<int, array{primary: string, mood: string, tags: array<int, string>}> |
| 71 |
*/ |
| 72 |
private static function palettes(): array { |
| 73 |
return [ |
| 74 |
[ 'primary' => '#6b63f6', 'mood' => 'trustworthy and modern', 'tags' => [ 'general', 'nonprofit', 'creative', 'event' ] ], |
| 75 |
[ 'primary' => '#e0533f', 'mood' => 'urgent and heartfelt', 'tags' => [ 'emergency', 'medical' ] ], |
| 76 |
[ 'primary' => '#2ea56b', 'mood' => 'hopeful and reassuring', 'tags' => [ 'medical', 'community', 'animal', 'education' ] ], |
| 77 |
[ 'primary' => '#3b6ef5', 'mood' => 'calm and credible', 'tags' => [ 'nonprofit', 'education', 'general' ] ], |
| 78 |
[ 'primary' => '#ef8f2b', 'mood' => 'warm and welcoming', 'tags' => [ 'community', 'event', 'animal' ] ], |
| 79 |
[ 'primary' => '#0ea5a5', 'mood' => 'fresh and optimistic', 'tags' => [ 'creative', 'community', 'general' ] ], |
| 80 |
[ 'primary' => '#8b5cf6', 'mood' => 'gentle and dignified', 'tags' => [ 'memorial', 'nonprofit' ] ], |
| 81 |
[ 'primary' => '#d64d76', 'mood' => 'compassionate and personal', 'tags' => [ 'medical', 'memorial', 'community' ] ], |
| 82 |
]; |
| 83 |
} |
| 84 |
|
| 85 |
/** |
| 86 |
* The built-in blueprints. Widths in a row total ~100%; `split` renders as a |
| 87 |
* full-width row (first column) then two 50/50 columns. |
| 88 |
* |
| 89 |
* @return array<int, array> |
| 90 |
*/ |
| 91 |
private static function blueprints(): array { |
| 92 |
return [ |
| 93 |
[ |
| 94 |
'key' => 'story-sidebar', |
| 95 |
'name' => 'Story with donation sidebar', |
| 96 |
'weight' => 3, |
| 97 |
'tags' => [ 'general', 'medical', 'community', 'nonprofit' ], |
| 98 |
'preset' => '2-column', |
| 99 |
'columns' => [ |
| 100 |
[ 'id' => 'main', 'label' => 'Main Content', 'width' => '64%', 'role' => 'Hero title, image and the campaign story, ending with the progress bar', 'suggested' => [ 'campaign_title', 'photo', 'campaign_description', 'progress_bar' ] ], |
| 101 |
[ 'id' => 'sidebar', 'label' => 'Sidebar', 'width' => '36%', 'role' => 'Donation actions and quick stats', 'suggested' => [ 'donation_form', 'donate_amount', 'campaign_summary', 'social_sharing' ] ], |
| 102 |
], |
| 103 |
], |
| 104 |
[ |
| 105 |
'key' => 'donate-first', |
| 106 |
'name' => 'Donation-first (action on the left)', |
| 107 |
'weight' => 2, |
| 108 |
'tags' => [ 'emergency', 'medical', 'nonprofit' ], |
| 109 |
'preset' => '2-column', |
| 110 |
'columns' => [ |
| 111 |
[ 'id' => 'action', 'label' => 'Donate', 'width' => '42%', 'role' => 'Prominent donation panel with amounts, progress and summary', 'suggested' => [ 'donate_amount', 'donation_form', 'progress_bar', 'campaign_summary' ] ], |
| 112 |
[ 'id' => 'story', 'label' => 'Story', 'width' => '58%', 'role' => 'Title, image, story and organizer', 'suggested' => [ 'campaign_title', 'photo', 'campaign_description', 'organizer' ] ], |
| 113 |
], |
| 114 |
], |
| 115 |
[ |
| 116 |
'key' => 'full-story', |
| 117 |
'name' => 'Full-width storytelling', |
| 118 |
'weight' => 2, |
| 119 |
'tags' => [ 'memorial', 'community', 'nonprofit', 'general' ], |
| 120 |
'preset' => '1-column', |
| 121 |
'columns' => [ |
| 122 |
[ 'id' => 'main', 'label' => 'Main Content', 'width' => '100%', 'role' => 'A single vertical narrative: hero, story, progress, then the donation ask and organizer', 'suggested' => [ 'campaign_title', 'photo', 'campaign_description', 'progress_bar', 'donate_amount', 'donation_form', 'organizer', 'social_sharing' ] ], |
| 123 |
], |
| 124 |
], |
| 125 |
[ |
| 126 |
'key' => 'hero-split', |
| 127 |
'name' => 'Hero on top, story and donation below', |
| 128 |
'weight' => 3, |
| 129 |
'tags' => [ 'event', 'community', 'general', 'creative' ], |
| 130 |
'preset' => 'split', |
| 131 |
'columns' => [ |
| 132 |
[ 'id' => 'hero', 'label' => 'Hero', 'width' => '100%', 'role' => 'Full-width hero: title and a strong image', 'suggested' => [ 'campaign_title', 'photo' ] ], |
| 133 |
[ 'id' => 'story', 'label' => 'Story', 'width' => '50%', 'role' => 'The story, progress and organizer', 'suggested' => [ 'campaign_description', 'progress_bar', 'organizer' ] ], |
| 134 |
[ 'id' => 'donate', 'label' => 'Donate', 'width' => '50%', 'role' => 'Summary and the donation ask', 'suggested' => [ 'campaign_summary', 'donate_amount', 'donation_form', 'social_sharing' ] ], |
| 135 |
], |
| 136 |
], |
| 137 |
[ |
| 138 |
'key' => 'impact-split', |
| 139 |
'name' => 'Impact overview then story and donation', |
| 140 |
'weight' => 2, |
| 141 |
'tags' => [ 'nonprofit', 'medical', 'education', 'community' ], |
| 142 |
'preset' => 'split', |
| 143 |
'columns' => [ |
| 144 |
[ 'id' => 'intro', 'label' => 'Intro', 'width' => '100%', 'role' => 'Full-width title and a concise mission statement', 'suggested' => [ 'campaign_title', 'campaign_description' ] ], |
| 145 |
[ 'id' => 'impact', 'label' => 'Impact', 'width' => '50%', 'role' => 'Visual impact: image, summary stats and progress', 'suggested' => [ 'photo', 'campaign_summary', 'progress_bar' ] ], |
| 146 |
[ 'id' => 'donate', 'label' => 'Donate', 'width' => '50%', 'role' => 'Donation amounts, the form and sharing', 'suggested' => [ 'donate_amount', 'donation_form', 'social_sharing' ] ], |
| 147 |
], |
| 148 |
], |
| 149 |
[ |
| 150 |
'key' => 'minimal', |
| 151 |
'name' => 'Minimal landing page', |
| 152 |
'weight' => 1, |
| 153 |
'tags' => [ 'event', 'creative', 'general' ], |
| 154 |
'preset' => '1-column', |
| 155 |
'columns' => [ |
| 156 |
[ 'id' => 'main', 'label' => 'Main Content', 'width' => '100%', 'role' => 'A short, focused page: title, one tight paragraph, progress and a single clear donate action', 'suggested' => [ 'campaign_title', 'campaign_description', 'progress_bar', 'donation_form' ] ], |
| 157 |
], |
| 158 |
], |
| 159 |
[ |
| 160 |
'key' => 'modern-trio', |
| 161 |
'name' => 'Three-column overview', |
| 162 |
'weight' => 1, |
| 163 |
'tags' => [ 'creative', 'community', 'nonprofit', 'event' ], |
| 164 |
'preset' => '3-column', |
| 165 |
'columns' => [ |
| 166 |
[ 'id' => 'story', 'label' => 'Story', 'width' => '38%', 'role' => 'Title and the story', 'suggested' => [ 'campaign_title', 'campaign_description' ] ], |
| 167 |
[ 'id' => 'visual', 'label' => 'Visual', 'width' => '32%', 'role' => 'Image and progress', 'suggested' => [ 'photo', 'progress_bar' ] ], |
| 168 |
[ 'id' => 'action', 'label' => 'Action', 'width' => '30%', 'role' => 'Amounts and the donation form', 'suggested' => [ 'donate_amount', 'donation_form', 'social_sharing' ] ], |
| 169 |
], |
| 170 |
], |
| 171 |
[ |
| 172 |
'key' => 'organizer-led', |
| 173 |
'name' => 'Organizer-led (profile sidebar on the left)', |
| 174 |
'weight' => 1, |
| 175 |
'tags' => [ 'nonprofit', 'community', 'memorial' ], |
| 176 |
'preset' => '2-column', |
| 177 |
'columns' => [ |
| 178 |
[ 'id' => 'profile', 'label' => 'Organizer', 'width' => '36%', 'role' => 'Who is running this: organizer card, summary and links', 'suggested' => [ 'organizer', 'campaign_summary', 'social_links' ] ], |
| 179 |
[ 'id' => 'main', 'label' => 'Main Content', 'width' => '64%', 'role' => 'Title, image, story, progress and the donation ask', 'suggested' => [ 'campaign_title', 'photo', 'campaign_description', 'progress_bar', 'donation_form' ] ], |
| 180 |
], |
| 181 |
], |
| 182 |
[ |
| 183 |
'key' => 'gallery-story', |
| 184 |
'name' => 'Gallery storytelling', |
| 185 |
'weight' => 1, |
| 186 |
'tags' => [ 'event', 'creative', 'community', 'animal' ], |
| 187 |
'preset' => '1-column', |
| 188 |
'columns' => [ |
| 189 |
[ 'id' => 'main', 'label' => 'Main Content', 'width' => '100%', 'role' => 'An image-rich narrative: title, image, story, a second image, progress, then donate and share', 'suggested' => [ 'campaign_title', 'photo', 'campaign_description', 'photo', 'progress_bar', 'donation_form', 'social_sharing' ] ], |
| 190 |
], |
| 191 |
], |
| 192 |
[ |
| 193 |
'key' => 'urgent-emergency', |
| 194 |
'name' => 'Urgent emergency appeal', |
| 195 |
'weight' => 2, |
| 196 |
'tags' => [ 'emergency', 'medical', 'animal' ], |
| 197 |
'preset' => '2-column', |
| 198 |
'columns' => [ |
| 199 |
[ 'id' => 'main', 'label' => 'Main Content', 'width' => '58%', 'role' => 'An urgent title, the situation, live progress and an image', 'suggested' => [ 'campaign_title', 'campaign_description', 'progress_bar', 'photo' ] ], |
| 200 |
[ 'id' => 'sidebar', 'label' => 'Sidebar', 'width' => '42%', 'role' => 'Immediate donation ask with amounts and summary', 'suggested' => [ 'donate_amount', 'donation_form', 'campaign_summary', 'social_sharing' ] ], |
| 201 |
], |
| 202 |
], |
| 203 |
[ |
| 204 |
'key' => 'balanced', |
| 205 |
'name' => 'Balanced two-column', |
| 206 |
'weight' => 1, |
| 207 |
'tags' => [ 'general', 'education', 'community', 'creative' ], |
| 208 |
'preset' => '2-column', |
| 209 |
'columns' => [ |
| 210 |
[ 'id' => 'left', 'label' => 'Left', 'width' => '50%', 'role' => 'Title, image and the story', 'suggested' => [ 'campaign_title', 'photo', 'campaign_description' ] ], |
| 211 |
[ 'id' => 'right', 'label' => 'Right', 'width' => '50%', 'role' => 'Progress, amounts, the form and summary', 'suggested' => [ 'progress_bar', 'donate_amount', 'donation_form', 'campaign_summary' ] ], |
| 212 |
], |
| 213 |
], |
| 214 |
[ |
| 215 |
'key' => 'progress-hero-split', |
| 216 |
'name' => 'Progress hero, then story and donation', |
| 217 |
'weight' => 1, |
| 218 |
'tags' => [ 'medical', 'emergency', 'education', 'general' ], |
| 219 |
'preset' => 'split', |
| 220 |
'columns' => [ |
| 221 |
[ 'id' => 'hero', 'label' => 'Hero', 'width' => '100%', 'role' => 'Full-width title with the progress bar right up top', 'suggested' => [ 'campaign_title', 'progress_bar' ] ], |
| 222 |
[ 'id' => 'story', 'label' => 'Story', 'width' => '50%', 'role' => 'Image and the story', 'suggested' => [ 'photo', 'campaign_description' ] ], |
| 223 |
[ 'id' => 'donate', 'label' => 'Donate', 'width' => '50%', 'role' => 'Amounts, the form and summary', 'suggested' => [ 'donate_amount', 'donation_form', 'campaign_summary', 'social_sharing' ] ], |
| 224 |
], |
| 225 |
], |
| 226 |
]; |
| 227 |
} |
| 228 |
|
| 229 |
/** |
| 230 |
* All blueprints, after the extension filter. |
| 231 |
* |
| 232 |
* @return array<int, array> |
| 233 |
*/ |
| 234 |
public static function all(): array { |
| 235 |
/** |
| 236 |
* Filter the AI layout blueprints. |
| 237 |
* |
| 238 |
* @param array<int, array> $blueprints |
| 239 |
*/ |
| 240 |
return apply_filters( 'better_payment/ai/layout_blueprints', self::blueprints() ); |
| 241 |
} |
| 242 |
|
| 243 |
/** |
| 244 |
* Infer the campaign's category tags from its brief (keyword match). |
| 245 |
* |
| 246 |
* @return array<int, string> |
| 247 |
*/ |
| 248 |
public static function infer_categories( string $brief ): array { |
| 249 |
$haystack = strtolower( $brief ); |
| 250 |
$matched = []; |
| 251 |
foreach ( self::category_keywords() as $category => $keywords ) { |
| 252 |
foreach ( $keywords as $kw ) { |
| 253 |
// Whole-word match so short words don't hit inside others |
| 254 |
// (e.g. "cat" must not match "category"). |
| 255 |
if ( preg_match( '/\b' . preg_quote( $kw, '/' ) . '\b/', $haystack ) ) { |
| 256 |
$matched[] = $category; |
| 257 |
break; |
| 258 |
} |
| 259 |
} |
| 260 |
} |
| 261 |
return $matched; |
| 262 |
} |
| 263 |
|
| 264 |
/** |
| 265 |
* Pick a blueprint for a brief: prefer ones matching the inferred category, |
| 266 |
* weight the choice, and avoid repeating $exclude_key when possible. |
| 267 |
* |
| 268 |
* @param string $brief |
| 269 |
* @param string $exclude_key Blueprint key to avoid (e.g. the last one used). |
| 270 |
* @return array The chosen blueprint. |
| 271 |
*/ |
| 272 |
public static function pick( string $brief, string $exclude_key = '' ): array { |
| 273 |
$all = array_values( self::all() ); |
| 274 |
$categories = self::infer_categories( $brief ); |
| 275 |
|
| 276 |
// Prefer blueprints tagged with an inferred category; fall back to all. |
| 277 |
$candidates = []; |
| 278 |
if ( ! empty( $categories ) ) { |
| 279 |
foreach ( $all as $bp ) { |
| 280 |
if ( array_intersect( $categories, $bp['tags'] ?? [] ) ) { |
| 281 |
$candidates[] = $bp; |
| 282 |
} |
| 283 |
} |
| 284 |
} |
| 285 |
if ( empty( $candidates ) ) { |
| 286 |
$candidates = $all; |
| 287 |
} |
| 288 |
|
| 289 |
// Avoid repeating the previous blueprint when there is an alternative. |
| 290 |
if ( '' !== $exclude_key && count( $candidates ) > 1 ) { |
| 291 |
$filtered = array_values( array_filter( |
| 292 |
$candidates, |
| 293 |
static function ( $bp ) use ( $exclude_key ) { |
| 294 |
return ( $bp['key'] ?? '' ) !== $exclude_key; |
| 295 |
} |
| 296 |
) ); |
| 297 |
if ( ! empty( $filtered ) ) { |
| 298 |
$candidates = $filtered; |
| 299 |
} |
| 300 |
} |
| 301 |
|
| 302 |
return self::weighted_pick( $candidates ); |
| 303 |
} |
| 304 |
|
| 305 |
/** |
| 306 |
* Pick a colour palette for a brief, biased toward the inferred category. |
| 307 |
* |
| 308 |
* @return array{primary: string, mood: string} |
| 309 |
*/ |
| 310 |
public static function pick_palette( string $brief ): array { |
| 311 |
$categories = self::infer_categories( $brief ); |
| 312 |
$all = self::palettes(); |
| 313 |
|
| 314 |
$candidates = []; |
| 315 |
if ( ! empty( $categories ) ) { |
| 316 |
foreach ( $all as $palette ) { |
| 317 |
if ( array_intersect( $categories, $palette['tags'] ) ) { |
| 318 |
$candidates[] = $palette; |
| 319 |
} |
| 320 |
} |
| 321 |
} |
| 322 |
if ( empty( $candidates ) ) { |
| 323 |
$candidates = $all; |
| 324 |
} |
| 325 |
|
| 326 |
$chosen = $candidates[ self::rand_index( count( $candidates ) ) ]; |
| 327 |
return [ 'primary' => $chosen['primary'], 'mood' => $chosen['mood'] ]; |
| 328 |
} |
| 329 |
|
| 330 |
/** |
| 331 |
* The trusted column structure for enforcement — only id/label/width (the |
| 332 |
* role/suggested hints are for the prompt, not the stored layout). |
| 333 |
* |
| 334 |
* @param array $blueprint |
| 335 |
* @return array{preset: string, columns: array} |
| 336 |
*/ |
| 337 |
public static function structure( array $blueprint ): array { |
| 338 |
$columns = []; |
| 339 |
foreach ( $blueprint['columns'] ?? [] as $col ) { |
| 340 |
$columns[] = [ |
| 341 |
'id' => $col['id'] ?? '', |
| 342 |
'label' => $col['label'] ?? '', |
| 343 |
'width' => $col['width'] ?? '100%', |
| 344 |
]; |
| 345 |
} |
| 346 |
return [ |
| 347 |
'preset' => $blueprint['preset'] ?? '1-column', |
| 348 |
'columns' => $columns, |
| 349 |
]; |
| 350 |
} |
| 351 |
|
| 352 |
// ------------------------------------------------------------------ helpers |
| 353 |
|
| 354 |
/** |
| 355 |
* Weighted random pick from a list of blueprints. |
| 356 |
* |
| 357 |
* @param array<int, array> $candidates |
| 358 |
* @return array |
| 359 |
*/ |
| 360 |
private static function weighted_pick( array $candidates ): array { |
| 361 |
$total = 0; |
| 362 |
foreach ( $candidates as $bp ) { |
| 363 |
$total += max( 1, (int) ( $bp['weight'] ?? 1 ) ); |
| 364 |
} |
| 365 |
$roll = self::rand_int( 1, $total ); |
| 366 |
$acc = 0; |
| 367 |
foreach ( $candidates as $bp ) { |
| 368 |
$acc += max( 1, (int) ( $bp['weight'] ?? 1 ) ); |
| 369 |
if ( $roll <= $acc ) { |
| 370 |
return $bp; |
| 371 |
} |
| 372 |
} |
| 373 |
return $candidates[0]; |
| 374 |
} |
| 375 |
|
| 376 |
private static function rand_int( int $min, int $max ): int { |
| 377 |
if ( $max <= $min ) { |
| 378 |
return $min; |
| 379 |
} |
| 380 |
return function_exists( 'wp_rand' ) ? wp_rand( $min, $max ) : mt_rand( $min, $max ); // phpcs:ignore WordPress.WP.AlternativeFunctions.rand_mt_rand |
| 381 |
} |
| 382 |
|
| 383 |
private static function rand_index( int $count ): int { |
| 384 |
return $count > 0 ? self::rand_int( 0, $count - 1 ) : 0; |
| 385 |
} |
| 386 |
} |
| 387 |
|