| 1 |
<?php |
| 2 |
/** |
| 3 |
* Frontend template. |
| 4 |
* |
| 5 |
* @author Paul Kilmurray <paul@kilbot.com> |
| 6 |
* |
| 7 |
* @see http://wcpos.com |
| 8 |
* @package WCPOS\WooCommercePOS |
| 9 |
*/ |
| 10 |
|
| 11 |
namespace WCPOS\WooCommercePOS\Templates; |
| 12 |
|
| 13 |
use WCPOS\WooCommercePOS\Services\Auth; |
| 14 |
use WCPOS\WooCommercePOS\Services\Cashier; |
| 15 |
use WCPOS\WooCommercePOS\Services\Lifecycle_Events; |
| 16 |
use WCPOS\WooCommercePOS\Services\Settings; |
| 17 |
use WCPOS\WooCommercePOS\Sync\Pos_Uuid; |
| 18 |
use WCPOS\WooCommercePOS\Template_Router; |
| 19 |
use const WCPOS\WooCommercePOS\PLUGIN_PATH; |
| 20 |
use const WCPOS\WooCommercePOS\PLUGIN_URL; |
| 21 |
use const WCPOS\WooCommercePOS\SHORT_NAME; |
| 22 |
use const WCPOS\WooCommercePOS\VERSION; |
| 23 |
|
| 24 |
/** |
| 25 |
* Frontend class. |
| 26 |
*/ |
| 27 |
class Frontend { |
| 28 |
/** |
| 29 |
* Stores user credentials data for use in footer(). |
| 30 |
* |
| 31 |
* @var array |
| 32 |
*/ |
| 33 |
/** Stores user credentials data for use in footer. |
| 34 |
* |
| 35 |
* @var array |
| 36 |
*/ |
| 37 |
private $wp_credentials = array(); |
| 38 |
|
| 39 |
/** |
| 40 |
* Render the frontend template. |
| 41 |
* |
| 42 |
* @return void |
| 43 |
*/ |
| 44 |
public function get_template(): void { |
| 45 |
// force ssl. |
| 46 |
if ( ! is_ssl() && Settings::instance()->force_ssl_enabled() ) { |
| 47 |
wp_safe_redirect( woocommerce_pos_url() ); |
| 48 |
exit; |
| 49 |
} |
| 50 |
|
| 51 |
// check auth. |
| 52 |
if ( ! is_user_logged_in() ) { |
| 53 |
add_filter( 'login_url', array( $this, 'login_url' ) ); |
| 54 |
auth_redirect(); |
| 55 |
} |
| 56 |
|
| 57 |
// check privileges. |
| 58 |
$user = wp_get_current_user(); |
| 59 |
if ( ! Cashier::instance()->can_open_pos( $user ) ) { |
| 60 |
wp_die( esc_html( Cashier::instance()->missing_pos_capabilities_message( $user ) ), '', array( 'response' => 403 ) ); |
| 61 |
} |
| 62 |
|
| 63 |
// disable cache plugins. |
| 64 |
$this->no_cache(); |
| 65 |
|
| 66 |
// last chance before frontend template is rendered. |
| 67 |
do_action( 'woocommerce_pos_frontend_template_redirect' ); |
| 68 |
|
| 69 |
/* |
| 70 |
* Deprecated action. |
| 71 |
* |
| 72 |
* @TODO remove in 1.5.0 |
| 73 |
*/ |
| 74 |
if ( has_action( 'woocommerce_pos_template_redirect' ) ) { |
| 75 |
do_action_deprecated( 'woocommerce_pos_template_redirect', array(), 'Version_1.4.0', 'woocommerce_pos_frontend_template_redirect' ); |
| 76 |
} |
| 77 |
|
| 78 |
// add head & footer actions. |
| 79 |
add_action( 'woocommerce_pos_head', array( $this, 'head' ) ); |
| 80 |
add_action( 'woocommerce_pos_footer', array( $this, 'footer' ) ); |
| 81 |
|
| 82 |
// Generate user credentials BEFORE including template to ensure cookies can be set. |
| 83 |
// The set_web_session_cookie() call in Auth::get_user_data() requires headers not yet sent. |
| 84 |
$user = wp_get_current_user(); |
| 85 |
$auth_service = Auth::instance(); |
| 86 |
$this->wp_credentials = $auth_service->get_user_data( $user, true ); |
| 87 |
|
| 88 |
// The activation funnel's step the admin side cannot see: the POS itself |
| 89 |
// being opened. Recorded here rather than by tracking the menu link, so |
| 90 |
// a bookmark, a direct URL or a till that never touches wp-admin all |
| 91 |
// count — and so it counts opens, not clicks that may never arrive. |
| 92 |
// |
| 93 |
// Everything above has already established that this is a logged-in user |
| 94 |
// with `access_woocommerce_pos`, past the SSL redirect. |
| 95 |
( new Lifecycle_Events() )->report_app_opened(); |
| 96 |
|
| 97 |
include woocommerce_pos_locate_template( 'pos.php' ); |
| 98 |
exit; |
| 99 |
} |
| 100 |
|
| 101 |
/** |
| 102 |
* Add variable to login url to signify POS login. |
| 103 |
* |
| 104 |
* @param string $login_url The login URL. |
| 105 |
* |
| 106 |
* @return mixed |
| 107 |
*/ |
| 108 |
public function login_url( $login_url ) { |
| 109 |
return add_query_arg( SHORT_NAME, '1', $login_url ); |
| 110 |
} |
| 111 |
|
| 112 |
/** |
| 113 |
* Output the head scripts. |
| 114 |
*/ |
| 115 |
public function head(): void { |
| 116 |
} |
| 117 |
|
| 118 |
/** |
| 119 |
* Output the footer scripts. |
| 120 |
*/ |
| 121 |
public function footer(): void { |
| 122 |
/** |
| 123 |
* Filters whether the POS is in development mode. |
| 124 |
* |
| 125 |
* When true, loads the web bundle from localhost instead of CDN. |
| 126 |
* Useful for local development of the web application. |
| 127 |
* |
| 128 |
* @since 1.8.0 |
| 129 |
* |
| 130 |
* @param bool $development Whether development mode is enabled. |
| 131 |
* Defaults to checking WCPOS_DEVELOPMENT constant, |
| 132 |
* then $_ENV['DEVELOPMENT']. |
| 133 |
* |
| 134 |
* @hook woocommerce_pos_development_mode |
| 135 |
*/ |
| 136 |
$development = apply_filters( |
| 137 |
'woocommerce_pos_development_mode', |
| 138 |
( \defined( 'WCPOS_DEVELOPMENT' ) && WCPOS_DEVELOPMENT ) || ( isset( $_ENV['DEVELOPMENT'] ) && wp_validate_boolean( sanitize_text_field( wp_unslash( $_ENV['DEVELOPMENT'] ) ) ) ) |
| 139 |
); |
| 140 |
|
| 141 |
$user = wp_get_current_user(); |
| 142 |
|
| 143 |
// Explicit web-bundle override (constant or env). Null when unset. |
| 144 |
$explicit_bundle_ref = null; |
| 145 |
$env_bundle_ref = getenv( 'WCPOS_WEB_BUNDLE_REF' ); |
| 146 |
if ( \defined( 'WCPOS_WEB_BUNDLE_REF' ) && WCPOS_WEB_BUNDLE_REF ) { |
| 147 |
$explicit_bundle_ref = WCPOS_WEB_BUNDLE_REF; |
| 148 |
} elseif ( ! empty( $_ENV['WCPOS_WEB_BUNDLE_REF'] ) ) { |
| 149 |
$explicit_bundle_ref = sanitize_text_field( wp_unslash( $_ENV['WCPOS_WEB_BUNDLE_REF'] ) ); |
| 150 |
} elseif ( false !== $env_bundle_ref && '' !== $env_bundle_ref ) { |
| 151 |
$explicit_bundle_ref = sanitize_text_field( wp_unslash( $env_bundle_ref ) ); |
| 152 |
} elseif ( ! empty( $_SERVER['WCPOS_WEB_BUNDLE_REF'] ) ) { |
| 153 |
$explicit_bundle_ref = sanitize_text_field( wp_unslash( $_SERVER['WCPOS_WEB_BUNDLE_REF'] ) ); |
| 154 |
} |
| 155 |
|
| 156 |
// Default to the plugin's own major.minor so the stable lane tracks the |
| 157 |
// version automatically: a 1.9.x plugin loads `@1.9`, a 1.10.x plugin loads |
| 158 |
// `@1.10`, etc. — no edit needed as versions roll. |
| 159 |
|
| 160 |
/* |
| 161 |
* One jsDelivr ref per lane, named after the lane (owner ruling, 2026-09-04): |
| 162 |
* released lane → `@<major.minor>` (this default; the tag is cut at release) |
| 163 |
* next lane → `@next` — the `next` BRANCH of wcpos/web-bundle IS the dev |
| 164 |
* lane's tag. There is no versioned/prerelease tag for `next`. |
| 165 |
* dev-next sets WCPOS_WEB_BUNDLE_REF=next to load it. |
| 166 |
*/ |
| 167 |
// When `next` becomes `main`, the released ref simply becomes the new |
| 168 |
// major.minor (e.g. `@1.11`) via this default. |
| 169 |
$default_bundle_ref = implode( '.', \array_slice( explode( '.', VERSION ), 0, 2 ) ); |
| 170 |
|
| 171 |
/** |
| 172 |
* The web-bundle ref served from jsDelivr (or a full base URL). |
| 173 |
* |
| 174 |
* Override via the WCPOS_WEB_BUNDLE_REF constant / env var or this filter to |
| 175 |
* point a site at another lane for testing the in-development build locally |
| 176 |
* or on staging: the `next` lane's ref is the branch `next` |
| 177 |
* (https://cdn.jsdelivr.net/gh/wcpos/web-bundle@next); a tag, a commit, or a |
| 178 |
* full base URL (anything containing `://`, e.g. a local dev server or an EAS |
| 179 |
* preview) also work. |
| 180 |
* |
| 181 |
* @hook woocommerce_pos_web_bundle_ref |
| 182 |
*/ |
| 183 |
$bundle_ref = (string) apply_filters( 'woocommerce_pos_web_bundle_ref', $explicit_bundle_ref ?? $default_bundle_ref ); |
| 184 |
$bundle_ref = trim( $bundle_ref ); |
| 185 |
if ( '' === $bundle_ref ) { |
| 186 |
$bundle_ref = $default_bundle_ref; |
| 187 |
} |
| 188 |
$bundle_overridden = $bundle_ref !== $default_bundle_ref; |
| 189 |
|
| 190 |
// No trailing slash: Metro's runtime concatenates `cdnBaseUrl` with leading-slash paths |
| 191 |
// (`/_expo/...`, `/assets/...`); a trailing slash here would produce `//`, which jsDelivr |
| 192 |
// 301-redirects with a year-long cache, breaking lazy chunk loads in the browser. |
| 193 |
if ( false !== strpos( (string) $bundle_ref, '://' ) ) { |
| 194 |
// Full base URL (local dev server, EAS preview, etc.). |
| 195 |
$cdn_base_url = rtrim( $bundle_ref, '/' ); |
| 196 |
} elseif ( $development && ! $bundle_overridden ) { |
| 197 |
// Development default: the local web build server. |
| 198 |
$cdn_base_url = 'http://localhost:4567/build'; |
| 199 |
} else { |
| 200 |
// jsDelivr web-bundle lane (e.g. `1.9`, `1.10`, `next`, a tag or commit). |
| 201 |
$cdn_base_url = 'https://cdn.jsdelivr.net/gh/wcpos/web-bundle@' . rawurlencode( $bundle_ref ) . '/build'; |
| 202 |
} |
| 203 |
$wcpos_base_path = rtrim( wp_parse_url( woocommerce_pos_url(), PHP_URL_PATH ), '/' ); |
| 204 |
$stores = array_map( |
| 205 |
function ( $store ) { |
| 206 |
return $store->get_data(); |
| 207 |
}, |
| 208 |
wcpos_get_stores() |
| 209 |
); |
| 210 |
|
| 211 |
$site_uuid = wcpos_get_site_uuid(); |
| 212 |
$opfs_worker_hash = hash_file( 'sha256', PLUGIN_PATH . 'assets/js/opfs.worker.js' ); |
| 213 |
if ( false === $opfs_worker_hash ) { |
| 214 |
$opfs_worker_hash = VERSION; |
| 215 |
} |
| 216 |
|
| 217 |
// Pos_Uuid is the sole authority for `_woocommerce_pos_uuid`: the value here |
| 218 |
// must match what /cashier and /customers serve, or the client forks identities. |
| 219 |
$user_uuid = Pos_Uuid::ensure_user_uuid( $user ); |
| 220 |
|
| 221 |
$vars = array( |
| 222 |
'version' => VERSION, |
| 223 |
'manifest' => $cdn_base_url . '/metadata.json?v=' . $opfs_worker_hash, |
| 224 |
'homepage' => woocommerce_pos_url(), |
| 225 |
'logout_url' => $this->pos_logout_url(), |
| 226 |
'site' => array( |
| 227 |
'uuid' => $site_uuid, |
| 228 |
'url' => get_option( 'siteurl' ), |
| 229 |
'name' => get_option( 'blogname' ), |
| 230 |
'description' => get_option( 'blogdescription' ), |
| 231 |
'home' => home_url(), |
| 232 |
'gmt_offset' => get_option( 'gmt_offset' ), |
| 233 |
'timezone_string' => get_option( 'timezone_string' ), |
| 234 |
'wp_version' => get_bloginfo( 'version' ), |
| 235 |
'wc_version' => WC()->version, |
| 236 |
'wcpos_version' => VERSION, |
| 237 |
'wp_api_url' => get_rest_url(), |
| 238 |
'wc_api_url' => trailingslashit( get_rest_url( null, 'wc/v3' ) ), |
| 239 |
'wcpos_api_url' => trailingslashit( get_rest_url( null, 'wcpos/v2' ) ), |
| 240 |
'wcpos_login_url' => Template_Router::get_auth_url(), |
| 241 |
'locale' => get_locale(), |
| 242 |
), |
| 243 |
'wp_credentials' => $this->wp_credentials, |
| 244 |
'stores' => $stores, |
| 245 |
); |
| 246 |
|
| 247 |
/** |
| 248 |
* Filters the javascript variables passed to the POS. |
| 249 |
* |
| 250 |
* @param array $vars |
| 251 |
* |
| 252 |
* @returns array $vars |
| 253 |
* |
| 254 |
* @since 1.0.0 |
| 255 |
* |
| 256 |
* @hook woocommerce_pos_inline_vars |
| 257 |
*/ |
| 258 |
$vars = apply_filters( 'woocommerce_pos_inline_vars', $vars ); |
| 259 |
$initial_props = wp_json_encode( $vars ); |
| 260 |
$cdn_base_url = wp_json_encode( $cdn_base_url ); |
| 261 |
|
| 262 |
/** |
| 263 |
* Add path to worker scripts. |
| 264 |
*/ |
| 265 |
$idb_worker = PLUGIN_URL . 'assets/js/indexeddb.worker.js'; |
| 266 |
$opfs_worker = add_query_arg( |
| 267 |
'ver', |
| 268 |
$opfs_worker_hash, |
| 269 |
PLUGIN_URL . 'assets/js/opfs.worker.js' |
| 270 |
); |
| 271 |
|
| 272 |
// getScript helper and initialProps. |
| 273 |
// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Inline JavaScript for POS frontend |
| 274 |
echo "<script> |
| 275 |
function getScript(source, callback, onError) { |
| 276 |
var script = document.createElement('script'); |
| 277 |
script.async = true; |
| 278 |
script.onload = script.onreadystatechange = function(_, isAbort) { |
| 279 |
if (isAbort || !script.readyState || /loaded|complete/.test(script.readyState)) { |
| 280 |
script.onload = script.onreadystatechange = null; |
| 281 |
script = undefined; |
| 282 |
if (!isAbort && callback) setTimeout(callback, 0); |
| 283 |
} |
| 284 |
}; |
| 285 |
script.onerror = function() { |
| 286 |
script.onload = script.onreadystatechange = null; |
| 287 |
if (onError) onError(new Error('Failed to load script: ' + source)); |
| 288 |
}; |
| 289 |
script.src = source; |
| 290 |
document.head.appendChild(script); |
| 291 |
} |
| 292 |
|
| 293 |
function loadCSS(source, callback) { |
| 294 |
var link = document.createElement('link'); |
| 295 |
link.rel = 'stylesheet'; |
| 296 |
link.href = source; |
| 297 |
link.onload = function() { |
| 298 |
if (callback) callback(); |
| 299 |
}; |
| 300 |
link.onerror = function() { |
| 301 |
console.error('Failed to load CSS file:', source); |
| 302 |
}; |
| 303 |
document.head.appendChild(link); |
| 304 |
} |
| 305 |
|
| 306 |
var idbWorker = '{$idb_worker}'; |
| 307 |
var opfsWorker = '{$opfs_worker}'; |
| 308 |
var initialProps = {$initial_props}; |
| 309 |
var cdnBaseUrl = {$cdn_base_url}; |
| 310 |
var baseUrl = '{$wcpos_base_path}'; |
| 311 |
</script>" . "\n"; |
| 312 |
|
| 313 |
echo "<script> |
| 314 |
// no-cache: revalidate the manifest with the CDN (ETag/304) on every boot. |
| 315 |
// jsDelivr serves it with max-age=604800 and the ?v= buster only changes on |
| 316 |
// plugin deploys, so a default fetch pins users to a stale bundle for up to |
| 317 |
// 7 days after a web-bundle publish. |
| 318 |
var request = new Request(initialProps.manifest, { cache: 'no-cache' }); |
| 319 |
|
| 320 |
window.fetch(request) |
| 321 |
.then(function(response) { return response.json(); }) |
| 322 |
.then(function(data) { |
| 323 |
// v1 metadata uses 'bundles' array (metro runtime, common, entry) |
| 324 |
// v0 fallback uses single 'bundle' string |
| 325 |
var webMeta = (data && data.fileMetadata && data.fileMetadata.web) || {}; |
| 326 |
var bundles = Array.isArray(webMeta.bundles) |
| 327 |
? webMeta.bundles.filter(Boolean) |
| 328 |
: (webMeta.bundle ? [webMeta.bundle] : []); |
| 329 |
|
| 330 |
if (!bundles.length) { |
| 331 |
throw new Error('No JavaScript bundles declared in metadata.json'); |
| 332 |
} |
| 333 |
|
| 334 |
function loadBundles(index) { |
| 335 |
if (index >= bundles.length) return; |
| 336 |
var source = cdnBaseUrl + '/' + bundles[index]; |
| 337 |
getScript(source, function() { |
| 338 |
loadBundles(index + 1); |
| 339 |
}, function(error) { |
| 340 |
console.error(error.message); |
| 341 |
}); |
| 342 |
} |
| 343 |
|
| 344 |
if (data.fileMetadata.web.css) { |
| 345 |
loadCSS(cdnBaseUrl + '/' + data.fileMetadata.web.css, function() { |
| 346 |
loadBundles(0); |
| 347 |
}); |
| 348 |
} else { |
| 349 |
loadBundles(0); |
| 350 |
} |
| 351 |
}) |
| 352 |
.catch(function(error) { |
| 353 |
console.error('Error fetching manifest:', error); |
| 354 |
}); |
| 355 |
</script>" . "\n"; |
| 356 |
} |
| 357 |
|
| 358 |
/** |
| 359 |
* Get the POS logout URL. |
| 360 |
* |
| 361 |
* @return string |
| 362 |
*/ |
| 363 |
private function pos_logout_url() { |
| 364 |
/** |
| 365 |
* Get the login URL, allow other plugins to customise the URL. eg: WPS Hide Login. |
| 366 |
*/ |
| 367 |
// phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- WordPress core hook |
| 368 |
$login_url = apply_filters( 'login_url', site_url( '/wp-login.php' ), 'logout', false ); |
| 369 |
|
| 370 |
$redirect_to = urlencode( woocommerce_pos_url() ); |
| 371 |
$reauth = 1; |
| 372 |
$wcpos = 1; |
| 373 |
$logout_nonce = wp_create_nonce( 'log-out' ); |
| 374 |
|
| 375 |
return "{$login_url}?action=logout&_wpnonce={$logout_nonce}&redirect_to={$redirect_to}&reauth={$reauth}&wcpos={$wcpos}"; |
| 376 |
} |
| 377 |
|
| 378 |
|
| 379 |
|
| 380 |
|
| 381 |
/** |
| 382 |
* Disable caching conflicts. |
| 383 |
*/ |
| 384 |
private function no_cache(): void { |
| 385 |
// disable W3 Total Cache minify. |
| 386 |
if ( ! \defined( 'DONOTMINIFY' ) ) { |
| 387 |
\define( 'DONOTMINIFY', 'true' ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedConstantFound -- Third-party constant |
| 388 |
} |
| 389 |
|
| 390 |
// disable WP Super Cache. |
| 391 |
if ( ! \defined( 'DONOTCACHEPAGE' ) ) { |
| 392 |
\define( 'DONOTCACHEPAGE', 'true' ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedConstantFound -- Third-party constant |
| 393 |
} |
| 394 |
|
| 395 |
// disable Lite Speed Cache. |
| 396 |
do_action( 'litespeed_control_set_nocache', 'nocache WoCommerce POS web application' ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- Third-party hook |
| 397 |
} |
| 398 |
} |
| 399 |
|