PluginProbe
Better Payment – Instant Payments, Donations, Fundraising with Subscriptions & More / 2.4.0
Better Payment – Instant Payments, Donations, Fundraising with Subscriptions & More v2.4.0
2.4.1 2.4.0 2.3.4 2.3.3 2.3.2 2.3.1 2.3.0 2.2.2 2.2.1 2.2.0 2.1.2 2.1.1 trunk 0.0.1 0.0.2 0.0.3 0.0.4 0.0.5 0.0.6 0.0.7 1.0.0 1.0.1 1.0.2 1.0.3 1.0.4 All 68 releases
better-payment / includes / AI / Layout / LayoutLibrary.php

LayoutLibrary.php in Better Payment – Instant Payments, Donations, Fundraising with Subscriptions & More 2.4.0, at includes/AI/Layout/LayoutLibrary.php

387 lines 19.6 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
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