PluginProbe
King Addons for Elementor – 80+ Elementor Widgets, 4 000+ Elementor Templates, WooCommerce, Mega Menu, Popup Builder / 51.1.78
King Addons for Elementor – 80+ Elementor Widgets, 4 000+ Elementor Templates, WooCommerce, Mega Menu, Popup Builder v51.1.78
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 51.1.46 51.1.47 51.1.49 All 37 releases
king-addons / includes / extensions / Pricing_Table_Builder / Pricing_Table_Builder.php

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

1,139 lines 38.5 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' => function () {
623 return current_user_can('manage_options');
624 },
625 ]);
626
627 register_rest_route(self::API_NAMESPACE, '/pricing-table/(?P<id>\d+)', [
628 'methods' => 'GET',
629 'callback' => [$this, 'rest_get_table'],
630 'permission_callback' => function () {
631 return current_user_can('manage_options');
632 },
633 ]);
634 }
635
636 /**
637 * REST preview endpoint.
638 *
639 * @param \WP_REST_Request $request Request object.
640 * @return \WP_REST_Response
641 */
642 public function rest_preview(\WP_REST_Request $request): \WP_REST_Response
643 {
644 $config = $request->get_json_params();
645 $html = $this->render_table($config, true);
646
647 return new \WP_REST_Response([
648 'success' => true,
649 'html' => $html,
650 ]);
651 }
652
653 /**
654 * REST get tables endpoint.
655 *
656 * @return \WP_REST_Response
657 */
658 public function rest_get_tables(): \WP_REST_Response
659 {
660 $tables = get_posts([
661 'post_type' => self::POST_TYPE,
662 'posts_per_page' => -1,
663 'post_status' => 'publish',
664 ]);
665
666 $result = [];
667 foreach ($tables as $table) {
668 $result[] = [
669 'id' => $table->ID,
670 'title' => $table->post_title,
671 ];
672 }
673
674 return new \WP_REST_Response($result);
675 }
676
677 /**
678 * REST get single table endpoint.
679 *
680 * @param \WP_REST_Request $request Request object.
681 * @return \WP_REST_Response
682 */
683 public function rest_get_table(\WP_REST_Request $request): \WP_REST_Response
684 {
685 $table_id = (int)$request->get_param('id');
686 $table = get_post($table_id);
687
688 if (!$table || $table->post_type !== self::POST_TYPE) {
689 return new \WP_REST_Response(['error' => 'Not found'], 404);
690 }
691
692 if ($table->post_status !== 'publish' && !current_user_can('edit_post', $table_id)) {
693 return new \WP_REST_Response(['error' => 'Not found'], 404);
694 }
695
696 $config = get_post_meta($table_id, self::META_CONFIG, true);
697
698 return new \WP_REST_Response([
699 'id' => $table_id,
700 'title' => $table->post_title,
701 'config' => json_decode($config, true),
702 ]);
703 }
704
705 /**
706 * AJAX preview handler.
707 *
708 * @return void
709 */
710 public function ajax_preview(): void
711 {
712 check_ajax_referer('kng_pt_admin', 'nonce');
713
714 $config_json = isset($_POST['config']) ? wp_unslash($_POST['config']) : '';
715 $config = json_decode($config_json, true);
716
717 if (!$config) {
718 wp_send_json_error('Invalid config');
719 }
720
721 $html = $this->render_table($config, true);
722 wp_send_json_success(['html' => $html]);
723 }
724
725 /**
726 * AJAX get tables handler.
727 *
728 * @return void
729 */
730 public function ajax_get_tables(): void
731 {
732 $tables = get_posts([
733 'post_type' => self::POST_TYPE,
734 'posts_per_page' => -1,
735 'post_status' => 'publish',
736 ]);
737
738 $result = [];
739 foreach ($tables as $table) {
740 $result[] = [
741 'id' => $table->ID,
742 'title' => $table->post_title,
743 ];
744 }
745
746 wp_send_json_success($result);
747 }
748
749 /**
750 * Renders shortcode.
751 *
752 * @param array $atts Shortcode attributes.
753 * @return string
754 */
755 public function render_shortcode(array $atts = []): string
756 {
757 $atts = shortcode_atts([
758 'id' => 0,
759 'period' => '',
760 'hide_toggle' => '',
761 ], $atts, 'kng_pricing_table');
762
763 $table_id = intval($atts['id']);
764 if ($table_id <= 0) {
765 return '';
766 }
767
768 $table = get_post($table_id);
769 if (!$table || $table->post_type !== self::POST_TYPE || $table->post_status !== 'publish') {
770 return '';
771 }
772
773 $config_json = get_post_meta($table_id, self::META_CONFIG, true);
774 $config = json_decode($config_json, true);
775
776 if (!$config) {
777 return '';
778 }
779
780 // Apply shortcode overrides
781 if (!empty($atts['period'])) {
782 $config['advanced']['force_period'] = sanitize_text_field($atts['period']);
783 }
784 if ($atts['hide_toggle'] === '1') {
785 $config['advanced']['hide_toggle'] = true;
786 }
787
788 $this->enqueue_frontend_assets_if_needed();
789
790 return $this->render_table($config);
791 }
792
793 /**
794 * Renders pricing table HTML.
795 *
796 * @param array $config Table config.
797 * @param bool $is_preview Whether this is a preview render.
798 * @return string
799 */
800 public function render_table(array $config, bool $is_preview = false): string
801 {
802 $preset = $this->get_preset($config['style']['preset_id'] ?? 'free_modern_cards');
803 $tokens = array_merge($preset['tokens'] ?? [], $config['style']['tokens'] ?? []);
804
805 // Generate CSS variables
806 $css_vars = $this->generate_css_variables($tokens);
807
808 // Get billing settings
809 $billing = $config['billing'] ?? [];
810 $billing_enabled = !empty($billing['enabled']) && empty($config['advanced']['hide_toggle']);
811 $periods = $billing['periods'] ?? [];
812 $default_period = '';
813 foreach ($periods as $period) {
814 if (!empty($period['is_default'])) {
815 $default_period = $period['key'];
816 break;
817 }
818 }
819 if (empty($default_period) && !empty($periods)) {
820 $default_period = $periods[0]['key'];
821 }
822
823 // Force period override
824 if (!empty($config['advanced']['force_period'])) {
825 $default_period = $config['advanced']['force_period'];
826 }
827
828 // Layout settings
829 $layout = $config['table']['layout'] ?? [];
830 $columns_desktop = $layout['columns_desktop'] ?? 3;
831 $columns_tablet = $layout['columns_tablet'] ?? 2;
832 $columns_mobile = $layout['columns_mobile'] ?? 1;
833 $gap = $layout['gap'] ?? 24;
834 $max_width = $layout['max_width'] ?? 1200;
835 $alignment = $layout['alignment'] ?? 'center';
836
837 // Plans
838 $plans = $config['plans'] ?? [];
839 usort($plans, function ($a, $b) {
840 return ($a['order'] ?? 0) - ($b['order'] ?? 0);
841 });
842
843 // Currency
844 $currency = $billing['currency'] ?? '$';
845 $currency_position = $billing['currency_position'] ?? 'before';
846
847 ob_start();
848 ?>
849 <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'] ?? ''); ?>"
850 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;"
851 data-period="<?php echo esc_attr($default_period); ?>"
852 data-billing="<?php echo $billing_enabled ? '1' : '0'; ?>">
853
854 <div class="kng-pt-container" style="max-width: var(--kng-pt-max-width); margin: 0 auto; text-align: <?php echo esc_attr($alignment); ?>;">
855
856 <?php if ($billing_enabled && count($periods) > 1): ?>
857 <!-- Billing Toggle -->
858 <div class="kng-pt-toggle-wrapper">
859 <div class="kng-pt-toggle" role="radiogroup" aria-label="<?php esc_attr_e('Billing period', 'king-addons'); ?>">
860 <?php foreach ($periods as $index => $period): ?>
861 <button type="button"
862 class="kng-pt-toggle-btn <?php echo $period['key'] === $default_period ? 'is-active' : ''; ?>"
863 role="radio"
864 aria-checked="<?php echo $period['key'] === $default_period ? 'true' : 'false'; ?>"
865 data-period="<?php echo esc_attr($period['key']); ?>">
866 <?php echo esc_html($period['label']); ?>
867 <?php if (!empty($period['badge']['enabled']) && !empty($period['badge']['text'])): ?>
868 <span class="kng-pt-toggle-badge"><?php echo esc_html($period['badge']['text']); ?></span>
869 <?php endif; ?>
870 </button>
871 <?php endforeach; ?>
872 </div>
873 </div>
874 <?php endif; ?>
875
876 <!-- Plans Grid -->
877 <div class="kng-pt-grid">
878 <?php foreach ($plans as $plan): ?>
879 <?php echo $this->render_plan_card($plan, $config, $default_period); ?>
880 <?php endforeach; ?>
881 </div>
882
883 </div>
884 </div>
885 <?php
886 return ob_get_clean();
887 }
888
889 /**
890 * Renders a single plan card.
891 *
892 * @param array $plan Plan config.
893 * @param array $config Full table config.
894 * @param string $active_period Active period key.
895 * @return string
896 */
897 private function render_plan_card(array $plan, array $config, string $active_period): string
898 {
899 $billing = $config['billing'] ?? [];
900 $currency = $billing['currency'] ?? '$';
901 $currency_position = $billing['currency_position'] ?? 'before';
902 $billing_enabled = !empty($billing['enabled']);
903 $periods = $billing['periods'] ?? [];
904
905 $is_highlighted = !empty($plan['highlight']['enabled']);
906 $badge_enabled = !empty($plan['badge']['enabled']);
907 $cta = $plan['cta'] ?? [];
908 $features = $plan['features'] ?? [];
909
910 ob_start();
911 ?>
912 <div class="kng-pt-card <?php echo $is_highlighted ? 'kng-pt-card--highlighted' : ''; ?>">
913
914 <?php if ($badge_enabled && !empty($plan['badge']['text'])): ?>
915 <div class="kng-pt-card-badge kng-pt-badge--<?php echo esc_attr($plan['badge']['style'] ?? 'pill'); ?>">
916 <?php echo esc_html($plan['badge']['text']); ?>
917 </div>
918 <?php endif; ?>
919
920 <div class="kng-pt-card-header">
921 <h3 class="kng-pt-card-name"><?php echo esc_html($plan['name']); ?></h3>
922 <?php if (!empty($plan['subtitle'])): ?>
923 <p class="kng-pt-card-subtitle"><?php echo esc_html($plan['subtitle']); ?></p>
924 <?php endif; ?>
925 </div>
926
927 <div class="kng-pt-card-pricing">
928 <?php if ($billing_enabled): ?>
929 <?php foreach ($periods as $period):
930 $period_key = $period['key'];
931 $price_data = $plan['pricing'][$period_key] ?? [];
932 $price = $price_data['price'] ?? '0';
933 $note = $price_data['note'] ?? '';
934 $suffix = $period['suffix'] ?? '';
935 ?>
936 <div class="kng-pt-price-group" data-period="<?php echo esc_attr($period_key); ?>" <?php echo $period_key !== $active_period ? 'style="display: none;"' : ''; ?>>
937 <div class="kng-pt-price">
938 <?php if ($currency_position === 'before'): ?>
939 <span class="kng-pt-price-currency"><?php echo esc_html($currency); ?></span>
940 <?php endif; ?>
941 <span class="kng-pt-price-value"><?php echo esc_html($price); ?></span>
942 <?php if ($currency_position === 'after'): ?>
943 <span class="kng-pt-price-currency"><?php echo esc_html($currency); ?></span>
944 <?php endif; ?>
945 <?php if (!empty($suffix)): ?>
946 <span class="kng-pt-price-suffix"><?php echo esc_html($suffix); ?></span>
947 <?php endif; ?>
948 </div>
949 <?php if (!empty($note)): ?>
950 <div class="kng-pt-price-note"><?php echo esc_html($note); ?></div>
951 <?php endif; ?>
952 </div>
953 <?php endforeach; ?>
954 <?php else: ?>
955 <?php
956 $price_data = $plan['pricing']['default'] ?? $plan['pricing']['monthly'] ?? [];
957 $price = $price_data['price'] ?? '0';
958 $note = $price_data['note'] ?? '';
959 ?>
960 <div class="kng-pt-price-group">
961 <div class="kng-pt-price">
962 <?php if ($currency_position === 'before'): ?>
963 <span class="kng-pt-price-currency"><?php echo esc_html($currency); ?></span>
964 <?php endif; ?>
965 <span class="kng-pt-price-value"><?php echo esc_html($price); ?></span>
966 <?php if ($currency_position === 'after'): ?>
967 <span class="kng-pt-price-currency"><?php echo esc_html($currency); ?></span>
968 <?php endif; ?>
969 </div>
970 <?php if (!empty($note)): ?>
971 <div class="kng-pt-price-note"><?php echo esc_html($note); ?></div>
972 <?php endif; ?>
973 </div>
974 <?php endif; ?>
975 </div>
976
977 <?php if (!empty($cta['enabled'])): ?>
978 <div class="kng-pt-card-cta">
979 <a href="<?php echo esc_url($cta['url'] ?? '#'); ?>"
980 class="kng-pt-btn kng-pt-btn--<?php echo esc_attr($cta['style'] ?? 'primary'); ?>"
981 <?php echo ($cta['target'] ?? '_self') === '_blank' ? 'target="_blank" rel="noopener"' : ''; ?>>
982 <?php echo esc_html($cta['text'] ?? __('Get Started', 'king-addons')); ?>
983 </a>
984 </div>
985 <?php endif; ?>
986
987 <?php if (!empty($features) && !empty($config['features']['show_icons'])): ?>
988 <div class="kng-pt-card-features">
989 <ul class="kng-pt-features-list">
990 <?php foreach ($features as $feature): ?>
991 <li class="kng-pt-feature kng-pt-feature--<?php echo esc_attr($feature['state'] ?? 'enabled'); ?>">
992 <span class="kng-pt-feature-icon">
993 <?php if (($feature['state'] ?? 'enabled') === 'enabled'): ?>
994 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><polyline points="20 6 9 17 4 12"/></svg>
995 <?php else: ?>
996 <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>
997 <?php endif; ?>
998 </span>
999 <span class="kng-pt-feature-text"><?php echo esc_html($feature['text']); ?></span>
1000 </li>
1001 <?php endforeach; ?>
1002 </ul>
1003 </div>
1004 <?php endif; ?>
1005
1006 <?php if (!empty($plan['fine_print'])): ?>
1007 <div class="kng-pt-card-fine-print">
1008 <?php echo esc_html($plan['fine_print']); ?>
1009 </div>
1010 <?php endif; ?>
1011
1012 </div>
1013 <?php
1014 return ob_get_clean();
1015 }
1016
1017 /**
1018 * Generates CSS variables from tokens.
1019 *
1020 * @param array $tokens Style tokens.
1021 * @return string
1022 */
1023 private function generate_css_variables(array $tokens): string
1024 {
1025 $vars = [];
1026
1027 // Colors
1028 $colors = $tokens['colors'] ?? [];
1029 if (!empty($colors['bg'])) $vars[] = '--kng-pt-bg:' . $colors['bg'];
1030 if (!empty($colors['card_bg'])) $vars[] = '--kng-pt-card-bg:' . $colors['card_bg'];
1031 if (!empty($colors['card_border'])) $vars[] = '--kng-pt-card-border:' . $colors['card_border'];
1032 if (!empty($colors['text'])) $vars[] = '--kng-pt-text:' . $colors['text'];
1033 if (!empty($colors['muted'])) $vars[] = '--kng-pt-muted:' . $colors['muted'];
1034 if (!empty($colors['accent'])) $vars[] = '--kng-pt-accent:' . $colors['accent'];
1035 if (!empty($colors['accent_text'])) $vars[] = '--kng-pt-accent-text:' . $colors['accent_text'];
1036 if (!empty($colors['badge_bg'])) $vars[] = '--kng-pt-badge-bg:' . $colors['badge_bg'];
1037 if (!empty($colors['badge_text'])) $vars[] = '--kng-pt-badge-text:' . $colors['badge_text'];
1038 if (!empty($colors['highlight'])) $vars[] = '--kng-pt-highlight:' . $colors['highlight'];
1039
1040 // Typography
1041 $typo = $tokens['typography'] ?? [];
1042 if (!empty($typo['title_size'])) $vars[] = '--kng-pt-title-size:' . $typo['title_size'] . 'px';
1043 if (!empty($typo['price_size'])) $vars[] = '--kng-pt-price-size:' . $typo['price_size'] . 'px';
1044 if (!empty($typo['body_size'])) $vars[] = '--kng-pt-body-size:' . $typo['body_size'] . 'px';
1045
1046 // Layout
1047 if (!empty($tokens['radius'])) $vars[] = '--kng-pt-radius:' . $tokens['radius'] . 'px';
1048 if (!empty($tokens['border_width'])) $vars[] = '--kng-pt-border-width:' . $tokens['border_width'] . 'px';
1049
1050 // Shadow
1051 $shadow_map = [
1052 'none' => 'none',
1053 'sm' => '0 2px 8px rgba(0,0,0,0.06)',
1054 'md' => '0 4px 20px rgba(0,0,0,0.08)',
1055 'lg' => '0 8px 40px rgba(0,0,0,0.12)',
1056 ];
1057 if (!empty($tokens['shadow']) && isset($shadow_map[$tokens['shadow']])) {
1058 $vars[] = '--kng-pt-shadow:' . $shadow_map[$tokens['shadow']];
1059 }
1060
1061 return implode(';', $vars);
1062 }
1063
1064 /**
1065 * Gets all presets.
1066 *
1067 * @return array<string, array>
1068 */
1069 public function get_presets(): array
1070 {
1071 return include __DIR__ . '/presets/presets.php';
1072 }
1073
1074 /**
1075 * Gets a single preset by ID.
1076 *
1077 * @param string $preset_id Preset ID.
1078 * @return array
1079 */
1080 public function get_preset(string $preset_id): array
1081 {
1082 $presets = $this->get_presets();
1083 return $presets[$preset_id] ?? $presets['free_modern_cards'] ?? [];
1084 }
1085
1086 /**
1087 * Renders table for Elementor widget.
1088 *
1089 * @param int $table_id Table ID.
1090 * @param array $overrides Override settings.
1091 * @return string
1092 */
1093 public function render_for_elementor(int $table_id, array $overrides = []): string
1094 {
1095 if ($table_id <= 0) {
1096 return '<p style="text-align:center;color:#999;">' . __('Please select a pricing table', 'king-addons') . '</p>';
1097 }
1098
1099 $table = get_post($table_id);
1100 if (!$table || $table->post_type !== self::POST_TYPE) {
1101 return '<p style="text-align:center;color:#999;">' . __('Pricing table not found', 'king-addons') . '</p>';
1102 }
1103
1104 $config_json = get_post_meta($table_id, self::META_CONFIG, true);
1105 $config = json_decode($config_json, true);
1106
1107 if (!$config) {
1108 return '<p style="text-align:center;color:#999;">' . __('Invalid pricing table config', 'king-addons') . '</p>';
1109 }
1110
1111 // Apply overrides
1112 if (!empty($overrides['force_period'])) {
1113 $config['advanced']['force_period'] = $overrides['force_period'];
1114 }
1115 if (!empty($overrides['hide_toggle'])) {
1116 $config['advanced']['hide_toggle'] = true;
1117 }
1118 if (!empty($overrides['columns_desktop'])) {
1119 $config['table']['layout']['columns_desktop'] = (int)$overrides['columns_desktop'];
1120 }
1121 if (!empty($overrides['columns_tablet'])) {
1122 $config['table']['layout']['columns_tablet'] = (int)$overrides['columns_tablet'];
1123 }
1124 if (!empty($overrides['columns_mobile'])) {
1125 $config['table']['layout']['columns_mobile'] = (int)$overrides['columns_mobile'];
1126 }
1127 if (!empty($overrides['max_width'])) {
1128 $config['table']['layout']['max_width'] = (int)$overrides['max_width'];
1129 }
1130 if (!empty($overrides['alignment'])) {
1131 $config['table']['layout']['alignment'] = $overrides['alignment'];
1132 }
1133
1134 $this->enqueue_frontend_assets_if_needed();
1135
1136 return $this->render_table($config);
1137 }
1138 }
1139