PluginProbe
King Addons for Elementor – 100+ Elementor Widgets, 4 000+ Elementor Templates, WooCommerce Builder, Mega Menu, Popup Builder / 51.1.44
King Addons for Elementor – 100+ Elementor Widgets, 4 000+ Elementor Templates, WooCommerce Builder, Mega Menu, Popup Builder v51.1.44
51.1.86 51.1.84 51.1.85 51.1.83 51.1.82 51.1.81 51.1.79 51.1.78 51.1.77 51.1.76 51.1.74 51.1.75 51.1.65 51.1.64 51.1.63 trunk 51.1.14 51.1.2 51.1.35 51.1.36 51.1.37 51.1.38 51.1.39 51.1.44 51.1.45 All 40 releases
king-addons / includes / extensions / Pricing_Table_Builder / Pricing_Table_Builder.php

Pricing_Table_Builder.php in King Addons for Elementor – 100+ Elementor Widgets, 4 000+ Elementor Templates, WooCommerce Builder, Mega Menu, Popup Builder 51.1.44, at includes/extensions/Pricing_Table_Builder/Pricing_Table_Builder.php

1,131 lines 38.2 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Pricing Table Builder Extension.
4 *
5 * Modern pricing table builder with billing toggle, presets, and Elementor integration.
6 *
7 * @package King_Addons
8 */
9
10 namespace King_Addons;
11
12 if (!defined('ABSPATH')) {
13 exit;
14 }
15
16 /**
17 * Main Pricing Table Builder class.
18 */
19 final class Pricing_Table_Builder
20 {
21 /**
22 * Option name for settings.
23 */
24 private const OPTION_NAME = 'king_addons_pricing_table_options';
25
26 /**
27 * Post type name.
28 */
29 public const POST_TYPE = 'kng_pricing_table';
30
31 /**
32 * Meta key for config.
33 */
34 public const META_CONFIG = '_kng_pt_config';
35
36 /**
37 * Meta key for version.
38 */
39 public const META_VERSION = '_kng_pt_version';
40
41 /**
42 * Current schema version.
43 */
44 public const SCHEMA_VERSION = 1;
45
46 /**
47 * REST API namespace.
48 */
49 public const API_NAMESPACE = 'king-addons/v1';
50
51 /**
52 * Singleton instance.
53 *
54 * @var Pricing_Table_Builder|null
55 */
56 private static ?Pricing_Table_Builder $instance = null;
57
58 /**
59 * Cached options.
60 *
61 * @var array<string, mixed>
62 */
63 private array $options = [];
64
65 /**
66 * Gets singleton instance.
67 *
68 * @return Pricing_Table_Builder
69 */
70 public static function instance(): Pricing_Table_Builder
71 {
72 if (is_null(self::$instance)) {
73 self::$instance = new self();
74 }
75 return self::$instance;
76 }
77
78 /**
79 * Constructor.
80 */
81 public function __construct()
82 {
83 $this->options = $this->get_options();
84
85 // Init
86 add_action('init', [$this, 'register_post_type']);
87
88 // Admin
89 add_action('admin_menu', [$this, 'add_admin_menu']);
90 add_action('admin_enqueue_scripts', [$this, 'enqueue_admin_assets']);
91 add_action('admin_post_king_addons_pt_save', [$this, 'handle_save_table']);
92 add_action('admin_post_king_addons_pt_delete', [$this, 'handle_delete_table']);
93 add_action('admin_post_king_addons_pt_duplicate', [$this, 'handle_duplicate_table']);
94
95 // Frontend
96 add_action('wp_enqueue_scripts', [$this, 'enqueue_frontend_assets']);
97 add_shortcode('kng_pricing_table', [$this, 'render_shortcode']);
98
99 // REST API
100 add_action('rest_api_init', [$this, 'register_rest_routes']);
101
102 // AJAX
103 add_action('wp_ajax_kng_pt_preview', [$this, 'ajax_preview']);
104 add_action('wp_ajax_kng_pt_get_tables', [$this, 'ajax_get_tables']);
105 }
106
107 /**
108 * Gets default options.
109 *
110 * @return array<string, mixed>
111 */
112 public function get_default_options(): array
113 {
114 return [
115 'enabled' => true,
116 'default_preset' => 'free_modern_cards',
117 'default_currency' => '$',
118 'default_currency_position' => 'before',
119 ];
120 }
121
122 /**
123 * Gets current options.
124 *
125 * @return array<string, mixed>
126 */
127 public function get_options(): array
128 {
129 $saved = get_option(self::OPTION_NAME, []);
130 return wp_parse_args($saved, $this->get_default_options());
131 }
132
133 /**
134 * Registers the pricing table CPT.
135 *
136 * @return void
137 */
138 public function register_post_type(): void
139 {
140 $labels = [
141 'name' => __('Pricing Tables', 'king-addons'),
142 'singular_name' => __('Pricing Table', 'king-addons'),
143 'add_new' => __('Add New', 'king-addons'),
144 'add_new_item' => __('Add New Pricing Table', 'king-addons'),
145 'edit_item' => __('Edit Pricing Table', 'king-addons'),
146 'new_item' => __('New Pricing Table', 'king-addons'),
147 'view_item' => __('View Pricing Table', 'king-addons'),
148 'search_items' => __('Search Pricing Tables', 'king-addons'),
149 'not_found' => __('No pricing tables found', 'king-addons'),
150 'not_found_in_trash' => __('No pricing tables found in Trash', 'king-addons'),
151 ];
152
153 register_post_type(self::POST_TYPE, [
154 'labels' => $labels,
155 'public' => false,
156 'show_ui' => false,
157 'show_in_menu' => false,
158 'capability_type' => 'post',
159 'supports' => ['title'],
160 'has_archive' => false,
161 'rewrite' => false,
162 ]);
163 }
164
165 /**
166 * Adds admin menu.
167 *
168 * @return void
169 */
170 public function add_admin_menu(): void
171 {
172 add_submenu_page(
173 'king-addons',
174 __('Pricing Tables', 'king-addons'),
175 __('Pricing Tables', 'king-addons'),
176 'manage_options',
177 'king-addons-pricing-tables',
178 [$this, 'render_admin_page']
179 );
180 }
181
182 /**
183 * Enqueues admin assets.
184 *
185 * @param string $hook Current admin page hook.
186 * @return void
187 */
188 public function enqueue_admin_assets(string $hook): void
189 {
190 // Check for our pricing tables page
191 if ($hook !== 'king-addons_page_king-addons-pricing-tables') {
192 return;
193 }
194
195 // Core admin styles
196 wp_enqueue_style('wp-color-picker');
197 wp_enqueue_script('wp-color-picker');
198
199 // Extension specific styles (includes all V3 base styles)
200 wp_enqueue_style(
201 'king-addons-pt-admin',
202 KING_ADDONS_URL . 'includes/extensions/Pricing_Table_Builder/assets/admin.css',
203 [],
204 KING_ADDONS_VERSION
205 );
206
207 // Frontend styles for preview
208 wp_enqueue_style(
209 'king-addons-pt-frontend',
210 KING_ADDONS_URL . 'includes/extensions/Pricing_Table_Builder/assets/frontend.css',
211 [],
212 KING_ADDONS_VERSION
213 );
214
215 wp_enqueue_script(
216 'king-addons-pt-admin',
217 KING_ADDONS_URL . 'includes/extensions/Pricing_Table_Builder/assets/admin.js',
218 ['jquery', 'wp-color-picker', 'jquery-ui-sortable'],
219 KING_ADDONS_VERSION,
220 true
221 );
222
223 // Frontend JS for preview toggle functionality
224 wp_enqueue_script(
225 'king-addons-pt-frontend',
226 KING_ADDONS_URL . 'includes/extensions/Pricing_Table_Builder/assets/frontend.js',
227 [],
228 KING_ADDONS_VERSION,
229 true
230 );
231
232 wp_localize_script('king-addons-pt-admin', 'kngPTAdmin', [
233 'ajaxUrl' => admin_url('admin-ajax.php'),
234 'restUrl' => rest_url(self::API_NAMESPACE . '/pricing-table/'),
235 'nonce' => wp_create_nonce('kng_pt_admin'),
236 'presets' => $this->get_presets(),
237 'i18n' => [
238 'confirmDelete' => __('Are you sure you want to delete this pricing table?', 'king-addons'),
239 'confirmDeletePlan' => __('Are you sure you want to delete this plan?', 'king-addons'),
240 'planName' => __('Plan', 'king-addons'),
241 'featureName' => __('Feature', 'king-addons'),
242 'copied' => __('Copied!', 'king-addons'),
243 'saving' => __('Saving...', 'king-addons'),
244 'saved' => __('Saved!', 'king-addons'),
245 ],
246 ]);
247 }
248
249 /**
250 * Enqueues frontend assets.
251 *
252 * @return void
253 */
254 public function enqueue_frontend_assets(): void
255 {
256 // Only enqueue if needed (shortcode or widget used)
257 // Assets will be enqueued in render methods
258 }
259
260 /**
261 * Enqueues frontend assets when needed.
262 *
263 * @return void
264 */
265 private function enqueue_frontend_assets_if_needed(): void
266 {
267 static $enqueued = false;
268
269 if ($enqueued) {
270 return;
271 }
272
273 wp_enqueue_style(
274 'king-addons-pt-frontend',
275 KING_ADDONS_URL . 'includes/extensions/Pricing_Table_Builder/assets/frontend.css',
276 [],
277 KING_ADDONS_VERSION
278 );
279
280 wp_enqueue_script(
281 'king-addons-pt-frontend',
282 KING_ADDONS_URL . 'includes/extensions/Pricing_Table_Builder/assets/frontend.js',
283 [],
284 KING_ADDONS_VERSION,
285 true
286 );
287
288 $enqueued = true;
289 }
290
291 /**
292 * Renders admin page.
293 *
294 * @return void
295 */
296 public function render_admin_page(): void
297 {
298 $action = isset($_GET['action']) ? sanitize_text_field($_GET['action']) : 'list';
299 $table_id = isset($_GET['table_id']) ? intval($_GET['table_id']) : 0;
300
301 switch ($action) {
302 case 'edit':
303 case 'new':
304 $this->render_editor_page($table_id);
305 break;
306 default:
307 $this->render_list_page();
308 break;
309 }
310 }
311
312 /**
313 * Renders list page.
314 *
315 * @return void
316 */
317 private function render_list_page(): void
318 {
319 $tables = get_posts([
320 'post_type' => self::POST_TYPE,
321 'posts_per_page' => -1,
322 'post_status' => ['publish', 'draft'],
323 'orderby' => 'modified',
324 'order' => 'DESC',
325 ]);
326
327 include __DIR__ . '/templates/admin-list.php';
328 }
329
330 /**
331 * Renders editor page.
332 *
333 * @param int $table_id Table ID.
334 * @return void
335 */
336 private function render_editor_page(int $table_id): void
337 {
338 $table = null;
339 $config = $this->get_default_table_config();
340
341 if ($table_id > 0) {
342 $table = get_post($table_id);
343 if ($table && $table->post_type === self::POST_TYPE) {
344 $saved_config = get_post_meta($table_id, self::META_CONFIG, true);
345 if (!empty($saved_config)) {
346 $config = wp_parse_args(json_decode($saved_config, true), $config);
347 }
348 }
349 }
350
351 $presets = $this->get_presets();
352 $is_premium = function_exists('king_addons_freemius') && king_addons_freemius()->can_use_premium_code__premium_only();
353
354 include __DIR__ . '/templates/admin-editor.php';
355 }
356
357 /**
358 * Gets default table config.
359 *
360 * @return array<string, mixed>
361 */
362 public function get_default_table_config(): array
363 {
364 return [
365 'schema_version' => self::SCHEMA_VERSION,
366 'table' => [
367 'name' => '',
368 'status' => 'draft',
369 'layout' => [
370 'mode' => 'cards',
371 'columns_desktop' => 3,
372 'columns_tablet' => 2,
373 'columns_mobile' => 1,
374 'card_equal_height' => true,
375 'gap' => 24,
376 'max_width' => 1200,
377 'alignment' => 'center',
378 ],
379 ],
380 'billing' => [
381 'enabled' => true,
382 'type' => 'segmented',
383 'periods' => [
384 [
385 'key' => 'monthly',
386 'label' => 'Monthly',
387 'suffix' => '/mo',
388 'is_default' => false,
389 ],
390 [
391 'key' => 'annual',
392 'label' => 'Annual',
393 'suffix' => '/yr',
394 'is_default' => true,
395 'badge' => [
396 'enabled' => true,
397 'text' => 'Save 16%',
398 ],
399 ],
400 ],
401 'currency' => '$',
402 'currency_position' => 'before',
403 'decimals' => 0,
404 ],
405 'plans' => [
406 $this->get_default_plan('basic', 'Basic', 'For individuals', 29, 290, 1),
407 $this->get_default_plan('pro', 'Pro', 'For teams', 79, 790, 2, true, 'Popular'),
408 $this->get_default_plan('enterprise', 'Enterprise', 'For organizations', 199, 1990, 3),
409 ],
410 'features' => [
411 'mode' => 'per_plan',
412 'show_icons' => true,
413 ],
414 'style' => [
415 'preset_id' => 'free_modern_cards',
416 'tokens' => [],
417 'overrides' => [
418 'custom_css_class' => '',
419 ],
420 ],
421 'advanced' => [
422 'hide_toggle' => false,
423 'force_period' => '',
424 'disable_animations' => false,
425 ],
426 ];
427 }
428
429 /**
430 * Gets default plan config.
431 *
432 * @param string $id Plan ID.
433 * @param string $name Plan name.
434 * @param string $subtitle Plan subtitle.
435 * @param int $monthly Monthly price.
436 * @param int $annual Annual price.
437 * @param int $order Order.
438 * @param bool $highlight Highlight.
439 * @param string $badge Badge text.
440 * @return array<string, mixed>
441 */
442 private function get_default_plan(
443 string $id,
444 string $name,
445 string $subtitle,
446 int $monthly,
447 int $annual,
448 int $order,
449 bool $highlight = false,
450 string $badge = ''
451 ): array {
452 return [
453 'id' => 'plan_' . $id,
454 'order' => $order,
455 'name' => $name,
456 'subtitle' => $subtitle,
457 'description' => '',
458 'badge' => [
459 'enabled' => !empty($badge),
460 'text' => $badge,
461 'style' => 'pill',
462 ],
463 'highlight' => [
464 'enabled' => $highlight,
465 'style' => 'border',
466 ],
467 'pricing' => [
468 'monthly' => [
469 'price' => (string)$monthly,
470 'note' => 'billed monthly',
471 ],
472 'annual' => [
473 'price' => (string)$annual,
474 'note' => 'billed annually',
475 ],
476 ],
477 'cta' => [
478 'enabled' => true,
479 'text' => 'Get Started',
480 'url' => '#',
481 'target' => '_self',
482 'style' => $highlight ? 'primary' : 'secondary',
483 ],
484 'features' => [
485 ['text' => 'Feature 1', 'state' => 'enabled'],
486 ['text' => 'Feature 2', 'state' => 'enabled'],
487 ['text' => 'Feature 3', 'state' => $highlight ? 'enabled' : 'disabled'],
488 ],
489 ];
490 }
491
492 /**
493 * Handles save table action.
494 *
495 * @return void
496 */
497 public function handle_save_table(): void
498 {
499 if (!current_user_can('manage_options')) {
500 wp_die(__('Unauthorized', 'king-addons'));
501 }
502
503 check_admin_referer('kng_pt_save', 'kng_pt_nonce');
504
505 $table_id = isset($_POST['table_id']) ? intval($_POST['table_id']) : 0;
506 $config_json = isset($_POST['config']) ? wp_unslash($_POST['config']) : '';
507
508 if (empty($config_json)) {
509 wp_die(__('Invalid config', 'king-addons'));
510 }
511
512 $config = json_decode($config_json, true);
513 if (json_last_error() !== JSON_ERROR_NONE) {
514 wp_die(__('Invalid JSON config', 'king-addons'));
515 }
516
517 $table_name = sanitize_text_field($config['table']['name'] ?? 'Untitled');
518 $table_status = ($config['table']['status'] ?? 'draft') === 'publish' ? 'publish' : 'draft';
519
520 $post_data = [
521 'post_title' => $table_name,
522 'post_status' => $table_status,
523 'post_type' => self::POST_TYPE,
524 ];
525
526 if ($table_id > 0) {
527 $post_data['ID'] = $table_id;
528 wp_update_post($post_data);
529 } else {
530 $table_id = wp_insert_post($post_data);
531 }
532
533 if (is_wp_error($table_id)) {
534 wp_die($table_id->get_error_message());
535 }
536
537 update_post_meta($table_id, self::META_CONFIG, wp_json_encode($config));
538 update_post_meta($table_id, self::META_VERSION, self::SCHEMA_VERSION);
539
540 wp_safe_redirect(admin_url('admin.php?page=king-addons-pricing-tables&action=edit&table_id=' . $table_id . '&saved=1'));
541 exit;
542 }
543
544 /**
545 * Handles delete table action.
546 *
547 * @return void
548 */
549 public function handle_delete_table(): void
550 {
551 if (!current_user_can('manage_options')) {
552 wp_die(__('Unauthorized', 'king-addons'));
553 }
554
555 $table_id = isset($_GET['table_id']) ? intval($_GET['table_id']) : 0;
556 check_admin_referer('kng_pt_delete_' . $table_id);
557
558 if ($table_id > 0) {
559 wp_delete_post($table_id, true);
560 }
561
562 wp_safe_redirect(admin_url('admin.php?page=king-addons-pricing-tables&deleted=1'));
563 exit;
564 }
565
566 /**
567 * Handles duplicate table action.
568 *
569 * @return void
570 */
571 public function handle_duplicate_table(): void
572 {
573 if (!current_user_can('manage_options')) {
574 wp_die(__('Unauthorized', 'king-addons'));
575 }
576
577 $table_id = isset($_GET['table_id']) ? intval($_GET['table_id']) : 0;
578 check_admin_referer('kng_pt_duplicate_' . $table_id);
579
580 if ($table_id > 0) {
581 $original = get_post($table_id);
582 if ($original && $original->post_type === self::POST_TYPE) {
583 $new_id = wp_insert_post([
584 'post_title' => sprintf(__('Copy of %s', 'king-addons'), $original->post_title),
585 'post_status' => 'draft',
586 'post_type' => self::POST_TYPE,
587 ]);
588
589 if (!is_wp_error($new_id)) {
590 $config = get_post_meta($table_id, self::META_CONFIG, true);
591 update_post_meta($new_id, self::META_CONFIG, $config);
592 update_post_meta($new_id, self::META_VERSION, self::SCHEMA_VERSION);
593
594 wp_safe_redirect(admin_url('admin.php?page=king-addons-pricing-tables&action=edit&table_id=' . $new_id));
595 exit;
596 }
597 }
598 }
599
600 wp_safe_redirect(admin_url('admin.php?page=king-addons-pricing-tables'));
601 exit;
602 }
603
604 /**
605 * Registers REST routes.
606 *
607 * @return void
608 */
609 public function register_rest_routes(): void
610 {
611 register_rest_route(self::API_NAMESPACE, '/pricing-table/preview', [
612 'methods' => 'POST',
613 'callback' => [$this, 'rest_preview'],
614 'permission_callback' => function () {
615 return current_user_can('manage_options');
616 },
617 ]);
618
619 register_rest_route(self::API_NAMESPACE, '/pricing-table/list', [
620 'methods' => 'GET',
621 'callback' => [$this, 'rest_get_tables'],
622 'permission_callback' => '__return_true',
623 ]);
624
625 register_rest_route(self::API_NAMESPACE, '/pricing-table/(?P<id>\d+)', [
626 'methods' => 'GET',
627 'callback' => [$this, 'rest_get_table'],
628 'permission_callback' => '__return_true',
629 ]);
630 }
631
632 /**
633 * REST preview endpoint.
634 *
635 * @param \WP_REST_Request $request Request object.
636 * @return \WP_REST_Response
637 */
638 public function rest_preview(\WP_REST_Request $request): \WP_REST_Response
639 {
640 $config = $request->get_json_params();
641 $html = $this->render_table($config, true);
642
643 return new \WP_REST_Response([
644 'success' => true,
645 'html' => $html,
646 ]);
647 }
648
649 /**
650 * REST get tables endpoint.
651 *
652 * @return \WP_REST_Response
653 */
654 public function rest_get_tables(): \WP_REST_Response
655 {
656 $tables = get_posts([
657 'post_type' => self::POST_TYPE,
658 'posts_per_page' => -1,
659 'post_status' => 'publish',
660 ]);
661
662 $result = [];
663 foreach ($tables as $table) {
664 $result[] = [
665 'id' => $table->ID,
666 'title' => $table->post_title,
667 ];
668 }
669
670 return new \WP_REST_Response($result);
671 }
672
673 /**
674 * REST get single table endpoint.
675 *
676 * @param \WP_REST_Request $request Request object.
677 * @return \WP_REST_Response
678 */
679 public function rest_get_table(\WP_REST_Request $request): \WP_REST_Response
680 {
681 $table_id = (int)$request->get_param('id');
682 $table = get_post($table_id);
683
684 if (!$table || $table->post_type !== self::POST_TYPE) {
685 return new \WP_REST_Response(['error' => 'Not found'], 404);
686 }
687
688 $config = get_post_meta($table_id, self::META_CONFIG, true);
689
690 return new \WP_REST_Response([
691 'id' => $table_id,
692 'title' => $table->post_title,
693 'config' => json_decode($config, true),
694 ]);
695 }
696
697 /**
698 * AJAX preview handler.
699 *
700 * @return void
701 */
702 public function ajax_preview(): void
703 {
704 check_ajax_referer('kng_pt_admin', 'nonce');
705
706 $config_json = isset($_POST['config']) ? wp_unslash($_POST['config']) : '';
707 $config = json_decode($config_json, true);
708
709 if (!$config) {
710 wp_send_json_error('Invalid config');
711 }
712
713 $html = $this->render_table($config, true);
714 wp_send_json_success(['html' => $html]);
715 }
716
717 /**
718 * AJAX get tables handler.
719 *
720 * @return void
721 */
722 public function ajax_get_tables(): void
723 {
724 $tables = get_posts([
725 'post_type' => self::POST_TYPE,
726 'posts_per_page' => -1,
727 'post_status' => 'publish',
728 ]);
729
730 $result = [];
731 foreach ($tables as $table) {
732 $result[] = [
733 'id' => $table->ID,
734 'title' => $table->post_title,
735 ];
736 }
737
738 wp_send_json_success($result);
739 }
740
741 /**
742 * Renders shortcode.
743 *
744 * @param array $atts Shortcode attributes.
745 * @return string
746 */
747 public function render_shortcode(array $atts = []): string
748 {
749 $atts = shortcode_atts([
750 'id' => 0,
751 'period' => '',
752 'hide_toggle' => '',
753 ], $atts, 'kng_pricing_table');
754
755 $table_id = intval($atts['id']);
756 if ($table_id <= 0) {
757 return '';
758 }
759
760 $table = get_post($table_id);
761 if (!$table || $table->post_type !== self::POST_TYPE || $table->post_status !== 'publish') {
762 return '';
763 }
764
765 $config_json = get_post_meta($table_id, self::META_CONFIG, true);
766 $config = json_decode($config_json, true);
767
768 if (!$config) {
769 return '';
770 }
771
772 // Apply shortcode overrides
773 if (!empty($atts['period'])) {
774 $config['advanced']['force_period'] = sanitize_text_field($atts['period']);
775 }
776 if ($atts['hide_toggle'] === '1') {
777 $config['advanced']['hide_toggle'] = true;
778 }
779
780 $this->enqueue_frontend_assets_if_needed();
781
782 return $this->render_table($config);
783 }
784
785 /**
786 * Renders pricing table HTML.
787 *
788 * @param array $config Table config.
789 * @param bool $is_preview Whether this is a preview render.
790 * @return string
791 */
792 public function render_table(array $config, bool $is_preview = false): string
793 {
794 $preset = $this->get_preset($config['style']['preset_id'] ?? 'free_modern_cards');
795 $tokens = array_merge($preset['tokens'] ?? [], $config['style']['tokens'] ?? []);
796
797 // Generate CSS variables
798 $css_vars = $this->generate_css_variables($tokens);
799
800 // Get billing settings
801 $billing = $config['billing'] ?? [];
802 $billing_enabled = !empty($billing['enabled']) && empty($config['advanced']['hide_toggle']);
803 $periods = $billing['periods'] ?? [];
804 $default_period = '';
805 foreach ($periods as $period) {
806 if (!empty($period['is_default'])) {
807 $default_period = $period['key'];
808 break;
809 }
810 }
811 if (empty($default_period) && !empty($periods)) {
812 $default_period = $periods[0]['key'];
813 }
814
815 // Force period override
816 if (!empty($config['advanced']['force_period'])) {
817 $default_period = $config['advanced']['force_period'];
818 }
819
820 // Layout settings
821 $layout = $config['table']['layout'] ?? [];
822 $columns_desktop = $layout['columns_desktop'] ?? 3;
823 $columns_tablet = $layout['columns_tablet'] ?? 2;
824 $columns_mobile = $layout['columns_mobile'] ?? 1;
825 $gap = $layout['gap'] ?? 24;
826 $max_width = $layout['max_width'] ?? 1200;
827 $alignment = $layout['alignment'] ?? 'center';
828
829 // Plans
830 $plans = $config['plans'] ?? [];
831 usort($plans, function ($a, $b) {
832 return ($a['order'] ?? 0) - ($b['order'] ?? 0);
833 });
834
835 // Currency
836 $currency = $billing['currency'] ?? '$';
837 $currency_position = $billing['currency_position'] ?? 'before';
838
839 ob_start();
840 ?>
841 <div class="kng-pt-wrapper kng-pt-preset-<?php echo esc_attr($config['style']['preset_id'] ?? 'default'); ?> <?php echo esc_attr($config['style']['overrides']['custom_css_class'] ?? ''); ?>"
842 style="<?php echo esc_attr($css_vars); ?> --kng-pt-columns-desktop: <?php echo esc_attr($columns_desktop); ?>; --kng-pt-columns-tablet: <?php echo esc_attr($columns_tablet); ?>; --kng-pt-columns-mobile: <?php echo esc_attr($columns_mobile); ?>; --kng-pt-gap: <?php echo esc_attr($gap); ?>px; --kng-pt-max-width: <?php echo esc_attr($max_width); ?>px;"
843 data-period="<?php echo esc_attr($default_period); ?>"
844 data-billing="<?php echo $billing_enabled ? '1' : '0'; ?>">
845
846 <div class="kng-pt-container" style="max-width: var(--kng-pt-max-width); margin: 0 auto; text-align: <?php echo esc_attr($alignment); ?>;">
847
848 <?php if ($billing_enabled && count($periods) > 1): ?>
849 <!-- Billing Toggle -->
850 <div class="kng-pt-toggle-wrapper">
851 <div class="kng-pt-toggle" role="radiogroup" aria-label="<?php esc_attr_e('Billing period', 'king-addons'); ?>">
852 <?php foreach ($periods as $index => $period): ?>
853 <button type="button"
854 class="kng-pt-toggle-btn <?php echo $period['key'] === $default_period ? 'is-active' : ''; ?>"
855 role="radio"
856 aria-checked="<?php echo $period['key'] === $default_period ? 'true' : 'false'; ?>"
857 data-period="<?php echo esc_attr($period['key']); ?>">
858 <?php echo esc_html($period['label']); ?>
859 <?php if (!empty($period['badge']['enabled']) && !empty($period['badge']['text'])): ?>
860 <span class="kng-pt-toggle-badge"><?php echo esc_html($period['badge']['text']); ?></span>
861 <?php endif; ?>
862 </button>
863 <?php endforeach; ?>
864 </div>
865 </div>
866 <?php endif; ?>
867
868 <!-- Plans Grid -->
869 <div class="kng-pt-grid">
870 <?php foreach ($plans as $plan): ?>
871 <?php echo $this->render_plan_card($plan, $config, $default_period); ?>
872 <?php endforeach; ?>
873 </div>
874
875 </div>
876 </div>
877 <?php
878 return ob_get_clean();
879 }
880
881 /**
882 * Renders a single plan card.
883 *
884 * @param array $plan Plan config.
885 * @param array $config Full table config.
886 * @param string $active_period Active period key.
887 * @return string
888 */
889 private function render_plan_card(array $plan, array $config, string $active_period): string
890 {
891 $billing = $config['billing'] ?? [];
892 $currency = $billing['currency'] ?? '$';
893 $currency_position = $billing['currency_position'] ?? 'before';
894 $billing_enabled = !empty($billing['enabled']);
895 $periods = $billing['periods'] ?? [];
896
897 $is_highlighted = !empty($plan['highlight']['enabled']);
898 $badge_enabled = !empty($plan['badge']['enabled']);
899 $cta = $plan['cta'] ?? [];
900 $features = $plan['features'] ?? [];
901
902 ob_start();
903 ?>
904 <div class="kng-pt-card <?php echo $is_highlighted ? 'kng-pt-card--highlighted' : ''; ?>">
905
906 <?php if ($badge_enabled && !empty($plan['badge']['text'])): ?>
907 <div class="kng-pt-card-badge kng-pt-badge--<?php echo esc_attr($plan['badge']['style'] ?? 'pill'); ?>">
908 <?php echo esc_html($plan['badge']['text']); ?>
909 </div>
910 <?php endif; ?>
911
912 <div class="kng-pt-card-header">
913 <h3 class="kng-pt-card-name"><?php echo esc_html($plan['name']); ?></h3>
914 <?php if (!empty($plan['subtitle'])): ?>
915 <p class="kng-pt-card-subtitle"><?php echo esc_html($plan['subtitle']); ?></p>
916 <?php endif; ?>
917 </div>
918
919 <div class="kng-pt-card-pricing">
920 <?php if ($billing_enabled): ?>
921 <?php foreach ($periods as $period):
922 $period_key = $period['key'];
923 $price_data = $plan['pricing'][$period_key] ?? [];
924 $price = $price_data['price'] ?? '0';
925 $note = $price_data['note'] ?? '';
926 $suffix = $period['suffix'] ?? '';
927 ?>
928 <div class="kng-pt-price-group" data-period="<?php echo esc_attr($period_key); ?>" <?php echo $period_key !== $active_period ? 'style="display: none;"' : ''; ?>>
929 <div class="kng-pt-price">
930 <?php if ($currency_position === 'before'): ?>
931 <span class="kng-pt-price-currency"><?php echo esc_html($currency); ?></span>
932 <?php endif; ?>
933 <span class="kng-pt-price-value"><?php echo esc_html($price); ?></span>
934 <?php if ($currency_position === 'after'): ?>
935 <span class="kng-pt-price-currency"><?php echo esc_html($currency); ?></span>
936 <?php endif; ?>
937 <?php if (!empty($suffix)): ?>
938 <span class="kng-pt-price-suffix"><?php echo esc_html($suffix); ?></span>
939 <?php endif; ?>
940 </div>
941 <?php if (!empty($note)): ?>
942 <div class="kng-pt-price-note"><?php echo esc_html($note); ?></div>
943 <?php endif; ?>
944 </div>
945 <?php endforeach; ?>
946 <?php else: ?>
947 <?php
948 $price_data = $plan['pricing']['default'] ?? $plan['pricing']['monthly'] ?? [];
949 $price = $price_data['price'] ?? '0';
950 $note = $price_data['note'] ?? '';
951 ?>
952 <div class="kng-pt-price-group">
953 <div class="kng-pt-price">
954 <?php if ($currency_position === 'before'): ?>
955 <span class="kng-pt-price-currency"><?php echo esc_html($currency); ?></span>
956 <?php endif; ?>
957 <span class="kng-pt-price-value"><?php echo esc_html($price); ?></span>
958 <?php if ($currency_position === 'after'): ?>
959 <span class="kng-pt-price-currency"><?php echo esc_html($currency); ?></span>
960 <?php endif; ?>
961 </div>
962 <?php if (!empty($note)): ?>
963 <div class="kng-pt-price-note"><?php echo esc_html($note); ?></div>
964 <?php endif; ?>
965 </div>
966 <?php endif; ?>
967 </div>
968
969 <?php if (!empty($cta['enabled'])): ?>
970 <div class="kng-pt-card-cta">
971 <a href="<?php echo esc_url($cta['url'] ?? '#'); ?>"
972 class="kng-pt-btn kng-pt-btn--<?php echo esc_attr($cta['style'] ?? 'primary'); ?>"
973 <?php echo ($cta['target'] ?? '_self') === '_blank' ? 'target="_blank" rel="noopener"' : ''; ?>>
974 <?php echo esc_html($cta['text'] ?? __('Get Started', 'king-addons')); ?>
975 </a>
976 </div>
977 <?php endif; ?>
978
979 <?php if (!empty($features) && !empty($config['features']['show_icons'])): ?>
980 <div class="kng-pt-card-features">
981 <ul class="kng-pt-features-list">
982 <?php foreach ($features as $feature): ?>
983 <li class="kng-pt-feature kng-pt-feature--<?php echo esc_attr($feature['state'] ?? 'enabled'); ?>">
984 <span class="kng-pt-feature-icon">
985 <?php if (($feature['state'] ?? 'enabled') === 'enabled'): ?>
986 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><polyline points="20 6 9 17 4 12"/></svg>
987 <?php else: ?>
988 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
989 <?php endif; ?>
990 </span>
991 <span class="kng-pt-feature-text"><?php echo esc_html($feature['text']); ?></span>
992 </li>
993 <?php endforeach; ?>
994 </ul>
995 </div>
996 <?php endif; ?>
997
998 <?php if (!empty($plan['fine_print'])): ?>
999 <div class="kng-pt-card-fine-print">
1000 <?php echo esc_html($plan['fine_print']); ?>
1001 </div>
1002 <?php endif; ?>
1003
1004 </div>
1005 <?php
1006 return ob_get_clean();
1007 }
1008
1009 /**
1010 * Generates CSS variables from tokens.
1011 *
1012 * @param array $tokens Style tokens.
1013 * @return string
1014 */
1015 private function generate_css_variables(array $tokens): string
1016 {
1017 $vars = [];
1018
1019 // Colors
1020 $colors = $tokens['colors'] ?? [];
1021 if (!empty($colors['bg'])) $vars[] = '--kng-pt-bg:' . $colors['bg'];
1022 if (!empty($colors['card_bg'])) $vars[] = '--kng-pt-card-bg:' . $colors['card_bg'];
1023 if (!empty($colors['card_border'])) $vars[] = '--kng-pt-card-border:' . $colors['card_border'];
1024 if (!empty($colors['text'])) $vars[] = '--kng-pt-text:' . $colors['text'];
1025 if (!empty($colors['muted'])) $vars[] = '--kng-pt-muted:' . $colors['muted'];
1026 if (!empty($colors['accent'])) $vars[] = '--kng-pt-accent:' . $colors['accent'];
1027 if (!empty($colors['accent_text'])) $vars[] = '--kng-pt-accent-text:' . $colors['accent_text'];
1028 if (!empty($colors['badge_bg'])) $vars[] = '--kng-pt-badge-bg:' . $colors['badge_bg'];
1029 if (!empty($colors['badge_text'])) $vars[] = '--kng-pt-badge-text:' . $colors['badge_text'];
1030 if (!empty($colors['highlight'])) $vars[] = '--kng-pt-highlight:' . $colors['highlight'];
1031
1032 // Typography
1033 $typo = $tokens['typography'] ?? [];
1034 if (!empty($typo['title_size'])) $vars[] = '--kng-pt-title-size:' . $typo['title_size'] . 'px';
1035 if (!empty($typo['price_size'])) $vars[] = '--kng-pt-price-size:' . $typo['price_size'] . 'px';
1036 if (!empty($typo['body_size'])) $vars[] = '--kng-pt-body-size:' . $typo['body_size'] . 'px';
1037
1038 // Layout
1039 if (!empty($tokens['radius'])) $vars[] = '--kng-pt-radius:' . $tokens['radius'] . 'px';
1040 if (!empty($tokens['border_width'])) $vars[] = '--kng-pt-border-width:' . $tokens['border_width'] . 'px';
1041
1042 // Shadow
1043 $shadow_map = [
1044 'none' => 'none',
1045 'sm' => '0 2px 8px rgba(0,0,0,0.06)',
1046 'md' => '0 4px 20px rgba(0,0,0,0.08)',
1047 'lg' => '0 8px 40px rgba(0,0,0,0.12)',
1048 ];
1049 if (!empty($tokens['shadow']) && isset($shadow_map[$tokens['shadow']])) {
1050 $vars[] = '--kng-pt-shadow:' . $shadow_map[$tokens['shadow']];
1051 }
1052
1053 return implode(';', $vars);
1054 }
1055
1056 /**
1057 * Gets all presets.
1058 *
1059 * @return array<string, array>
1060 */
1061 public function get_presets(): array
1062 {
1063 return include __DIR__ . '/presets/presets.php';
1064 }
1065
1066 /**
1067 * Gets a single preset by ID.
1068 *
1069 * @param string $preset_id Preset ID.
1070 * @return array
1071 */
1072 public function get_preset(string $preset_id): array
1073 {
1074 $presets = $this->get_presets();
1075 return $presets[$preset_id] ?? $presets['free_modern_cards'] ?? [];
1076 }
1077
1078 /**
1079 * Renders table for Elementor widget.
1080 *
1081 * @param int $table_id Table ID.
1082 * @param array $overrides Override settings.
1083 * @return string
1084 */
1085 public function render_for_elementor(int $table_id, array $overrides = []): string
1086 {
1087 if ($table_id <= 0) {
1088 return '<p style="text-align:center;color:#999;">' . __('Please select a pricing table', 'king-addons') . '</p>';
1089 }
1090
1091 $table = get_post($table_id);
1092 if (!$table || $table->post_type !== self::POST_TYPE) {
1093 return '<p style="text-align:center;color:#999;">' . __('Pricing table not found', 'king-addons') . '</p>';
1094 }
1095
1096 $config_json = get_post_meta($table_id, self::META_CONFIG, true);
1097 $config = json_decode($config_json, true);
1098
1099 if (!$config) {
1100 return '<p style="text-align:center;color:#999;">' . __('Invalid pricing table config', 'king-addons') . '</p>';
1101 }
1102
1103 // Apply overrides
1104 if (!empty($overrides['force_period'])) {
1105 $config['advanced']['force_period'] = $overrides['force_period'];
1106 }
1107 if (!empty($overrides['hide_toggle'])) {
1108 $config['advanced']['hide_toggle'] = true;
1109 }
1110 if (!empty($overrides['columns_desktop'])) {
1111 $config['table']['layout']['columns_desktop'] = (int)$overrides['columns_desktop'];
1112 }
1113 if (!empty($overrides['columns_tablet'])) {
1114 $config['table']['layout']['columns_tablet'] = (int)$overrides['columns_tablet'];
1115 }
1116 if (!empty($overrides['columns_mobile'])) {
1117 $config['table']['layout']['columns_mobile'] = (int)$overrides['columns_mobile'];
1118 }
1119 if (!empty($overrides['max_width'])) {
1120 $config['table']['layout']['max_width'] = (int)$overrides['max_width'];
1121 }
1122 if (!empty($overrides['alignment'])) {
1123 $config['table']['layout']['alignment'] = $overrides['alignment'];
1124 }
1125
1126 $this->enqueue_frontend_assets_if_needed();
1127
1128 return $this->render_table($config);
1129 }
1130 }
1131