PluginProbe ʕ •ᴥ•ʔ
SureForms – Contact Form Builder, AI Forms, Payment Form, Survey & Quiz / trunk
SureForms – Contact Form Builder, AI Forms, Payment Form, Survey & Quiz vtrunk
2.12.5 2.12.4 2.12.3 2.12.2 2.12.1 2.12.0 2.11.1 2.11.0 2.10.1 2.10.0 2.9.1 2.9.0 2.8.2 2.8.1 2.7.0 2.7.1 2.8.0 trunk 0.0.10 0.0.11 0.0.12 0.0.13 0.0.2 0.0.3 0.0.4 0.0.5 0.0.6 0.0.7 0.0.8 0.0.9 1.0.0 1.0.1 1.0.2 1.0.3 1.0.4 1.0.5 1.0.6 1.0.7 1.1.0 1.1.1 1.1.2 1.10.0 1.10.1 1.11.0 1.12.0 1.12.1 1.12.2 1.12.3 1.13.0 1.13.1 1.13.2 1.2.0 1.2.1 1.2.2 1.2.3 1.2.4 1.2.5 1.3.0 1.3.1 1.3.2 1.4.0 1.4.1 1.4.2 1.4.3 1.4.4 1.4.5 1.5.0 1.5.1 1.6.0 1.6.1 1.6.2 1.6.3 1.6.4 1.6.5 1.7.0 1.7.1 1.7.2 1.7.3 1.7.4 1.8.0 1.9.0 1.9.1 2.0.0 2.0.1 2.0.2 2.1.0 2.1.1 2.2.0 2.2.1 2.2.2 2.3.0 2.4.0 2.5.0 2.5.2 2.6.0
sureforms / inc / fields / payment-markup.php
sureforms / inc / fields Last commit date
address-markup.php 3 months ago base.php 3 weeks ago checkbox-markup.php 1 year ago countries.json 1 year ago dropdown-markup.php 2 months ago email-markup.php 3 weeks ago gdpr-markup.php 5 months ago inlinebutton-markup.php 3 weeks ago input-markup.php 5 months ago multichoice-markup.php 3 months ago number-markup.php 5 months ago payment-markup.php 3 weeks ago phone-markup.php 2 months ago textarea-markup.php 2 months ago url-markup.php 5 months ago
payment-markup.php
919 lines
1 <?php
2 /**
3 * SureForms Payment Markup Class file.
4 *
5 * @package sureforms.
6 * @since 2.0.0
7 */
8
9 namespace SRFM\Inc\Fields;
10
11 use SRFM\Inc\Helper;
12 use SRFM\Inc\Payments\Payment_Helper;
13 use SRFM\Inc\Payments\Stripe\Stripe_Helper;
14
15 if ( ! defined( 'ABSPATH' ) ) {
16 exit; // Exit if accessed directly.
17 }
18
19 /**
20 * SureForms Payment Markup Class.
21 *
22 * @since 2.0.0
23 */
24 class Payment_Markup extends Base {
25 /**
26 * Payment amount.
27 *
28 * @var float
29 * @since 2.0.0
30 */
31 protected $amount;
32
33 /**
34 * Payment currency.
35 *
36 * @var string
37 * @since 2.0.0
38 */
39 protected $currency;
40
41 /**
42 * Stripe publishable key.
43 *
44 * @var string
45 * @since 2.0.0
46 */
47 protected $stripe_publishable_key;
48
49 /**
50 * Whether Stripe is connected.
51 *
52 * @var bool
53 * @since 2.0.0
54 */
55 protected $stripe_connected;
56
57 /**
58 * Payment mode (live or test).
59 *
60 * @var string
61 * @since 2.0.0
62 */
63 protected $payment_mode;
64
65 /**
66 * Payment type.
67 *
68 * @var string
69 * @since 2.0.0
70 */
71 protected $payment_type;
72
73 /**
74 * Subscription plans.
75 *
76 * @var array
77 * @since 2.0.0
78 */
79 protected $subscription_plan;
80
81 /**
82 * Amount type.
83 *
84 * @var string
85 * @since 2.0.0
86 */
87 protected $amount_type;
88
89 /**
90 * Fixed amount.
91 *
92 * @var float
93 * @since 2.0.0
94 */
95 protected $fixed_amount;
96
97 /**
98 * Minimum amount.
99 *
100 * @var float
101 * @since 2.0.0
102 */
103 protected $minimum_amount;
104
105 /**
106 * Customer name field slug.
107 *
108 * @var string
109 * @since 2.0.0
110 */
111 protected $customer_name_field;
112
113 /**
114 * Customer email field slug.
115 *
116 * @var string
117 * @since 2.0.0
118 */
119 protected $customer_email_field;
120
121 /**
122 * Variable amount field slug.
123 *
124 * @var string
125 * @since 2.0.0
126 */
127 protected $variable_amount_field;
128
129 /**
130 * Payment methods enabled for this form.
131 *
132 * @var array
133 * @since 2.4.0
134 */
135 protected $payment_methods;
136
137 /**
138 * Payment description shown on receipts and in the payment dashboard.
139 *
140 * @var string
141 * @since 2.7.1
142 */
143 protected $payment_description;
144
145 // BOTH MODE: start — admin-configurable dual-mode attributes.
146 /**
147 * Original payment type as configured in the editor (one-time / subscription / both).
148 * While rendering we may rewrite $this->payment_type to the resolved default-choice,
149 * so we keep the original intent here for conditionals.
150 *
151 * @var string
152 * @since 2.8.2
153 */
154 protected $original_payment_type;
155
156 /**
157 * Label shown next to the "one-time" radio choice in both mode.
158 *
159 * @var string
160 * @since 2.8.2
161 */
162 protected $one_time_label;
163
164 /**
165 * Label shown next to the "subscription" radio choice in both mode.
166 *
167 * @var string
168 * @since 2.8.2
169 */
170 protected $subscription_label;
171
172 /**
173 * Default radio selection in both mode ("one-time" or "subscription").
174 *
175 * @var string
176 * @since 2.8.2
177 */
178 protected $default_payment_choice;
179
180 /**
181 * One-time amount configuration (used only in both mode).
182 *
183 * @var array<mixed>
184 * @since 2.8.2
185 */
186 protected $one_time_config = [];
187
188 /**
189 * Subscription amount configuration (used only in both mode).
190 *
191 * @var array<mixed>
192 * @since 2.8.2
193 */
194 protected $subscription_config = [];
195 // BOTH MODE: end.
196
197 /**
198 * Constructor for the Payment Markup class.
199 *
200 * @param array<mixed> $attributes Block attributes.
201 * @since 2.0.0
202 */
203 public function __construct( $attributes ) {
204 // Get payment settings from Stripe Helper.
205 $this->stripe_connected = Stripe_Helper::is_stripe_connected();
206 $this->payment_mode = Stripe_Helper::get_stripe_mode();
207
208 $this->slug = 'payment';
209 $this->set_properties( $attributes );
210 $this->set_input_label( 'Payment' );
211 $this->set_error_msg( $attributes, 'srfm_payment_block_required_text' );
212 $this->set_unique_slug();
213 $this->set_markup_properties();
214 $this->set_aria_described_by();
215
216 $this->set_field_name( $this->unique_slug );
217
218 // Set payment-specific properties.
219 $this->amount = $attributes['amount'] ?? 10;
220 $this->currency = $attributes['currency'] ?? 'USD';
221
222 // Use currency from settings if not specified in block.
223 if ( empty( $this->currency ) || 'USD' === $this->currency ) {
224 $this->currency = Stripe_Helper::get_currency();
225 }
226
227 // Get appropriate Stripe publishable key based on mode.
228 $this->stripe_publishable_key = Stripe_Helper::get_stripe_publishable_key();
229
230 $this->payment_type = $attributes['paymentType'] ?? 'one-time';
231 $this->subscription_plan = $attributes['subscriptionPlan'] ?? [];
232 $this->amount_type = $attributes['amountType'] ?? 'fixed';
233 $this->fixed_amount = $attributes['fixedAmount'] ?? 10;
234 $this->minimum_amount = $attributes['minimumAmount'] ?? 0;
235
236 // Set customer field mappings.
237 $this->customer_name_field = $attributes['customerNameField'] ?? '';
238 $this->customer_email_field = $attributes['customerEmailField'] ?? '';
239
240 // Set variable amount field mapping.
241 $this->variable_amount_field = $attributes['variableAmountField'] ?? '';
242
243 // BOTH MODE: start — capture original intent and dual-mode configuration.
244 $this->original_payment_type = $this->payment_type;
245
246 $this->one_time_label = $attributes['oneTimeLabel'] ?? __( 'One-Time Payment', 'sureforms' );
247 $this->subscription_label = $attributes['subscriptionLabel'] ?? __( 'Subscription', 'sureforms' );
248 $this->default_payment_choice = $attributes['defaultPaymentChoice'] ?? 'one-time';
249 if ( ! in_array( $this->default_payment_choice, [ 'one-time', 'subscription' ], true ) ) {
250 $this->default_payment_choice = 'one-time';
251 }
252
253 $this->one_time_config = [
254 'amount_type' => $attributes['oneTimeAmountType'] ?? 'fixed',
255 'fixed_amount' => isset( $attributes['oneTimeFixedAmount'] ) ? (float) $attributes['oneTimeFixedAmount'] : 10.0,
256 'minimum_amount' => isset( $attributes['oneTimeMinimumAmount'] ) ? (float) $attributes['oneTimeMinimumAmount'] : 0.0,
257 'variable_field' => $attributes['oneTimeVariableAmountField'] ?? '',
258 ];
259
260 $this->subscription_config = [
261 'amount_type' => $attributes['subscriptionAmountType'] ?? 'fixed',
262 'fixed_amount' => isset( $attributes['subscriptionFixedAmount'] ) ? (float) $attributes['subscriptionFixedAmount'] : 10.0,
263 'minimum_amount' => isset( $attributes['subscriptionMinimumAmount'] ) ? (float) $attributes['subscriptionMinimumAmount'] : 0.0,
264 'variable_field' => $attributes['subscriptionVariableAmountField'] ?? '',
265 ];
266
267 // When rendering the "both" block, the visible amount + Stripe mode is driven by
268 // the default choice. Rewrite the scalar properties so the existing rendering
269 // logic below continues to work with zero changes.
270 if ( 'both' === $this->original_payment_type ) {
271 $active_config = 'subscription' === $this->default_payment_choice ? $this->subscription_config : $this->one_time_config;
272 $this->amount_type = $active_config['amount_type'];
273 $this->fixed_amount = $active_config['fixed_amount'];
274 $this->minimum_amount = $active_config['minimum_amount'];
275 $this->variable_amount_field = $active_config['variable_field'];
276 // $this->payment_type now represents the *active* rendering type, not the admin-set value.
277 $this->payment_type = $this->default_payment_choice;
278 }
279 // BOTH MODE: end.
280
281 // Set payment methods from block attributes, default to 'stripe' for backward compatibility.
282 $this->payment_methods = $attributes['paymentMethods'] ?? [ 'stripe' ];
283
284 // Set custom payment description (empty means JS/gateway will use its own default).
285 $this->payment_description = $attributes['paymentDescription'] ?? '';
286
287 // BACKWARD COMPATIBILITY: Migrate customer fields from subscriptionPlan.
288 if ( empty( $this->customer_name_field ) && ! empty( $this->subscription_plan['customer_name'] ) ) {
289 $this->customer_name_field = $this->subscription_plan['customer_name'];
290 }
291
292 if ( empty( $this->customer_email_field ) && ! empty( $this->subscription_plan['customer_email'] ) ) {
293 $this->customer_email_field = $this->subscription_plan['customer_email'];
294 }
295 }
296
297 /**
298 * Render the payment field markup.
299 *
300 * @return string|bool
301 * @since 2.0.0
302 */
303 public function markup() {
304 // Get registered payment methods.
305 $registered_methods = $this->get_registered_payment_methods();
306
307 // Check if any payment methods are available.
308 if ( empty( $registered_methods ) ) {
309 return '';
310 }
311
312 // Validate payment field requirements.
313 $is_valid = $this->validate_payment_requirements();
314 if ( ! $is_valid ) {
315 return '';
316 }
317
318 $field_classes = $this->get_field_classes();
319
320 // Get first payment method as default.
321 $first_method_id = array_key_first( $registered_methods );
322
323 $data_input_attributes = [
324 'name' => $this->field_name,
325 'class' => 'srfm-payment-input',
326 'data-currency' => strtolower( $this->currency ),
327 'data-stripe-key' => $this->stripe_publishable_key,
328 'data-payment-mode' => $this->payment_mode,
329 'data-amount-type' => $this->amount_type,
330 'data-fixed-amount' => $this->fixed_amount,
331 'aria-describedby' => trim( $this->aria_described_by ),
332 'data-payment-type' => $this->payment_type,
333 'data-customer-name-field' => $this->customer_name_field,
334 'data-customer-email-field' => $this->customer_email_field,
335 'data-payment-methods' => wp_json_encode( array_keys( $registered_methods ) ),
336 'data-selected-method' => $first_method_id,
337 ];
338
339 // Subscription plan attributes are emitted when subscription is the active type
340 // OR when "both" mode allows it as a possible end-user choice.
341 $has_subscription_path = 'subscription' === $this->payment_type || 'both' === $this->original_payment_type;
342 if ( $has_subscription_path && ! empty( $this->subscription_plan ) ) {
343 // Defense-in-depth: coerce to scalar before output. A malformed REST
344 // save could store these as arrays, which would otherwise emit notices
345 // or empty strings through esc_attr() downstream.
346 $data_input_attributes['data-subscription-plan-name'] = (string) ( $this->subscription_plan['name'] ?? __( 'Subscription Plan', 'sureforms' ) );
347 $data_input_attributes['data-subscription-interval'] = (string) ( $this->subscription_plan['interval'] ?? 'month' );
348 $data_input_attributes['data-subscription-billing-cycles'] = $this->subscription_plan['billingCycles'] ?? 0;
349 }
350
351 if ( ! empty( $this->payment_description ) ) {
352 $data_input_attributes['data-description'] = $this->payment_description;
353 }
354
355 if ( 'variable' === $this->amount_type ) {
356 $data_input_attributes['data-variable-amount-field'] = $this->variable_amount_field;
357 }
358
359 // BOTH MODE: ensure data-variable-amount-field is emitted at page load if
360 // EITHER type uses variable amount. listenAmountChanges() runs once at init
361 // and queries [data-variable-amount-field] — without this, the listener is
362 // never wired when the default choice is fixed but the other type is variable.
363 if ( 'both' === $this->original_payment_type && 'variable' !== $this->amount_type ) {
364 $other_config = 'subscription' === $this->default_payment_choice
365 ? $this->one_time_config
366 : $this->subscription_config;
367 if ( 'variable' === $other_config['amount_type'] && ! empty( $other_config['variable_field'] ) ) {
368 $data_input_attributes['data-variable-amount-field'] = $other_config['variable_field'];
369 }
370 }
371
372 // If minimum amount is greater than 0, add it to the data input attributes.
373 if ( $this->minimum_amount > 0 ) {
374 $data_input_attributes['data-minimum-amount'] = $this->minimum_amount;
375 }
376
377 // BOTH MODE: start — emit per-type configuration for the JS chooser to read.
378 if ( 'both' === $this->original_payment_type ) {
379 $data_input_attributes['data-original-payment-type'] = 'both';
380 $data_input_attributes['data-default-payment-choice'] = $this->default_payment_choice;
381
382 $data_input_attributes['data-one-time-amount-type'] = $this->one_time_config['amount_type'];
383 $data_input_attributes['data-one-time-fixed-amount'] = $this->one_time_config['fixed_amount'];
384 $data_input_attributes['data-one-time-minimum-amount'] = $this->one_time_config['minimum_amount'];
385 if ( 'variable' === $this->one_time_config['amount_type'] ) {
386 $data_input_attributes['data-one-time-variable-amount-field'] = $this->one_time_config['variable_field'];
387 }
388
389 $data_input_attributes['data-subscription-amount-type'] = $this->subscription_config['amount_type'];
390 $data_input_attributes['data-subscription-fixed-amount'] = $this->subscription_config['fixed_amount'];
391 $data_input_attributes['data-subscription-minimum-amount'] = $this->subscription_config['minimum_amount'];
392 if ( 'variable' === $this->subscription_config['amount_type'] ) {
393 $data_input_attributes['data-subscription-variable-amount-field'] = $this->subscription_config['variable_field'];
394 }
395 }
396 // BOTH MODE: end.
397
398 ob_start();
399 ?>
400 <div data-block-id="<?php echo esc_attr( $this->block_id ); ?>" class="<?php echo esc_attr( $field_classes ); ?>">
401 <?php echo wp_kses_post( $this->label_markup ); ?>
402 <?php echo wp_kses_post( $this->help_markup ); ?>
403 <div class="srfm-payment-field-wrapper">
404 <?php
405 // BOTH MODE: start — render the payment-type chooser.
406 if ( 'both' === $this->original_payment_type ) {
407 echo $this->render_payment_type_chooser(); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
408 }
409 // BOTH MODE: end.
410
411 if ( 'both' === $this->original_payment_type ) {
412 // BOTH MODE: render two amount displays, one per choice. JS toggles visibility.
413 $is_one_time_default = 'one-time' === $this->default_payment_choice;
414 ?>
415 <div
416 id="srfm-payment-amount-<?php echo esc_attr( $this->block_id ); ?>-one-time"
417 class="srfm-payment-amount-block srfm-payment-amount-block-one-time"
418 data-payment-type="one-time"
419 aria-labelledby="srfm-payment-type-<?php echo esc_attr( $this->block_id ); ?>-one-time"
420 <?php echo esc_attr( $is_one_time_default ? '' : 'hidden' ); ?>
421 >
422 <?php
423 // phpcs:disable WordPress.Security.EscapeOutput.OutputNotEscaped -- render_amount_display handles its own escaping.
424 echo $this->render_amount_display(
425 'one-time',
426 Helper::get_string_value( $this->one_time_config['amount_type'] ?? 'fixed' ),
427 is_numeric( $this->one_time_config['fixed_amount'] ?? null ) ? (float) $this->one_time_config['fixed_amount'] : 0.0,
428 is_numeric( $this->one_time_config['minimum_amount'] ?? null ) ? (float) $this->one_time_config['minimum_amount'] : 0.0
429 );
430 // phpcs:enable WordPress.Security.EscapeOutput.OutputNotEscaped
431 ?>
432 </div>
433 <div
434 id="srfm-payment-amount-<?php echo esc_attr( $this->block_id ); ?>-subscription"
435 class="srfm-payment-amount-block srfm-payment-amount-block-subscription"
436 data-payment-type="subscription"
437 aria-labelledby="srfm-payment-type-<?php echo esc_attr( $this->block_id ); ?>-subscription"
438 <?php echo esc_attr( $is_one_time_default ? 'hidden' : '' ); ?>
439 >
440 <?php
441 // phpcs:disable WordPress.Security.EscapeOutput.OutputNotEscaped -- render_amount_display handles its own escaping.
442 echo $this->render_amount_display(
443 'subscription',
444 Helper::get_string_value( $this->subscription_config['amount_type'] ?? 'fixed' ),
445 is_numeric( $this->subscription_config['fixed_amount'] ?? null ) ? (float) $this->subscription_config['fixed_amount'] : 0.0,
446 is_numeric( $this->subscription_config['minimum_amount'] ?? null ) ? (float) $this->subscription_config['minimum_amount'] : 0.0
447 );
448 // phpcs:enable WordPress.Security.EscapeOutput.OutputNotEscaped
449 ?>
450 </div>
451 <?php
452 } else {
453 // phpcs:disable WordPress.Security.EscapeOutput.OutputNotEscaped -- render_amount_display handles its own escaping.
454 echo $this->render_amount_display(
455 $this->payment_type,
456 $this->amount_type,
457 $this->fixed_amount,
458 $this->minimum_amount
459 );
460 // phpcs:enable WordPress.Security.EscapeOutput.OutputNotEscaped
461 }
462 ?>
463
464 <?php
465 if ( 'test' === $this->payment_mode ) {
466 echo $this->get_test_mode_notice(); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
467 }
468 ?>
469
470 <!-- Payment Methods Accordion -->
471 <?php echo $this->render_payment_methods_accordion( $registered_methods ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
472
473 <!-- Hidden fields for payment data -->
474 <input type="hidden"
475 <?php
476 foreach ( $data_input_attributes as $attr_key => $attr_value ) {
477 echo esc_attr( $attr_key ) . '="' . esc_attr( $attr_value ) . '" ';
478 }
479 ?>
480 />
481
482 <!-- Payment processing status -->
483 <div id="srfm-payment-status-<?php echo esc_attr( $this->block_id ); ?>" class="srfm-payment-status" style="display: none;">
484 <div class="srfm-payment-processing">
485 <span class="srfm-spinner"></span>
486 <?php esc_html_e( 'Processing payment...', 'sureforms' ); ?>
487 </div>
488 </div>
489 </div>
490 </div>
491 <?php
492 return ob_get_clean();
493 }
494
495 /**
496 * Get registered payment methods for display.
497 *
498 * @return array Array of payment method configurations.
499 * @since 2.4.0
500 */
501 private function get_registered_payment_methods() {
502 // Shared with the submission-side guard (Front_End::validate_payment_fields), so
503 // the renderer and the guard can never disagree about whether this field is usable.
504 return Payment_Helper::get_registered_payment_methods( [ 'paymentMethods' => $this->payment_methods ] );
505 }
506
507 // BOTH MODE: start — helper renderers for the dual-mode chooser and amount displays.
508
509 /**
510 * Render the one-time / subscription radio chooser shown only in "both" mode.
511 *
512 * @return string Chooser markup.
513 * @since 2.8.2
514 */
515 private function render_payment_type_chooser() {
516 $chooser_name = 'srfm-payment-type-choice-' . $this->block_id;
517 $is_one_time = 'one-time' === $this->default_payment_choice;
518
519 $options = [
520 [
521 'value' => 'one-time',
522 'label' => $this->one_time_label,
523 'checked' => $is_one_time,
524 ],
525 [
526 'value' => 'subscription',
527 'label' => $this->subscription_label,
528 'checked' => ! $is_one_time,
529 ],
530 ];
531
532 ob_start();
533 ?>
534 <div
535 class="srfm-payment-type-chooser srfm-block-wrap"
536 role="radiogroup"
537 aria-label="<?php esc_attr_e( 'Choose payment type', 'sureforms' ); ?>"
538 >
539 <?php
540 foreach ( $options as $opt ) {
541 $radio_id = 'srfm-payment-type-' . $this->block_id . '-' . $opt['value'];
542 $panel_id = 'srfm-payment-amount-' . $this->block_id . '-' . $opt['value'];
543 // phpcs:disable WordPress.Security.EscapeOutput.OutputNotEscaped -- render_radio_pill handles its own escaping.
544 echo $this->render_radio_pill(
545 [
546 'wrapper_class' => 'srfm-payment-type-choice srfm-payment-type-choice--' . $opt['value'],
547 'radio_id' => $radio_id,
548 'name' => $chooser_name,
549 'value' => $opt['value'],
550 'label' => $opt['label'],
551 'checked' => $opt['checked'],
552 'radio_class' => 'srfm-payment-type-choice-radio',
553 'aria_controls' => $panel_id,
554 'data_attrs' => [ 'data-payment-type' => $opt['value'] ],
555 ]
556 );
557 // phpcs:enable WordPress.Security.EscapeOutput.OutputNotEscaped
558 }
559 ?>
560 </div>
561 <?php
562 $markup = ob_get_clean();
563 return is_string( $markup ) ? $markup : '';
564 }
565
566 /**
567 * Render a single radio "pill" using the same DOM shape as the multi-choice
568 * block (`.srfm-block-content-wrap` + `.srfm-option-container` + circle SVGs).
569 * This lets us inherit the existing visual language: bordered box, hover/focus
570 * states, primary-tinted background when selected, animated check icon.
571 *
572 * @param array{wrapper_class:string,radio_id:string,name:string,value:string,label:string,checked:bool,radio_class:string,aria_controls?:string,data_attrs?:array<string,string>} $args Pill config.
573 * @return string Pill markup.
574 * @since 2.8.2
575 */
576 private function render_radio_pill( $args ) {
577 $check_svg = Helper::fetch_svg( 'circle-checked', 'srfm-payment-icon', 'aria-hidden="true"' );
578 $unchecked_svg = Helper::fetch_svg( 'circle-unchecked', 'srfm-payment-icon-unchecked', 'aria-hidden="true"' );
579
580 $data_attrs_html = '';
581 if ( ! empty( $args['data_attrs'] ) && is_array( $args['data_attrs'] ) ) {
582 foreach ( $args['data_attrs'] as $key => $val ) {
583 $data_attrs_html .= ' ' . esc_attr( $key ) . '="' . esc_attr( $val ) . '"';
584 }
585 }
586
587 ob_start();
588 ?>
589 <label class="<?php echo esc_attr( $args['wrapper_class'] ); ?>">
590 <input
591 type="radio"
592 id="<?php echo esc_attr( $args['radio_id'] ); ?>"
593 name="<?php echo esc_attr( $args['name'] ); ?>"
594 value="<?php echo esc_attr( $args['value'] ); ?>"
595 class="<?php echo esc_attr( $args['radio_class'] ); ?>"
596 <?php if ( ! empty( $args['aria_controls'] ) ) { ?>
597 aria-controls="<?php echo esc_attr( $args['aria_controls'] ); ?>"
598 <?php } ?>
599 <?php echo $data_attrs_html; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
600 <?php checked( ! empty( $args['checked'] ) ); ?>
601 />
602 <div class="srfm-block-content-wrap">
603 <div class="srfm-option-container">
604 <span class="srfm-payment-option-label"><?php echo esc_html( $args['label'] ); ?></span>
605 </div>
606 <div class="srfm-icon-container">
607 <?php
608 echo wp_kses( $check_svg, Helper::$allowed_tags_svg );
609 echo wp_kses( $unchecked_svg, Helper::$allowed_tags_svg );
610 ?>
611 </div>
612 </div>
613 </label>
614 <?php
615 $markup = ob_get_clean();
616 return is_string( $markup ) ? $markup : '';
617 }
618
619 /**
620 * Render the amount display block (fixed or variable, one-time or subscription).
621 * Extracted so the "both" mode can render two of these — one per choice.
622 *
623 * @param string $payment_type The payment type for this block ("one-time" or "subscription").
624 * @param string $amount_type Either "fixed" or "variable".
625 * @param float $fixed_amount The configured fixed amount.
626 * @param float $minimum_amount The configured minimum amount.
627 * @return string Amount display markup.
628 * @since 2.8.2
629 */
630 private function render_amount_display( $payment_type, $amount_type, $fixed_amount, $minimum_amount ) {
631 ob_start();
632 if ( 'fixed' === $amount_type ) {
633 ?>
634 <!-- Fixed Payment Amount Display. -->
635 <div class="srfm-payment-amount srfm-block-label">
636 <span class="srfm-payment-value">
637 <?php
638 if ( 'subscription' === $payment_type && ! empty( $this->subscription_plan ) ) {
639 $interval = $this->subscription_plan['interval'] ?? 'month';
640 $billing_cycles = $this->subscription_plan['billingCycles'] ?? 0;
641 $interval_label = $this->get_interval_label( $interval );
642
643 if ( 'ongoing' === $billing_cycles ) {
644 echo esc_html(
645 sprintf(
646 /* translators: 1: Amount with currency, 2: Interval (day/week/month/quarter/year) */
647 __( '%1$s per %2$s (until cancelled)', 'sureforms' ),
648 $this->format_currency( $fixed_amount, $this->currency ),
649 $interval_label
650 )
651 );
652 } elseif ( $billing_cycles > 0 ) {
653 echo esc_html(
654 sprintf(
655 /* translators: 1: Amount with currency, 2: Interval (day/week/month/quarter/year), 3: Number of billing cycles */
656 __( '%1$s per %2$s (%3$s payments)', 'sureforms' ),
657 $this->format_currency( $fixed_amount, $this->currency ),
658 $interval_label,
659 $billing_cycles
660 )
661 );
662 } else {
663 echo esc_html(
664 sprintf(
665 /* translators: 1: Amount with currency, 2: Interval (day/week/month/quarter/year) */
666 __( '%1$s per %2$s', 'sureforms' ),
667 $this->format_currency( $fixed_amount, $this->currency ),
668 $interval_label
669 )
670 );
671 }
672 } else {
673 echo esc_html( $this->format_currency( $fixed_amount, $this->currency ) );
674 }
675 ?>
676 </span>
677 </div>
678 <?php
679 } else {
680 // Variable amount display.
681 $message_format = '{amount}';
682 if ( 'subscription' === $payment_type && ! empty( $this->subscription_plan ) ) {
683 $interval = $this->subscription_plan['interval'] ?? 'month';
684 $billing_cycles = $this->subscription_plan['billingCycles'] ?? 0;
685 $interval_label = $this->get_interval_label( $interval );
686
687 if ( 'ongoing' === $billing_cycles ) {
688 /* translators: 1: Amount with currency placeholder, 2: Interval (day/week/month/quarter/year) */
689 $message_format = sprintf( __( '{amount} per %s (until cancelled)', 'sureforms' ), $interval_label );
690 } elseif ( $billing_cycles > 0 ) {
691 /* translators: 1: Amount with currency placeholder, 2: Interval (day/week/month/quarter/year), 3: Number of billing cycles */
692 $message_format = sprintf( __( '{amount} per %1$s (%2$s payments)', 'sureforms' ), $interval_label, $billing_cycles );
693 } else {
694 /* translators: 1: Amount with currency placeholder, 2: Interval (day/week/month/quarter/year) */
695 $message_format = sprintf( __( '{amount} per %s', 'sureforms' ), $interval_label );
696 }
697 }
698 ?>
699 <!-- Variable Payment Amount Display. -->
700 <div class="srfm-variable-amount-display srfm-block-label">
701 <div class="srfm-payment-amount-wrapper">
702 <span
703 class="srfm-payment-value"
704 data-currency="<?php echo esc_attr( strtolower( $this->currency ) ); ?>"
705 data-currency-symbol="<?php echo esc_attr( Stripe_Helper::get_currency_symbol( $this->currency ) ); ?>"
706 data-message-format="<?php echo esc_attr( $message_format ); ?>"
707 ></span>
708 </div>
709 <?php if ( $minimum_amount > 0 ) { ?>
710 <span class="srfm-description">
711 <?php
712 echo esc_html(
713 sprintf(
714 /* translators: %s: Minimum amount with currency */
715 __( 'Minimum amount: %s', 'sureforms' ),
716 $this->format_currency( $minimum_amount, $this->currency )
717 )
718 );
719 ?>
720 </span>
721 <?php } ?>
722 </div>
723 <?php
724 }
725
726 $markup = ob_get_clean();
727 return is_string( $markup ) ? $markup : '';
728 }
729
730 // BOTH MODE: end.
731
732 /**
733 * Render payment methods as accordion.
734 * Each payment method is an accordion item with header and collapsible content.
735 *
736 * @param array<mixed> $methods Array of payment methods.
737 * @return string Payment methods accordion markup.
738 * @since 2.4.0
739 */
740 private function render_payment_methods_accordion( $methods ) {
741 if ( empty( $methods ) || ! is_array( $methods ) ) {
742 return '';
743 }
744
745 $is_single_method = count( $methods ) === 1;
746 $is_first = true; // Track the first payment method.
747
748 ob_start();
749 ?>
750 <div class="srfm-payment-methods-accordion <?php echo $is_single_method ? 'srfm-single-payment-method' : ''; ?>">
751 <?php foreach ( $methods as $method ) { ?>
752 <div
753 class="srfm-accordion-item"
754 data-method="<?php echo esc_attr( $method['id'] ); ?>"
755 >
756 <div
757 class="srfm-accordion-header"
758 role="button"
759 tabindex="0"
760 aria-expanded="<?php echo $is_first ? 'true' : 'false'; ?>"
761 aria-controls="srfm-accordion-content-<?php echo esc_attr( $method['id'] ); ?>-<?php echo esc_attr( $this->block_id ); ?>"
762 >
763 <div class="srfm-payment-input-wrapper">
764 <input
765 type="radio"
766 name="payment-method-<?php echo esc_attr( $this->block_id ); ?>"
767 value="<?php echo esc_attr( $method['id'] ); ?>"
768 class="srfm-payment-method-radio"
769 data-method="<?php echo esc_attr( $method['id'] ); ?>"
770 <?php checked( $is_first ); ?>
771 aria-label="<?php echo esc_attr( $method['label'] ); ?>"
772 />
773 <span class="srfm-accordion-title srfm-block-label">
774 <?php echo esc_html( $method['label'] ); ?>
775 </span>
776 </div>
777 </div>
778 <div
779 id="srfm-accordion-content-<?php echo esc_attr( $method['id'] ); ?>-<?php echo esc_attr( $this->block_id ); ?>"
780 class="srfm-accordion-content"
781 role="region"
782 aria-labelledby="srfm-accordion-header-<?php echo esc_attr( $method['id'] ); ?>"
783 >
784 <div
785 class="srfm-payment-method-content"
786 data-method="<?php echo esc_attr( $method['id'] ); ?>"
787 >
788 <div
789 id="srfm-<?php echo esc_attr( $method['id'] ); ?>-<?php echo esc_attr( $this->block_id ); ?>"
790 class="<?php echo esc_attr( $method['container_class'] ); ?>"
791 >
792 <?php
793 // Output provider's placeholder content if available, otherwise show a generic hint for JS rendering.
794 if ( ! empty( $method['place_holder_content'] ) ) {
795 echo $method['place_holder_content']; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
796 } else {
797 ?>
798 <!-- Provider JS will render content here -->
799 <?php
800 }
801 ?>
802 </div>
803 </div>
804 </div>
805 </div>
806 <?php $is_first = false; // Set to false after first iteration. ?>
807 <?php } ?>
808 </div>
809 <?php
810 $template = ob_get_clean();
811
812 return is_string( $template ) ? $template : '';
813 }
814
815 /**
816 * Validate payment field requirements.
817 *
818 * @return bool True if validation passes, false otherwise.
819 * @since 2.0.0
820 */
821 private function validate_payment_requirements() {
822 // Check customer email field requirement (highest priority).
823 if ( empty( $this->customer_email_field ) ) {
824 return false;
825 }
826
827 // Check subscription-specific requirements.
828 // BOTH MODE: subscription is a possible end-user choice, so name field is also required.
829 if ( 'subscription' === $this->original_payment_type || 'both' === $this->original_payment_type ) {
830 if ( empty( $this->customer_name_field ) ) {
831 return false;
832 }
833 }
834
835 return true;
836 }
837
838 /**
839 * Format currency for display.
840 *
841 * @param float $amount Amount to format.
842 * @param string $currency Currency code.
843 * @return string
844 * @since 2.0.0
845 */
846 private function format_currency( $amount, $currency ) {
847 $symbol = Stripe_Helper::get_currency_symbol( $currency );
848 $position = Payment_Helper::get_currency_sign_position();
849
850 // Format based on currency.
851 if ( in_array( $currency, [ 'JPY', 'KRW' ], true ) ) {
852 // No decimal places for these currencies.
853 $formatted_amount = number_format( $amount, 0 );
854 } else {
855 $formatted_amount = number_format( $amount, 2 );
856 }
857
858 // Apply currency sign position.
859 switch ( $position ) {
860 case 'right':
861 return $formatted_amount . $symbol;
862 case 'left_space':
863 return $symbol . ' ' . $formatted_amount;
864 case 'right_space':
865 return $formatted_amount . ' ' . $symbol;
866 case 'left':
867 default:
868 return $symbol . $formatted_amount;
869 }
870 }
871
872 /**
873 * Get the human-readable label for a payment interval slug.
874 *
875 * @param string $interval_slug The slug (e.g., 'day', 'week', 'month', 'quarter', 'yearly').
876 * @return string The translated interval label, or the slug itself if not found.
877 * @since 2.0.0
878 */
879 private function get_interval_label( $interval_slug ) {
880 $interval_labels = [
881 'day' => __( 'day', 'sureforms' ),
882 'week' => __( 'week', 'sureforms' ),
883 'month' => __( 'month', 'sureforms' ),
884 'quarter' => __( 'quarter', 'sureforms' ),
885 'yearly' => __( 'year', 'sureforms' ),
886 ];
887
888 return $interval_labels[ $interval_slug ] ?? $interval_slug;
889 }
890
891 /**
892 * Render test mode notice for admin users.
893 * Only shows if user has manage_options capability.
894 *
895 * @return string|bool Test mode notice markup or empty string.
896 * @since 2.0.0
897 */
898 private function get_test_mode_notice() {
899 // Only show to users with manage_options capability.
900 if ( ! current_user_can( 'manage_options' ) ) {
901 return '';
902 }
903
904 // Build dynamic link to payment settings.
905 $settings_url = admin_url( 'admin.php?page=sureforms_form_settings&tab=payments-settings&subpage=general' );
906
907 ob_start();
908 ?>
909 <div class="srfm-test-mode-notice" style="background-color: #fff3cd; border: 1px solid #ffc107; border-radius: 4px; padding: 12px; margin-bottom: 16px; color: #856404;">
910 <strong><?php esc_html_e( 'Test mode is enabled:', 'sureforms' ); ?></strong>
911 <a href="<?php echo esc_url( $settings_url ); ?>" style="color: #856404;" target="_blank" rel="noopener noreferrer">
912 <?php esc_html_e( 'Click here to enable live mode and accept payment', 'sureforms' ); ?>
913 </a>
914 </div>
915 <?php
916 return ob_get_clean();
917 }
918 }
919