PluginProbe
WCPOS – Point of Sale (POS) plugin for WooCommerce / trunk
WCPOS – Point of Sale (POS) plugin for WooCommerce vtrunk
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 1.9.13 1.9.12 All 161 releases
woocommerce-pos / includes / Admin / Templates / Single_Template.php

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

589 lines 19.8 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Admin Single Template View.
4 *
5 * Handles the admin UI for editing a single template.
6 *
7 * @author Paul Kilmurray <paul@kilbot.com>
8 *
9 * @see http://wcpos.com
10 * @package WCPOS\WooCommercePOS
11 */
12
13 namespace WCPOS\WooCommercePOS\Admin\Templates;
14
15 use WCPOS\WooCommercePOS\Logger;
16 use WCPOS\WooCommercePOS\Templates as TemplatesManager;
17 use const WCPOS\WooCommercePOS\PLUGIN_URL;
18 use const WCPOS\WooCommercePOS\TRANSLATION_VERSION;
19 use const WCPOS\WooCommercePOS\VERSION as PLUGIN_VERSION;
20
21 /**
22 * Single_Template class.
23 */
24 class Single_Template {
25 private const ENGINE_LANGUAGE_MAP = array(
26 'thermal' => 'xml',
27 'logicless' => 'html',
28 'legacy-php' => 'php',
29 );
30
31 /**
32 * Resolve the template engine to display in the editor.
33 *
34 * - If _template_engine meta is stored, use it.
35 * - New auto-drafts (no meta, never saved) default to 'logicless'.
36 * - Existing posts with no stored engine (pre-date this feature) fall back to
37 * 'legacy-php' so old PHP templates are never opened in the wrong mode.
38 *
39 * @param \WP_Post $post Post object.
40 *
41 * @return string Engine slug.
42 */
43 private static function get_editor_engine( \WP_Post $post ): string {
44 $engine_meta = get_post_meta( $post->ID, '_template_engine', true );
45 if ( $engine_meta ) {
46 return $engine_meta;
47 }
48 return 'auto-draft' === $post->post_status ? 'logicless' : 'legacy-php';
49 }
50
51 /**
52 * Canonical engine slug → label map.
53 *
54 * @return array<string,string>
55 */
56 private static function get_engine_options(): array {
57 return array(
58 'logicless' => /* translators: Label or action in the receipt templates admin screen. */ __( 'HTML (Offline)', 'woocommerce-pos' ),
59 'thermal' => /* translators: Label or action in the receipt templates admin screen. */ __( 'XML (Receipt Printer)', 'woocommerce-pos' ),
60 'legacy-php' => /* translators: Label or action in the receipt templates admin screen. */ __( 'PHP (Legacy)', 'woocommerce-pos' ),
61 );
62 }
63
64 /**
65 * Constructor.
66 */
67 public function __construct() {
68 // Disable Gutenberg for template post type.
69 add_filter( 'use_block_editor_for_post_type', array( $this, 'disable_gutenberg' ), 10, 2 );
70
71 // Disable visual editor (TinyMCE) for templates.
72 add_filter( 'user_can_richedit', array( $this, 'disable_visual_editor' ) );
73
74 add_action( 'add_meta_boxes_wcpos_template', array( $this, 'add_meta_boxes' ) );
75 add_action( 'save_post_wcpos_template', array( $this, 'save_post' ), 10, 2 );
76 add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_scripts' ) );
77 add_action( 'admin_head-post.php', array( $this, 'hide_publish_status_controls' ) );
78 add_action( 'admin_head-post-new.php', array( $this, 'hide_publish_status_controls' ) );
79 add_filter( 'enter_title_here', array( $this, 'change_title_placeholder' ), 10, 2 );
80 add_action( 'edit_form_after_title', array( $this, 'add_template_info' ) );
81
82 // Remove the default content editor — our React app replaces it.
83 // Called directly because this class is instantiated after init.
84 remove_post_type_support( 'wcpos_template', 'editor' );
85 }
86
87 /**
88 * Disable Gutenberg editor for template post type.
89 *
90 * @param bool $use_block_editor Whether to use the block editor.
91 * @param string $post_type Post type.
92 *
93 * @return bool Modified value.
94 */
95 public function disable_gutenberg( bool $use_block_editor, string $post_type ): bool {
96 if ( 'wcpos_template' === $post_type ) {
97 return false;
98 }
99
100 return $use_block_editor;
101 }
102
103 /**
104 * Disable visual editor for template post type.
105 * This is safe because we're only instantiated on wcpos_template screens.
106 *
107 * @param bool $default Whether the user can use the visual editor.
108 *
109 * @return bool Modified value.
110 */
111 public function disable_visual_editor( bool $default ): bool {
112 return false;
113 }
114
115 /**
116 * Change the title placeholder text.
117 *
118 * @param string $title Placeholder text.
119 * @param \WP_Post $post Post object.
120 *
121 * @return string Modified placeholder text.
122 */
123 public function change_title_placeholder( string $title, \WP_Post $post ): string {
124 if ( 'wcpos_template' === $post->post_type ) {
125 $title = /* translators: Label or action in the receipt templates admin screen. */ __( 'Enter template name', 'woocommerce-pos' );
126 }
127
128 return $title;
129 }
130
131 /**
132 * Add template info after title.
133 *
134 * @param \WP_Post $post Post object.
135 *
136 * @return void
137 */
138 public function add_template_info( \WP_Post $post ): void {
139 if ( 'wcpos_template' !== $post->post_type ) {
140 return;
141 }
142
143 // Back link to the gallery page.
144 $gallery_url = admin_url( 'admin.php?page=wcpos-templates' );
145 printf(
146 '<p style="margin: 0 0 12px;"><a href="%s">&larr; %s</a></p>',
147 esc_url( $gallery_url ),
148 /* translators: Label or action in the receipt templates admin screen. */
149 esc_html__( 'Back to Templates', 'woocommerce-pos' )
150 );
151
152 // Hidden textarea for WordPress save flow — React syncs content here.
153 echo '<textarea name="content" id="wcpos-template-content" style="display:none;">';
154 echo esc_textarea( $post->post_content );
155 echo '</textarea>';
156
157 // React editor mount point.
158 echo '<div id="wcpos-template-editor"></div>';
159 }
160
161 /**
162 * Add meta boxes.
163 *
164 * @param \WP_Post|null $post Post object.
165 *
166 * @return void
167 */
168 public function add_meta_boxes( ?\WP_Post $post = null ): void {
169 // Remove default taxonomy metaboxes — consolidated into Template Settings.
170 remove_meta_box( 'wcpos_template_typediv', 'wcpos_template', 'side' );
171 remove_meta_box( 'wcpos_template_categorydiv', 'wcpos_template', 'side' );
172
173 // Move Publish box to the top of the sidebar by re-registering it first.
174 remove_meta_box( 'submitdiv', 'wcpos_template', 'side' );
175 add_meta_box(
176 'submitdiv',
177 /* translators: Label or action in the receipt templates admin screen. */
178 __( 'Publish', 'woocommerce-pos' ),
179 'post_submit_meta_box',
180 'wcpos_template',
181 'side',
182 'high'
183 );
184
185 add_meta_box(
186 'wcpos_template_settings',
187 /* translators: Label or action in the receipt templates admin screen. */
188 __( 'Template Settings', 'woocommerce-pos' ),
189 array( $this, 'render_settings_metabox' ),
190 'wcpos_template',
191 'side',
192 'high'
193 );
194
195 if ( $post instanceof \WP_Post && wp_get_post_revisions( $post->ID ) ) {
196 add_meta_box(
197 'revisionsdiv',
198 /* translators: Label or action in the receipt templates admin screen. */
199 __( 'Revisions', 'woocommerce-pos' ),
200 'post_revisions_meta_box',
201 'wcpos_template',
202 'normal',
203 'low'
204 );
205 }
206 }
207
208 /**
209 * Render settings metabox.
210 *
211 * @param \WP_Post $post Post object.
212 *
213 * @return void
214 */
215 public function render_settings_metabox( \WP_Post $post ): void {
216 wp_nonce_field( 'wcpos_template_settings', 'wcpos_template_settings_nonce' );
217
218 $template = TemplatesManager::get_template( $post->ID );
219 $engine = self::get_editor_engine( $post );
220 $paper_width = $template ? ( $template['paper_width'] ?? '' ) : '';
221 $is_premade = $template && ! empty( $template['is_premade'] );
222 $is_new = 'auto-draft' === $post->post_status;
223
224 $disabled = ! $is_new ? 'disabled="disabled"' : '';
225
226 $engines = self::get_engine_options();
227
228 $engine_descriptions = array(
229 'logicless' => __( 'Prints using your browser\'s print dialog. Renders on the device without needing a server connection.', 'woocommerce-pos' ),
230 'thermal' => __( 'Sends output directly to thermal printers like Epson or Star. Works offline.', 'woocommerce-pos' ),
231 'legacy-php' => __( 'Prints using your browser\'s print dialog. Requires a server connection to generate the receipt.', 'woocommerce-pos' ),
232 );
233
234 ?>
235 <!-- Engine -->
236 <p>
237 <label><strong><?php /* translators: Label or action in the receipt templates admin screen. */ esc_html_e( 'Template Engine', 'woocommerce-pos' ); ?></strong></label>
238 <select name="wcpos_template_engine" id="wcpos-template-engine" style="width: 100%;" <?php echo $disabled; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>>
239 <?php foreach ( $engines as $value => $label ) : ?>
240 <option value="<?php echo esc_attr( $value ); ?>" <?php selected( $engine, $value ); ?>>
241 <?php echo esc_html( $label ); ?>
242 </option>
243 <?php endforeach; ?>
244 </select>
245 </p>
246 <p id="wcpos-engine-description" class="description" style="margin-top: -8px;">
247 <?php echo esc_html( $engine_descriptions[ $engine ] ?? '' ); ?>
248 </p>
249
250 <!-- Paper Size only visible for thermal engine -->
251 <?php
252 $paper_disabled = ! $is_new || 'thermal' !== $engine ? 'disabled="disabled"' : '';
253 ?>
254 <p id="wcpos-paper-size-field" style="<?php echo 'thermal' !== $engine ? 'display:none;' : ''; ?>">
255 <label><strong><?php /* translators: Label or action in the receipt templates admin screen. */ esc_html_e( 'Paper Size', 'woocommerce-pos' ); ?></strong></label>
256 <select name="wcpos_template_paper_width" id="wcpos-template-paper-width" style="width: 100%;" <?php echo $paper_disabled; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>>
257 <option value="80mm" <?php selected( $paper_width, '80mm' ); ?>><?php /* translators: Label or action in the receipt templates admin screen. */ esc_html_e( '80mm (Standard)', 'woocommerce-pos' ); ?></option>
258 <option value="58mm" <?php selected( $paper_width, '58mm' ); ?>><?php /* translators: Label or action in the receipt templates admin screen. */ esc_html_e( '58mm (Narrow)', 'woocommerce-pos' ); ?></option>
259 </select>
260 </p>
261
262 <?php if ( $is_new ) : ?>
263 <script>
264 (function() {
265 var engineSelect = document.getElementById('wcpos-template-engine');
266 var paperField = document.getElementById('wcpos-paper-size-field');
267 var paperSelect = document.getElementById('wcpos-template-paper-width');
268 var descEl = document.getElementById('wcpos-engine-description');
269 var descriptions = <?php echo wp_json_encode( $engine_descriptions ); ?>;
270
271 if (engineSelect) {
272 engineSelect.addEventListener('change', function() {
273 var val = this.value;
274 if (paperField) {
275 paperField.style.display = val === 'thermal' ? '' : 'none';
276 }
277 if (paperSelect) {
278 paperSelect.disabled = val !== 'thermal';
279 }
280 if (descEl) {
281 descEl.textContent = descriptions[val] || '';
282 }
283 window.dispatchEvent(new CustomEvent('wcposEngineChange', { detail: { engine: val } }));
284 });
285 }
286
287 if (paperSelect) {
288 paperSelect.addEventListener('change', function() {
289 window.dispatchEvent(new CustomEvent('wcposPaperWidthChange', { detail: { paperWidth: this.value } }));
290 });
291 }
292 })();
293 </script>
294 <?php endif; ?>
295 <?php
296 }
297
298 /**
299 * Hide confusing WordPress Status and Visibility controls in the Publish box.
300 *
301 * Template availability is managed by the Template Gallery. WordPress visibility
302 * does not affect POS template usage.
303 *
304 * @return void
305 */
306 public function hide_publish_status_controls(): void {
307 $screen = get_current_screen();
308 if ( ! $screen || 'wcpos_template' !== $screen->post_type ) {
309 return;
310 }
311
312 ?>
313 <style>
314 #misc-publishing-actions .misc-pub-post-status,
315 #misc-publishing-actions #visibility {
316 display: none;
317 }
318 </style>
319 <?php
320 }
321
322 /**
323 * Save post meta.
324 *
325 * @param int $post_id Post ID.
326 * @param \WP_Post $post Post object.
327 *
328 * @return void
329 */
330 public function save_post( int $post_id, \WP_Post $post ): void {
331 if ( ! isset( $_POST['wcpos_template_settings_nonce'] ) ||
332 ! wp_verify_nonce( $_POST['wcpos_template_settings_nonce'], 'wcpos_template_settings' ) ) {
333 return;
334 }
335
336 if ( \defined( 'DOING_AUTOSAVE' ) && DOING_AUTOSAVE ) {
337 return;
338 }
339
340 if ( ! current_user_can( 'manage_woocommerce_pos' ) ) {
341 return;
342 }
343
344 // Premade gallery templates are immutable — ignore POSTed settings.
345 $template = TemplatesManager::get_template( $post_id );
346 $is_premade = $template && ! empty( $template['is_premade'] );
347 if ( $is_premade ) {
348 return;
349 }
350
351 // Ensure template type term exists (default to receipt).
352 $terms = wp_get_post_terms( $post_id, 'wcpos_template_type' );
353 if ( is_wp_error( $terms ) ) {
354 return;
355 }
356 if ( empty( $terms ) ) {
357 wp_set_object_terms( $post_id, 'receipt', 'wcpos_template_type' );
358 }
359
360 // Engine and paper size are only settable on new templates (first save).
361 // Once the engine meta exists the engine is locked — changing it would
362 // break the template content. We check metadata_exists rather than
363 // post_status because save_post fires after WordPress has already
364 // transitioned auto-draft → draft/publish.
365 if ( ! metadata_exists( 'post', $post_id, '_template_engine' ) ) {
366 if ( isset( $_POST['wcpos_template_engine'] ) ) {
367 $engine = sanitize_text_field( wp_unslash( $_POST['wcpos_template_engine'] ) );
368 if ( \in_array( $engine, array_keys( self::get_engine_options() ), true ) ) {
369 update_post_meta( $post_id, '_template_engine', $engine );
370
371 // Derive output_type from engine.
372 $output_type = 'thermal' === $engine ? 'escpos' : 'html';
373 update_post_meta( $post_id, '_template_output_type', $output_type );
374
375 // Derive language from engine.
376 update_post_meta( $post_id, '_template_language', self::ENGINE_LANGUAGE_MAP[ $engine ] );
377 }
378 } else {
379 // Existing posts with no stored engine (pre-date this feature)
380 // default to legacy-php so old PHP templates are not reclassified.
381 // New auto-drafts will have the engine POSTed from the form above.
382 update_post_meta( $post_id, '_template_engine', 'legacy-php' );
383 update_post_meta( $post_id, '_template_output_type', 'html' );
384 update_post_meta( $post_id, '_template_language', self::ENGINE_LANGUAGE_MAP['legacy-php'] );
385 }
386
387 // Save paper width — only relevant for thermal engine.
388 $saved_engine = get_post_meta( $post_id, '_template_engine', true );
389 if ( 'thermal' === $saved_engine && isset( $_POST['wcpos_template_paper_width'] ) ) {
390 $paper_width = sanitize_text_field( wp_unslash( $_POST['wcpos_template_paper_width'] ) );
391 if ( \in_array( $paper_width, array( '80mm', '58mm' ), true ) ) {
392 update_post_meta( $post_id, '_template_paper_width', $paper_width );
393 }
394 }
395 }
396
397 $this->ensure_template_title( $post_id, $post );
398
399 // Save raw content for offline-capable engines only. Legacy-php templates
400 // are executed via include, so their content must go through wp_kses.
401 $saved_engine = get_post_meta( $post_id, '_template_engine', true );
402 if ( \in_array( $saved_engine, TemplatesManager::OFFLINE_CAPABLE_ENGINES, true ) ) {
403 $this->save_raw_content( $post_id );
404 }
405 }
406
407 /**
408 * Ensure templates saved without a title still have a readable POS label.
409 *
410 * @param int $post_id Post ID.
411 * @param \WP_Post $post Post object from the current save.
412 *
413 * @return void
414 */
415 private function ensure_template_title( int $post_id, \WP_Post $post ): void {
416 if ( '' !== trim( $post->post_title ) ) {
417 return;
418 }
419
420 $terms = wp_get_post_terms( $post_id, 'wcpos_template_type' );
421 $type = ! empty( $terms ) && ! is_wp_error( $terms ) ? $terms[0]->slug : 'receipt';
422
423 $title = TemplatesManager::get_fallback_template_title(
424 $post_id,
425 $type,
426 (string) get_post_meta( $post_id, '_template_engine', true ),
427 (string) get_post_meta( $post_id, '_template_paper_width', true )
428 );
429
430 remove_action( 'save_post_wcpos_template', array( $this, 'save_post' ), 10 );
431 wp_update_post(
432 array(
433 'ID' => $post_id,
434 'post_title' => $title,
435 )
436 );
437 add_action( 'save_post_wcpos_template', array( $this, 'save_post' ), 10, 2 );
438 }
439
440 /**
441 * Save raw template content directly to the database.
442 *
443 * WordPress applies wp_kses and other content filters during wp_insert_post()
444 * that encode HTML entities or strip tags in template markup. This method
445 * overwrites post_content with the raw value from $_POST to preserve the
446 * original HTML/XML content.
447 *
448 * SECURITY: Only call this for non-PHP engines (logicless, thermal).
449 * Legacy-php templates are executed via include in Legacy_Php_Renderer,
450 * so their content must remain filtered by wp_kses to prevent code injection.
451 *
452 * @param int $post_id Post ID.
453 *
454 * @return void
455 */
456 private function save_raw_content( int $post_id ): void {
457 // Nonce already verified in save_post() which calls this method.
458 // phpcs:ignore WordPress.Security.NonceVerification.Missing
459 if ( ! isset( $_POST['content'] ) || ! is_string( $_POST['content'] ) ) {
460 return;
461 }
462
463 // phpcs:ignore WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
464 $raw_content = wp_unslash( $_POST['content'] );
465
466 $result = TemplatesManager::save_raw_post_content( $post_id, $raw_content );
467
468 if ( ! $result ) {
469 // Log failure for debugging; the user will see the filtered content on reload.
470 Logger::log( sprintf( 'Failed to save raw template content for post %d', $post_id ) );
471 }
472 }
473
474 /**
475 * Enqueue scripts and styles.
476 *
477 * @param string $hook Current admin page hook.
478 *
479 * @return void
480 */
481 public function enqueue_scripts( string $hook ): void {
482 if ( ! \in_array( $hook, array( 'post.php', 'post-new.php' ), true ) ) {
483 return;
484 }
485
486 $screen = get_current_screen();
487 if ( ! $screen || 'wcpos_template' !== $screen->post_type ) {
488 return;
489 }
490
491 $post = get_post();
492 if ( ! $post instanceof \WP_Post ) {
493 return;
494 }
495
496 $is_development = isset( $_ENV['DEVELOPMENT'] )
497 && wp_validate_boolean( sanitize_text_field( wp_unslash( $_ENV['DEVELOPMENT'] ) ) );
498 $dir = $is_development ? 'build' : 'assets';
499
500 wp_enqueue_style(
501 'wcpos-template-editor-styles',
502 PLUGIN_URL . $dir . '/css/template-editor.css',
503 array(),
504 PLUGIN_VERSION
505 );
506
507 wp_enqueue_script(
508 'wcpos-template-editor',
509 PLUGIN_URL . $dir . '/js/template-editor.js',
510 array( 'react', 'react-dom', 'wp-api-fetch', \WCPOS\WooCommercePOS\Admin::API_FETCH_METHOD_PARAM_HANDLE ),
511 PLUGIN_VERSION,
512 true
513 );
514
515 wp_add_inline_script(
516 'wcpos-template-editor',
517 $this->get_editor_inline_script( $post ),
518 'before'
519 );
520 }
521
522 /**
523 * Build the sample receipt data passed to the template editor.
524 *
525 * Money fields are run through Receipt_Data_Schema::format_money_fields() so
526 * the editor's sample-mode preview renders formatted currency strings — the
527 * same shape the live /preview endpoint returns. Without this the starter
528 * templates' `*_display` placeholders resolve to empty strings.
529 *
530 * @return array<string,mixed>
531 */
532 public static function get_sample_receipt_data(): array {
533 $raw = ( new \WCPOS\WooCommercePOS\Services\Preview_Receipt_Builder() )->build();
534
535 return \WCPOS\WooCommercePOS\Services\Receipt_Data_Schema::format_money_fields(
536 $raw,
537 $raw['order']['currency'] ?? 'USD'
538 );
539 }
540
541 /**
542 * Generate the inline script data for the template editor React app.
543 *
544 * @param \WP_Post $post Post object.
545 *
546 * @return string JavaScript to inject before the editor script.
547 */
548 private function get_editor_inline_script( \WP_Post $post ): string {
549 $template = TemplatesManager::get_template( $post->ID );
550 $engine = self::get_editor_engine( $post );
551
552 // Get sample receipt data from the preview builder.
553 $sample_data = self::get_sample_receipt_data();
554
555 $preview_url = rest_url( 'wcpos/v2/templates/' . $post->ID . '/preview' );
556
557 $paper_width = get_post_meta( $post->ID, '_template_paper_width', true );
558
559 $config = array(
560 'fieldSchema' => \WCPOS\WooCommercePOS\Services\Receipt_Data_Schema::get_field_tree(),
561 'sampleData' => $sample_data,
562 'engine' => $engine,
563 'paperWidth' => $paper_width ? $paper_width : null,
564 'templateId' => $post->ID,
565 'previewUrl' => $preview_url,
566 'postContent' => $post->post_content,
567 'hasPosOrders' => (bool) wc_get_orders(
568 array(
569 'limit' => 1,
570 'return' => 'ids',
571 'status' => array( 'completed', 'processing', 'on-hold', 'pending' ),
572 'created_via' => 'woocommerce-pos',
573 )
574 ),
575 );
576
577 $encoded_config = wp_json_encode( $config );
578 if ( false === $encoded_config ) {
579 $encoded_config = '{}';
580 }
581
582 return \sprintf(
583 'var wcpos = wcpos || {}; wcpos.translationVersion = %s; var wcposTemplateEditor = %s;',
584 wp_json_encode( TRANSLATION_VERSION ),
585 $encoded_config
586 );
587 }
588 }
589