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