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

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

972 lines 43.0 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_video_urls()).
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 $result['operations'] = self::guard_video_urls( $result['operations'], $video_urls );
193
194 // The amount tiers and the button that submits them are one action: the
195 // button must sit directly under the Donate Amount block. Structural, so
196 // the prompt alone cannot guarantee it — this is the part that holds.
197 $result['operations'] = self::enforce_donate_button_placement( $result['operations'] );
198
199 // The user's own answers outrank the model's: a field they left blank
200 // stays blank, and a value they gave is restored verbatim. Runs BEFORE
201 // meta is derived below, so the operations and that meta always agree.
202 $result['operations'] = UserFieldGuard::apply( $result['operations'], $fields );
203
204 // Bake every generated widget's registry defaults into its settings, so a
205 // generated element never ships missing a default the way a hand-dropped
206 // one never does. The model routinely omits keys, or emits a blank
207 // ("" / [] / null) for a value it has no real answer for — and a blank
208 // must fall back to the default, not overwrite it. The visible casualty is
209 // the Video widget: guard_video_urls() drops an invented url and the model
210 // often sends an empty one, which then clobbered the default sample video
211 // and rendered an empty embed. This is general — every widget, every
212 // default. Runs AFTER guard_video_urls() (so an invented/empty url is gone
213 // and the default now fills it) and AFTER fill_default_photos() (so the
214 // forced, non-blank photo src is preserved).
215 $result['operations'] = self::fill_element_defaults( $result['operations'] );
216
217 $layout = null;
218 $meta = [];
219 foreach ( $result['operations'] as $operation ) {
220 $op = $operation['op'] ?? '';
221 if ( 'set_layout' === $op ) {
222 $layout = [ 'layout' => $operation['layout'], 'columns' => $operation['columns'] ];
223 } elseif ( 'update_meta' === $op ) {
224 $meta[ $operation['key'] ] = $operation['value'];
225 } elseif ( 'set_colors' === $op ) {
226 if ( isset( $operation['primary'] ) ) {
227 $meta['bpc_color_primary'] = $operation['primary'];
228 }
229 if ( isset( $operation['background'] ) ) {
230 $meta['bpc_color_background'] = $operation['background'];
231 }
232 } elseif ( 'set_donation_amounts' === $op ) {
233 $meta['bpc_suggested_amounts'] = $operation['amounts'];
234 }
235 }
236
237 // An all-empty layout must not wipe the canvas — drop the set_layout op.
238 if ( null !== $layout ) {
239 $total = 0;
240 foreach ( $layout['columns'] as $col ) {
241 $total += is_array( $col['elements'] ?? null ) ? count( $col['elements'] ) : 0;
242 }
243 if ( 0 === $total ) {
244 $layout = null;
245 $result['operations'] = array_values( array_filter(
246 $result['operations'],
247 static function ( $op ) {
248 return is_array( $op ) && 'set_layout' !== ( $op['op'] ?? '' );
249 }
250 ) );
251 }
252 }
253
254 $result['layout'] = $layout;
255 $result['meta'] = $meta;
256 return $result;
257 }
258
259 /**
260 * Instruction appended to the retry brief when the first attempt produced no
261 * usable layout.
262 *
263 * The type list is derived, never hardcoded. It used to be a literal string of
264 * the ten free types, which meant a Pro install's retry told the model its Pro
265 * widgets did not exist — and a retry is exactly the attempt that has to
266 * produce the finished page. It also silently went stale the moment any
267 * element was added to the registry.
268 */
269 private static function retry_instruction(): string {
270 $types = implode( ', ', CampaignSchema::offerable_element_types() );
271
272 return "\n\n" . sprintf(
273 /* translators: %s: comma-separated list of allowed element type keys. */
274 __( "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' ),
275 $types
276 );
277 }
278
279 /**
280 * Instruction naming the Pro widgets, appended only on an entitled install.
281 *
282 * The blueprint's per-column `pro` hints say *where* these go; this says what
283 * they are and when they earn their place, so the model treats them as
284 * deliberate additions rather than boxes to fill. Without it a Pro install
285 * generated pages indistinguishable from a free one — the user paid for three
286 * widgets the assistant never reached for.
287 *
288 * Two of the three carry a correctness constraint rather than a stylistic one,
289 * repeated here because this is the text that actually invites their use:
290 * Donors Wall renders live transaction data (writing donor names into a
291 * fundraising page would be fabricating a record of who gave money), and Video
292 * must not carry an invented URL.
293 */
294 private static function pro_elements_instruction(): string {
295 if ( ! CampaignSchema::pro_enabled() ) {
296 return '';
297 }
298
299 return "\n\n" . __( "PRO WIDGETS AVAILABLE — this site has Better Payment Pro, so you may also use `donors_wall`, `faq` and `video`. Use them where they genuinely strengthen THIS campaign, not as a checklist: `faq` when a donor would hesitate over how the money is spent or whether giving is safe; `donors_wall` beside the donation ask to show momentum; `video` in the hero only when the brief actually supplies a video URL. Do not write donor names, amounts or dates into `donors_wall` — it renders real donations by itself. Do not invent a `video` URL.", 'better-payment' );
300 }
301
302 /**
303 * Settle on the campaign's category: the one the user explicitly chose, else
304 * one inferred from the brief, else '' for a campaign that fits no category
305 * we know ("a new category" — it gets the default hero).
306 *
307 * An explicit choice always wins. Only an *unknown* slug falls through to
308 * inference, so a user who picked "Medical" never gets Environmental artwork
309 * because their story happened to mention a flood.
310 *
311 * @param string $category Slug from the wizard; '' when none was picked.
312 * @param string $brief The user's brief, for inference.
313 * @return string A category slug, or ''.
314 */
315 public static function resolve_category( string $category, string $brief ): string {
316 if ( CategoryRegistry::exists( $category ) ) {
317 return $category;
318 }
319 return CategoryRegistry::match_text( $brief );
320 }
321
322 /**
323 * The default hero image for AI-generated campaigns — used for EVERY generated
324 * photo, in every category. A neutral, on-brand illustration that suits any
325 * fundraiser and reads as intentional (not a broken/empty box); users replace
326 * it via the media library. Filterable so a site can point it at its own photo
327 * globally.
328 *
329 * Every generated photo gets this one asset. Category-specific artwork was
330 * retired here on purpose: a category photo can be missing on disk (a broken
331 * `src` rendering as bare alt text), whereas `ai-default-hero.svg` always ships
332 * with the plugin. Category detection ({@see self::resolve_category()}) remains
333 * for metadata only.
334 */
335 public static function default_photo_url(): string {
336 $default = defined( 'BETTER_PAYMENT_ASSETS' )
337 ? BETTER_PAYMENT_ASSETS . '/img/campaign/ai-default-hero.svg'
338 : '';
339 /**
340 * Filter the default image applied to AI-generated photo elements.
341 *
342 * @param string $url
343 */
344 return (string) apply_filters( 'better_payment/ai/default_photo_url', $default );
345 }
346
347 /**
348 * Point every generated photo element at `$url` — always the default hero
349 * ({@see self::default_photo_url()}), which is what {@see self::generate()}
350 * passes.
351 *
352 * A text model cannot produce a real image URL, so any `src` it emits is a
353 * hallucination that renders as a broken image. We therefore ALWAYS replace
354 * the src (and reset the attachment id/sizes), while keeping the model's
355 * descriptive `alt`. Real images come from the `generate_image` operation
356 * instead, which runs client-side after generation.
357 *
358 * @param array $operations
359 * @param string $url Image URL; falls back to the default hero when '', and
360 * only skips the pass if that is empty too (never blanks a
361 * photo).
362 * @return array
363 */
364 public static function fill_default_photos( array $operations, string $url = '' ): array {
365 if ( '' === $url ) {
366 $url = self::default_photo_url();
367 }
368 if ( '' === $url ) {
369 return $operations;
370 }
371
372 foreach ( $operations as &$op ) {
373 if ( ! is_array( $op ) ) {
374 continue;
375 }
376 $name = $op['op'] ?? '';
377
378 if ( 'set_layout' === $name && ! empty( $op['columns'] ) && is_array( $op['columns'] ) ) {
379 foreach ( $op['columns'] as &$col ) {
380 if ( empty( $col['elements'] ) || ! is_array( $col['elements'] ) ) {
381 continue;
382 }
383 foreach ( $col['elements'] as &$el ) {
384 if ( is_array( $el ) && 'photo' === ( $el['type'] ?? '' ) ) {
385 $el['settings'] = self::default_photo_settings( $el['settings'] ?? [], $url );
386 }
387 }
388 unset( $el );
389 }
390 unset( $col );
391 } elseif ( 'insert_block' === $name && 'photo' === ( $op['type'] ?? '' ) ) {
392 $op['settings'] = self::default_photo_settings( $op['settings'] ?? [], $url );
393 }
394 }
395 unset( $op );
396
397 return $operations;
398 }
399
400 /**
401 * Force a photo element's settings onto the default image, keeping the model's
402 * alt text when it provided one.
403 *
404 * @param mixed $settings
405 * @param string $url
406 * @return array
407 */
408 private static function default_photo_settings( $settings, string $url ): array {
409 $settings = is_array( $settings ) ? $settings : [];
410 $settings['src'] = $url;
411 $settings['src_id'] = 0;
412 $settings['src_sizes'] = [];
413 if ( empty( $settings['alt'] ) ) {
414 $settings['alt'] = __( 'Campaign image', 'better-payment' );
415 }
416 return $settings;
417 }
418
419 /**
420 * Every http(s) URL appearing in a string.
421 *
422 * @return array<int, string>
423 */
424 public static function urls_in( string $text ): array {
425 if ( ! preg_match_all( '#https?://[^\s<>"\'\)\]]+#i', $text, $matches ) ) {
426 return [];
427 }
428
429 $urls = [];
430 foreach ( $matches[0] as $url ) {
431 // Trailing sentence punctuation is not part of the address.
432 $urls[] = rtrim( (string) $url, '.,;:!?' );
433 }
434
435 return array_values( array_unique( array_filter( $urls ) ) );
436 }
437
438 /**
439 * Drop a `video` element's `url` unless the user's brief actually contained it.
440 *
441 * The exact problem {@see self::fill_default_photos()} solves, with a worse
442 * failure mode. A text model has no way to know a real video address, so asked
443 * for a campaign video it produces a well-formed YouTube URL with an invented
444 * ID — and that ID belongs to *something*. The result is an unrelated
445 * stranger's video embedded on a fundraising page, presented as the campaign's
446 * own appeal. That is materially worse than a broken image, because it looks
447 * entirely intentional.
448 *
449 * A URL the user typed is theirs and is kept verbatim. Anything else is
450 * removed, which lets the element's schema default apply — the same sample
451 * video a user gets when they drop the Video widget by hand, so the outcome is
452 * a widget in its ordinary unconfigured state rather than a false claim.
453 *
454 * Note this cannot be left to the prompt: `rules.php` already forbids inventing
455 * values, and a prompt is a request. This is the part that holds.
456 *
457 * @param array $operations
458 * @param array $allowed URLs found in the user's brief.
459 * @return array
460 */
461 public static function guard_video_urls( array $operations, array $allowed = [] ): array {
462 foreach ( $operations as &$op ) {
463 if ( ! is_array( $op ) ) {
464 continue;
465 }
466 $name = $op['op'] ?? '';
467
468 if ( 'set_layout' === $name && ! empty( $op['columns'] ) && is_array( $op['columns'] ) ) {
469 foreach ( $op['columns'] as &$col ) {
470 if ( empty( $col['elements'] ) || ! is_array( $col['elements'] ) ) {
471 continue;
472 }
473 foreach ( $col['elements'] as &$el ) {
474 if ( is_array( $el ) && 'video' === ( $el['type'] ?? '' ) ) {
475 $el['settings'] = self::guarded_video_settings( $el['settings'] ?? [], $allowed );
476 }
477 }
478 unset( $el );
479 }
480 unset( $col );
481 } elseif ( 'insert_block' === $name && 'video' === ( $op['type'] ?? '' ) ) {
482 $op['settings'] = self::guarded_video_settings( $op['settings'] ?? [], $allowed );
483 }
484 }
485 unset( $op );
486
487 return $operations;
488 }
489
490 /**
491 * Remove an unvouched `url` from a video element's settings.
492 *
493 * Matching is exact against the brief's URLs, plus a prefix match so a user's
494 * link that the model reproduced with extra query parameters (`&t=30s`) still
495 * counts as theirs.
496 *
497 * @param mixed $settings
498 * @param array $allowed
499 * @return array
500 */
501 private static function guarded_video_settings( $settings, array $allowed ): array {
502 $settings = is_array( $settings ) ? $settings : [];
503
504 if ( ! isset( $settings['url'] ) ) {
505 return $settings;
506 }
507
508 $url = trim( (string) $settings['url'] );
509 if ( '' === $url ) {
510 return $settings;
511 }
512
513 foreach ( $allowed as $candidate ) {
514 $candidate = trim( (string) $candidate );
515 if ( '' === $candidate ) {
516 continue;
517 }
518 if ( $url === $candidate || 0 === strpos( $url, $candidate ) ) {
519 return $settings;
520 }
521 }
522
523 unset( $settings['url'] );
524
525 return $settings;
526 }
527
528 /**
529 * Merge each generated element's registry defaults UNDER its settings, so a
530 * generated widget carries the same complete defaults a hand-dropped one does.
531 *
532 * A model value overrides the default only when it is a real value. A blank —
533 * `null`, an empty/whitespace string, or an empty array — is treated as "not
534 * provided" and the default is kept. This is the crucial difference from a
535 * plain `array_merge`: the client's own merge (`freshIds`/`withDefaults`) fills
536 * only keys the model *omitted*, so a key the model emitted as `""` (which the
537 * model does whenever it has no real value) still clobbered the default. That
538 * is why a generated Video rendered an empty embed instead of the sample video.
539 *
540 * Generation-only on purpose. This is NOT the "emptied means empty" contract
541 * that governs edits: there is no prior user state on a freshly generated page,
542 * so a blank from the model is a non-answer, never a deliberate clear. Edits
543 * (CampaignEditor) never run through here, and templates apply client-side
544 * without it — so Pro's deliberately-empty template video `url` is untouched.
545 *
546 * @param array $operations
547 * @return array
548 */
549 public static function fill_element_defaults( array $operations ): array {
550 foreach ( $operations as &$op ) {
551 if ( ! is_array( $op ) ) {
552 continue;
553 }
554 $name = $op['op'] ?? '';
555
556 if ( 'set_layout' === $name && ! empty( $op['columns'] ) && is_array( $op['columns'] ) ) {
557 foreach ( $op['columns'] as &$col ) {
558 if ( empty( $col['elements'] ) || ! is_array( $col['elements'] ) ) {
559 continue;
560 }
561 foreach ( $col['elements'] as &$el ) {
562 if ( is_array( $el ) && ! empty( $el['type'] ) ) {
563 $el['settings'] = self::with_element_defaults(
564 (string) $el['type'],
565 is_array( $el['settings'] ?? null ) ? $el['settings'] : []
566 );
567 }
568 }
569 unset( $el );
570 }
571 unset( $col );
572 } elseif ( 'insert_block' === $name && ! empty( $op['type'] ) ) {
573 $op['settings'] = self::with_element_defaults(
574 (string) $op['type'],
575 is_array( $op['settings'] ?? null ) ? $op['settings'] : []
576 );
577 }
578 }
579 unset( $op );
580
581 return $operations;
582 }
583
584 /**
585 * The registry defaults for `$type`, with any non-blank model value laid on top.
586 *
587 * @param string $type
588 * @param array $settings Model-supplied settings.
589 * @return array
590 */
591 private static function with_element_defaults( string $type, array $settings ): array {
592 $defaults = ElementRegistry::get_defaults( $type );
593 if ( empty( $defaults ) ) {
594 return $settings;
595 }
596
597 $merged = $defaults;
598 foreach ( $settings as $key => $value ) {
599 if ( self::is_blank_value( $value ) ) {
600 continue; // keep the default
601 }
602 $merged[ $key ] = $value;
603 }
604
605 return $merged;
606 }
607
608 /**
609 * Whether a model-supplied setting value counts as "not provided", so the
610 * registry default should stand.
611 *
612 * `null`, empty/whitespace strings and empty arrays are blank. `0`, `false`
613 * and `'0'` are meaningful (a `number_to_show` of 0, a toggle turned off) and
614 * must override the default, so they are NOT blank.
615 *
616 * @param mixed $value
617 * @return bool
618 */
619 private static function is_blank_value( $value ): bool {
620 if ( null === $value ) {
621 return true;
622 }
623 if ( is_string( $value ) ) {
624 return '' === trim( $value );
625 }
626 if ( is_array( $value ) ) {
627 return empty( $value );
628 }
629
630 return false;
631 }
632
633 /**
634 * Keep the Donate Button (`donation_form`) directly beneath the Donate Amount
635 * (`donate_amount`) block in every generated campaign.
636 *
637 * The amount tiers and the button that submits them are one action split
638 * across two widgets: a donor picks an amount and the very next thing they
639 * must see is the button that takes it. A model composing "for the strongest
640 * narrative" will happily park the button in the opposite column, or three
641 * elements further down — which reads as two unrelated controls and loses the
642 * click. So on every `set_layout`, whenever a `donate_amount` block is present:
643 *
644 * - an existing `donation_form` anywhere in the layout is moved to sit
645 * immediately after it, in the SAME column (pulled across columns if the
646 * model split them apart);
647 * - if the layout has no `donation_form` at all, one is inserted there with
648 * its registry defaults — every amount block must be followed by a button
649 * (the same requirement `generate.php` states, made real here).
650 *
651 * Structural only — element settings are never touched. Same reasoning as the
652 * photo and video guards: the prompt asks for this, and this is what holds it.
653 *
654 * @param array $operations
655 * @return array
656 */
657 public static function enforce_donate_button_placement( array $operations ): array {
658 foreach ( $operations as &$op ) {
659 if ( ! is_array( $op ) || 'set_layout' !== ( $op['op'] ?? '' ) ) {
660 continue;
661 }
662 if ( empty( $op['columns'] ) || ! is_array( $op['columns'] ) ) {
663 continue;
664 }
665 $op['columns'] = self::place_donate_button( $op['columns'] );
666 }
667 unset( $op );
668
669 return $operations;
670 }
671
672 /**
673 * Position (or create) the Donate Button so it immediately follows the first
674 * Donate Amount block. No-op when the layout carries no `donate_amount`.
675 *
676 * @param array $columns
677 * @return array
678 */
679 private static function place_donate_button( array $columns ): array {
680 if ( null === self::find_element( $columns, 'donate_amount' ) ) {
681 return $columns;
682 }
683
684 // Take any existing button out of the layout (wherever the model put it)
685 // so it can be dropped back in the right place; build a default when the
686 // layout has none.
687 $existing = self::find_element( $columns, 'donation_form' );
688 if ( null !== $existing ) {
689 list( $bci, $bei ) = $existing;
690 $button = $columns[ $bci ]['elements'][ $bei ];
691 array_splice( $columns[ $bci ]['elements'], $bei, 1 );
692 } else {
693 $button = self::default_donate_button();
694 }
695
696 // Re-find the amount block AFTER the removal — pulling the button out from
697 // above it in the same column shifts its index — then drop the button in
698 // directly beneath it.
699 $amount_at = self::find_element( $columns, 'donate_amount' );
700 if ( null === $amount_at ) {
701 return $columns; // Defensive: donate_amount was never removed, so unreachable.
702 }
703 list( $aci, $aei ) = $amount_at;
704 array_splice( $columns[ $aci ]['elements'], $aei + 1, 0, array( $button ) );
705
706 return $columns;
707 }
708
709 /**
710 * The [column index, element index] of the first element of `$type`, or null.
711 *
712 * @param array $columns
713 * @param string $type
714 * @return array|null
715 */
716 private static function find_element( array $columns, string $type ) {
717 foreach ( $columns as $ci => $col ) {
718 $elements = is_array( $col['elements'] ?? null ) ? $col['elements'] : [];
719 foreach ( $elements as $ei => $el ) {
720 if ( is_array( $el ) && $type === ( $el['type'] ?? '' ) ) {
721 return [ $ci, $ei ];
722 }
723 }
724 }
725 return null;
726 }
727
728 /**
729 * A Donate Button element carrying its registry defaults — the same widget a
730 * user gets by dropping "Donate Button" onto the canvas by hand. No `id`: the
731 * client assigns ids to every `set_layout` element via freshIds().
732 *
733 * @return array
734 */
735 private static function default_donate_button(): array {
736 return [
737 'type' => 'donation_form',
738 'settings' => ElementRegistry::get_defaults( 'donation_form' ),
739 ];
740 }
741
742 /**
743 * The first campaign_title element's title text in a layout, or ''.
744 *
745 * @param array $layout
746 */
747 private static function first_title( array $layout ): string {
748 foreach ( $layout['columns'] ?? [] as $col ) {
749 foreach ( $col['elements'] ?? [] as $el ) {
750 if ( 'campaign_title' === ( $el['type'] ?? '' ) ) {
751 $title = trim( (string) ( $el['settings']['title'] ?? '' ) );
752 if ( '' !== $title ) {
753 return $title;
754 }
755 }
756 }
757 }
758 return '';
759 }
760
761 /**
762 * The layout structure the user pre-selected, or null when the campaign is
763 * a truly blank slate (no columns yet — the model may choose freely).
764 *
765 * @param array $context
766 * @return array|null [ 'preset' => string, 'columns' => array ]
767 */
768 private static function selected_structure( array $context ) {
769 $columns = $context['layout']['columns'] ?? null;
770 if ( ! is_array( $columns ) || 0 === count( $columns ) ) {
771 return null;
772 }
773 return [
774 'preset' => (string) ( $context['layout']['layout'] ?? '' ),
775 'columns' => array_values( $columns ),
776 ];
777 }
778
779 /**
780 * Instruction appended to the brief when a layout is already selected. Lists
781 * the exact columns (id + width + label) so the model returns that structure.
782 *
783 * @param array $selected
784 */
785 private static function structure_instruction( array $selected ): string {
786 $lines = [];
787 foreach ( $selected['columns'] as $i => $col ) {
788 $lines[] = sprintf(
789 ' %d) id "%s", width %s%s',
790 $i + 1,
791 (string) ( $col['id'] ?? ( 'col' . ( $i + 1 ) ) ),
792 (string) ( $col['width'] ?? '100%' ),
793 ! empty( $col['label'] ) ? '' . $col['label'] : ''
794 );
795 }
796 $preset = '' !== $selected['preset'] ? $selected['preset'] : 'multi-column';
797
798 return "\n\n" . sprintf(
799 /* translators: 1: layout preset, 2: column count, 3: bullet list of columns. */
800 __( "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' ),
801 $preset,
802 count( $selected['columns'] ),
803 implode( "\n", $lines )
804 );
805 }
806
807 /**
808 * Prompt section for the free-form path: the page structure comes from the
809 * user's own brief, and from nothing else.
810 *
811 * This replaces `blueprint_instruction()`, which named a pre-built layout and
812 * ordered the model to reproduce its exact column count and widths. That was
813 * a second, more specific instruction competing with whatever the user had
814 * actually asked for — and the user lost every time. "Create a 50/50 column
815 * campaign" arrived alongside "build the Donation-first layout … width 42% …
816 * width 58% … keep the column count and widths above", and the page came back
817 * 42/58.
818 *
819 * Nothing here prescribes a shape. It tells the model to read the user's
820 * words for structure and obey them literally when they are there, and to
821 * compose a structure that suits *this* story when they are not. The trade is
822 * deliberate: the blueprint library existed to keep generated campaigns from
823 * looking cloned, and that variety now has to come from the brief rather than
824 * from a rotation we control. A page that ignores what the user typed is not
825 * worth the variety.
826 */
827 private static function layout_instruction(): string {
828 return "\n\n" . __(
829 "PAGE STRUCTURE — take it from the brief above, never from a default.\n"
830 . "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"
831 . "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"
832 . '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`.',
833 'better-payment'
834 );
835 }
836
837 /**
838 * Prompt section suggesting a primary brand colour — explicitly as a
839 * **fallback**, never as an override.
840 *
841 * Same failure mode as the old blueprint instruction, one notch quieter: this
842 * used to read "Set a primary brand colour near #E5484D", flatly, with no
843 * deference to the brief. A user who asked for their charity's green got our
844 * red, for the same reason they got 42/58 columns — a specific, imperative
845 * instruction of ours arriving after theirs. A colour named in the brief is a
846 * brand decision, and it is not ours to make.
847 *
848 * @param array $palette [ 'primary' => hex, 'mood' => string ]
849 */
850 private static function palette_instruction( array $palette ): string {
851 if ( empty( $palette['primary'] ) ) {
852 return '';
853 }
854 return "\n\n" . sprintf(
855 /* translators: 1: hex colour, 2: mood description. */
856 __( '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' ),
857 (string) $palette['primary'],
858 (string) ( $palette['mood'] ?? '' )
859 );
860 }
861
862 /**
863 * Snap any `set_layout` operation onto the user's selected structure so the
864 * preset, column ids, labels and widths are always preserved — regardless of
865 * what the model returned:
866 * - Column counts match → keep the model's per-column elements, force the
867 * column meta (id/label/width) from the selection.
868 * - Counts differ → rebuild the selected columns and redistribute all the
869 * model's elements across them proportionally to column width, so the
870 * layout is never left broken (the split case's classic failure).
871 *
872 * @param array $operations
873 * @param array $selected
874 * @return array
875 */
876 private static function enforce_structure( array $operations, array $selected ): array {
877 $orig = $selected['columns'];
878
879 foreach ( $operations as &$operation ) {
880 if ( ! is_array( $operation ) || 'set_layout' !== ( $operation['op'] ?? '' ) ) {
881 continue;
882 }
883
884 if ( '' !== $selected['preset'] ) {
885 $operation['layout'] = $selected['preset'];
886 }
887
888 $model_cols = is_array( $operation['columns'] ?? null ) ? $operation['columns'] : [];
889
890 if ( count( $model_cols ) === count( $orig ) ) {
891 foreach ( $model_cols as $i => $col ) {
892 foreach ( [ 'id', 'label', 'width', 'widthTablet', 'widthMobile' ] as $key ) {
893 if ( isset( $orig[ $i ][ $key ] ) ) {
894 $model_cols[ $i ][ $key ] = $orig[ $i ][ $key ];
895 }
896 }
897 }
898 $operation['columns'] = $model_cols;
899 continue;
900 }
901
902 // Column count mismatch — flatten every element the model produced and
903 // redistribute them across the selected columns by width weight.
904 $all = [];
905 foreach ( $model_cols as $col ) {
906 $elements = is_array( $col['elements'] ?? null ) ? $col['elements'] : [];
907 foreach ( $elements as $el ) {
908 $all[] = $el;
909 }
910 }
911 $operation['columns'] = self::distribute_elements( $all, $orig );
912 }
913 unset( $operation );
914
915 return $operations;
916 }
917
918 /**
919 * Distribute a flat list of elements across the given columns, weighted by
920 * each column's width so wider columns receive more elements. Elements stay in
921 * their original order and are placed contiguously.
922 *
923 * @param array $elements
924 * @param array $columns The selected columns (id/label/width, no elements).
925 * @return array Columns with an `elements` array on each.
926 */
927 private static function distribute_elements( array $elements, array $columns ): array {
928 $out = [];
929 foreach ( $columns as $col ) {
930 $col['elements'] = [];
931 $out[] = $col;
932 }
933 $n = count( $out );
934 if ( 0 === $n ) {
935 return $out;
936 }
937
938 // Cumulative width fractions → boundaries for contiguous assignment.
939 $weights = [];
940 $total = 0.0;
941 foreach ( $columns as $col ) {
942 $w = (float) preg_replace( '/[^0-9.]/', '', (string) ( $col['width'] ?? '100' ) );
943 if ( $w <= 0 ) {
944 $w = 1.0;
945 }
946 $weights[] = $w;
947 $total += $w;
948 }
949 $bounds = [];
950 $acc = 0.0;
951 foreach ( $weights as $w ) {
952 $acc += $w / $total;
953 $bounds[] = $acc;
954 }
955
956 $count = count( $elements );
957 foreach ( $elements as $index => $element ) {
958 $ratio = $count > 0 ? ( $index + 0.5 ) / $count : 0.0;
959 $target = $n - 1;
960 foreach ( $bounds as $i => $boundary ) {
961 if ( $ratio <= $boundary ) {
962 $target = $i;
963 break;
964 }
965 }
966 $out[ $target ]['elements'][] = $element;
967 }
968
969 return $out;
970 }
971 }
972