PluginProbe
Better Payment – Instant Payments, Donations, Fundraising with Subscriptions & More / 2.3.4
Better Payment – Instant Payments, Donations, Fundraising with Subscriptions & More v2.3.4
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 1.0.5 1.0.6 All 66 releases
better-payment / includes / AI / Layout / LayoutLibrary.php

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

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