| 1 |
<?php |
| 2 |
|
| 3 |
declare(strict_types=1); |
| 4 |
|
| 5 |
namespace Yatra\Core\Handlers; |
| 6 |
|
| 7 |
use Yatra\Core\Assets\BaseAssetManager; |
| 8 |
use Yatra\Core\Routing\PageContext; |
| 9 |
|
| 10 |
/** |
| 11 |
* Base Page Handler |
| 12 |
* |
| 13 |
* Provides common functionality for all page handlers |
| 14 |
*/ |
| 15 |
abstract class BasePageHandler |
| 16 |
{ |
| 17 |
/** |
| 18 |
* Handle the page request |
| 19 |
* |
| 20 |
* @param array $route_data Route data from RouteMatcher |
| 21 |
* @return bool True if handled successfully |
| 22 |
*/ |
| 23 |
abstract public function handle(array $route_data): bool; |
| 24 |
|
| 25 |
/** |
| 26 |
* Set query vars for backward compatibility |
| 27 |
* |
| 28 |
* @param array $vars Query vars to set |
| 29 |
*/ |
| 30 |
protected function setQueryVars(array $vars): void |
| 31 |
{ |
| 32 |
global $wp_query; |
| 33 |
|
| 34 |
foreach ($vars as $key => $value) { |
| 35 |
$wp_query->set($key, $value); |
| 36 |
} |
| 37 |
} |
| 38 |
|
| 39 |
/** |
| 40 |
* Set 404 status |
| 41 |
*/ |
| 42 |
protected function set404(): void |
| 43 |
{ |
| 44 |
global $wp_query; |
| 45 |
|
| 46 |
$wp_query->set_404(); |
| 47 |
status_header(404); |
| 48 |
} |
| 49 |
|
| 50 |
/** |
| 51 |
* Prevent 404 handling and set 200 status |
| 52 |
*/ |
| 53 |
protected function prevent404(): void |
| 54 |
{ |
| 55 |
global $wp_query; |
| 56 |
|
| 57 |
$wp_query->is_404 = false; |
| 58 |
status_header(200); |
| 59 |
} |
| 60 |
|
| 61 |
/** |
| 62 |
* Configure $wp_query and globals so the current request looks like a real WP page to |
| 63 |
* the theme — required for FSE/block themes to resolve the correct block template |
| 64 |
* (singular/archive/index) instead of falling back to 404.html. |
| 65 |
* |
| 66 |
* @param string $context 'singular' for single-trip / booking-confirmation / account, 'archive' for listings. |
| 67 |
* @param array $args { |
| 68 |
* @type string $title Page title used in <title> and queried-object stub. |
| 69 |
* @type int $object_id Optional ID for the virtual queried object (defaults to 0). |
| 70 |
* @type string $post_type Post type for the faux WP_Post (default 'page'). |
| 71 |
* @type string $post_name Slug for the faux WP_Post. |
| 72 |
* @type string|null $content Optional post content placeholder. |
| 73 |
* } |
| 74 |
*/ |
| 75 |
protected function setupPageEnvironment(string $context = 'singular', array $args = []): void |
| 76 |
{ |
| 77 |
global $wp_query, $post; |
| 78 |
|
| 79 |
$title = (string) ($args['title'] ?? ''); |
| 80 |
$object_id = (int) ($args['object_id'] ?? 0); |
| 81 |
$post_type = (string) ($args['post_type'] ?? 'page'); |
| 82 |
$post_name = (string) ($args['post_name'] ?? ''); |
| 83 |
$content = (string) ($args['content'] ?? ''); |
| 84 |
|
| 85 |
// Clear the conditional-tag state — WP_Query keeps stale flags from the main query. |
| 86 |
$wp_query->init_query_flags(); |
| 87 |
$wp_query->is_404 = false; |
| 88 |
$wp_query->is_home = false; |
| 89 |
$wp_query->is_front_page = false; |
| 90 |
|
| 91 |
if ($context === 'archive') { |
| 92 |
$wp_query->is_archive = true; |
| 93 |
// Do NOT set is_post_type_archive — we are a virtual page, not a |
| 94 |
// real WP post-type archive. Setting it triggers WP code paths |
| 95 |
// (feed_links_extra, post_type_archive_title, …) that call |
| 96 |
// get_post_type_object() and crash on null when no CPT is set. |
| 97 |
} else { |
| 98 |
$wp_query->is_singular = true; |
| 99 |
$wp_query->is_page = ($post_type === 'page'); |
| 100 |
$wp_query->is_single = ($post_type !== 'page'); |
| 101 |
} |
| 102 |
|
| 103 |
// Build a virtual WP_Post so the_post(), get_the_ID(), body_class(), and FSE |
| 104 |
// block-template selection have something coherent to read. |
| 105 |
$virtual = new \WP_Post((object) [ |
| 106 |
'ID' => $object_id, |
| 107 |
'post_author' => 0, |
| 108 |
'post_date' => current_time('mysql'), |
| 109 |
'post_date_gmt' => current_time('mysql', true), |
| 110 |
'post_content' => $content, |
| 111 |
'post_title' => $title, |
| 112 |
'post_excerpt' => '', |
| 113 |
'post_status' => 'publish', |
| 114 |
'comment_status' => 'closed', |
| 115 |
'ping_status' => 'closed', |
| 116 |
'post_password' => '', |
| 117 |
'post_name' => $post_name, |
| 118 |
'to_ping' => '', |
| 119 |
'pinged' => '', |
| 120 |
'post_modified' => current_time('mysql'), |
| 121 |
'post_modified_gmt' => current_time('mysql', true), |
| 122 |
'post_content_filtered' => '', |
| 123 |
'post_parent' => 0, |
| 124 |
'guid' => home_url($post_name ? '/' . $post_name : '/'), |
| 125 |
'menu_order' => 0, |
| 126 |
'post_type' => $post_type, |
| 127 |
'post_mime_type' => '', |
| 128 |
'comment_count' => 0, |
| 129 |
'filter' => 'raw', |
| 130 |
]); |
| 131 |
|
| 132 |
$wp_query->queried_object = $virtual; |
| 133 |
$wp_query->queried_object_id = $object_id; |
| 134 |
$wp_query->post = $virtual; |
| 135 |
$wp_query->posts = [$virtual]; |
| 136 |
$wp_query->post_count = 1; |
| 137 |
$wp_query->found_posts = 1; |
| 138 |
$wp_query->max_num_pages = 1; |
| 139 |
$wp_query->current_post = -1; |
| 140 |
|
| 141 |
$post = $virtual; |
| 142 |
|
| 143 |
// ── Strip WP core hooks that don't make sense for virtual pages ───────────── |
| 144 |
// |
| 145 |
// Our queried-object ID is a Yatra trips-table row id, NOT a wp_posts row id. |
| 146 |
// Several WP core hooks call `get_post( get_queried_object_id() )` expecting a |
| 147 |
// real WP_Post — they receive null and crash with |
| 148 |
// "Attempt to read property 'post_type' on null". |
| 149 |
// |
| 150 |
// wp_shortlink_wp_head → calls wp_get_shortlink('query') → get_post(id) → null |
| 151 |
// wp_shortlink_header → same code path, called on template_redirect |
| 152 |
// adjacent_posts_rel_link_wp_head → walks $post->post_type for prev/next links |
| 153 |
// |
| 154 |
// None of these emit anything meaningful for a virtual Yatra page, so we strip |
| 155 |
// them at the start of the render. Re-checking they exist (rather than calling |
| 156 |
// remove_action blindly) keeps us forward-compatible if WP core retires a hook. |
| 157 |
if (function_exists('wp_shortlink_wp_head')) { |
| 158 |
remove_action('wp_head', 'wp_shortlink_wp_head', 10); |
| 159 |
} |
| 160 |
if (function_exists('wp_shortlink_header')) { |
| 161 |
remove_action('template_redirect', 'wp_shortlink_header', 11); |
| 162 |
} |
| 163 |
if (function_exists('adjacent_posts_rel_link_wp_head')) { |
| 164 |
remove_action('wp_head', 'adjacent_posts_rel_link_wp_head', 10); |
| 165 |
} |
| 166 |
|
| 167 |
// WP admin-bar "Edit Page" link — wp_admin_bar_edit_menu reads our virtual |
| 168 |
// $post->post_type ('page') and adds an Edit link pointing at |
| 169 |
// wp-admin/post.php?post=<our_virtual_id>&action=edit. That ID is a Yatra trip |
| 170 |
// row id, not a wp_posts row, so the link 404s (or worse, edits an unrelated |
| 171 |
// post that happens to share the ID). Yatra registers its own "Edit Trip" |
| 172 |
// admin-bar item via AdminBarProvider, which is the correct link for trips. |
| 173 |
if (function_exists('wp_admin_bar_edit_menu')) { |
| 174 |
remove_action('admin_bar_menu', 'wp_admin_bar_edit_menu', 80); |
| 175 |
} |
| 176 |
|
| 177 |
// Belt-and-braces: short-circuit `pre_get_shortlink` for THIS request so any |
| 178 |
// other plugin/theme code that calls `wp_get_shortlink( 0, 'query' )` directly |
| 179 |
// also bails out cleanly instead of feeding our virtual ID to `get_post()`. |
| 180 |
if (!has_filter('pre_get_shortlink', [self::class, 'suppressVirtualShortlink'])) { |
| 181 |
add_filter('pre_get_shortlink', [self::class, 'suppressVirtualShortlink'], 1); |
| 182 |
} |
| 183 |
|
| 184 |
status_header(200); |
| 185 |
nocache_headers(); |
| 186 |
} |
| 187 |
|
| 188 |
/** |
| 189 |
* Returning an empty string from `pre_get_shortlink` short-circuits |
| 190 |
* {@see wp_get_shortlink()} BEFORE it calls `get_post()` on our virtual ID. Used |
| 191 |
* to silence "Attempt to read property 'post_type' on null" warnings on Yatra |
| 192 |
* trip / listing / account pages where the queried-object ID is a Yatra row id, |
| 193 |
* not a wp_posts row id. |
| 194 |
*/ |
| 195 |
public static function suppressVirtualShortlink($shortlink): string |
| 196 |
{ |
| 197 |
return ''; |
| 198 |
} |
| 199 |
|
| 200 |
/** |
| 201 |
* Set global variable |
| 202 |
* |
| 203 |
* @param string $name Variable name |
| 204 |
* @param mixed $value Variable value |
| 205 |
*/ |
| 206 |
protected function setGlobal(string $name, $value): void |
| 207 |
{ |
| 208 |
$GLOBALS[$name] = $value; |
| 209 |
} |
| 210 |
|
| 211 |
/** |
| 212 |
* Log error message |
| 213 |
* |
| 214 |
* @param string $message Error message |
| 215 |
*/ |
| 216 |
protected function logError(string $message): void |
| 217 |
{ |
| 218 |
if (defined('WP_DEBUG') && WP_DEBUG) { |
| 219 |
} |
| 220 |
} |
| 221 |
|
| 222 |
/** |
| 223 |
* Hand off a Yatra template to the `template_include` filter and return |
| 224 |
* a routing result. Replaces the legacy `include + exit` pattern. |
| 225 |
* |
| 226 |
* The handler should call this as the last step in handle(). It does NOT |
| 227 |
* render the template — that happens later via WordPress's normal |
| 228 |
* template-loader pipeline, so wp_head/wp_footer fire, other plugins can |
| 229 |
* hook in, and child themes can override. |
| 230 |
* |
| 231 |
* @param string $templateName Template filename relative to /templates/, with or without .php. |
| 232 |
* @param BaseAssetManager|null $assetManager Optional asset manager whose assets to enqueue. |
| 233 |
* @param string $pageType Logical page type for body_class hints + FSE template slug. |
| 234 |
* @return bool true if the template was queued, false if missing. |
| 235 |
*/ |
| 236 |
protected function selectTemplate(string $templateName, ?BaseAssetManager $assetManager = null, string $pageType = ''): bool |
| 237 |
{ |
| 238 |
$name = ltrim($templateName, '/'); |
| 239 |
if (substr($name, -4) !== '.php') { |
| 240 |
$name .= '.php'; |
| 241 |
} |
| 242 |
|
| 243 |
$absolute = YATRA_PLUGIN_PATH . 'templates/' . $name; |
| 244 |
|
| 245 |
// Allow themes/child-themes to override via templates/yatra/<name>. |
| 246 |
$located = locate_template(['yatra/' . $name], false, false); |
| 247 |
if (is_string($located) && $located !== '') { |
| 248 |
$absolute = $located; |
| 249 |
} |
| 250 |
|
| 251 |
$absolute = (string) apply_filters('yatra_template_path', $absolute, $name, $pageType); |
| 252 |
|
| 253 |
if (!is_readable($absolute)) { |
| 254 |
$this->logError("Template not readable: {$absolute}"); |
| 255 |
return false; |
| 256 |
} |
| 257 |
|
| 258 |
if ($assetManager !== null) { |
| 259 |
$assetManager->enqueueAssets(); |
| 260 |
} |
| 261 |
|
| 262 |
$ctx = PageContext::instance(); |
| 263 |
$ok = $ctx->select($absolute, $pageType); |
| 264 |
if ($ok && $pageType !== '') { |
| 265 |
$ctx->addBodyClass('yatra-page-' . sanitize_html_class($pageType)); |
| 266 |
} |
| 267 |
return $ok; |
| 268 |
} |
| 269 |
|
| 270 |
/** |
| 271 |
* @deprecated Kept only for backwards-compat with handlers not yet migrated. |
| 272 |
* New handlers should call selectTemplate() and return. |
| 273 |
* |
| 274 |
* @param string|null $message Optional exit message |
| 275 |
*/ |
| 276 |
protected function exit(?string $message = null): void |
| 277 |
{ |
| 278 |
if ($message && defined('WP_DEBUG') && WP_DEBUG) { |
| 279 |
echo '<!-- ' . esc_html($message) . ' -->'; |
| 280 |
} |
| 281 |
exit; |
| 282 |
} |
| 283 |
} |
| 284 |
|