PluginProbe
Subscriptions for WooCommerce with Stripe Recurring Payments / 1.10.4
Subscriptions for WooCommerce with Stripe Recurring Payments v1.10.4
1.11.2 1.11.1 1.11.0 1.10.9 1.10.8 1.10.7 1.10.6 1.10.5 1.10.4 1.10.3 1.10.2 1.10.1 1.10.0 1.9.6 1.9.5 trunk 1.3.0 1.3.1 1.3.2 1.4.0 1.4.1 1.4.2 1.5.0 1.5.1 1.5.2 All 60 releases
subscription / includes / functions.php

functions.php in Subscriptions for WooCommerce with Stripe Recurring Payments 1.10.4, at includes/functions.php

1,093 lines 36.0 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 use Automattic\WooCommerce\Internal\DataStores\Orders\CustomOrdersTableController;
4 use SpringDevs\Subscription\Illuminate\Subscription\Subscription;
5 use SpringDevs\Subscription\Utils\Product;
6
7 /**
8 * Include tailwind CSS file
9 *
10 * * Add this class to the parent element to apply tailwind css styles:
11 * * "`wpsubs-tw-root`"
12 * *
13 * * Use "`yarn build:tailwind`" to build the tailwind CSS file.
14 * * Use "`yarn watch:tailwind`" to continuously build the tailwind CSS file.
15 *
16 * ? This stylesheet is added to the all admin pages of the plugin.
17 * ? You can use this function to add the stylesheet on other pages if necessary.
18 */
19 function subscrpt_include_tailwind_css() {
20 wp_enqueue_style( 'wpsubs-tailwind', SUBSCRPT_ASSETS . '/css/tailwind/output.css', [], SUBSCRPT_VERSION );
21 }
22
23 /**
24 * Generate URL for Subscription Action.
25 *
26 * @param string $action Action.
27 * @param string $nonce nonce.
28 * @param int $subscription_id Subscription ID.
29 *
30 * @return string
31 */
32 function subscrpt_get_action_url( $action, $nonce, $subscription_id ) {
33 $view_subscription_endpoint = Subscription::get_user_endpoint( 'view_subs' );
34 return add_query_arg(
35 array(
36 'subscrpt_id' => $subscription_id,
37 'action' => $action,
38 'wpnonce' => $nonce,
39 ),
40 wc_get_endpoint_url( $view_subscription_endpoint, $subscription_id, wc_get_page_permalink( 'myaccount' ) )
41 );
42 }
43
44
45 /**
46 * Get typos.
47 *
48 * @param int $number Number.
49 * @param string $typo Typo.
50 *
51 * @return string
52 */
53 function subscrpt_get_typos( $number, $typo ) {
54 if ( $number == 1 && $typo == 'days' ) {
55 return ucfirst( __( 'day', 'subscription' ) );
56 } elseif ( $number == 1 && $typo == 'weeks' ) {
57 return ucfirst( __( 'week', 'subscription' ) );
58 } elseif ( $number == 1 && $typo == 'months' ) {
59 return ucfirst( __( 'month', 'subscription' ) );
60 } elseif ( $number == 1 && $typo == 'years' ) {
61 return ucfirst( __( 'year', 'subscription' ) );
62 } else {
63 return ucfirst( $typo );
64 }
65 }
66
67 /**
68 * Format time with trial.
69 *
70 * @param mixed $time Time.
71 * @param null|string $trial Trial.
72 *
73 * @return string
74 */
75 function subscrpt_next_date( $time, $trial = null ) {
76 if ( null === $trial ) {
77 $start_date = time();
78 } else {
79 $start_date = strtotime( $trial );
80 }
81
82 return gmdate( 'F d, Y', strtotime( $time, $start_date ) );
83 }
84
85 /**
86 * Check if subscription-pro activated.
87 *
88 * @return bool
89 */
90 function subscrpt_pro_activated(): bool {
91 return class_exists( 'Sdevs_Wc_Subscription_Pro' );
92 }
93
94 /**
95 * Get renewal process settings.
96 *
97 * @return bool
98 */
99 function subscrpt_is_auto_renew_enabled() {
100 return 'auto' === get_option( 'subscrpt_renewal_process', 'auto' );
101 }
102
103 /**
104 * Get maximum payments for a subscription, checking variation, product, and subscription meta.
105 *
106 * @param int $subscription_id Subscription ID.
107 * @return string|int Maximum payments or empty string if not set.
108 */
109 function subscrpt_get_max_payments( $subscription_id ) {
110 $product_id = get_post_meta( $subscription_id, '_subscrpt_product_id', true );
111 if ( ! $product_id ) {
112 return '';
113 }
114
115 $max_payments = null;
116
117 // Check for variation first
118 $variation_id = get_post_meta( $subscription_id, '_subscrpt_variation_id', true );
119 if ( $variation_id ) {
120 $max_payments = get_post_meta( $variation_id, '_subscrpt_max_no_payment', true );
121 }
122
123 // Fallback to product if variation doesn't have max payments or no variation
124 if ( ! $max_payments ) {
125 $max_payments = get_post_meta( $product_id, '_subscrpt_max_no_payment', true );
126 }
127
128 // Also check subscription's own meta data as final fallback
129 if ( ! $max_payments ) {
130 $max_payments = get_post_meta( $subscription_id, '_subscrpt_max_no_payment', true );
131 }
132
133 return $max_payments ?: '';
134 }
135
136 /**
137 * Count total payments made.
138 *
139 * @param int $subscription_id Subscription ID.
140 * @return int Number of payments made.
141 */
142 function subscrpt_count_payments_made( $subscription_id ) {
143 global $wpdb;
144
145 $table_name = $wpdb->prefix . 'subscrpt_order_relation';
146
147 // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
148 $relations = $wpdb->get_results(
149 $wpdb->prepare(
150 "SELECT sr.*, p.post_status, p.post_date
151 FROM $table_name sr
152 INNER JOIN {$wpdb->posts} p ON sr.order_id = p.ID
153 WHERE sr.subscription_id = %d
154 ORDER BY p.post_date ASC",
155 $subscription_id
156 )
157 );
158 // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
159
160 // Define all payment-related order types (allow filtering for extensibility)
161 $payment_types = apply_filters( 'subscrpt_payment_order_types', array( 'new', 'renew', 'early-renew' ) );
162
163 // Count successful payments
164 $successful_count = 0;
165 foreach ( $relations as $relation ) {
166 // Count all payment-related types
167 if ( in_array( $relation->type, $payment_types ) ) {
168 // Get the actual WooCommerce order
169 $order = wc_get_order( $relation->order_id );
170 if ( $order ) {
171 // Check if order was paid/successful
172 if ( $order->is_paid() || in_array( $order->get_status(), array( 'completed', 'processing', 'on-hold' ) ) ) {
173 ++$successful_count;
174 }
175 }
176 }
177 }
178
179 return $successful_count;
180 }
181
182 /**
183 * Check if subscription has reached its maximum payment limit.
184 *
185 * @param int $subscription_id Subscription ID.
186 * @return bool True if limit reached, false otherwise.
187 */
188 function subscrpt_is_max_payments_reached( $subscription_id ) {
189 // Get the product ID from subscription
190 $product_id = get_post_meta( $subscription_id, '_subscrpt_product_id', true );
191 if ( ! $product_id ) {
192 return false;
193 }
194
195 // Get maximum payments using helper function
196 $max_payments = subscrpt_get_max_payments( $subscription_id );
197
198 // Allow override of total installments
199 $max_payments = apply_filters( 'subscrpt_split_payment_total_override', $max_payments, $subscription_id, $product_id );
200
201 // If no limit set or unlimited, more payments are allowed
202 if ( ! $max_payments || intval( $max_payments ) <= 0 ) {
203 return false;
204 }
205
206 // Count payments made
207 $payments_made = subscrpt_count_payments_made( $subscription_id );
208
209 // Enhanced completion logic considering failed payments
210 $is_reached = subscrpt_check_enhanced_completion( $subscription_id, $payments_made, $max_payments );
211
212 // Fire action when split payment plan is completed (first time only)
213 if ( $is_reached && ! get_post_meta( $subscription_id, '_subscrpt_split_payment_completed_fired', true ) ) {
214 // Add completion milestone note
215 subscrpt_add_payment_completion_note( $subscription_id, $payments_made, $max_payments );
216
217 // Allow customization of subscription status after completion
218 $expire_status = apply_filters( 'subscrpt_split_payment_expire_status', 'expired', $subscription_id, $payments_made, $max_payments );
219
220 // Update subscription status if different from current
221 $current_status = get_post_status( $subscription_id );
222 if ( $current_status !== $expire_status ) {
223 wp_update_post(
224 array(
225 'ID' => $subscription_id,
226 'post_status' => $expire_status,
227 )
228 );
229 }
230
231 do_action( 'subscrpt_split_payment_completed', $subscription_id, $payments_made, $max_payments );
232 update_post_meta( $subscription_id, '_subscrpt_split_payment_completed_fired', true );
233
234 // Handle split payment access timing if Pro version is active
235 if ( function_exists( 'subscrpt_pro_activated' ) && subscrpt_pro_activated() ) {
236 if ( class_exists( '\SpringDevs\SubscriptionPro\Illuminate\SplitPaymentHandler' ) ) {
237 \SpringDevs\SubscriptionPro\Illuminate\SplitPaymentHandler::handle_split_payment_completion( $subscription_id, $payments_made, $max_payments );
238 }
239 }
240 }
241
242 return $is_reached;
243 }
244
245 /**
246 * Get remaining payments for a subscription.
247 *
248 * @param int $subscription_id Subscription ID.
249 * @return int|string Number of remaining payments or 'unlimited'.
250 */
251 function subscrpt_get_remaining_payments( $subscription_id ) {
252 // Get maximum payments using helper function
253 $max_payments = subscrpt_get_max_payments( $subscription_id );
254
255 // If no limit set or unlimited
256 if ( ! $max_payments || intval( $max_payments ) <= 0 ) {
257 return 'unlimited';
258 }
259
260 // Count payments made
261 $payments_made = subscrpt_count_payments_made( $subscription_id );
262
263 // Calculate remaining
264 $remaining = intval( $max_payments ) - intval( $payments_made );
265
266 return max( 0, $remaining );
267 }
268
269 /**
270 * Get payment type for a subscription (handles variations properly).
271 *
272 * @param int $subscription_id Subscription ID.
273 * @return string Payment type ('split_payment' or 'recurring').
274 */
275 function subscrpt_get_payment_type( $subscription_id ) {
276 $product_id = get_post_meta( $subscription_id, '_subscrpt_product_id', true );
277 $variation_id = get_post_meta( $subscription_id, '_subscrpt_variation_id', true );
278
279 $payment_type = 'recurring'; // Default
280
281 // Check variation first if it exists
282 if ( $variation_id ) {
283 $variation_payment_type = get_post_meta( $variation_id, '_subscrpt_payment_type', true );
284 if ( $variation_payment_type ) {
285 $payment_type = $variation_payment_type;
286 }
287 }
288
289 // Fallback to product if no variation payment type
290 if ( $payment_type === 'recurring' && $product_id ) {
291 $product_payment_type = get_post_meta( $product_id, '_subscrpt_payment_type', true );
292 if ( $product_payment_type ) {
293 $payment_type = $product_payment_type;
294 }
295 }
296
297 // Final fallback: check subscription's own meta data
298 if ( $payment_type === 'recurring' ) {
299 $subscription_payment_type = get_post_meta( $subscription_id, '_subscrpt_payment_type', true );
300 if ( $subscription_payment_type ) {
301 $payment_type = $subscription_payment_type;
302 }
303 }
304
305 return $payment_type;
306 }
307
308 /**
309 * Enhanced completion check considering failed payments and access suspension.
310 *
311 * @param int $subscription_id Subscription ID.
312 * @param int $payments_made Number of successful payments made.
313 * @param int $max_payments Maximum payments required.
314 * @return bool True if subscription should be considered complete.
315 */
316 function subscrpt_check_enhanced_completion( $subscription_id, $payments_made, $max_payments ) {
317 // Standard completion check
318 if ( $payments_made >= $max_payments ) {
319 return true;
320 }
321
322 // Check for access suspension due to payment failures
323 if ( function_exists( '\SpringDevs\SubscriptionPro\Illuminate\PaymentFailureHandler::is_access_suspended' ) ) {
324 $is_suspended = \SpringDevs\SubscriptionPro\Illuminate\PaymentFailureHandler::is_access_suspended( $subscription_id );
325 if ( $is_suspended ) {
326 // If access is suspended, check if we should force completion
327 $force_completion_on_suspension = apply_filters( 'subscrpt_force_completion_on_suspension', false, $subscription_id );
328 if ( $force_completion_on_suspension ) {
329 return true;
330 }
331 }
332 }
333
334 // Check for maximum failure threshold
335 $failure_count = get_post_meta( $subscription_id, '_subscrpt_payment_failure_count', true ) ?: 0;
336 $max_failures_before_completion = apply_filters( 'subscrpt_max_failures_before_completion', 0, $subscription_id );
337
338 if ( $max_failures_before_completion > 0 && $failure_count >= $max_failures_before_completion ) {
339 // Force completion after too many failures
340 return true;
341 }
342
343 // Check for time-based completion (e.g., if too much time has passed)
344 $completion_timeout_days = apply_filters( 'subscrpt_completion_timeout_days', 0, $subscription_id );
345 if ( $completion_timeout_days > 0 ) {
346 $start_date = get_post_meta( $subscription_id, '_subscrpt_start_date', true );
347 if ( $start_date ) {
348 $timeout_timestamp = $start_date + ( $completion_timeout_days * DAY_IN_SECONDS );
349 if ( current_time( 'timestamp' ) >= $timeout_timestamp ) {
350 return true;
351 }
352 }
353 }
354
355 return false;
356 }
357
358 /**
359 * Count total payment attempts (including failed ones) for a subscription.
360 *
361 * @param int $subscription_id Subscription ID.
362 * @return array Array with 'successful', 'failed', and 'total' counts.
363 */
364 function subscrpt_count_all_payment_attempts( $subscription_id ) {
365 global $wpdb;
366
367 $table_name = $wpdb->prefix . 'subscrpt_order_relation';
368
369 // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
370 $relations = $wpdb->get_results(
371 $wpdb->prepare(
372 "SELECT sr.*, p.post_status, p.post_date
373 FROM $table_name sr
374 INNER JOIN {$wpdb->posts} p ON sr.order_id = p.ID
375 WHERE sr.subscription_id = %d
376 ORDER BY p.post_date ASC",
377 $subscription_id
378 )
379 );
380 // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
381
382 // Define all payment-related order types
383 $payment_types = apply_filters( 'subscrpt_payment_order_types', array( 'new', 'renew', 'early-renew' ) );
384
385 $successful_count = 0;
386 $failed_count = 0;
387
388 foreach ( $relations as $relation ) {
389 if ( in_array( $relation->type, $payment_types ) ) {
390 $order = wc_get_order( $relation->order_id );
391 if ( $order ) {
392 if ( $order->is_paid() || in_array( $order->get_status(), array( 'completed', 'processing', 'on-hold' ) ) ) {
393 ++$successful_count;
394 } elseif ( in_array( $order->get_status(), array( 'failed', 'cancelled' ) ) ) {
395 ++$failed_count;
396 }
397 }
398 }
399 }
400
401 return array(
402 'successful' => $successful_count,
403 'failed' => $failed_count,
404 'total' => $successful_count + $failed_count,
405 );
406 }
407
408 if ( ! function_exists( 'wps_subscription_order_relation_type_cast' ) ) {
409 /**
410 * Return Label against key.
411 *
412 * @param string $key Key to return cast Value.
413 *
414 * @return string
415 */
416 function order_relation_type_cast( string $key ) {
417 // add Deprecated notice
418 _deprecated_function( 'order_relation_type_cast', '1.5.3', 'wps_subscription_order_relation_type_cast' );
419 return wps_subscription_order_relation_type_cast( $key );
420 }
421 /**
422 * Order relation type cast.
423 *
424 * @param string $key Key.
425 *
426 * @return string
427 */
428 function wps_subscription_order_relation_type_cast( string $key ) {
429 $relational_type_keys = apply_filters(
430 'subscrpt_order_relational_types',
431 array(
432 'new' => __( 'New Subscription Order', 'subscription' ),
433 'renew' => __( 'Renewal Order', 'subscription' ),
434 )
435 );
436
437 return isset( $relational_type_keys[ $key ] ) ? $relational_type_keys[ $key ] : '-';
438 }
439 }
440
441 if ( ! function_exists( 'wps_subscription_is_wc_order_hpos_enabled' ) ) {
442 /**
443 * Check if HPOS enabled.
444 */
445 function is_wc_order_hpos_enabled() {
446 // add Deprecated notice
447 _deprecated_function( 'is_wc_order_hpos_enabled', '1.5.3', 'wps_subscription_is_wc_order_hpos_enabled' );
448 return wps_subscription_is_wc_order_hpos_enabled();
449 }
450 /**
451 * Check if HPOS enabled.
452 *
453 * @return bool
454 */
455 function wps_subscription_is_wc_order_hpos_enabled() {
456 return function_exists( 'wc_get_container' ) ?
457 wc_get_container()
458 ->get( CustomOrdersTableController::class )
459 ->custom_orders_table_usage_is_enabled()
460 : false;
461 }
462 }
463
464 if ( ! function_exists( 'sdevs_wp_strtotime' ) ) {
465 /**
466 * Get strtotime with WordPress timezone config.
467 *
468 * @param string $str string.
469 * @param int|null $base_timestamp base timestamp.
470 *
471 * @return int
472 */
473 function sdevs_wp_strtotime( $str, $base_timestamp = null ) {
474 return strtotime( wp_date( 'Y-m-d H:i:s', strtotime( $str, $base_timestamp ) ) );
475 }
476 }
477
478 if ( ! function_exists( 'sdevs_order_status_label' ) ) {
479 /**
480 * Get order status label from slug.
481 *
482 * @param string $status Status.
483 *
484 * @return string
485 */
486 function sdevs_order_status_label( $status ) {
487 $order_statuses = wc_get_order_statuses();
488
489 return ( isset( $order_statuses[ "wc-{$status}" ] ) ? $order_statuses[ "wc-{$status}" ] : $status );
490 }
491 }
492
493 if ( ! function_exists( 'wps_subscription_get_timing_types' ) ) {
494 /**
495 * Get labels.
496 *
497 * @param bool $key_value key_value.
498 *
499 * @return array
500 */
501 function get_timing_types( $key_value = false ): array {
502 // add Deprecated notice
503 _deprecated_function( 'get_timing_types', '1.5.3', 'wps_subscription_get_timing_types' );
504 return wps_subscription_get_timing_types( $key_value );
505 }
506 /**
507 * Get timing types.
508 *
509 * @param bool $key_value Key value.
510 *
511 * @return array
512 */
513 function wps_subscription_get_timing_types( $key_value = false ): array {
514 return $key_value ? array(
515 'days' => 'Daily',
516 'weeks' => 'Weekly',
517 'months' => 'Monthly',
518 'years' => 'Yearly',
519 ) : array(
520 array(
521 'label' => __( 'Day', 'subscription' ),
522 'value' => 'days',
523 ),
524 array(
525 'label' => __( 'Week', 'subscription' ),
526 'value' => 'weeks',
527 ),
528 array(
529 'label' => __( 'Month', 'subscription' ),
530 'value' => 'months',
531 ),
532 array(
533 'label' => __( 'Year', 'subscription' ),
534 'value' => 'years',
535 ),
536 );
537 }
538 }
539
540 /**
541 * Get WC product in subscription wrapper.
542 *
543 * @deprecated 1.8.17 Use SpringDevs\Subscription\Illuminate\Subscription\Subscription::get_subs_product().
544 */
545 function sdevs_get_subscription_product( $product ) {
546 // Deprecated notice.
547 _deprecated_function( 'sdevs_get_subscription_product', '1.8.17', 'SpringDevs\Subscription\Illuminate\Subscription\Subscription::get_subs_product' );
548
549 return Subscription::get_subs_product( $product );
550 }
551
552 /**
553 * Logger
554 *
555 * @param mixed $message Message.
556 * @param bool $should_print Print the output.
557 */
558 function subscrpt_write_log( $message, bool $should_print = false ): void {
559 $logger = wc_get_logger();
560
561 $message = is_array( $message ) || is_object( $message ) ? wp_json_encode( $message ) : $message;
562 $logger->add( 'wp_subscription', $message );
563
564 echo esc_html( $should_print ? $message : '' );
565 }
566
567 /**
568 * Debug Logger
569 *
570 * @param mixed $log logs.
571 */
572 function subscrpt_write_debug_log( $log ): void {
573 if ( defined( 'WP_DEBUG' ) && WP_DEBUG === true ) {
574 if ( is_array( $log ) || is_object( $log ) ) {
575 error_log( print_r( $log, true ) ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions
576 } else {
577 error_log( 'wp_subscription: ' . $log ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions
578 }
579 }
580 }
581
582 /**
583 * Add payment completion note for split payment subscriptions.
584 *
585 * @param int $subscription_id Subscription ID.
586 * @param int $payments_made Number of payments made.
587 * @param int $max_payments Maximum number of payments.
588 */
589 function subscrpt_add_payment_completion_note( $subscription_id, $payments_made, $max_payments ) {
590 // Check if this is a split payment subscription
591 if ( ! function_exists( 'subscrpt_get_payment_type' ) ) {
592 return;
593 }
594
595 $payment_type = subscrpt_get_payment_type( $subscription_id );
596 if ( 'split_payment' !== $payment_type ) {
597 return;
598 }
599
600 // Create completion note
601 $completion_note = sprintf(
602 /* translators: %1$d: payments made, %2$d: total payments */
603 __( 'Split payment plan completed successfully! %1$d of %2$d payments received.', 'subscription' ),
604 $payments_made,
605 $max_payments
606 );
607
608 // Add the completion note
609 $comment_id = wp_insert_comment(
610 array(
611 'comment_author' => 'Subscription for WooCommerce',
612 'comment_content' => $completion_note,
613 'comment_post_ID' => $subscription_id,
614 'comment_type' => 'order_note',
615 )
616 );
617 update_comment_meta( $comment_id, '_subscrpt_activity', __( 'Split Payment - Plan Complete', 'subscription' ) );
618 update_comment_meta( $comment_id, '_subscrpt_activity_type', 'split_payment' );
619
620 // Add payment summary note
621 $payment_summary = sprintf(
622 /* translators: %1$d: payments made, %2$d: total payments, %3$s: completion date */
623 __( 'Payment Summary: %1$d of %2$d installments completed on %3$s. All payments received successfully.', 'subscription' ),
624 $payments_made,
625 $max_payments,
626 date_i18n( wc_date_format(), current_time( 'timestamp' ) )
627 );
628
629 $summary_comment_id = wp_insert_comment(
630 array(
631 'comment_author' => 'Subscription for WooCommerce',
632 'comment_content' => $payment_summary,
633 'comment_post_ID' => $subscription_id,
634 'comment_type' => 'order_note',
635 )
636 );
637 update_comment_meta( $summary_comment_id, '_subscrpt_activity', __( 'Payment Summary - Complete', 'subscription' ) );
638 update_comment_meta( $summary_comment_id, '_subscrpt_activity_type', 'split_payment_summary' );
639 }
640
641
642 /**
643 * Render a WooCommerce-style multiselect field.
644 *
645 * @param array $field {
646 * Field arguments.
647 *
648 * @type string $id Required. Meta key / input ID.
649 * @type string $label Field label.
650 * @type array $options Key => Label pairs for options.
651 * @type array|string $selected Optional. Selected value(s). Array, JSON, or CSV.
652 * @type string $desc_tip Optional. Description tooltip text.
653 * @type string $description Optional. Field description text.
654 * @type string $wrapper_class Optional. Extra wrapper classes.
655 * @type string $class Optional. Extra <select> classes.
656 * @type string $name Optional. Input name. Defaults to $id.'[]'.
657 * }
658 */
659 function subscrpt_multiselect_field( $field ) {
660 $defaults = [
661 'id' => '',
662 'label' => '',
663 'options' => [],
664 'selected' => [],
665 'desc_tip' => false,
666 'description' => '',
667 'wrapper_class' => '',
668 'wrapper_style' => '',
669 'class' => 'wc-enhanced-select',
670 'style' => '',
671 'name' => '',
672 ];
673
674 $field = wp_parse_args( $field, $defaults );
675
676 if ( empty( $field['id'] ) ) {
677 return;
678 }
679
680 $id = esc_attr( $field['id'] );
681 $name = $field['name'] ? $field['name'] : $id . '[]';
682 $label = esc_html( $field['label'] );
683 $description = $field['description'];
684 $desc_tip = $field['desc_tip'];
685
686 // Normalize selected values into array.
687 $selected = [];
688 if ( is_array( $field['selected'] ) ) {
689 $selected = $field['selected'];
690 } elseif ( is_string( $field['selected'] ) && $field['selected'] !== '' ) {
691 if ( false !== strpos( $field['selected'], '[' ) ) {
692 $tmp = json_decode( $field['selected'], true );
693 $selected = is_array( $tmp ) ? $tmp : [];
694 } else {
695 $selected = array_filter( array_map( 'trim', explode( ',', $field['selected'] ) ) );
696 }
697 }
698
699 // Build <option> list.
700 $options_html = '';
701 foreach ( $field['options'] as $key => $text ) {
702 $is_selected = in_array( (string) $key, (array) $selected, true ) ? ' selected="selected"' : '';
703 $options_html .= sprintf(
704 '<option value="%s"%s>%s</option>',
705 esc_attr( $key ),
706 $is_selected,
707 esc_html( $text )
708 );
709 }
710
711 $tooltip_html = '';
712 if ( $desc_tip && $description ) {
713 $tooltip_html = wc_help_tip( $description );
714 }
715
716 $description_html = '';
717 if ( $description && ! $desc_tip ) {
718 $description_html = '<span class="description">' . wp_kses_post( $description ) . '</span>';
719 }
720
721 // ? Escaped intentionally.
722 // phpcs:disable WordPress.Security.EscapeOutput.OutputNotEscaped
723 ?>
724 <p
725 class="form-field <?php echo esc_attr( $id . '_field ' . ( $field['wrapper_class'] ) ); ?>"
726 style="<?php echo esc_attr( $field['wrapper_style'] ); ?>"
727 >
728 <label for="<?php echo esc_attr( $id ); ?>">
729 <?php echo esc_html( $label ); ?>
730 </label>
731
732 <?php echo $tooltip_html; ?>
733
734 <select
735 multiple="multiple"
736 id="<?php echo esc_attr( $id ); ?>"
737 name="<?php echo esc_attr( $name ); ?>"
738 class="<?php echo esc_attr( $field['class'] ); ?>"
739 style="<?php echo esc_attr( $field['style'] ); ?>"
740 >
741 <?php echo $options_html; ?>
742 </select>
743
744 <?php echo $description_html; ?>
745 </p>
746 <?php
747 // phpcs:enable WordPress.Security.EscapeOutput.OutputNotEscaped
748 }
749
750 /**
751 * Render a preview for pages that require WPSubscription Pro, with a blurred background image and a call-to-action overlay.
752 *
753 * @param array $args Preview arguments.
754 */
755 function subscrpt_render_page_preview( array $args = [] ) {
756 $defaults = [
757 'preview_image_url' => SUBSCRPT_ASSETS . '/images/previews/subscrpt-health-preview.png',
758 'cta_title' => __( 'Upgrade to WPSubscription Pro', 'subscription' ),
759 'cta_description' => __( 'This page requires WPSubscription Pro. Unlock advanced features, priority support, and more with WPSubscription Pro.', 'subscription' ),
760 'cta_button_text' => __( 'âš¡ Upgrade to Pro', 'subscription' ),
761 'cta_button_url' => 'https://wpsubscription.co/?utm_source=plugin&utm_medium=admin&utm_campaign=upgrade_pro',
762 ];
763
764 $args = wp_parse_args( $args, $defaults );
765
766 ob_start();
767 ?>
768 <div style="position: relative;">
769 <div style="filter:blur(4px);pointer-events:none;">
770 <div style="max-width:1240px;margin:32px auto 0 auto;">
771 <img
772 src="<?php echo esc_url( $args['preview_image_url'] ); ?>"
773 alt="<?php esc_attr_e( 'page preview', 'subscription' ); ?>"
774 style="width:100%;display:block;"
775 />
776 </div>
777 </div>
778 <div style="position:absolute;inset:0;display:flex;align-items:top;justify-content:center;padding:100px 32px 32px;">
779 <div style="height:fit-content;background:#fff;border-radius:12px;padding:28px 32px;text-align:center;max-width:440px;box-shadow:0 8px 48px rgba(0,0,0,0.22);">
780
781 <!-- Lock icon with radial glow -->
782 <div style="position:relative;display:flex;align-items:center;justify-content:center;margin-bottom:20px;">
783 <div style="position:absolute;width:100px;height:100px;background:radial-gradient(circle,var(--wpsubs-brand-ring) 0%,transparent 70%);border-radius:50%;"></div>
784 <div style="position:relative;width:56px;height:56px;border:1.5px solid var(--wpsubs-brand);border-radius:14px;display:flex;align-items:center;justify-content:center;background:#fff;">
785 <svg width="24" height="24" fill="none" viewBox="0 0 24 24" style="stroke:var(--wpsubs-brand);" stroke-width="2" aria-hidden="true">
786 <rect x="5" y="11" width="14" height="10" rx="2"/>
787 <path stroke-linecap="round" d="M8 11V7a4 4 0 018 0v4"/>
788 </svg>
789 </div>
790 </div>
791
792 <!-- Title -->
793 <div style="font-size:22px;font-weight:700;color:#111;margin-bottom:10px;line-height:1.3;">
794 <?php echo esc_html( $args['cta_title'] ); ?>
795 </div>
796
797 <!-- Subtitle -->
798 <div style="font-size:14px;color:#6b7280;margin-bottom:20px;line-height:1.6;">
799 <?php echo esc_html( $args['cta_description'] ); ?>
800 </div>
801
802 <!-- CTA button -->
803 <a href="<?php echo esc_url( $args['cta_button_url'] ); ?>" target="_blank" style="display:flex;align-items:center;justify-content:center;gap:8px;background:var(--wpsubs-brand);color:#fff;font-size:15px;font-weight:600;padding:14px 28px;border-radius:8px;text-decoration:none;">
804 <?php echo esc_html( $args['cta_button_text'] ); ?>
805 </a>
806 </div>
807 </div>
808 </div>
809 <?php
810 return ob_get_clean();
811 }
812
813 /**
814 * Render an Advanced Select component.
815 *
816 * Outputs a styled trigger-button + dropdown that replaces a native <select>.
817 * A hidden <input> carries the selected value for form submission.
818 * JS (admin-components.js WPSubsAdvSelect) handles open/close and selection.
819 *
820 * @param array $args {
821 * @type string $name Hidden input name attribute. Required.
822 * @type string $placeholder Trigger label when nothing is selected.
823 * @type string $value Initial hidden-input value (default: '').
824 * @type array $options Each item: {
825 * string value Value submitted on selection.
826 * string label Display text.
827 * bool danger Red destructive style.
828 * string confirm JS confirm() message before selecting.
829 * bool divider Render a divider BEFORE this item.
830 * bool disabled Non-selectable item.
831 * }
832 * @type string $align Menu alignment: 'left' (default) or 'right'.
833 * @type string $id Optional id on the root element.
834 * @type string $class Extra classes on the root element.
835 * }
836 */
837 function wpsubs_render_adv_select( array $args ): void {
838 $args = wp_parse_args(
839 $args,
840 array(
841 'name' => '',
842 'placeholder' => __( 'Select', 'subscription' ),
843 'value' => '',
844 'options' => array(),
845 'align' => 'left',
846 'id' => '',
847 'class' => '',
848 'attrs' => array(),
849 )
850 );
851
852 $root_classes = 'wpsubs-adv-select wpsubs-adv-select--' . ( 'right' === $args['align'] ? 'right' : 'left' );
853 if ( $args['class'] ) {
854 $root_classes .= ' ' . $args['class'];
855 }
856
857 // Resolve trigger label: use matching option's label when a value is already set.
858 $trigger_label = $args['placeholder'];
859 $current_value = (string) $args['value'];
860 if ( '' !== $current_value && '-1' !== $current_value ) {
861 foreach ( $args['options'] as $opt ) {
862 if ( (string) ( $opt['value'] ?? '' ) === $current_value ) {
863 $trigger_label = $opt['label'] ?? $args['placeholder'];
864 break;
865 }
866 }
867 }
868
869 $chevron_svg = '<svg class="wpsubs-adv-select__chevron" xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M6 9l6 6 6-6"/></svg>';
870 ?>
871 <div class="<?php echo esc_attr( $root_classes ); ?>"
872 <?php
873 if ( $args['id'] ) :
874 ?>
875 id="<?php echo esc_attr( $args['id'] ); ?>"<?php endif; ?>
876 data-placeholder="<?php echo esc_attr( $args['placeholder'] ); ?>"
877 data-default-value="<?php echo esc_attr( $args['value'] ); ?>"
878 <?php
879 foreach ( $args['attrs'] as $attr_name => $attr_value ) :
880 echo esc_attr( $attr_name ) . '="' . esc_attr( $attr_value ) . '" '; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Both parts escaped.
881 endforeach;
882 ?>
883 >
884 <button type="button" class="wpsubs-adv-select__trigger" aria-haspopup="listbox" aria-expanded="false">
885 <span class="wpsubs-adv-select__label"><?php echo esc_html( $trigger_label ); ?></span>
886 <?php echo $chevron_svg; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
887 </button>
888
889 <div class="wpsubs-adv-select__menu" role="listbox">
890 <?php
891 foreach ( $args['options'] as $option ) :
892 $option = wp_parse_args(
893 $option,
894 array(
895 'value' => '',
896 'label' => '',
897 'danger' => false,
898 'confirm' => '',
899 'divider' => false,
900 'disabled' => false,
901 )
902 );
903 if ( $option['divider'] ) :
904 ?>
905 <div class="wpsubs-adv-select__divider"></div>
906 <?php
907 continue;
908 endif;
909 ?>
910 <button
911 type="button"
912 class="wpsubs-adv-select__item<?php echo $option['danger'] ? ' wpsubs-adv-select__item--danger' : ''; ?>"
913 data-value="<?php echo esc_attr( $option['value'] ); ?>"
914 <?php
915 if ( $option['confirm'] ) :
916 ?>
917 data-confirm="<?php echo esc_attr( $option['confirm'] ); ?>"<?php endif; ?>
918 <?php
919 if ( $option['disabled'] ) :
920 ?>
921 data-disabled<?php endif; ?>
922 role="option"
923 >
924 <span class="wpsubs-adv-select__item-label"><?php echo esc_html( $option['label'] ); ?></span>
925 </button>
926 <?php endforeach; ?>
927 </div>
928
929 <?php if ( $args['name'] ) : ?>
930 <input type="hidden" name="<?php echo esc_attr( $args['name'] ); ?>" value="<?php echo esc_attr( $args['value'] ); ?>">
931 <?php endif; ?>
932 </div>
933 <?php
934 }
935
936 /**
937 * Render a tag/pill select input with an inline filter and filterable dropdown.
938 * Supports single and multiple selection. No external dependencies.
939 *
940 * JS: WPSubsTagSelect (admin-components.js) auto-inits elements.
941 * Event fired on root: `wpsubs:select` — detail: { value, label, selected }
942 *
943 * @param array $args {
944 * string $name Form field name (base name, without [] suffix).
945 * string $placeholder Input placeholder shown when nothing is selected.
946 * string|array $value Current value(s). Array for multiple, string for single.
947 * array $options Options: array of { value, label, disabled? }.
948 * bool $multiple Enable multi-select mode.
949 * string $id Optional root element id.
950 * string $class Extra CSS classes for the root element.
951 * array $attrs Extra HTML attributes for the root element.
952 * }
953 */
954 function wpsubs_render_tag_select( array $args ): void {
955 $args = wp_parse_args(
956 $args,
957 array(
958 'name' => '',
959 'placeholder' => __( 'Select...', 'subscription' ),
960 'value' => '',
961 'options' => array(),
962 'multiple' => false,
963 'id' => '',
964 'class' => '',
965 'attrs' => array(),
966 )
967 );
968
969 $multiple = (bool) $args['multiple'];
970 $current_value = $multiple ? (array) $args['value'] : (string) $args['value'];
971
972 if ( $multiple ) {
973 $selected_values = array_filter( array_map( 'strval', $current_value ), fn( $v ) => '' !== $v );
974 } else {
975 $selected_values = ( '' !== $current_value ) ? array( $current_value ) : array();
976 }
977
978 // Map selected values to their labels for pill rendering.
979 $selected_labels = array();
980 foreach ( $args['options'] as $opt ) {
981 $opt_val = (string) ( $opt['value'] ?? '' );
982 if ( in_array( $opt_val, $selected_values, true ) ) {
983 $selected_labels[ $opt_val ] = $opt['label'] ?? $opt_val;
984 }
985 }
986
987 $root_classes = 'wpsubs-tag-select';
988 if ( $multiple ) {
989 $root_classes .= ' wpsubs-tag-select--multi';
990 }
991 if ( $args['class'] ) {
992 $root_classes .= ' ' . $args['class'];
993 }
994
995 $has_pills = ! empty( $selected_values );
996 $placeholder = $has_pills ? '' : esc_attr( $args['placeholder'] );
997
998 $chevron_svg = '<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M6 9l6 6 6-6"/></svg>';
999 ?>
1000 <div
1001 class="<?php echo esc_attr( $root_classes ); ?>"
1002 <?php
1003 if ( $args['id'] ) :
1004 ?>
1005 id="<?php echo esc_attr( $args['id'] ); ?>"<?php endif; ?>
1006 data-placeholder="<?php echo esc_attr( $args['placeholder'] ); ?>"
1007 data-name="<?php echo esc_attr( $args['name'] ); ?>"
1008 <?php
1009 if ( $multiple ) :
1010 ?>
1011 data-multiple="1"<?php endif; ?>
1012 <?php
1013 foreach ( $args['attrs'] as $attr_name => $attr_value ) :
1014 echo esc_attr( $attr_name ) . '="' . esc_attr( $attr_value ) . '" '; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Both parts escaped.
1015 endforeach;
1016 ?>
1017 >
1018 <div class="wpsubs-tag-select__field">
1019 <?php foreach ( $selected_labels as $val => $lbl ) : ?>
1020 <span class="wpsubs-tag-select__pill" data-value="<?php echo esc_attr( $val ); ?>">
1021 <span class="wpsubs-tag-select__pill-label"><?php echo esc_html( $lbl ); ?></span>
1022 <button type="button" class="wpsubs-tag-select__pill-remove" aria-label="<?php esc_attr_e( 'Remove', 'subscription' ); ?>">&#x2715;</button>
1023 </span>
1024 <?php endforeach; ?>
1025 <input
1026 type="text"
1027 class="wpsubs-tag-select__input"
1028 placeholder="<?php echo $placeholder; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- already esc_attr'd above. ?>"
1029 autocomplete="off"
1030 aria-label="<?php esc_attr_e( 'Filter options', 'subscription' ); ?>"
1031 />
1032 <span class="wpsubs-tag-select__chevron" aria-hidden="true">
1033 <?php echo $chevron_svg; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
1034 </span>
1035 </div>
1036
1037 <div class="wpsubs-tag-select__dropdown">
1038 <div class="wpsubs-tag-select__list" role="listbox"
1039 <?php
1040 if ( $multiple ) :
1041 ?>
1042 aria-multiselectable="true"<?php endif; ?>>
1043 <?php
1044 foreach ( $args['options'] as $option ) :
1045 $option = wp_parse_args(
1046 $option,
1047 array(
1048 'value' => '',
1049 'label' => '',
1050 'disabled' => false,
1051 )
1052 );
1053 $opt_value = (string) $option['value'];
1054 $is_selected = in_array( $opt_value, $selected_values, true );
1055 ?>
1056 <button
1057 type="button"
1058 class="wpsubs-tag-select__item"
1059 data-value="<?php echo esc_attr( $opt_value ); ?>"
1060 role="option"
1061 aria-selected="<?php echo $is_selected ? 'true' : 'false'; ?>"
1062 <?php
1063 if ( $is_selected ) :
1064 ?>
1065 data-selected<?php endif; ?>
1066 <?php
1067 if ( $option['disabled'] ) :
1068 ?>
1069 data-disabled<?php endif; ?>
1070 style="<?php echo $is_selected ? 'display:none;' : ''; ?>"
1071 ><?php echo esc_html( $option['label'] ); ?></button>
1072 <?php endforeach; ?>
1073 </div>
1074 <div class="wpsubs-tag-select__empty"><?php esc_html_e( 'No results found.', 'subscription' ); ?></div>
1075 </div>
1076
1077 <?php if ( $args['name'] ) : ?>
1078 <?php if ( $multiple ) : ?>
1079 <?php if ( empty( $selected_values ) ) : ?>
1080 <input type="hidden" name="<?php echo esc_attr( $args['name'] ); ?>[]" value="" data-ts-val />
1081 <?php else : ?>
1082 <?php foreach ( $selected_values as $val ) : ?>
1083 <input type="hidden" name="<?php echo esc_attr( $args['name'] ); ?>[]" value="<?php echo esc_attr( $val ); ?>" data-ts-val />
1084 <?php endforeach; ?>
1085 <?php endif; ?>
1086 <?php else : ?>
1087 <input type="hidden" name="<?php echo esc_attr( $args['name'] ); ?>" value="<?php echo esc_attr( $current_value ); ?>" data-ts-val />
1088 <?php endif; ?>
1089 <?php endif; ?>
1090 </div>
1091 <?php
1092 }
1093