PluginProbe
WCPOS – Point of Sale (POS) plugin for WooCommerce / 1.10.4
WCPOS – Point of Sale (POS) plugin for WooCommerce v1.10.4
1.10.19 1.10.18 1.10.17 1.10.16 1.10.15 1.10.13 1.10.14 1.10.12 1.10.11 1.10.10 1.10.9 1.10.8 untagged-3d9b7ccddc54df87c672 1.10.7 1.10.6 1.10.5 1.10.3 1.10.4 1.10.2 1.10.1 1.10.0 1.9.17 1.9.15 1.9.16 1.9.14 All 163 releases
woocommerce-pos / includes / Templates.php

Templates.php in WCPOS – Point of Sale (POS) plugin for WooCommerce 1.10.4, at includes/Templates.php

1,322 lines 47.0 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Templates Class.
4 *
5 * Handles registration and management of templates.
6 * Plugin and theme templates are detected from filesystem (virtual).
7 * Custom templates are stored in database as wcpos_template posts.
8 *
9 * @author Paul Kilmurray <paul@kilbot.com>
10 *
11 * @see http://wcpos.com
12 * @package WCPOS\WooCommercePOS
13 */
14
15 namespace WCPOS\WooCommercePOS;
16
17 use WP_Query;
18 use WCPOS\WooCommercePOS\Services\Receipt_I18n_Labels;
19 use WCPOS\WooCommercePOS\Templates\Gallery_Registry;
20
21 /**
22 * Templates class.
23 */
24 class Templates {
25 /**
26 * Virtual template ID constants.
27 */
28 const TEMPLATE_THEME = 'theme';
29 const TEMPLATE_PLUGIN_PRO = 'plugin-pro';
30 const TEMPLATE_PLUGIN_CORE = 'plugin-core';
31 const TEMPLATE_WP_OVERNIGHT_INVOICE = 'wp-overnight-invoice';
32 const TEMPLATE_WP_OVERNIGHT_PACKING_SLIP = 'wp-overnight-packing-slip';
33
34 /**
35 * Supported template types.
36 */
37 const SUPPORTED_TYPES = array( 'receipt', 'report' );
38
39 /**
40 * Engines that support offline (client-side) rendering.
41 */
42 const OFFLINE_CAPABLE_ENGINES = array( 'logicless', 'thermal' );
43
44 /**
45 * Per-request cache of installed gallery template preview data profiles.
46 *
47 * @var array<string,string|null>
48 */
49 private static $gallery_preview_data_cache = array();
50
51 /**
52 * Constructor.
53 */
54 public function __construct() {
55 // Register immediately since this is already being called during 'init'.
56 $this->register_post_type();
57 $this->register_taxonomy();
58 }
59
60 /**
61 * Register the custom post type for templates.
62 * Only custom user-created templates are stored in the database.
63 *
64 * @return void
65 */
66 public function register_post_type(): void {
67 $labels = array(
68 'name' => /* translators: Receipt template post type or template option label. */ _x( 'Templates', 'Post Type General Name', 'woocommerce-pos' ),
69 'singular_name' => /* translators: Receipt template post type or template option label. */ _x( 'Template', 'Post Type Singular Name', 'woocommerce-pos' ),
70 'menu_name' => /* translators: Receipt template post type or template option label. */ __( 'Templates', 'woocommerce-pos' ),
71 'name_admin_bar' => /* translators: Receipt template post type or template option label. */ __( 'Template', 'woocommerce-pos' ),
72 'archives' => /* translators: Receipt template post type or template option label. */ __( 'Template Archives', 'woocommerce-pos' ),
73 'attributes' => /* translators: Receipt template post type or template option label. */ __( 'Template Attributes', 'woocommerce-pos' ),
74 'parent_item_colon' => /* translators: Receipt template post type or template option label. */ __( 'Parent Template:', 'woocommerce-pos' ),
75 'all_items' => /* translators: Receipt template post type or template option label. */ __( 'Templates', 'woocommerce-pos' ),
76 'add_new_item' => /* translators: Receipt template post type or template option label. */ __( 'Add New Template', 'woocommerce-pos' ),
77 'add_new' => /* translators: Receipt template post type or template option label. */ __( 'Add New', 'woocommerce-pos' ),
78 'new_item' => /* translators: Receipt template post type or template option label. */ __( 'New Template', 'woocommerce-pos' ),
79 'edit_item' => /* translators: Receipt template post type or template option label. */ __( 'Edit Template', 'woocommerce-pos' ),
80 'update_item' => /* translators: Receipt template post type or template option label. */ __( 'Update Template', 'woocommerce-pos' ),
81 'view_item' => /* translators: Receipt template post type or template option label. */ __( 'View Template', 'woocommerce-pos' ),
82 'view_items' => /* translators: Receipt template post type or template option label. */ __( 'View Templates', 'woocommerce-pos' ),
83 'search_items' => /* translators: Receipt template post type or template option label. */ __( 'Search Template', 'woocommerce-pos' ),
84 'not_found' => /* translators: Receipt template post type or template option label. */ __( 'Not found', 'woocommerce-pos' ),
85 'not_found_in_trash' => __( 'Not found in Trash', 'woocommerce-pos' ),
86 'featured_image' => /* translators: Receipt template post type or template option label. */ __( 'Featured Image', 'woocommerce-pos' ),
87 'set_featured_image' => /* translators: Receipt template post type or template option label. */ __( 'Set featured image', 'woocommerce-pos' ),
88 'remove_featured_image' => /* translators: Receipt template post type or template option label. */ __( 'Remove featured image', 'woocommerce-pos' ),
89 'use_featured_image' => __( 'Use as featured image', 'woocommerce-pos' ),
90 'insert_into_item' => /* translators: Receipt template post type or template option label. */ __( 'Insert into template', 'woocommerce-pos' ),
91 'uploaded_to_this_item' => __( 'Uploaded to this template', 'woocommerce-pos' ),
92 'items_list' => /* translators: Receipt template post type or template option label. */ __( 'Templates list', 'woocommerce-pos' ),
93 'items_list_navigation' => /* translators: Receipt template post type or template option label. */ __( 'Templates list navigation', 'woocommerce-pos' ),
94 'filter_items_list' => /* translators: Receipt template post type or template option label. */ __( 'Filter templates list', 'woocommerce-pos' ),
95 );
96
97 $args = array(
98 'label' => /* translators: Receipt template post type or template option label. */ __( 'Template', 'woocommerce-pos' ),
99 'description' => /* translators: Receipt template post type or template option label. */ __( 'POS Templates', 'woocommerce-pos' ),
100 'labels' => $labels,
101 'supports' => array( 'title', 'editor', 'revisions' ),
102 'taxonomies' => array( 'wcpos_template_type', 'wcpos_template_category' ),
103 'hierarchical' => false,
104 'public' => false,
105 'show_ui' => true,
106 'show_in_menu' => false, // Hidden from menu; Gallery SPA provides the submenu entry.
107 'menu_position' => 5,
108 'show_in_admin_bar' => true,
109 'show_in_nav_menus' => false,
110 'can_export' => true,
111 'has_archive' => false,
112 'exclude_from_search' => true,
113 'publicly_queryable' => false,
114 'capability_type' => 'post',
115 'capabilities' => array(
116 'edit_post' => 'manage_woocommerce_pos',
117 'read_post' => 'manage_woocommerce_pos',
118 'delete_post' => 'manage_woocommerce_pos',
119 'edit_posts' => 'manage_woocommerce_pos',
120 'edit_others_posts' => 'manage_woocommerce_pos',
121 'delete_posts' => 'manage_woocommerce_pos',
122 'publish_posts' => 'manage_woocommerce_pos',
123 'read_private_posts' => 'manage_woocommerce_pos',
124 ),
125 'show_in_rest' => false, // Disable Gutenberg.
126 'rest_base' => 'wcpos_templates',
127 );
128
129 register_post_type( 'wcpos_template', $args );
130 }
131
132 /**
133 * Register the taxonomy for template types.
134 *
135 * @return void
136 */
137 public function register_taxonomy(): void {
138 $labels = array(
139 'name' => /* translators: Receipt template post type or template option label. */ _x( 'Template Types', 'Taxonomy General Name', 'woocommerce-pos' ),
140 'singular_name' => /* translators: Receipt template post type or template option label. */ _x( 'Template Type', 'Taxonomy Singular Name', 'woocommerce-pos' ),
141 'menu_name' => /* translators: Receipt template post type or template option label. */ __( 'Template Types', 'woocommerce-pos' ),
142 'all_items' => /* translators: Receipt template post type or template option label. */ __( 'All Template Types', 'woocommerce-pos' ),
143 'parent_item' => /* translators: Receipt template post type or template option label. */ __( 'Parent Template Type', 'woocommerce-pos' ),
144 'parent_item_colon' => /* translators: Receipt template post type or template option label. */ __( 'Parent Template Type:', 'woocommerce-pos' ),
145 'new_item_name' => /* translators: Receipt template post type or template option label. */ __( 'New Template Type Name', 'woocommerce-pos' ),
146 'add_new_item' => /* translators: Receipt template post type or template option label. */ __( 'Add New Template Type', 'woocommerce-pos' ),
147 'edit_item' => /* translators: Receipt template post type or template option label. */ __( 'Edit Template Type', 'woocommerce-pos' ),
148 'update_item' => /* translators: Receipt template post type or template option label. */ __( 'Update Template Type', 'woocommerce-pos' ),
149 'view_item' => /* translators: Receipt template post type or template option label. */ __( 'View Template Type', 'woocommerce-pos' ),
150 'separate_items_with_commas' => __( 'Separate template types with commas', 'woocommerce-pos' ),
151 'add_or_remove_items' => __( 'Add or remove template types', 'woocommerce-pos' ),
152 'choose_from_most_used' => __( 'Choose from the most used', 'woocommerce-pos' ),
153 'popular_items' => /* translators: Receipt template post type or template option label. */ __( 'Popular Template Types', 'woocommerce-pos' ),
154 'search_items' => /* translators: Receipt template post type or template option label. */ __( 'Search Template Types', 'woocommerce-pos' ),
155 'not_found' => /* translators: Receipt template post type or template option label. */ __( 'Not Found', 'woocommerce-pos' ),
156 'no_terms' => /* translators: Receipt template post type or template option label. */ __( 'No template types', 'woocommerce-pos' ),
157 'items_list' => /* translators: Receipt template post type or template option label. */ __( 'Template types list', 'woocommerce-pos' ),
158 'items_list_navigation' => /* translators: Receipt template post type or template option label. */ __( 'Template types list navigation', 'woocommerce-pos' ),
159 );
160
161 $args = array(
162 'labels' => $labels,
163 'hierarchical' => false,
164 'public' => false,
165 'show_ui' => true,
166 'show_admin_column' => true,
167 'show_in_nav_menus' => false,
168 'show_tagcloud' => false,
169 'show_in_rest' => true,
170 'meta_box_cb' => array( $this, 'template_type_metabox' ),
171 'capabilities' => array(
172 'manage_terms' => 'manage_woocommerce_pos',
173 'edit_terms' => 'manage_woocommerce_pos',
174 'delete_terms' => 'manage_woocommerce_pos',
175 'assign_terms' => 'manage_woocommerce_pos',
176 ),
177 );
178
179 register_taxonomy( 'wcpos_template_type', array( 'wcpos_template' ), $args );
180
181 // Register default template types.
182 $this->register_default_template_types();
183
184 // Register category taxonomy for gallery filtering.
185 register_taxonomy(
186 'wcpos_template_category',
187 array( 'wcpos_template' ),
188 array(
189 'labels' => array(
190 'name' => /* translators: Receipt template post type or template option label. */ _x( 'Template Categories', 'Taxonomy General Name', 'woocommerce-pos' ),
191 'singular_name' => /* translators: Receipt template post type or template option label. */ _x( 'Template Category', 'Taxonomy Singular Name', 'woocommerce-pos' ),
192 ),
193 'hierarchical' => false,
194 'public' => false,
195 'show_ui' => true,
196 'show_admin_column' => true,
197 'show_in_nav_menus' => false,
198 'show_tagcloud' => false,
199 'show_in_rest' => true,
200 'capabilities' => array(
201 'manage_terms' => 'manage_woocommerce_pos',
202 'edit_terms' => 'manage_woocommerce_pos',
203 'delete_terms' => 'manage_woocommerce_pos',
204 'assign_terms' => 'manage_woocommerce_pos',
205 ),
206 )
207 );
208
209 $this->register_default_template_categories();
210 }
211
212 /**
213 * Save raw post content directly to the database, bypassing wp_kses.
214 *
215 * WordPress applies wp_kses and other content filters during wp_insert_post()
216 * that strip unknown HTML/XML tags from template markup. This method writes
217 * raw content via $wpdb->update() to preserve the original markup.
218 *
219 * SECURITY: this bypass is self-defending. It refuses to write unless:
220 *
221 * 1. The target post type is `wcpos_template` — prevents accidental use on
222 * any other post type (post, page, etc.) where raw HTML would become a
223 * stored-XSS vector.
224 * 2. The post's `_template_engine` meta is in OFFLINE_CAPABLE_ENGINES
225 * (logicless, thermal). legacy-php templates are executed via include
226 * and MUST stay kses-filtered to prevent code injection.
227 *
228 * Callers are still responsible for capability checks; this function only
229 * enforces the structural invariants needed for safe storage. Reads from the
230 * stored content remain responsible for sanitisation at render time (see
231 * Logicless_Renderer::render() which applies wp_kses_post to output).
232 *
233 * @param int $post_id Post ID. Must reference an existing `wcpos_template` post.
234 * @param string $content Raw template content to save.
235 *
236 * @return bool True on success, false if the guards rejected the call or the DB write failed.
237 */
238 public static function save_raw_post_content( int $post_id, string $content ): bool {
239 // Refuse anything that isn't a WCPOS template — the bypass is only safe
240 // for content that flows through the WCPOS render pipeline.
241 if ( 'wcpos_template' !== get_post_type( $post_id ) ) {
242 return false;
243 }
244
245 // Refuse engines whose render path executes code (legacy-php) or that
246 // haven't been classified yet (missing meta). OFFLINE_CAPABLE_ENGINES is
247 // the explicit allowlist of engines whose output is re-sanitised at render.
248 $engine = get_post_meta( $post_id, '_template_engine', true );
249 if ( ! \in_array( $engine, self::OFFLINE_CAPABLE_ENGINES, true ) ) {
250 return false;
251 }
252
253 global $wpdb;
254
255 $result = $wpdb->update(
256 $wpdb->posts,
257 array( 'post_content' => $content ),
258 array( 'ID' => $post_id ),
259 array( '%s' ),
260 array( '%d' )
261 );
262
263 if ( false === $result ) {
264 return false;
265 }
266
267 clean_post_cache( $post_id );
268
269 return true;
270 }
271
272 /**
273 * Generate a readable fallback title for templates saved without a name.
274 *
275 * @param int $template_id Template post ID.
276 * @param string $type Template type slug.
277 * @param string $engine Template engine slug.
278 * @param string $paper_width Thermal paper width, when available.
279 *
280 * @return string Fallback template title.
281 */
282 public static function get_fallback_template_title( int $template_id, string $type = 'receipt', string $engine = '', string $paper_width = '' ): string {
283 if ( 'report' === $type ) {
284 /* translators: %d: template post ID. */
285 return sprintf( __( 'Report Template #%d', 'woocommerce-pos' ), $template_id );
286 }
287
288 if ( 'thermal' === $engine ) {
289 if ( '' !== $paper_width ) {
290 /* translators: 1: thermal paper width, 2: template post ID. */
291 return sprintf( __( 'Thermal Receipt Template (%1$s) #%2$d', 'woocommerce-pos' ), $paper_width, $template_id );
292 }
293
294 /* translators: %d: template post ID. */
295 return sprintf( __( 'Thermal Receipt Template #%d', 'woocommerce-pos' ), $template_id );
296 }
297
298 if ( 'logicless' === $engine ) {
299 /* translators: %d: template post ID. */
300 return sprintf( __( 'HTML Receipt Template #%d', 'woocommerce-pos' ), $template_id );
301 }
302
303 if ( 'legacy-php' === $engine ) {
304 /* translators: %d: template post ID. */
305 return sprintf( __( 'PHP Receipt Template #%d', 'woocommerce-pos' ), $template_id );
306 }
307
308 /* translators: %d: template post ID. */
309 return sprintf( __( 'Receipt Template #%d', 'woocommerce-pos' ), $template_id );
310 }
311
312 /**
313 * Get a database template by ID.
314 *
315 * @param int $template_id Template post ID.
316 *
317 * @return null|array Template data or null if not found.
318 */
319 public static function get_template( int $template_id ): ?array {
320 $post = get_post( $template_id );
321
322 if ( ! $post || 'wcpos_template' !== $post->post_type ) {
323 return null;
324 }
325
326 $terms = wp_get_post_terms( $template_id, 'wcpos_template_type' );
327 $type = ! empty( $terms ) && ! is_wp_error( $terms ) ? $terms[0]->slug : 'receipt';
328
329 $description = get_post_meta( $template_id, '_template_description', true );
330 $language = get_post_meta( $template_id, '_template_language', true );
331 $engine = get_post_meta( $template_id, '_template_engine', true );
332 $output_type = get_post_meta( $template_id, '_template_output_type', true );
333 $tax_display = get_post_meta( $template_id, '_template_tax_display', true );
334 $gallery_key = get_post_meta( $template_id, '_template_gallery_key', true );
335 $preview_data = null;
336 if ( \is_string( $gallery_key ) && '' !== $gallery_key ) {
337 if ( ! array_key_exists( $gallery_key, self::$gallery_preview_data_cache ) ) {
338 $gallery_template = self::get_gallery_template_by_key( $gallery_key );
339 self::$gallery_preview_data_cache[ $gallery_key ] = $gallery_template['preview_data'] ?? null;
340 }
341
342 $preview_data = self::$gallery_preview_data_cache[ $gallery_key ];
343 }
344 $paper_width = get_post_meta( $template_id, '_template_paper_width', true );
345 $category = self::get_template_category( $template_id );
346 $title = trim( $post->post_title );
347 if ( '' === $title ) {
348 $title = self::get_fallback_template_title(
349 $template_id,
350 $type,
351 $engine ? $engine : 'legacy-php',
352 $paper_width ? $paper_width : ''
353 );
354 }
355
356 return array(
357 'id' => $post->ID,
358 'title' => $title,
359 'description' => $description ? $description : '',
360 'content' => $post->post_content,
361 'type' => $type,
362 'category' => '' !== $category ? $category : ( 'receipt' === $type ? 'receipt' : '' ),
363 'language' => $language ? $language : 'php',
364 'file_path' => get_post_meta( $template_id, '_template_file_path', true ),
365 'engine' => $engine ? $engine : 'legacy-php',
366 'output_type' => $output_type ? $output_type : 'html',
367 'paper_width' => $paper_width ? $paper_width : null,
368 'tax_display' => $tax_display ? $tax_display : 'default',
369 'is_virtual' => false,
370 'is_premade' => (bool) get_post_meta( $template_id, '_template_is_premade', true ),
371 'gallery_key' => $gallery_key ? $gallery_key : null,
372 'preview_data' => $preview_data,
373 'gallery_version' => (int) get_post_meta( $template_id, '_template_gallery_version', true ),
374 'status' => $post->post_status,
375 'source' => 'custom',
376 'menu_order' => $post->menu_order,
377 'date_created' => $post->post_date,
378 'date_modified' => $post->post_modified,
379 'date_modified_gmt' => $post->post_modified_gmt,
380 );
381 }
382
383 /**
384 * Get the category slug for a template.
385 *
386 * @param int $template_id Template post ID.
387 *
388 * @return string Category slug or empty string.
389 */
390 private static function get_template_category( int $template_id ): string {
391 $terms = wp_get_post_terms( $template_id, 'wcpos_template_category' );
392 if ( ! empty( $terms ) && ! is_wp_error( $terms ) ) {
393 return $terms[0]->slug;
394 }
395 return '';
396 }
397
398 /**
399 * Get a virtual (filesystem) template by ID.
400 *
401 * @param string $template_id Virtual template ID (theme, plugin-pro, plugin-core).
402 * @param string $type Template type (receipt, report).
403 *
404 * @return null|array Template data or null if not found.
405 */
406 public static function get_virtual_template( string $template_id, string $type = 'receipt' ): ?array {
407 $file_path = self::get_virtual_template_path( $template_id, $type );
408
409 if ( ! $file_path || ! file_exists( $file_path ) ) {
410 return null;
411 }
412
413 $metadata = array(
414 self::TEMPLATE_THEME => array(
415 'title' => /* translators: Receipt template post type or template option label. */ __( 'Theme Receipt Template', 'woocommerce-pos' ),
416 'description' => '',
417 'category' => 'receipt',
418 ),
419 self::TEMPLATE_PLUGIN_PRO => array(
420 'title' => /* translators: Receipt template post type or template option label. */ __( 'Pro Receipt Template', 'woocommerce-pos' ),
421 'description' => '',
422 'category' => 'receipt',
423 ),
424 self::TEMPLATE_PLUGIN_CORE => array(
425 'title' => /* translators: Receipt template post type or template option label. */ __( 'Legacy PHP Template', 'woocommerce-pos' ),
426 'description' => '',
427 'category' => 'receipt',
428 ),
429 self::TEMPLATE_WP_OVERNIGHT_INVOICE => array(
430 'title' => __( 'Invoice (WP Overnight)', 'woocommerce-pos' ),
431 'description' => __( 'Renders the PDF Invoices & Packing Slips invoice HTML through the WP Overnight document API.', 'woocommerce-pos' ),
432 'category' => 'invoice',
433 ),
434 self::TEMPLATE_WP_OVERNIGHT_PACKING_SLIP => array(
435 'title' => __( 'Packing Slip (WP Overnight)', 'woocommerce-pos' ),
436 'description' => __( 'Renders the PDF Invoices & Packing Slips packing slip HTML through the WP Overnight document API.', 'woocommerce-pos' ),
437 'category' => 'receipt',
438 ),
439 );
440
441 return array(
442 'id' => $template_id,
443 'title' => $metadata[ $template_id ]['title'] ?? $template_id,
444 'description' => $metadata[ $template_id ]['description'] ?? '',
445 'content' => file_get_contents( $file_path ),
446 'type' => $type,
447 'category' => 'receipt' === $type ? ( $metadata[ $template_id ]['category'] ?? 'receipt' ) : '',
448 'language' => 'php',
449 'file_path' => $file_path,
450 'engine' => 'legacy-php',
451 'output_type' => 'html',
452 'paper_width' => null,
453 'is_virtual' => true,
454 'source' => self::TEMPLATE_THEME === $template_id ? 'theme' : 'plugin',
455 'menu_order' => 0,
456 'date_modified_gmt' => gmdate( 'Y-m-d H:i:s', filemtime( $file_path ) ),
457 );
458 }
459
460 /**
461 * Check if the Pro license is active.
462 *
463 * @return bool True if Pro license is active.
464 */
465 public static function is_pro_license_active(): bool {
466 if ( \function_exists( 'woocommerce_pos_pro_activated' ) ) {
467 return (bool) woocommerce_pos_pro_activated();
468 }
469 return false;
470 }
471
472 /**
473 * Get the file path for a virtual template.
474 *
475 * @param string $template_id Virtual template ID.
476 * @param string $type Template type.
477 *
478 * @return null|string File path or null if not found.
479 */
480 public static function get_virtual_template_path( string $template_id, string $type = 'receipt' ): ?string {
481 if ( ! in_array( $type, self::SUPPORTED_TYPES, true ) ) {
482 return null;
483 }
484
485 $file_name = $type . '.php';
486 $directory = null;
487 $path = null;
488
489 switch ( $template_id ) {
490 case self::TEMPLATE_THEME:
491 $directory = get_stylesheet_directory() . '/woocommerce-pos/';
492 $path = $directory . $file_name;
493 break;
494
495 case self::TEMPLATE_PLUGIN_PRO:
496 // Pro template requires both the plugin AND an active license.
497 if ( \defined( 'WCPOS\WooCommercePOSPro\PLUGIN_PATH' ) && self::is_pro_license_active() ) {
498 $directory = \WCPOS\WooCommercePOSPro\PLUGIN_PATH . 'templates/';
499 $path = $directory . $file_name;
500 break;
501 }
502 return null;
503
504 case self::TEMPLATE_PLUGIN_CORE:
505 $directory = \WCPOS\WooCommercePOS\PLUGIN_PATH . 'templates/';
506 $path = $directory . $file_name;
507 break;
508
509 case self::TEMPLATE_WP_OVERNIGHT_INVOICE:
510 if ( 'receipt' !== $type || ! self::is_wp_overnight_pdf_templates_available() ) {
511 return null;
512 }
513 $directory = \WCPOS\WooCommercePOS\PLUGIN_PATH . 'templates/';
514 $path = $directory . 'wp-overnight-invoice.php';
515 break;
516
517 case self::TEMPLATE_WP_OVERNIGHT_PACKING_SLIP:
518 if ( 'receipt' !== $type || ! self::is_wp_overnight_pdf_templates_available() ) {
519 return null;
520 }
521 $directory = \WCPOS\WooCommercePOS\PLUGIN_PATH . 'templates/';
522 $path = $directory . 'wp-overnight-packing-slip.php';
523 break;
524
525 default:
526 return null;
527 }
528
529 if ( null === $directory || null === $path ) {
530 return null;
531 }
532
533 $real_directory = realpath( $directory );
534 $real_path = realpath( $path );
535
536 if ( false === $real_directory || false === $real_path || ! is_file( $real_path ) ) {
537 return null;
538 }
539
540 $trusted_prefix = trailingslashit( wp_normalize_path( $real_directory ) );
541 $real_path = wp_normalize_path( $real_path );
542
543 return 0 === strpos( $real_path, $trusted_prefix ) ? $real_path : null;
544 }
545
546 /**
547 * Check whether WP Overnight PDF Invoices & Packing Slips APIs are available.
548 *
549 * @return bool True when the integration templates can be exposed.
550 */
551 private static function is_wp_overnight_pdf_templates_available(): bool {
552 $available = \function_exists( 'wcpdf_get_document' );
553
554 /**
555 * Filters WP Overnight PDF Invoices & Packing Slips template availability.
556 *
557 * @param bool $available Whether the third-party document API appears available.
558 *
559 * @returns bool Whether to expose the WP Overnight virtual receipt templates.
560 *
561 * @since 1.9.2
562 *
563 * @hook woocommerce_pos_wp_overnight_pdf_templates_enabled
564 */
565 return (bool) apply_filters( 'woocommerce_pos_wp_overnight_pdf_templates_enabled', $available );
566 }
567
568 /**
569 * Detect all available filesystem templates for a type.
570 * Returns templates in priority order: Theme > Pro > Core.
571 *
572 * @param string $type Template type (receipt, report).
573 *
574 * @return array Array of available virtual templates.
575 */
576 public static function detect_filesystem_templates( string $type = 'receipt' ): array {
577 $templates = array();
578
579 // Check in priority order: Theme > Pro > Core.
580 $priority_order = array(
581 self::TEMPLATE_THEME,
582 self::TEMPLATE_PLUGIN_PRO,
583 self::TEMPLATE_PLUGIN_CORE,
584 self::TEMPLATE_WP_OVERNIGHT_INVOICE,
585 self::TEMPLATE_WP_OVERNIGHT_PACKING_SLIP,
586 );
587
588 foreach ( $priority_order as $template_id ) {
589 $template = self::get_virtual_template( $template_id, $type );
590 if ( $template ) {
591 $templates[] = $template;
592 }
593 }
594
595 return $templates;
596 }
597
598 /**
599 * Get the default (highest priority) filesystem template for a type.
600 *
601 * @param string $type Template type (receipt, report).
602 *
603 * @return null|array Default template data or null if none found.
604 */
605 public static function get_default_template( string $type = 'receipt' ): ?array {
606 $templates = self::detect_filesystem_templates( $type );
607 return ! empty( $templates ) ? $templates[0] : null;
608 }
609
610 /**
611 * Get the ID of the active template for a type.
612 *
613 * @param string $type Template type (receipt, report).
614 *
615 * @return null|int|string Active template ID (int for database, string for virtual), or null.
616 */
617 public static function get_active_template_id( string $type = 'receipt' ) {
618 $active_id = get_option( 'wcpos_active_template_' . $type, null );
619 $enabled = self::get_enabled_templates( $type );
620 $enabled_ids = array_map(
621 static function ( $template ) {
622 return (string) $template['id'];
623 },
624 $enabled
625 );
626
627 // If no explicit active template, use first from enabled list.
628 if ( null === $active_id || '' === $active_id ) {
629 return ! empty( $enabled ) ? $enabled[0]['id'] : null;
630 }
631
632 // Validate that the stored active template is still enabled.
633 if ( ! \in_array( (string) $active_id, $enabled_ids, true ) ) {
634 delete_option( 'wcpos_active_template_' . $type );
635 return ! empty( $enabled ) ? $enabled[0]['id'] : null;
636 }
637
638 return is_numeric( $active_id ) ? (int) $active_id : $active_id;
639 }
640
641 /**
642 * Get active template for a specific type.
643 * Returns the full template data.
644 *
645 * @param string $type Template type (receipt, report).
646 *
647 * @return null|array Active template data or null if not found.
648 */
649 public static function get_active_template( string $type = 'receipt' ): ?array {
650 $active_id = self::get_active_template_id( $type );
651
652 if ( null === $active_id ) {
653 return null;
654 }
655
656 // Check if it's a database template (numeric ID).
657 if ( is_numeric( $active_id ) ) {
658 return self::get_template( (int) $active_id );
659 }
660
661 // It's a virtual template.
662 return self::get_virtual_template( $active_id, $type );
663 }
664
665 /**
666 * Set the active template by ID.
667 *
668 * @param int|string $template_id Template ID (int for database, string for virtual).
669 * @param string $type Template type (receipt, report).
670 *
671 * @return bool True on success, false on failure.
672 */
673 public static function set_active_template_id( $template_id, string $type = 'receipt' ): bool {
674 // Validate the template exists.
675 if ( is_numeric( $template_id ) ) {
676 $template = self::get_template( (int) $template_id );
677 if ( ! $template ) {
678 return false;
679 }
680 } else {
681 $template = self::get_virtual_template( $template_id, $type );
682 if ( ! $template ) {
683 return false;
684 }
685 }
686
687 return update_option( 'wcpos_active_template_' . $type, $template_id );
688 }
689
690 /**
691 * Set template as active (legacy method for backwards compatibility).
692 *
693 * @param int $template_id Template post ID.
694 *
695 * @return bool True on success, false on failure.
696 */
697 public static function set_active_template( int $template_id ): bool {
698 $template = self::get_template( $template_id );
699 if ( ! $template ) {
700 return false;
701 }
702
703 return self::set_active_template_id( $template_id, $template['type'] );
704 }
705
706 /**
707 * Get the stored display order for templates of a given type.
708 *
709 * @param string $type Template type (receipt, report).
710 *
711 * @return array Ordered array of template IDs (int for database, string for virtual).
712 */
713 public static function get_template_order( string $type = 'receipt' ): array {
714 $order = get_option( 'wcpos_template_order_' . $type, array() );
715
716 if ( ! \is_array( $order ) ) {
717 return array();
718 }
719
720 return $order;
721 }
722
723 /**
724 * Save the display order for templates of a given type.
725 *
726 * @param array $order Array of template IDs in display order.
727 * @param string $type Template type (receipt, report).
728 *
729 * @return bool True on success.
730 */
731 public static function save_template_order( array $order, string $type = 'receipt' ): bool {
732 // Sanitize: keep only integers and safe strings.
733 $sanitized = array();
734 foreach ( $order as $id ) {
735 if ( \is_int( $id ) || ( \is_numeric( $id ) && (int) $id > 0 ) ) {
736 $sanitized[] = (int) $id;
737 } elseif ( \is_string( $id ) ) {
738 $clean = sanitize_text_field( $id );
739 if ( '' !== $clean ) {
740 $sanitized[] = $clean;
741 }
742 }
743 }
744
745 return update_option( 'wcpos_template_order_' . $type, $sanitized );
746 }
747
748 /**
749 * Get the list of disabled virtual template IDs for a given type.
750 *
751 * @param string $type Template type (receipt, report).
752 *
753 * @return string[] Array of disabled virtual template IDs.
754 */
755 public static function get_disabled_virtual_templates( string $type = 'receipt' ): array {
756 $disabled = get_option( 'wcpos_disabled_virtual_templates_' . $type, array() );
757
758 if ( ! \is_array( $disabled ) ) {
759 return array();
760 }
761
762 return $disabled;
763 }
764
765 /**
766 * Check if a virtual template is disabled.
767 *
768 * @param string $template_id Virtual template ID.
769 * @param string $type Template type (receipt, report).
770 *
771 * @return bool True if disabled.
772 */
773 public static function is_virtual_template_disabled( string $template_id, string $type = 'receipt' ): bool {
774 $disabled = self::get_disabled_virtual_templates( $type );
775
776 return \in_array( $template_id, $disabled, true );
777 }
778
779 /**
780 * Set the disabled state of a virtual template.
781 *
782 * @param string $template_id Virtual template ID.
783 * @param bool $disabled True to disable, false to enable.
784 * @param string $type Template type (receipt, report).
785 *
786 * @return bool True on success.
787 */
788 public static function set_virtual_template_disabled( string $template_id, bool $disabled, string $type = 'receipt' ): bool {
789 $current = self::get_disabled_virtual_templates( $type );
790
791 if ( $disabled ) {
792 if ( ! \in_array( $template_id, $current, true ) ) {
793 $current[] = $template_id;
794 }
795 } else {
796 $current = array_values(
797 array_filter(
798 $current,
799 function ( $id ) use ( $template_id ) {
800 return $id !== $template_id;
801 }
802 )
803 );
804 }
805
806 return update_option( 'wcpos_disabled_virtual_templates_' . $type, $current );
807 }
808
809 /**
810 * Check if a template is currently active.
811 *
812 * @param int|string $template_id Template ID.
813 * @param string $type Template type.
814 *
815 * @return bool True if active.
816 */
817 public static function is_active_template( $template_id, string $type = 'receipt' ): bool {
818 $active_id = self::get_active_template_id( $type );
819 if ( null === $active_id ) {
820 return false;
821 }
822
823 // Normalize for comparison.
824 if ( is_numeric( $template_id ) && is_numeric( $active_id ) ) {
825 return (int) $template_id === (int) $active_id;
826 }
827
828 return (string) $template_id === (string) $active_id;
829 }
830
831 /**
832 * Resolve the ordered list of templates for a given store.
833 *
834 * If the store has a per-store override (active_receipt_templates meta),
835 * returns only those templates intersected with the global enabled list.
836 * Otherwise returns the full global enabled list.
837 *
838 * @param int $store_id Store post ID. 0 for global defaults.
839 * @param string $type Template type (e.g. 'receipt').
840 *
841 * @return array Array of template data arrays, in display order.
842 */
843 public static function resolve_templates( int $store_id, string $type = 'receipt' ): array {
844 $global_list = self::get_enabled_templates( $type );
845
846 if ( ! $store_id ) {
847 return $global_list;
848 }
849
850 $meta_key = '_wcpos_active_' . sanitize_key( $type ) . '_templates';
851 $raw = get_post_meta( $store_id, $meta_key, true );
852 $override = is_string( $raw ) ? json_decode( $raw, true ) : array();
853
854 if ( empty( $override ) || ! \is_array( $override ) ) {
855 return $global_list;
856 }
857
858 // Build a lookup of global templates by ID (normalize to string for comparison).
859 $global_by_id = array();
860 foreach ( $global_list as $template ) {
861 $global_by_id[ (string) $template['id'] ] = $template;
862 }
863
864 // Intersect store override with global enabled list, preserving store order.
865 $resolved = array();
866 foreach ( $override as $template_id ) {
867 $key = (string) $template_id;
868 if ( isset( $global_by_id[ $key ] ) ) {
869 $resolved[] = $global_by_id[ $key ];
870 }
871 }
872
873 // Fallback: if all overridden templates are globally disabled, use global list.
874 if ( empty( $resolved ) ) {
875 return $global_list;
876 }
877
878 return $resolved;
879 }
880
881 /**
882 * Get all enabled templates for a type, in stored order.
883 *
884 * Combines virtual (filesystem) templates and database (custom) templates,
885 * filters to only enabled ones, and sorts by the stored template order.
886 *
887 * @param string $type Template type.
888 *
889 * @return array Array of template data arrays.
890 */
891 public static function get_enabled_templates( string $type = 'receipt' ): array {
892 $disabled_virtual = self::get_disabled_virtual_templates( $type );
893 $order = self::get_template_order( $type );
894 $templates = array();
895
896 // Collect enabled virtual templates.
897 $virtual = self::detect_filesystem_templates( $type );
898 foreach ( $virtual as $template ) {
899 if ( ! \in_array( (string) $template['id'], $disabled_virtual, true ) ) {
900 $templates[] = $template;
901 }
902 }
903
904 // Collect enabled database (custom) templates.
905 $posts = get_posts(
906 array(
907 'post_type' => 'wcpos_template',
908 'post_status' => 'publish',
909 'posts_per_page' => -1,
910 'tax_query' => array(
911 array(
912 'taxonomy' => 'wcpos_template_type',
913 'field' => 'slug',
914 'terms' => $type,
915 ),
916 ),
917 )
918 );
919 foreach ( $posts as $post ) {
920 $template = self::get_template( $post->ID );
921 if ( $template ) {
922 $templates[] = $template;
923 }
924 }
925
926 // Sort by stored order if available.
927 if ( ! empty( $order ) ) {
928 $order_map = array_flip( array_map( 'strval', $order ) );
929 usort(
930 $templates,
931 function ( $a, $b ) use ( $order_map ) {
932 $pos_a = $order_map[ (string) $a['id'] ] ?? PHP_INT_MAX;
933 $pos_b = $order_map[ (string) $b['id'] ] ?? PHP_INT_MAX;
934 return $pos_a - $pos_b;
935 }
936 );
937 }
938
939 return $templates;
940 }
941
942 /**
943 * One-time migration: move the active template to first position in the order,
944 * then delete the wcpos_active_template_{type} option.
945 *
946 * @param string $type Template type.
947 */
948 public static function migrate_active_template_to_order( string $type = 'receipt' ): void {
949 $option_key = 'wcpos_active_template_' . $type;
950 $active_id = get_option( $option_key );
951
952 if ( false === $active_id ) {
953 return; // Nothing to migrate.
954 }
955
956 $order = self::get_template_order( $type );
957
958 if ( ! empty( $order ) ) {
959 // Remove active_id from its current position.
960 $order = array_values(
961 array_filter(
962 $order,
963 function ( $id ) use ( $active_id ) {
964 return (string) $id !== (string) $active_id;
965 }
966 )
967 );
968
969 // Prepend it.
970 array_unshift( $order, $active_id );
971 self::save_template_order( $order, $type );
972 }
973
974 delete_option( $option_key );
975 }
976
977 /**
978 * Get available starter/example templates.
979 *
980 * Returns metadata for example templates bundled with the plugin.
981 * These can be installed as custom templates by the user.
982 *
983 * @return array Array of starter template definitions.
984 */
985 public static function get_starter_templates(): array {
986 $starters = array();
987
988 // Only include starters whose files actually exist.
989 return array_filter(
990 $starters,
991 function ( $starter ) {
992 return file_exists( $starter['file'] );
993 }
994 );
995 }
996
997 /**
998 * Get gallery templates from the templates/gallery/ directory.
999 *
1000 * @param string|null $type Filter by type. Null for all.
1001 * @param string|null $category Filter by category. Null for all.
1002 *
1003 * @return array Array of gallery template data.
1004 */
1005 public static function get_gallery_templates( ?string $type = null, ?string $category = null ): array {
1006 $gallery_dir = \WCPOS\WooCommercePOS\PLUGIN_PATH . 'templates/gallery/';
1007
1008 if ( ! is_dir( $gallery_dir ) ) {
1009 return array();
1010 }
1011
1012 $templates = array();
1013 $extensions = array( 'html', 'php', 'xml' );
1014
1015 foreach ( Gallery_Registry::all() as $key => $metadata ) {
1016 if ( $type && ( $metadata['type'] ?? '' ) !== $type ) {
1017 continue;
1018 }
1019 if ( $category && ( $metadata['category'] ?? '' ) !== $category ) {
1020 continue;
1021 }
1022
1023 $content_file = null;
1024 foreach ( $extensions as $ext ) {
1025 $candidate = $gallery_dir . $key . '.' . $ext;
1026 if ( file_exists( $candidate ) ) {
1027 $content_file = $candidate;
1028 break;
1029 }
1030 }
1031
1032 if ( ! $content_file ) {
1033 continue;
1034 }
1035
1036 $metadata['key'] = $key;
1037 $metadata['direction'] = isset( $metadata['direction'] ) && 'rtl' === $metadata['direction']
1038 ? 'rtl'
1039 : 'ltr';
1040
1041 $templates[] = array_merge(
1042 $metadata,
1043 array(
1044 'content' => file_get_contents( $content_file ),
1045 'content_file' => $content_file,
1046 'is_premade' => true,
1047 'is_virtual' => true,
1048 'source' => 'gallery',
1049 'offline_capable' => in_array( $metadata['engine'] ?? 'logicless', self::OFFLINE_CAPABLE_ENGINES, true ),
1050 )
1051 );
1052 }
1053
1054 usort(
1055 $templates,
1056 function ( $a, $b ) {
1057 return strcmp( $a['key'], $b['key'] );
1058 }
1059 );
1060
1061 return $templates;
1062 }
1063
1064 /**
1065 * Get a single gallery template by its key.
1066 *
1067 * @param string $key Gallery template key (e.g. "standard-receipt").
1068 *
1069 * @return null|array Gallery template data or null if not found.
1070 */
1071 public static function get_gallery_template_by_key( string $key ): ?array {
1072 $templates = self::get_gallery_templates();
1073
1074 foreach ( $templates as $template ) {
1075 if ( ( $template['key'] ?? '' ) === $key ) {
1076 return $template;
1077 }
1078 }
1079
1080 return null;
1081 }
1082
1083 /**
1084 * Install a starter template as a custom (database) template.
1085 *
1086 * @param string $starter_key Key from get_starter_templates().
1087 *
1088 * @return int|\WP_Error Post ID on success, WP_Error on failure.
1089 */
1090 public static function install_starter_template( string $starter_key ) {
1091 $starters = self::get_starter_templates();
1092
1093 if ( ! isset( $starters[ $starter_key ] ) ) {
1094 return new \WP_Error( 'invalid_starter', __( 'Starter template not found.', 'woocommerce-pos' ) );
1095 }
1096
1097 $starter = $starters[ $starter_key ];
1098 $content = file_get_contents( $starter['file'] );
1099
1100 if ( false === $content ) {
1101 return new \WP_Error( 'read_failed', __( 'Could not read starter template file.', 'woocommerce-pos' ) );
1102 }
1103
1104 $post_id = wp_insert_post(
1105 array(
1106 'post_title' => $starter['title'],
1107 'post_content' => $content,
1108 'post_status' => 'publish',
1109 'post_type' => 'wcpos_template',
1110 ),
1111 true
1112 );
1113
1114 if ( is_wp_error( $post_id ) ) {
1115 return $post_id;
1116 }
1117
1118 wp_set_object_terms( $post_id, $starter['type'], 'wcpos_template_type' );
1119 update_post_meta( $post_id, '_template_language', $starter['language'] );
1120 update_post_meta( $post_id, '_template_engine', $starter['engine'] );
1121 update_post_meta( $post_id, '_template_output_type', 'html' );
1122
1123 // Bypass wp_kses for offline-capable engines — it strips unknown HTML/XML tags.
1124 if ( \in_array( $starter['engine'], self::OFFLINE_CAPABLE_ENGINES, true ) ) {
1125 if ( ! self::save_raw_post_content( $post_id, $content ) ) {
1126 wp_delete_post( $post_id, true );
1127
1128 return new \WP_Error(
1129 'wcpos_template_content_save_failed',
1130 __( 'Template was created but raw content could not be saved.', 'woocommerce-pos' )
1131 );
1132 }
1133 }
1134
1135 return $post_id;
1136 }
1137
1138 /**
1139 * Install a gallery template as a custom (database) template.
1140 *
1141 * @param string $gallery_key Key matching a gallery template JSON file.
1142 *
1143 * @return int|\WP_Error Post ID on success, WP_Error on failure.
1144 */
1145 public static function install_gallery_template( string $gallery_key ) {
1146 $gallery_templates = self::get_gallery_templates();
1147 $template = null;
1148
1149 foreach ( $gallery_templates as $gt ) {
1150 if ( $gt['key'] === $gallery_key ) {
1151 $template = $gt;
1152 break;
1153 }
1154 }
1155
1156 if ( ! $template ) {
1157 return new \WP_Error( 'invalid_gallery_key', __( 'Gallery template not found.', 'woocommerce-pos' ) );
1158 }
1159
1160 // Translate interpolated phrases (text mixed with Mustache variables) for the current locale.
1161 $content = Receipt_I18n_Labels::translate_interpolated_phrases( $template['content'] );
1162
1163 $post_id = wp_insert_post(
1164 array(
1165 'post_title' => $template['title'],
1166 'post_content' => $content,
1167 'post_status' => 'publish',
1168 'post_type' => 'wcpos_template',
1169 ),
1170 true
1171 );
1172
1173 if ( is_wp_error( $post_id ) ) {
1174 return $post_id;
1175 }
1176
1177 // Set taxonomies.
1178 wp_set_object_terms( $post_id, $template['type'] ?? 'receipt', 'wcpos_template_type' );
1179 if ( ! empty( $template['category'] ) ) {
1180 wp_set_object_terms( $post_id, $template['category'], 'wcpos_template_category' );
1181 }
1182
1183 // Set meta fields — normalize engine once for consistent derived values.
1184 $engine = $template['engine'] ?? 'logicless';
1185 update_post_meta( $post_id, '_template_description', $template['description'] ?? '' );
1186 update_post_meta( $post_id, '_template_engine', $engine );
1187 update_post_meta( $post_id, '_template_output_type', $template['output_type'] ?? 'html' );
1188 if ( 'logicless' === $engine ) {
1189 $language = 'html';
1190 } elseif ( 'thermal' === $engine ) {
1191 $language = 'xml';
1192 } else {
1193 $language = 'php';
1194 }
1195 update_post_meta( $post_id, '_template_language', $language );
1196 update_post_meta( $post_id, '_template_gallery_key', $gallery_key );
1197 update_post_meta( $post_id, '_template_gallery_version', $template['version'] ?? 1 );
1198 update_post_meta( $post_id, '_template_tax_display', 'default' );
1199
1200 if ( ! empty( $template['paper_width'] ) ) {
1201 update_post_meta( $post_id, '_template_paper_width', $template['paper_width'] );
1202 }
1203
1204 // Bypass wp_kses for offline-capable engines — it strips unknown HTML/XML tags.
1205 if ( \in_array( $engine, self::OFFLINE_CAPABLE_ENGINES, true ) ) {
1206 if ( ! self::save_raw_post_content( $post_id, $content ) ) {
1207 wp_delete_post( $post_id, true );
1208
1209 return new \WP_Error(
1210 'wcpos_template_content_save_failed',
1211 __( 'Template was created but raw content could not be saved.', 'woocommerce-pos' )
1212 );
1213 }
1214 }
1215
1216 return $post_id;
1217 }
1218
1219 /**
1220 * Register default template types (receipt, report).
1221 *
1222 * @return void
1223 */
1224 private function register_default_template_types(): void {
1225 // Check if terms already exist to avoid duplicates.
1226 if ( ! term_exists( 'receipt', 'wcpos_template_type' ) ) {
1227 wp_insert_term(
1228 'Receipt',
1229 'wcpos_template_type',
1230 array(
1231 'slug' => 'receipt',
1232 'description' => /* translators: Receipt template post type or template option label. */ __( 'Receipt templates for printing orders', 'woocommerce-pos' ),
1233 )
1234 );
1235 }
1236
1237 if ( ! term_exists( 'report', 'wcpos_template_type' ) ) {
1238 wp_insert_term(
1239 'Report',
1240 'wcpos_template_type',
1241 array(
1242 'slug' => 'report',
1243 'description' => __( 'Report templates for analytics', 'woocommerce-pos' ),
1244 )
1245 );
1246 }
1247 }
1248
1249 /**
1250 * Register default template categories.
1251 *
1252 * @return void
1253 */
1254 private function register_default_template_categories(): void {
1255 $categories = array(
1256 'receipt' => /* translators: Receipt template post type or template option label. */ __( 'Receipt', 'woocommerce-pos' ),
1257 'invoice' => /* translators: Receipt template post type or template option label. */ __( 'Invoice', 'woocommerce-pos' ),
1258 'gift-receipt' => /* translators: Receipt template post type or template option label. */ __( 'Gift Receipt', 'woocommerce-pos' ),
1259 'credit-note' => /* translators: Receipt template post type or template option label. */ __( 'Credit Note', 'woocommerce-pos' ),
1260 'purchase-order' => /* translators: Receipt template post type or template option label. */ __( 'Purchase Order', 'woocommerce-pos' ),
1261 'kitchen-ticket' => /* translators: Receipt template post type or template option label. */ __( 'Kitchen Ticket', 'woocommerce-pos' ),
1262 'bar-ticket' => /* translators: Receipt template post type or template option label. */ __( 'Bar Ticket', 'woocommerce-pos' ),
1263 );
1264
1265 foreach ( $categories as $slug => $name ) {
1266 if ( ! term_exists( $slug, 'wcpos_template_category' ) ) {
1267 wp_insert_term( $name, 'wcpos_template_category', array( 'slug' => $slug ) );
1268 }
1269 }
1270 }
1271
1272 /**
1273 * Custom metabox for template type selection.
1274 * Ensures one type is always selected with 'receipt' as default.
1275 *
1276 * @param \WP_Post $post Post object.
1277 * @param array $box Metabox arguments.
1278 *
1279 * @return void
1280 */
1281 public function template_type_metabox( \WP_Post $post, array $box ): void {
1282 $taxonomy = $box['args']['taxonomy'];
1283 $terms = get_terms(
1284 array(
1285 'taxonomy' => $taxonomy,
1286 'hide_empty' => false,
1287 )
1288 );
1289
1290 if ( empty( $terms ) || is_wp_error( $terms ) ) {
1291 return;
1292 }
1293
1294 // Get current terms.
1295 $current_terms = wp_get_post_terms( $post->ID, $taxonomy );
1296 $current_slug = ! empty( $current_terms ) && ! is_wp_error( $current_terms ) ? $current_terms[0]->slug : 'receipt';
1297
1298 ?>
1299 <div id="taxonomy-<?php echo esc_attr( $taxonomy ); ?>" class="categorydiv">
1300 <div id="<?php echo esc_attr( $taxonomy ); ?>-all" class="tabs-panel">
1301 <ul id="<?php echo esc_attr( $taxonomy ); ?>checklist" class="categorychecklist form-no-clear">
1302 <?php foreach ( $terms as $term ) : ?>
1303 <li>
1304 <label class="selectit">
1305 <input
1306 type="radio"
1307 name="tax_input[<?php echo esc_attr( $taxonomy ); ?>][]"
1308 value="<?php echo esc_attr( $term->slug ); ?>"
1309 <?php checked( $current_slug, $term->slug ); ?>
1310 required
1311 />
1312 <?php echo esc_html( $term->name ); ?>
1313 </label>
1314 </li>
1315 <?php endforeach; ?>
1316 </ul>
1317 </div>
1318 </div>
1319 <?php
1320 }
1321 }
1322