PluginProbe
Better Payment – Instant Payments, Donations, Fundraising with Subscriptions & More / 2.4.1
Better Payment – Instant Payments, Donations, Fundraising with Subscriptions & More v2.4.1
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 / Services / CampaignGenerator.php

CampaignGenerator.php in Better Payment – Instant Payments, Donations, Fundraising with Subscriptions & More 2.4.1, at includes/AI/Services/CampaignGenerator.php

914 lines 40.7 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\Services;
4
5 use Better_Payment\Lite\AI\AIManager;
6 use Better_Payment\Lite\AI\Layout\LayoutLibrary;
7 use Better_Payment\Lite\AI\Schema\CampaignSchema;
8 use Better_Payment\Lite\Campaign\Elements\ElementRegistry;
9 use Better_Payment\Lite\Campaign\Templates\CategoryRegistry;
10
11 if ( ! defined( 'ABSPATH' ) ) {
12 exit;
13 }
14
15 /**
16 * Full-campaign generation from a natural-language brief.
17 *
18 * Thin wrapper over {@see AIService} in 'generate' mode that also derives a
19 * convenience { layout, meta } from the returned operations, so the /ai/generate
20 * endpoint can hand the client a ready-to-apply campaign in addition to the raw
21 * operations.
22 */
23 class CampaignGenerator {
24
25 /**
26 * A full page layout (the large `set_layout` tool call plus its element JSON)
27 * needs far more output than a conversational edit. Generation therefore uses
28 * a generous token floor so the layout is not truncated and silently dropped,
29 * leaving only the small meta calls applied.
30 */
31 const GENERATE_TOKEN_FLOOR = 8000;
32
33 /**
34 * @param string $brief
35 * @param array $context Optional starting state.
36 * @param string $category Campaign category slug the user explicitly chose in
37 * the Smart Prompt Wizard. '' when they picked no
38 * category tile (or wrote their own prompt), in which
39 * case one is inferred from the brief.
40 * @param array $fields Campaign fields the user filled in themselves, keyed
41 * by meta key. A key present but empty means they were
42 * asked and left it blank, which is enforced as "stays
43 * empty" — see {@see UserFieldGuard}. Absent entirely
44 * (the free-form prompt path) leaves the model's value.
45 * @return array|\WP_Error { assistant_message, operations, layout, meta, usage, layout_generated, category }
46 */
47 public static function generate( string $brief, array $context = [], string $category = '', array $fields = [] ) {
48 $config = AIManager::config_for( AIManager::active_provider_id() );
49 $max_tokens = max( self::GENERATE_TOKEN_FLOOR, (int) ( $config['max_tokens'] ?? 0 ) );
50 $original = $brief;
51
52 // Detect the category once, from the ORIGINAL brief — before the
53 // structure/palette instructions are appended below. Those append element
54 // names ("photo"), column roles and colour words that are ours, not the
55 // user's, and matching against them would classify the campaign by our own
56 // boilerplate. It is returned as metadata; it no longer picks the artwork.
57 $resolved = self::resolve_category( $category, $original );
58
59 // Every AI-generated photo uses the single neutral default hero
60 // (`ai-default-hero.svg`), for every category and template — never
61 // category-specific artwork. This asset is always present, so a generated
62 // photo can never fall back to an empty/broken `src` that renders as bare
63 // alt text in the builder. The model cannot produce a real image URL, so
64 // fill_default_photos() forces this onto every photo element.
65 $photo_url = self::default_photo_url();
66
67 // Two structural paths, and only ONE of them may impose a structure:
68 //
69 // - The user pre-chose a layout in the template picker → build into that
70 // exact structure, and snap the model's output onto it afterwards. The
71 // structure is enforced because it is *the user's own choice*.
72 // - No layout chosen → the structure is derived from the user's BRIEF.
73 // Nothing is imposed, and `$selected` stays null so `attempt()` never
74 // runs `enforce_structure()`.
75 //
76 // This branch used to pick a blueprint from LayoutLibrary and treat it
77 // exactly like a user selection: it appended "build this layout with
78 // exactly these columns and widths", and then rewrote every column's
79 // width from the blueprint regardless of what came back. A user who asked
80 // for "a 50/50 column campaign" was answered with a 42%/58% page — their
81 // instruction was first contradicted by a more specific competing one, and
82 // then overwritten by code that ignored the model's answer entirely. The
83 // model could not have complied even if it tried.
84 $selected = self::selected_structure( $context );
85
86 if ( null !== $selected ) {
87 $brief .= self::structure_instruction( $selected );
88 } else {
89 $brief .= self::layout_instruction();
90 $brief .= self::palette_instruction( LayoutLibrary::pick_palette( $original ) );
91 }
92
93 // Offer the Pro widgets on an entitled install — in both structural paths,
94 // since a user who pre-picked a layout has paid for them just the same.
95 $brief .= self::pro_elements_instruction();
96
97 // Any video URL the model may keep has to come from the user, so read them
98 // from the ORIGINAL brief — before our own instruction text is appended.
99 $video_urls = self::urls_in( $original );
100
101 // Attempt generation. If the page layout doesn't come through (empty or
102 // dropped set_layout), retry once — forcing a tool call and nudging toward a
103 // concise, complete layout — before giving up honestly.
104 $result = self::attempt( $brief, $context, $selected, $max_tokens, false, $photo_url, $fields, $video_urls );
105 if ( is_wp_error( $result ) ) {
106 return $result;
107 }
108 if ( null === $result['layout'] ) {
109 $retry = self::attempt( $brief . self::retry_instruction(), $context, $selected, $max_tokens, true, $photo_url, $fields, $video_urls );
110 if ( ! is_wp_error( $retry ) && null !== $retry['layout'] ) {
111 $result = $retry;
112 }
113 }
114
115 $layout = $result['layout'];
116 $meta = $result['meta'];
117
118 // Name the campaign: if the model didn't set a title but the layout has a
119 // campaign_title, adopt its text (so the builder isn't left "Campaign Name").
120 if ( null !== $layout ) {
121 $has_title_op = false;
122 foreach ( $result['operations'] as $op ) {
123 if ( is_array( $op ) && 'update_meta' === ( $op['op'] ?? '' ) && 'title' === ( $op['key'] ?? '' ) ) {
124 $has_title_op = true;
125 break;
126 }
127 }
128 if ( ! $has_title_op && empty( $meta['title'] ) ) {
129 $title = self::first_title( $layout );
130 if ( '' !== $title ) {
131 $result['operations'][] = [ 'op' => 'update_meta', 'key' => 'title', 'value' => $title ];
132 $meta['title'] = $title;
133 }
134 }
135 }
136
137 $result['layout'] = $layout;
138 $result['meta'] = $meta;
139 $result['layout_generated'] = null !== $layout;
140 // '' when the campaign matched no known category — the client can tell
141 // "we used your category's artwork" from "we used the default hero".
142 $result['category'] = $resolved;
143
144 // Be honest: a generation that produced no page layout (only meta) left the
145 // canvas empty. Do not report success — tell the user so they can retry.
146 if ( null === $layout ) {
147 $result['assistant_message'] = __(
148 'I set up the campaign details, but the page layout did not come through — the response may have been too long. Please try generating again, optionally with a shorter brief.',
149 'better-payment'
150 );
151 }
152
153 return $result;
154 }
155
156 /**
157 * One generation attempt: run the model, snap onto the chosen structure, and
158 * extract { layout, meta }. An all-empty layout is dropped (returns layout null).
159 *
160 * @param string $brief
161 * @param array $context
162 * @param array|null $selected
163 * @param int $max_tokens
164 * @param bool $force Force a tool call (tool_choice: required).
165 * @param string $photo_url Image to force onto every photo element.
166 * @param array $fields The user's own campaign fields (see generate()).
167 * @param array $video_urls URLs found in the user's brief — the only ones a
168 * `video` element may keep (see guard_generated_operations()).
169 * @return array|\WP_Error
170 */
171 private static function attempt( string $brief, array $context, $selected, int $max_tokens, bool $force, string $photo_url = '', array $fields = [], array $video_urls = [] ) {
172 $options = [ 'max_tokens' => $max_tokens ];
173 if ( $force ) {
174 $options['tool_choice'] = 'required';
175 }
176
177 $result = AIService::run( 'generate', $brief, $context, [], $options );
178 if ( is_wp_error( $result ) ) {
179 return $result;
180 }
181
182 if ( null !== $selected ) {
183 $result['operations'] = self::enforce_structure( $result['operations'], $selected );
184 }
185
186 // Give photo elements a real image (the AI can't produce one), so the
187 // campaign looks finished — users swap it via the media library.
188 $result['operations'] = self::fill_default_photos( $result['operations'], $photo_url );
189
190 // Same reasoning as photos, sharper consequence: a model cannot know a real
191 // video address either, and an invented one resolves to a stranger's video.
192 // The video guard ships with Pro, on `better_payment/ai/generated_operations`.
193 $result['operations'] = self::guard_generated_operations( $result['operations'], $video_urls );
194
195 // The amount tiers and the button that submits them are one action: the
196 // button must sit directly under the Donate Amount block. Structural, so
197 // the prompt alone cannot guarantee it — this is the part that holds.
198 $result['operations'] = self::enforce_donate_button_placement( $result['operations'] );
199
200 // The user's own answers outrank the model's: a field they left blank
201 // stays blank, and a value they gave is restored verbatim. Runs BEFORE
202 // meta is derived below, so the operations and that meta always agree.
203 $result['operations'] = UserFieldGuard::apply( $result['operations'], $fields );
204
205 // Bake every generated widget's registry defaults into its settings, so a
206 // generated element never ships missing a default the way a hand-dropped
207 // one never does. The model routinely omits keys, or emits a blank
208 // ("" / [] / null) for a value it has no real answer for — and a blank
209 // must fall back to the default, not overwrite it. The visible casualty is
210 // the Video widget: Pro's video guard drops an invented url and the model
211 // often sends an empty one, which then clobbered the default sample video
212 // and rendered an empty embed. This is general — every widget, every
213 // default. Runs AFTER guard_generated_operations() (so an invented/empty url is gone
214 // and the default now fills it) and AFTER fill_default_photos() (so the
215 // forced, non-blank photo src is preserved).
216 $result['operations'] = self::fill_element_defaults( $result['operations'] );
217
218 $layout = null;
219 $meta = [];
220 foreach ( $result['operations'] as $operation ) {
221 $op = $operation['op'] ?? '';
222 if ( 'set_layout' === $op ) {
223 $layout = [ 'layout' => $operation['layout'], 'columns' => $operation['columns'] ];
224 } elseif ( 'update_meta' === $op ) {
225 $meta[ $operation['key'] ] = $operation['value'];
226 } elseif ( 'set_colors' === $op ) {
227 if ( isset( $operation['primary'] ) ) {
228 $meta['bpc_color_primary'] = $operation['primary'];
229 }
230 if ( isset( $operation['background'] ) ) {
231 $meta['bpc_color_background'] = $operation['background'];
232 }
233 } elseif ( 'set_donation_amounts' === $op ) {
234 $meta['bpc_suggested_amounts'] = $operation['amounts'];
235 }
236 }
237
238 // An all-empty layout must not wipe the canvas — drop the set_layout op.
239 if ( null !== $layout ) {
240 $total = 0;
241 foreach ( $layout['columns'] as $col ) {
242 $total += is_array( $col['elements'] ?? null ) ? count( $col['elements'] ) : 0;
243 }
244 if ( 0 === $total ) {
245 $layout = null;
246 $result['operations'] = array_values( array_filter(
247 $result['operations'],
248 static function ( $op ) {
249 return is_array( $op ) && 'set_layout' !== ( $op['op'] ?? '' );
250 }
251 ) );
252 }
253 }
254
255 $result['layout'] = $layout;
256 $result['meta'] = $meta;
257 return $result;
258 }
259
260 /**
261 * Instruction appended to the retry brief when the first attempt produced no
262 * usable layout.
263 *
264 * The type list is derived, never hardcoded. It used to be a literal string of
265 * the ten free types, which meant a Pro install's retry told the model its Pro
266 * widgets did not exist — and a retry is exactly the attempt that has to
267 * produce the finished page. It also silently went stale the moment any
268 * element was added to the registry.
269 */
270 private static function retry_instruction(): string {
271 $types = implode( ', ', CampaignSchema::offerable_element_types() );
272
273 return "\n\n" . sprintf(
274 /* translators: %s: comma-separated list of allowed element type keys. */
275 __( "Your previous attempt did not return a usable page layout. This time, call set_layout FIRST and keep every element's copy concise so the whole call fits in one response. Use ONLY these exact element type keys: %s.", 'better-payment' ),
276 $types
277 );
278 }
279
280 /**
281 * Instruction naming the Pro widgets, appended only on an entitled install.
282 *
283 * Generation no longer prescribes a blueprint, so this is the only text that
284 * proposes a placement: it says what the widgets are and when they earn their
285 * place, so the model treats them as deliberate additions rather than boxes to
286 * fill. Without it a Pro install
287 * generated pages indistinguishable from a free one — the user paid for three
288 * widgets the assistant never reached for.
289 *
290 * Better Payment Pro supplies the text (`AI\ProWidgetGuidance`), including the two
291 * correctness constraints it must repeat because this is the text that invites
292 * the widgets' use: Donors Wall renders live transaction data (writing donor names
293 * into a fundraising page would fabricate a record of who gave money), and Video
294 * must not carry an invented URL.
295 */
296 private static function pro_elements_instruction(): string {
297 if ( ! CampaignSchema::pro_enabled() ) {
298 return '';
299 }
300
301 /**
302 * Filter the Pro-widget instruction appended to a generation brief on a Pro
303 * install. Better Payment Pro supplies it; the value is the text appended to
304 * the brief, including its leading blank line.
305 */
306 return (string) apply_filters( 'better_payment/ai/pro_generation_instruction', '' );
307 }
308
309 /**
310 * The generation guards that only concern Pro widgets, for one attempt.
311 *
312 * Runs after fill_default_photos() and before donate-button placement, user
313 * fields and element defaults, so a `url` dropped here lets the Video widget's
314 * default fill in. Better Payment Pro guards the Video widget's URL through the
315 * filter. A free install needs no guard: its `video` is a lock stub with no
316 * settings keys, so the validator has already dropped any `url`.
317 *
318 * @param array $operations Validated operations.
319 * @param array $brief_urls http(s) URLs found in the user's original brief.
320 * @return array
321 */
322 public static function guard_generated_operations( array $operations, array $brief_urls = [] ): array {
323 /**
324 * Filter one generation attempt's operations before donate-button placement,
325 * user-field enforcement and element defaults are applied. The second
326 * argument is `[ 'brief_urls' => string[] ]`, the URLs in the user's original
327 * brief — the only ones a generated element may keep.
328 */
329 $filtered = apply_filters( 'better_payment/ai/generated_operations', $operations, [ 'brief_urls' => $brief_urls ] );
330
331 return is_array( $filtered ) ? $filtered : $operations;
332 }
333
334 /**
335 * Settle on the campaign's category: the one the user explicitly chose, else
336 * one inferred from the brief, else '' for a campaign that fits no category
337 * we know ("a new category" — it gets the default hero).
338 *
339 * An explicit choice always wins. Only an *unknown* slug falls through to
340 * inference, so a user who picked "Medical" never gets Environmental artwork
341 * because their story happened to mention a flood.
342 *
343 * @param string $category Slug from the wizard; '' when none was picked.
344 * @param string $brief The user's brief, for inference.
345 * @return string A category slug, or ''.
346 */
347 public static function resolve_category( string $category, string $brief ): string {
348 if ( CategoryRegistry::exists( $category ) ) {
349 return $category;
350 }
351 return CategoryRegistry::match_text( $brief );
352 }
353
354 /**
355 * The default hero image for AI-generated campaigns — used for EVERY generated
356 * photo, in every category. A neutral, on-brand illustration that suits any
357 * fundraiser and reads as intentional (not a broken/empty box); users replace
358 * it via the media library. Filterable so a site can point it at its own photo
359 * globally.
360 *
361 * Every generated photo gets this one asset. Category-specific artwork was
362 * retired here on purpose: a category photo can be missing on disk (a broken
363 * `src` rendering as bare alt text), whereas `ai-default-hero.svg` always ships
364 * with the plugin. Category detection ({@see self::resolve_category()}) remains
365 * for metadata only.
366 */
367 public static function default_photo_url(): string {
368 $default = defined( 'BETTER_PAYMENT_ASSETS' )
369 ? BETTER_PAYMENT_ASSETS . '/img/campaign/ai-default-hero.svg'
370 : '';
371 /**
372 * Filter the default image applied to AI-generated photo elements.
373 *
374 * @param string $url
375 */
376 return (string) apply_filters( 'better_payment/ai/default_photo_url', $default );
377 }
378
379 /**
380 * Point every generated photo element at `$url` — always the default hero
381 * ({@see self::default_photo_url()}), which is what {@see self::generate()}
382 * passes.
383 *
384 * A text model cannot produce a real image URL, so any `src` it emits is a
385 * hallucination that renders as a broken image. We therefore ALWAYS replace
386 * the src (and reset the attachment id/sizes), while keeping the model's
387 * descriptive `alt`. Real images come from the `generate_image` operation
388 * instead, which runs client-side after generation.
389 *
390 * @param array $operations
391 * @param string $url Image URL; falls back to the default hero when '', and
392 * only skips the pass if that is empty too (never blanks a
393 * photo).
394 * @return array
395 */
396 public static function fill_default_photos( array $operations, string $url = '' ): array {
397 if ( '' === $url ) {
398 $url = self::default_photo_url();
399 }
400 if ( '' === $url ) {
401 return $operations;
402 }
403
404 foreach ( $operations as &$op ) {
405 if ( ! is_array( $op ) ) {
406 continue;
407 }
408 $name = $op['op'] ?? '';
409
410 if ( 'set_layout' === $name && ! empty( $op['columns'] ) && is_array( $op['columns'] ) ) {
411 foreach ( $op['columns'] as &$col ) {
412 if ( empty( $col['elements'] ) || ! is_array( $col['elements'] ) ) {
413 continue;
414 }
415 foreach ( $col['elements'] as &$el ) {
416 if ( is_array( $el ) && 'photo' === ( $el['type'] ?? '' ) ) {
417 $el['settings'] = self::default_photo_settings( $el['settings'] ?? [], $url );
418 }
419 }
420 unset( $el );
421 }
422 unset( $col );
423 } elseif ( 'insert_block' === $name && 'photo' === ( $op['type'] ?? '' ) ) {
424 $op['settings'] = self::default_photo_settings( $op['settings'] ?? [], $url );
425 }
426 }
427 unset( $op );
428
429 return $operations;
430 }
431
432 /**
433 * Force a photo element's settings onto the default image, keeping the model's
434 * alt text when it provided one.
435 *
436 * @param mixed $settings
437 * @param string $url
438 * @return array
439 */
440 private static function default_photo_settings( $settings, string $url ): array {
441 $settings = is_array( $settings ) ? $settings : [];
442 $settings['src'] = $url;
443 $settings['src_id'] = 0;
444 $settings['src_sizes'] = [];
445 if ( empty( $settings['alt'] ) ) {
446 $settings['alt'] = __( 'Campaign image', 'better-payment' );
447 }
448 return $settings;
449 }
450
451 /**
452 * Every http(s) URL appearing in a string.
453 *
454 * @return array<int, string>
455 */
456 public static function urls_in( string $text ): array {
457 if ( ! preg_match_all( '#https?://[^\s<>"\'\)\]]+#i', $text, $matches ) ) {
458 return [];
459 }
460
461 $urls = [];
462 foreach ( $matches[0] as $url ) {
463 // Trailing sentence punctuation is not part of the address.
464 $urls[] = rtrim( (string) $url, '.,;:!?' );
465 }
466
467 return array_values( array_unique( array_filter( $urls ) ) );
468 }
469
470 /**
471 * Merge each generated element's registry defaults UNDER its settings, so a
472 * generated widget carries the same complete defaults a hand-dropped one does.
473 *
474 * A model value overrides the default only when it is a real value. A blank —
475 * `null`, an empty/whitespace string, or an empty array — is treated as "not
476 * provided" and the default is kept. This is the crucial difference from a
477 * plain `array_merge`: the client's own merge (`freshIds`/`withDefaults`) fills
478 * only keys the model *omitted*, so a key the model emitted as `""` (which the
479 * model does whenever it has no real value) still clobbered the default. That
480 * is why a generated Video rendered an empty embed instead of the sample video.
481 *
482 * Generation-only on purpose. This is NOT the "emptied means empty" contract
483 * that governs edits: there is no prior user state on a freshly generated page,
484 * so a blank from the model is a non-answer, never a deliberate clear. Edits
485 * (CampaignEditor) never run through here, and templates apply client-side
486 * without it — so Pro's deliberately-empty template video `url` is untouched.
487 *
488 * @param array $operations
489 * @return array
490 */
491 public static function fill_element_defaults( array $operations ): array {
492 foreach ( $operations as &$op ) {
493 if ( ! is_array( $op ) ) {
494 continue;
495 }
496 $name = $op['op'] ?? '';
497
498 if ( 'set_layout' === $name && ! empty( $op['columns'] ) && is_array( $op['columns'] ) ) {
499 foreach ( $op['columns'] as &$col ) {
500 if ( empty( $col['elements'] ) || ! is_array( $col['elements'] ) ) {
501 continue;
502 }
503 foreach ( $col['elements'] as &$el ) {
504 if ( is_array( $el ) && ! empty( $el['type'] ) ) {
505 $el['settings'] = self::with_element_defaults(
506 (string) $el['type'],
507 is_array( $el['settings'] ?? null ) ? $el['settings'] : []
508 );
509 }
510 }
511 unset( $el );
512 }
513 unset( $col );
514 } elseif ( 'insert_block' === $name && ! empty( $op['type'] ) ) {
515 $op['settings'] = self::with_element_defaults(
516 (string) $op['type'],
517 is_array( $op['settings'] ?? null ) ? $op['settings'] : []
518 );
519 }
520 }
521 unset( $op );
522
523 return $operations;
524 }
525
526 /**
527 * The registry defaults for `$type`, with any non-blank model value laid on top.
528 *
529 * @param string $type
530 * @param array $settings Model-supplied settings.
531 * @return array
532 */
533 private static function with_element_defaults( string $type, array $settings ): array {
534 $defaults = ElementRegistry::get_defaults( $type );
535 if ( empty( $defaults ) ) {
536 return $settings;
537 }
538
539 $merged = $defaults;
540 foreach ( $settings as $key => $value ) {
541 if ( self::is_blank_value( $value ) ) {
542 continue; // keep the default
543 }
544 $merged[ $key ] = $value;
545 }
546
547 return $merged;
548 }
549
550 /**
551 * Whether a model-supplied setting value counts as "not provided", so the
552 * registry default should stand.
553 *
554 * `null`, empty/whitespace strings and empty arrays are blank. `0`, `false`
555 * and `'0'` are meaningful (a `number_to_show` of 0, a toggle turned off) and
556 * must override the default, so they are NOT blank.
557 *
558 * @param mixed $value
559 * @return bool
560 */
561 private static function is_blank_value( $value ): bool {
562 if ( null === $value ) {
563 return true;
564 }
565 if ( is_string( $value ) ) {
566 return '' === trim( $value );
567 }
568 if ( is_array( $value ) ) {
569 return empty( $value );
570 }
571
572 return false;
573 }
574
575 /**
576 * Keep the Donate Button (`donation_form`) directly beneath the Donate Amount
577 * (`donate_amount`) block in every generated campaign.
578 *
579 * The amount tiers and the button that submits them are one action split
580 * across two widgets: a donor picks an amount and the very next thing they
581 * must see is the button that takes it. A model composing "for the strongest
582 * narrative" will happily park the button in the opposite column, or three
583 * elements further down — which reads as two unrelated controls and loses the
584 * click. So on every `set_layout`, whenever a `donate_amount` block is present:
585 *
586 * - an existing `donation_form` anywhere in the layout is moved to sit
587 * immediately after it, in the SAME column (pulled across columns if the
588 * model split them apart);
589 * - if the layout has no `donation_form` at all, one is inserted there with
590 * its registry defaults — every amount block must be followed by a button
591 * (the same requirement `generate.php` states, made real here).
592 *
593 * Structural only — element settings are never touched. Same reasoning as the
594 * photo and video guards: the prompt asks for this, and this is what holds it.
595 *
596 * @param array $operations
597 * @return array
598 */
599 public static function enforce_donate_button_placement( array $operations ): array {
600 foreach ( $operations as &$op ) {
601 if ( ! is_array( $op ) || 'set_layout' !== ( $op['op'] ?? '' ) ) {
602 continue;
603 }
604 if ( empty( $op['columns'] ) || ! is_array( $op['columns'] ) ) {
605 continue;
606 }
607 $op['columns'] = self::place_donate_button( $op['columns'] );
608 }
609 unset( $op );
610
611 return $operations;
612 }
613
614 /**
615 * Position (or create) the Donate Button so it immediately follows the first
616 * Donate Amount block. No-op when the layout carries no `donate_amount`.
617 *
618 * @param array $columns
619 * @return array
620 */
621 private static function place_donate_button( array $columns ): array {
622 if ( null === self::find_element( $columns, 'donate_amount' ) ) {
623 return $columns;
624 }
625
626 // Take any existing button out of the layout (wherever the model put it)
627 // so it can be dropped back in the right place; build a default when the
628 // layout has none.
629 $existing = self::find_element( $columns, 'donation_form' );
630 if ( null !== $existing ) {
631 list( $bci, $bei ) = $existing;
632 $button = $columns[ $bci ]['elements'][ $bei ];
633 array_splice( $columns[ $bci ]['elements'], $bei, 1 );
634 } else {
635 $button = self::default_donate_button();
636 }
637
638 // Re-find the amount block AFTER the removal — pulling the button out from
639 // above it in the same column shifts its index — then drop the button in
640 // directly beneath it.
641 $amount_at = self::find_element( $columns, 'donate_amount' );
642 if ( null === $amount_at ) {
643 return $columns; // Defensive: donate_amount was never removed, so unreachable.
644 }
645 list( $aci, $aei ) = $amount_at;
646 array_splice( $columns[ $aci ]['elements'], $aei + 1, 0, array( $button ) );
647
648 return $columns;
649 }
650
651 /**
652 * The [column index, element index] of the first element of `$type`, or null.
653 *
654 * @param array $columns
655 * @param string $type
656 * @return array|null
657 */
658 private static function find_element( array $columns, string $type ) {
659 foreach ( $columns as $ci => $col ) {
660 $elements = is_array( $col['elements'] ?? null ) ? $col['elements'] : [];
661 foreach ( $elements as $ei => $el ) {
662 if ( is_array( $el ) && $type === ( $el['type'] ?? '' ) ) {
663 return [ $ci, $ei ];
664 }
665 }
666 }
667 return null;
668 }
669
670 /**
671 * A Donate Button element carrying its registry defaults — the same widget a
672 * user gets by dropping "Donate Button" onto the canvas by hand. No `id`: the
673 * client assigns ids to every `set_layout` element via freshIds().
674 *
675 * @return array
676 */
677 private static function default_donate_button(): array {
678 return [
679 'type' => 'donation_form',
680 'settings' => ElementRegistry::get_defaults( 'donation_form' ),
681 ];
682 }
683
684 /**
685 * The first campaign_title element's title text in a layout, or ''.
686 *
687 * @param array $layout
688 */
689 private static function first_title( array $layout ): string {
690 foreach ( $layout['columns'] ?? [] as $col ) {
691 foreach ( $col['elements'] ?? [] as $el ) {
692 if ( 'campaign_title' === ( $el['type'] ?? '' ) ) {
693 $title = trim( (string) ( $el['settings']['title'] ?? '' ) );
694 if ( '' !== $title ) {
695 return $title;
696 }
697 }
698 }
699 }
700 return '';
701 }
702
703 /**
704 * The layout structure the user pre-selected, or null when the campaign is
705 * a truly blank slate (no columns yet — the model may choose freely).
706 *
707 * @param array $context
708 * @return array|null [ 'preset' => string, 'columns' => array ]
709 */
710 private static function selected_structure( array $context ) {
711 $columns = $context['layout']['columns'] ?? null;
712 if ( ! is_array( $columns ) || 0 === count( $columns ) ) {
713 return null;
714 }
715 return [
716 'preset' => (string) ( $context['layout']['layout'] ?? '' ),
717 'columns' => array_values( $columns ),
718 ];
719 }
720
721 /**
722 * Instruction appended to the brief when a layout is already selected. Lists
723 * the exact columns (id + width + label) so the model returns that structure.
724 *
725 * @param array $selected
726 */
727 private static function structure_instruction( array $selected ): string {
728 $lines = [];
729 foreach ( $selected['columns'] as $i => $col ) {
730 $lines[] = sprintf(
731 ' %d) id "%s", width %s%s',
732 $i + 1,
733 (string) ( $col['id'] ?? ( 'col' . ( $i + 1 ) ) ),
734 (string) ( $col['width'] ?? '100%' ),
735 ! empty( $col['label'] ) ? ' — ' . $col['label'] : ''
736 );
737 }
738 $preset = '' !== $selected['preset'] ? $selected['preset'] : 'multi-column';
739
740 return "\n\n" . sprintf(
741 /* translators: 1: layout preset, 2: column count, 3: bullet list of columns. */
742 __( "IMPORTANT: The user has already chosen a %1\$s layout with exactly %2\$d column(s). Your set_layout MUST return exactly these columns, in this order, with these ids and widths — do not add, remove, or resize columns:\n%3\$s\nPlace appropriate elements into each column so the campaign fills the layout the user selected.", 'better-payment' ),
743 $preset,
744 count( $selected['columns'] ),
745 implode( "\n", $lines )
746 );
747 }
748
749 /**
750 * Prompt section for the free-form path: the page structure comes from the
751 * user's own brief, and from nothing else.
752 *
753 * This replaces `blueprint_instruction()`, which named a pre-built layout and
754 * ordered the model to reproduce its exact column count and widths. That was
755 * a second, more specific instruction competing with whatever the user had
756 * actually asked for — and the user lost every time. "Create a 50/50 column
757 * campaign" arrived alongside "build the Donation-first layout … width 42% …
758 * width 58% … keep the column count and widths above", and the page came back
759 * 42/58.
760 *
761 * Nothing here prescribes a shape. It tells the model to read the user's
762 * words for structure and obey them literally when they are there, and to
763 * compose a structure that suits *this* story when they are not. The trade is
764 * deliberate: the blueprint library existed to keep generated campaigns from
765 * looking cloned, and that variety now has to come from the brief rather than
766 * from a rotation we control. A page that ignores what the user typed is not
767 * worth the variety.
768 */
769 private static function layout_instruction(): string {
770 return "\n\n" . __(
771 "PAGE STRUCTURE — take it from the brief above, never from a default.\n"
772 . "1. Read the user's brief for anything that describes the page's shape: a number of columns, a split or ratio (\"50/50\", \"two equal columns\", \"70/30\"), a sidebar and which side it is on, a full-width or single-column page, a hero band, or the order sections should appear in. If the brief says it, build EXACTLY that — the stated column count, in the stated order, with widths matching the stated ratio (\"50/50\" means two columns of width \"50%\" and \"50%\"). A structural instruction from the user is not a preference to balance against your own judgement; it is the requirement.\n"
773 . "2. If the brief says nothing about structure, choose the one that best serves THIS campaign's story and audience, and vary it to suit the subject — an emergency appeal, a memorial and a school fundraiser should not come out identically composed. Do not default to a single house layout.\n"
774 . '3. Either way, set each column\'s `width` explicitly as a percentage string, pick the `layout` preset that matches the column count, and give every column a short, meaningful `label`.',
775 'better-payment'
776 );
777 }
778
779 /**
780 * Prompt section suggesting a primary brand colour — explicitly as a
781 * **fallback**, never as an override.
782 *
783 * Same failure mode as the old blueprint instruction, one notch quieter: this
784 * used to read "Set a primary brand colour near #E5484D", flatly, with no
785 * deference to the brief. A user who asked for their charity's green got our
786 * red, for the same reason they got 42/58 columns — a specific, imperative
787 * instruction of ours arriving after theirs. A colour named in the brief is a
788 * brand decision, and it is not ours to make.
789 *
790 * @param array $palette [ 'primary' => hex, 'mood' => string ]
791 */
792 private static function palette_instruction( array $palette ): string {
793 if ( empty( $palette['primary'] ) ) {
794 return '';
795 }
796 return "\n\n" . sprintf(
797 /* translators: 1: hex colour, 2: mood description. */
798 __( 'COLOUR — if the brief names a colour, a brand or an existing palette, use that and ignore this line. Only if it names none, set a primary brand colour near %1$s for a %2$s tone (call set_colors).', 'better-payment' ),
799 (string) $palette['primary'],
800 (string) ( $palette['mood'] ?? '' )
801 );
802 }
803
804 /**
805 * Snap any `set_layout` operation onto the user's selected structure so the
806 * preset, column ids, labels and widths are always preserved — regardless of
807 * what the model returned:
808 * - Column counts match → keep the model's per-column elements, force the
809 * column meta (id/label/width) from the selection.
810 * - Counts differ → rebuild the selected columns and redistribute all the
811 * model's elements across them proportionally to column width, so the
812 * layout is never left broken (the split case's classic failure).
813 *
814 * @param array $operations
815 * @param array $selected
816 * @return array
817 */
818 private static function enforce_structure( array $operations, array $selected ): array {
819 $orig = $selected['columns'];
820
821 foreach ( $operations as &$operation ) {
822 if ( ! is_array( $operation ) || 'set_layout' !== ( $operation['op'] ?? '' ) ) {
823 continue;
824 }
825
826 if ( '' !== $selected['preset'] ) {
827 $operation['layout'] = $selected['preset'];
828 }
829
830 $model_cols = is_array( $operation['columns'] ?? null ) ? $operation['columns'] : [];
831
832 if ( count( $model_cols ) === count( $orig ) ) {
833 foreach ( $model_cols as $i => $col ) {
834 foreach ( [ 'id', 'label', 'width', 'widthTablet', 'widthMobile' ] as $key ) {
835 if ( isset( $orig[ $i ][ $key ] ) ) {
836 $model_cols[ $i ][ $key ] = $orig[ $i ][ $key ];
837 }
838 }
839 }
840 $operation['columns'] = $model_cols;
841 continue;
842 }
843
844 // Column count mismatch — flatten every element the model produced and
845 // redistribute them across the selected columns by width weight.
846 $all = [];
847 foreach ( $model_cols as $col ) {
848 $elements = is_array( $col['elements'] ?? null ) ? $col['elements'] : [];
849 foreach ( $elements as $el ) {
850 $all[] = $el;
851 }
852 }
853 $operation['columns'] = self::distribute_elements( $all, $orig );
854 }
855 unset( $operation );
856
857 return $operations;
858 }
859
860 /**
861 * Distribute a flat list of elements across the given columns, weighted by
862 * each column's width so wider columns receive more elements. Elements stay in
863 * their original order and are placed contiguously.
864 *
865 * @param array $elements
866 * @param array $columns The selected columns (id/label/width, no elements).
867 * @return array Columns with an `elements` array on each.
868 */
869 private static function distribute_elements( array $elements, array $columns ): array {
870 $out = [];
871 foreach ( $columns as $col ) {
872 $col['elements'] = [];
873 $out[] = $col;
874 }
875 $n = count( $out );
876 if ( 0 === $n ) {
877 return $out;
878 }
879
880 // Cumulative width fractions → boundaries for contiguous assignment.
881 $weights = [];
882 $total = 0.0;
883 foreach ( $columns as $col ) {
884 $w = (float) preg_replace( '/[^0-9.]/', '', (string) ( $col['width'] ?? '100' ) );
885 if ( $w <= 0 ) {
886 $w = 1.0;
887 }
888 $weights[] = $w;
889 $total += $w;
890 }
891 $bounds = [];
892 $acc = 0.0;
893 foreach ( $weights as $w ) {
894 $acc += $w / $total;
895 $bounds[] = $acc;
896 }
897
898 $count = count( $elements );
899 foreach ( $elements as $index => $element ) {
900 $ratio = $count > 0 ? ( $index + 0.5 ) / $count : 0.0;
901 $target = $n - 1;
902 foreach ( $bounds as $i => $boundary ) {
903 if ( $ratio <= $boundary ) {
904 $target = $i;
905 break;
906 }
907 }
908 $out[ $target ]['elements'][] = $element;
909 }
910
911 return $out;
912 }
913 }
914