PluginProbe ʕ •ᴥ•ʔ
SureForms – Drag & Drop Contact Form & Form Builder, Payment Form, Survey, Quiz & Calculator / 2.12.1
SureForms – Drag & Drop Contact Form & Form Builder, Payment Form, Survey, Quiz & Calculator v2.12.1
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 1 month ago base.php 2 months ago checkbox-markup.php 1 year ago countries.json 1 year ago dropdown-markup.php 1 month ago email-markup.php 2 months ago gdpr-markup.php 4 months ago inlinebutton-markup.php 4 weeks ago input-markup.php 4 months ago multichoice-markup.php 1 month ago number-markup.php 4 months ago payment-markup.php 2 months ago phone-markup.php 1 month ago textarea-markup.php 1 month ago url-markup.php 4 months ago
payment-markup.php
944 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 $methods = [];
503
504 // Get enabled payment methods from block attributes.
505 $enabled_methods = $this->payment_methods;
506
507 // Filter to get method configurations - start with Stripe as default.
508 $available_methods = apply_filters(
509 'srfm_payment_methods_registry',
510 [
511 'stripe' => [
512 'id' => 'stripe',
513 'label' => __( 'Stripe', 'sureforms' ),
514 'description' => __( 'Pay with credit or debit card', 'sureforms' ),
515 'icon' => 'credit-card',
516 'enabled' => $this->stripe_connected,
517 'container_class' => 'srfm-stripe-payment-element',
518 ],
519 ]
520 );
521
522 // Filter enabled methods.
523 foreach ( $enabled_methods as $method_id ) {
524 if ( isset( $available_methods[ $method_id ] ) && $available_methods[ $method_id ]['enabled'] ) {
525 $methods[ $method_id ] = $available_methods[ $method_id ];
526 }
527 }
528
529 return $methods;
530 }
531
532 // BOTH MODE: start — helper renderers for the dual-mode chooser and amount displays.
533
534 /**
535 * Render the one-time / subscription radio chooser shown only in "both" mode.
536 *
537 * @return string Chooser markup.
538 * @since 2.8.2
539 */
540 private function render_payment_type_chooser() {
541 $chooser_name = 'srfm-payment-type-choice-' . $this->block_id;
542 $is_one_time = 'one-time' === $this->default_payment_choice;
543
544 $options = [
545 [
546 'value' => 'one-time',
547 'label' => $this->one_time_label,
548 'checked' => $is_one_time,
549 ],
550 [
551 'value' => 'subscription',
552 'label' => $this->subscription_label,
553 'checked' => ! $is_one_time,
554 ],
555 ];
556
557 ob_start();
558 ?>
559 <div
560 class="srfm-payment-type-chooser srfm-block-wrap"
561 role="radiogroup"
562 aria-label="<?php esc_attr_e( 'Choose payment type', 'sureforms' ); ?>"
563 >
564 <?php
565 foreach ( $options as $opt ) {
566 $radio_id = 'srfm-payment-type-' . $this->block_id . '-' . $opt['value'];
567 $panel_id = 'srfm-payment-amount-' . $this->block_id . '-' . $opt['value'];
568 // phpcs:disable WordPress.Security.EscapeOutput.OutputNotEscaped -- render_radio_pill handles its own escaping.
569 echo $this->render_radio_pill(
570 [
571 'wrapper_class' => 'srfm-payment-type-choice srfm-payment-type-choice--' . $opt['value'],
572 'radio_id' => $radio_id,
573 'name' => $chooser_name,
574 'value' => $opt['value'],
575 'label' => $opt['label'],
576 'checked' => $opt['checked'],
577 'radio_class' => 'srfm-payment-type-choice-radio',
578 'aria_controls' => $panel_id,
579 'data_attrs' => [ 'data-payment-type' => $opt['value'] ],
580 ]
581 );
582 // phpcs:enable WordPress.Security.EscapeOutput.OutputNotEscaped
583 }
584 ?>
585 </div>
586 <?php
587 $markup = ob_get_clean();
588 return is_string( $markup ) ? $markup : '';
589 }
590
591 /**
592 * Render a single radio "pill" using the same DOM shape as the multi-choice
593 * block (`.srfm-block-content-wrap` + `.srfm-option-container` + circle SVGs).
594 * This lets us inherit the existing visual language: bordered box, hover/focus
595 * states, primary-tinted background when selected, animated check icon.
596 *
597 * @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.
598 * @return string Pill markup.
599 * @since 2.8.2
600 */
601 private function render_radio_pill( $args ) {
602 $check_svg = Helper::fetch_svg( 'circle-checked', 'srfm-payment-icon', 'aria-hidden="true"' );
603 $unchecked_svg = Helper::fetch_svg( 'circle-unchecked', 'srfm-payment-icon-unchecked', 'aria-hidden="true"' );
604
605 $data_attrs_html = '';
606 if ( ! empty( $args['data_attrs'] ) && is_array( $args['data_attrs'] ) ) {
607 foreach ( $args['data_attrs'] as $key => $val ) {
608 $data_attrs_html .= ' ' . esc_attr( $key ) . '="' . esc_attr( $val ) . '"';
609 }
610 }
611
612 ob_start();
613 ?>
614 <label class="<?php echo esc_attr( $args['wrapper_class'] ); ?>">
615 <input
616 type="radio"
617 id="<?php echo esc_attr( $args['radio_id'] ); ?>"
618 name="<?php echo esc_attr( $args['name'] ); ?>"
619 value="<?php echo esc_attr( $args['value'] ); ?>"
620 class="<?php echo esc_attr( $args['radio_class'] ); ?>"
621 <?php if ( ! empty( $args['aria_controls'] ) ) { ?>
622 aria-controls="<?php echo esc_attr( $args['aria_controls'] ); ?>"
623 <?php } ?>
624 <?php echo $data_attrs_html; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
625 <?php checked( ! empty( $args['checked'] ) ); ?>
626 />
627 <div class="srfm-block-content-wrap">
628 <div class="srfm-option-container">
629 <span class="srfm-payment-option-label"><?php echo esc_html( $args['label'] ); ?></span>
630 </div>
631 <div class="srfm-icon-container">
632 <?php
633 echo wp_kses( $check_svg, Helper::$allowed_tags_svg );
634 echo wp_kses( $unchecked_svg, Helper::$allowed_tags_svg );
635 ?>
636 </div>
637 </div>
638 </label>
639 <?php
640 $markup = ob_get_clean();
641 return is_string( $markup ) ? $markup : '';
642 }
643
644 /**
645 * Render the amount display block (fixed or variable, one-time or subscription).
646 * Extracted so the "both" mode can render two of these — one per choice.
647 *
648 * @param string $payment_type The payment type for this block ("one-time" or "subscription").
649 * @param string $amount_type Either "fixed" or "variable".
650 * @param float $fixed_amount The configured fixed amount.
651 * @param float $minimum_amount The configured minimum amount.
652 * @return string Amount display markup.
653 * @since 2.8.2
654 */
655 private function render_amount_display( $payment_type, $amount_type, $fixed_amount, $minimum_amount ) {
656 ob_start();
657 if ( 'fixed' === $amount_type ) {
658 ?>
659 <!-- Fixed Payment Amount Display. -->
660 <div class="srfm-payment-amount srfm-block-label">
661 <span class="srfm-payment-value">
662 <?php
663 if ( 'subscription' === $payment_type && ! empty( $this->subscription_plan ) ) {
664 $interval = $this->subscription_plan['interval'] ?? 'month';
665 $billing_cycles = $this->subscription_plan['billingCycles'] ?? 0;
666 $interval_label = $this->get_interval_label( $interval );
667
668 if ( 'ongoing' === $billing_cycles ) {
669 echo esc_html(
670 sprintf(
671 /* translators: 1: Amount with currency, 2: Interval (day/week/month/quarter/year) */
672 __( '%1$s per %2$s (until cancelled)', 'sureforms' ),
673 $this->format_currency( $fixed_amount, $this->currency ),
674 $interval_label
675 )
676 );
677 } elseif ( $billing_cycles > 0 ) {
678 echo esc_html(
679 sprintf(
680 /* translators: 1: Amount with currency, 2: Interval (day/week/month/quarter/year), 3: Number of billing cycles */
681 __( '%1$s per %2$s (%3$s payments)', 'sureforms' ),
682 $this->format_currency( $fixed_amount, $this->currency ),
683 $interval_label,
684 $billing_cycles
685 )
686 );
687 } else {
688 echo esc_html(
689 sprintf(
690 /* translators: 1: Amount with currency, 2: Interval (day/week/month/quarter/year) */
691 __( '%1$s per %2$s', 'sureforms' ),
692 $this->format_currency( $fixed_amount, $this->currency ),
693 $interval_label
694 )
695 );
696 }
697 } else {
698 echo esc_html( $this->format_currency( $fixed_amount, $this->currency ) );
699 }
700 ?>
701 </span>
702 </div>
703 <?php
704 } else {
705 // Variable amount display.
706 $message_format = '{amount}';
707 if ( 'subscription' === $payment_type && ! empty( $this->subscription_plan ) ) {
708 $interval = $this->subscription_plan['interval'] ?? 'month';
709 $billing_cycles = $this->subscription_plan['billingCycles'] ?? 0;
710 $interval_label = $this->get_interval_label( $interval );
711
712 if ( 'ongoing' === $billing_cycles ) {
713 /* translators: 1: Amount with currency placeholder, 2: Interval (day/week/month/quarter/year) */
714 $message_format = sprintf( __( '{amount} per %s (until cancelled)', 'sureforms' ), $interval_label );
715 } elseif ( $billing_cycles > 0 ) {
716 /* translators: 1: Amount with currency placeholder, 2: Interval (day/week/month/quarter/year), 3: Number of billing cycles */
717 $message_format = sprintf( __( '{amount} per %1$s (%2$s payments)', 'sureforms' ), $interval_label, $billing_cycles );
718 } else {
719 /* translators: 1: Amount with currency placeholder, 2: Interval (day/week/month/quarter/year) */
720 $message_format = sprintf( __( '{amount} per %s', 'sureforms' ), $interval_label );
721 }
722 }
723 ?>
724 <!-- Variable Payment Amount Display. -->
725 <div class="srfm-variable-amount-display srfm-block-label">
726 <div class="srfm-payment-amount-wrapper">
727 <span
728 class="srfm-payment-value"
729 data-currency="<?php echo esc_attr( strtolower( $this->currency ) ); ?>"
730 data-currency-symbol="<?php echo esc_attr( Stripe_Helper::get_currency_symbol( $this->currency ) ); ?>"
731 data-message-format="<?php echo esc_attr( $message_format ); ?>"
732 ></span>
733 </div>
734 <?php if ( $minimum_amount > 0 ) { ?>
735 <span class="srfm-description">
736 <?php
737 echo esc_html(
738 sprintf(
739 /* translators: %s: Minimum amount with currency */
740 __( 'Minimum amount: %s', 'sureforms' ),
741 $this->format_currency( $minimum_amount, $this->currency )
742 )
743 );
744 ?>
745 </span>
746 <?php } ?>
747 </div>
748 <?php
749 }
750
751 $markup = ob_get_clean();
752 return is_string( $markup ) ? $markup : '';
753 }
754
755 // BOTH MODE: end.
756
757 /**
758 * Render payment methods as accordion.
759 * Each payment method is an accordion item with header and collapsible content.
760 *
761 * @param array<mixed> $methods Array of payment methods.
762 * @return string Payment methods accordion markup.
763 * @since 2.4.0
764 */
765 private function render_payment_methods_accordion( $methods ) {
766 if ( empty( $methods ) || ! is_array( $methods ) ) {
767 return '';
768 }
769
770 $is_single_method = count( $methods ) === 1;
771 $is_first = true; // Track the first payment method.
772
773 ob_start();
774 ?>
775 <div class="srfm-payment-methods-accordion <?php echo $is_single_method ? 'srfm-single-payment-method' : ''; ?>">
776 <?php foreach ( $methods as $method ) { ?>
777 <div
778 class="srfm-accordion-item"
779 data-method="<?php echo esc_attr( $method['id'] ); ?>"
780 >
781 <div
782 class="srfm-accordion-header"
783 role="button"
784 tabindex="0"
785 aria-expanded="<?php echo $is_first ? 'true' : 'false'; ?>"
786 aria-controls="srfm-accordion-content-<?php echo esc_attr( $method['id'] ); ?>-<?php echo esc_attr( $this->block_id ); ?>"
787 >
788 <div class="srfm-payment-input-wrapper">
789 <input
790 type="radio"
791 name="payment-method-<?php echo esc_attr( $this->block_id ); ?>"
792 value="<?php echo esc_attr( $method['id'] ); ?>"
793 class="srfm-payment-method-radio"
794 data-method="<?php echo esc_attr( $method['id'] ); ?>"
795 <?php checked( $is_first ); ?>
796 aria-label="<?php echo esc_attr( $method['label'] ); ?>"
797 />
798 <span class="srfm-accordion-title srfm-block-label">
799 <?php echo esc_html( $method['label'] ); ?>
800 </span>
801 </div>
802 </div>
803 <div
804 id="srfm-accordion-content-<?php echo esc_attr( $method['id'] ); ?>-<?php echo esc_attr( $this->block_id ); ?>"
805 class="srfm-accordion-content"
806 role="region"
807 aria-labelledby="srfm-accordion-header-<?php echo esc_attr( $method['id'] ); ?>"
808 >
809 <div
810 class="srfm-payment-method-content"
811 data-method="<?php echo esc_attr( $method['id'] ); ?>"
812 >
813 <div
814 id="srfm-<?php echo esc_attr( $method['id'] ); ?>-<?php echo esc_attr( $this->block_id ); ?>"
815 class="<?php echo esc_attr( $method['container_class'] ); ?>"
816 >
817 <?php
818 // Output provider's placeholder content if available, otherwise show a generic hint for JS rendering.
819 if ( ! empty( $method['place_holder_content'] ) ) {
820 echo $method['place_holder_content']; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
821 } else {
822 ?>
823 <!-- Provider JS will render content here -->
824 <?php
825 }
826 ?>
827 </div>
828 </div>
829 </div>
830 </div>
831 <?php $is_first = false; // Set to false after first iteration. ?>
832 <?php } ?>
833 </div>
834 <?php
835 $template = ob_get_clean();
836
837 return is_string( $template ) ? $template : '';
838 }
839
840 /**
841 * Validate payment field requirements.
842 *
843 * @return bool True if validation passes, false otherwise.
844 * @since 2.0.0
845 */
846 private function validate_payment_requirements() {
847 // Check customer email field requirement (highest priority).
848 if ( empty( $this->customer_email_field ) ) {
849 return false;
850 }
851
852 // Check subscription-specific requirements.
853 // BOTH MODE: subscription is a possible end-user choice, so name field is also required.
854 if ( 'subscription' === $this->original_payment_type || 'both' === $this->original_payment_type ) {
855 if ( empty( $this->customer_name_field ) ) {
856 return false;
857 }
858 }
859
860 return true;
861 }
862
863 /**
864 * Format currency for display.
865 *
866 * @param float $amount Amount to format.
867 * @param string $currency Currency code.
868 * @return string
869 * @since 2.0.0
870 */
871 private function format_currency( $amount, $currency ) {
872 $symbol = Stripe_Helper::get_currency_symbol( $currency );
873 $position = Payment_Helper::get_currency_sign_position();
874
875 // Format based on currency.
876 if ( in_array( $currency, [ 'JPY', 'KRW' ], true ) ) {
877 // No decimal places for these currencies.
878 $formatted_amount = number_format( $amount, 0 );
879 } else {
880 $formatted_amount = number_format( $amount, 2 );
881 }
882
883 // Apply currency sign position.
884 switch ( $position ) {
885 case 'right':
886 return $formatted_amount . $symbol;
887 case 'left_space':
888 return $symbol . ' ' . $formatted_amount;
889 case 'right_space':
890 return $formatted_amount . ' ' . $symbol;
891 case 'left':
892 default:
893 return $symbol . $formatted_amount;
894 }
895 }
896
897 /**
898 * Get the human-readable label for a payment interval slug.
899 *
900 * @param string $interval_slug The slug (e.g., 'day', 'week', 'month', 'quarter', 'yearly').
901 * @return string The translated interval label, or the slug itself if not found.
902 * @since 2.0.0
903 */
904 private function get_interval_label( $interval_slug ) {
905 $interval_labels = [
906 'day' => __( 'day', 'sureforms' ),
907 'week' => __( 'week', 'sureforms' ),
908 'month' => __( 'month', 'sureforms' ),
909 'quarter' => __( 'quarter', 'sureforms' ),
910 'yearly' => __( 'year', 'sureforms' ),
911 ];
912
913 return $interval_labels[ $interval_slug ] ?? $interval_slug;
914 }
915
916 /**
917 * Render test mode notice for admin users.
918 * Only shows if user has manage_options capability.
919 *
920 * @return string|bool Test mode notice markup or empty string.
921 * @since 2.0.0
922 */
923 private function get_test_mode_notice() {
924 // Only show to users with manage_options capability.
925 if ( ! current_user_can( 'manage_options' ) ) {
926 return '';
927 }
928
929 // Build dynamic link to payment settings.
930 $settings_url = admin_url( 'admin.php?page=sureforms_form_settings&tab=payments-settings&subpage=general' );
931
932 ob_start();
933 ?>
934 <div class="srfm-test-mode-notice" style="background-color: #fff3cd; border: 1px solid #ffc107; border-radius: 4px; padding: 12px; margin-bottom: 16px; color: #856404;">
935 <strong><?php esc_html_e( 'Test mode is enabled:', 'sureforms' ); ?></strong>
936 <a href="<?php echo esc_url( $settings_url ); ?>" style="color: #856404;" target="_blank" rel="noopener noreferrer">
937 <?php esc_html_e( 'Click here to enable live mode and accept payment', 'sureforms' ); ?>
938 </a>
939 </div>
940 <?php
941 return ob_get_clean();
942 }
943 }
944