PluginProbe
Yatra – Travel Booking & Tour Operator Software / 3.0.16
Yatra – Travel Booking & Tour Operator Software v3.0.16
3.0.16 3.0.15 3.0.14 3.0.14.1 3.0.14.2 3.0.12 3.0.13 3.0.11 3.0.10 3.0.9 3.0.8 3.0.7 3.0.6 3.0.5 3.0.5.1 3.0.4 3.0.3 3.0.2.9 3.0.2.7 3.0.2.8 3.0.2.6 trunk 1.0.0 2.0.0 2.0.1 All 84 releases
← All changes | templates/pdf/itinerary.php +756 -210 3.0.4 → 3.0.16 View file →
@@ -1,8 +1,19 @@
1 1 <?php
2 2
3 3 $companyName = (string) ($company_name ?? '');
4 4 $companyAddress = (string) ($company_address ?? '');
5 +// Settings keep the business address in separate fields (street, city, state,
6 +// postcode, country); documents only ever printed the street, so everything after
7 +// it was missing and the address looked cut off at its last line. Prefer the
8 +// composed lines, falling back to the raw street for any caller not passing them.
9 +$companyAddressLines = (isset($company_address_lines) && is_array($company_address_lines))
10 + ? array_values(array_filter(array_map('strval', $company_address_lines), static function ($line) {
11 + return trim($line) !== '';
12 + }))
13 + : array_values(array_filter(preg_split('/\R/', $companyAddress) ?: [], static function ($line) {
14 + return trim($line) !== '';
15 + }));
5 16 $companyEmail = (string) ($company_email ?? '');
6 17 $companyPhone = (string) ($company_phone ?? '');
7 18
8 19 $customerName = (string) ($customer_name ?? '');
@@ -16,11 +27,16 @@
16 27 $tripTitle = (string) ($trip_title ?? '');
17 28 $tripDescription = (string) ($trip_description ?? '');
18 29 $tripDuration = (string) ($trip_duration ?? '');
19 30 $tripDifficulty = (string) ($trip_difficulty ?? '');
20 -$tripHighlights = (string) ($trip_highlights ?? '');
21 -$tripIncludes = (string) ($trip_includes ?? '');
22 -$tripExcludes = (string) ($trip_excludes ?? '');
31 +// These three may arrive as either a string (line-separated) OR an
32 +// array (parsed JSON). DON'T cast to string here — `(string) $array`
33 +// produces the literal text "Array", which is exactly the bug that
34 +// made the Trip Highlights section render "• Array" in the PDF.
35 +// The template below already handles both shapes via `is_array(...)`.
36 +$tripHighlights = $trip_highlights ?? '';
37 +$tripIncludes = $trip_includes ?? '';
38 +$tripExcludes = $trip_excludes ?? '';
23 39
24 40 $travelDate = (string) ($travel_date ?? '');
25 41 $returnDate = (string) ($return_date ?? '');
26 42
@@ -32,8 +48,70 @@
32 48 $travelerCount = (int) ($traveler_count ?? 1);
33 49 $departureLocation = (string) ($departure_location ?? '');
34 50 $destination = (string) ($destination ?? '');
35 51
52 +// Day-by-day timeline data. ItineraryPdfBuilder always passes an array
53 +// (may be empty when the trip has no itinerary recorded). Each entry
54 +// can be a stdClass (from TripRepository::getItineraryDays) or an
55 +// associative array (from the JSON-encoded itinerary_days column), so
56 +// we read fields through a small helper that accepts both shapes.
57 +$itineraryDays = is_array($itinerary_days ?? null) ? $itinerary_days : [];
58 +
59 +$yatra_itinerary_get = static function ($source, string $key, $default = null) {
60 + if (is_object($source)) {
61 + return $source->{$key} ?? $default;
62 + }
63 + if (is_array($source)) {
64 + return $source[$key] ?? $default;
65 + }
66 + return $default;
67 +};
68 +
69 +/**
70 + * Normalize a mixed-shape list (highlights, includes, excludes) to a
71 + * flat array of trimmed strings. Each input item may be a plain
72 + * string, an associative array with a `title`/`name`/`text` key, or a
73 + * stdClass with the same properties — the trip builder writes either
74 + * shape depending on which UI version saved the record. The previous
75 + * loop called `trim($item)` directly and 500'd whenever it hit a
76 + * stdClass (see the PHP fatal error in the previous render). Empty
77 + * entries are filtered out so we never print bullet points with no
78 + * text.
79 + */
80 +$yatra_normalize_list = static function ($value): array {
81 + if (is_string($value)) {
82 + $value = explode("\n", $value);
83 + }
84 + if (!is_array($value)) {
85 + return [];
86 + }
87 + $out = [];
88 + foreach ($value as $item) {
89 + if (is_string($item)) {
90 + $s = trim($item);
91 + } elseif (is_array($item)) {
92 + $s = trim((string) ($item['title'] ?? $item['name'] ?? $item['text'] ?? ''));
93 + } elseif (is_object($item)) {
94 + $s = trim((string) ($item->title ?? $item->name ?? $item->text ?? ''));
95 + } else {
96 + $s = '';
97 + }
98 + if ($s !== '') {
99 + $out[] = $s;
100 + }
101 + }
102 + return $out;
103 +};
104 +
105 +
106 +// Optional logo + header colour supplied by the White Label module.
107 +// Unbranded sites keep this document's original header colour and show no logo.
108 +$pdfBranding = function_exists('yatra_get_pdf_branding')
109 + ? yatra_get_pdf_branding('#1e40af')
110 + : ['logo_url' => '', 'header_color' => '#1e40af'];
111 +$brandLogoUrl = (string) ($pdfBranding['logo_url'] ?? '');
112 +$brandHeaderColor = (string) ($pdfBranding['header_color'] ?? '#1e40af');
113 +
36 114 ?><!DOCTYPE html>
37 115 <html lang="en">
38 116 <head>
39 117 <meta charset="UTF-8">
@@ -39,12 +117,51 @@
39 117 <meta charset="UTF-8">
40 118 <meta name="viewport" content="width=device-width, initial-scale=1.0">
41 119 <title><?php echo esc_html__('Travel Itinerary', 'yatra'); ?> - <?php echo htmlspecialchars($bookingRef, ENT_QUOTES, 'UTF-8'); ?></title>
42 120 <style>
43 - @page { size: A4 portrait; margin: 0mm; }
121 + /* A4 page geometry.
122 + Dompdf's `@page margin` has TWO known quirks in this build:
123 + 1. only the single-value shorthand (`margin: Xmm`) is
124 + reliably parsed,
125 + 2. the top/bottom margin doesn't always carry across
126 + page-breaks initiated by `page-break-before: always`,
127 + so content on subsequent pages starts flush at the
128 + page top and overlaps with whatever we drew with
129 + `$pdf->page_script()`.
130 + We work around it by setting the @page margin to ZERO and
131 + emulating page margins ourselves: body padding handles the
132 + LEFT/RIGHT inset (Dompdf applies body horizontal padding
133 + per-line, so it's consistent on every page); the page-break
134 + sections get an explicit `padding-top` (room for the
135 + per-page header) and the last visible content has bottom
136 + clearance so the footer rule isn't crowded. */
137 + @page {
138 + size: A4 portrait;
139 + margin: 0;
140 + }
44 141 * { margin: 0; padding: 0; box-sizing: border-box; }
45 - body { margin: 0; padding: 0; font-family: "DejaVu Sans", sans-serif; font-size: 12px; color: #111; }
46 - .content { padding: 12mm 12mm 14mm 12mm; }
142 + /* Explicit font-family on every text-bearing element. Dompdf's
143 + default CSS resets some inherit chains; without this, table
144 + cells were occasionally falling back to DejaVu Sans Bold
145 + (which has no Devanagari) for the Travel Date value, even
146 + though the parent body declared the Noto chain. */
147 + body, table, td, th, p, div, span, h1, h2, h3, h4, h5, h6, li, a, strong, em {
148 + font-family: "Noto Sans Devanagari", "Noto Sans Arabic", "Noto Sans CJK", "DejaVu Sans", sans-serif;
149 + }
150 + /* Body padding emulates @page margin:
151 + LEFT / RIGHT 18mm — applied per line, so consistent across
152 + every page
153 + TOP / BOTTOM 22mm — applies at document start / end. For
154 + the per-page top/bottom we rely on the
155 + page-break-before sections having their
156 + own padding-top below. */
157 + body {
158 + margin: 0;
159 + padding: 22mm 18mm;
160 + font-size: 12px;
161 + color: #111;
162 + }
163 + .content { padding: 0; }
47 164
48 165 .page-break { page-break-before: always; }
49 166 .avoid-break { page-break-inside: avoid; }
50 167
@@ -49,9 +166,10 @@
49 166 .avoid-break { page-break-inside: avoid; }
50 167
51 168 .header { width: 100%; border-collapse: collapse; }
52 169 .header td { vertical-align: top; }
53 - .brand { background: #1e40af; color: #fff; padding: 6mm 6mm; }
170 + .brand { background: <?php echo htmlspecialchars($brandHeaderColor, ENT_QUOTES, 'UTF-8'); ?>; color: #fff; padding: 6mm 6mm; }
171 + .brand-logo { max-height: 18mm; max-width: 60mm; margin-bottom: 3mm; }
54 172 .brand h1 { font-size: 18px; font-weight: 700; margin: 0; }
55 173 .brand p { font-size: 11px; margin-top: 4px; }
56 174
57 175 .itinerary-number { background: #f3f4f6; padding: 4mm 6mm; border-bottom: 2px solid #1e40af; margin-top: 2mm; }
@@ -57,10 +175,23 @@
57 175 .itinerary-number { background: #f3f4f6; padding: 4mm 6mm; border-bottom: 2px solid #1e40af; margin-top: 2mm; }
58 176 .itinerary-number h2 { font-size: 16px; font-weight: 700; color: #1e40af; margin: 0; }
59 177 .itinerary-number .ref { font-size: 13px; color: #6b7280; margin-top: 2px; }
60 178
61 - .section { margin-top: 6mm; page-break-inside: avoid; }
62 - .section-title { font-size: 14px; font-weight: 700; color: #374151; margin-bottom: 3mm; padding-bottom: 2mm; border-bottom: 1px solid #e5e7eb; }
179 + .section { margin-top: 6mm; }
180 + /* Each top-level section starts on its own page. `.section +
181 + .section` pushes the second-and-later sections to new pages,
182 + and `padding-top: 16mm` reserves clearance for the
183 + per-page running header drawn via $pdf->page_script() so
184 + the section title doesn't end up under the header strip.
185 + Page 1 (the first .section) skips both rules — it shows
186 + the existing tall brand banner in normal flow above. */
187 + .section + .section {
188 + page-break-before: always;
189 + padding-top: 16mm;
190 + }
191 + .section.section-skip-break { page-break-before: avoid; }
192 + /* Headings shouldn't orphan at the bottom of a page. */
193 + .section-title { font-size: 14px; font-weight: 700; color: #374151; margin-bottom: 3mm; padding-bottom: 2mm; border-bottom: 1px solid #e5e7eb; page-break-after: avoid; }
63 194
64 195 .panel { width: 100%; border: 1px solid #e5e7eb; border-collapse: collapse; margin-top: 6mm; margin-bottom: 6mm; }
65 196 .panel td { padding: 5mm 5mm; vertical-align: top; }
66 197 .panel h3 { font-size: 11px; text-transform: uppercase; color: #6b7280; margin-bottom: 8px; letter-spacing: 0.5px; }
@@ -66,28 +197,56 @@
66 197 .panel h3 { font-size: 11px; text-transform: uppercase; color: #6b7280; margin-bottom: 8px; letter-spacing: 0.5px; }
67 198 .muted { color: #6b7280; }
68 199
69 200 .trip-details { width: 100%; border-collapse: collapse; margin-bottom: 6mm; }
70 - .trip-details th { background: #f9fafb; font-weight: 600; text-align: left; padding: 3mm 4mm; border: 1px solid #e5e7eb; }
201 + .trip-details th { background: #f9fafb; font-weight: 700; text-align: left; padding: 3mm 4mm; border: 1px solid #e5e7eb; }
71 202 .trip-details td { padding: 3mm 4mm; border: 1px solid #e5e7eb; }
72 - .trip-details .label { font-weight: 600; color: #6b7280; width: 30%; }
203 + .trip-details .label { font-weight: 700; color: #6b7280; width: 30%; }
73 204
205 + /* Info-card grid for Trip Info / Traveler & Booking. Replaces
206 + the previous "label-value-as-table-row" layout, which read
207 + more like a tax form than a travel document. Two columns,
208 + each column = a stack of label+value rows where the label
209 + sits above the value (vertical pairing) and the value uses
210 + the document's primary text colour. Empty rows are hidden
211 + upstream so this never renders a half-empty card. */
212 + .info-grid { width: 100%; border-collapse: collapse; margin-top: 2mm; }
213 + .info-grid > tbody > tr > td { vertical-align: top; padding: 0; }
214 + .info-card { background: #f8fafc; border: 1px solid #e5e7eb; border-radius: 2mm; padding: 5mm 5mm; }
215 + .info-card h3 { font-size: 11px; text-transform: uppercase; color: #1e40af; letter-spacing: 0.5px; margin: 0 0 3mm 0; padding-bottom: 2mm; border-bottom: 1px solid #dbeafe; }
216 + .info-row { padding: 1.5mm 0; }
217 + .info-row + .info-row { border-top: 1px dashed #e5e7eb; }
218 + .info-label { display: block; font-size: 9.5px; text-transform: uppercase; color: #6b7280; letter-spacing: 0.4px; margin-bottom: 1mm; }
219 + .info-value { display: block; color: #111827; font-size: 12px; line-height: 1.4; }
220 +
74 221 .timeline { margin: 4mm 0; }
75 222 .timeline-item { position: relative; padding-left: 8mm; margin-bottom: 6mm; page-break-inside: avoid; }
76 223 .timeline-item:before { content: ''; position: absolute; left: 0; top: 6px; width: 8px; height: 8px; background: #1e40af; border-radius: 50%; }
77 224 .timeline-item:after { content: ''; position: absolute; left: 3px; top: 14px; width: 2px; height: calc(100% - 8px); background: #e5e7eb; }
78 225 .timeline-item:last-child:after { display: none; }
79 - .timeline-date { font-weight: 600; color: #1e40af; margin-bottom: 1mm; }
80 - .timeline-title { font-weight: 600; margin-bottom: 1mm; }
226 + .timeline-date { font-weight: 700; color: #1e40af; margin-bottom: 1mm; }
227 + .timeline-title { font-weight: 700; margin-bottom: 1mm; }
81 228 .timeline-desc { color: #6b7280; line-height: 1.4; }
82 229
83 - .status { display: inline-block; padding: 1mm 3mm; border-radius: 2mm; font-size: 10px; font-weight: 600; text-transform: uppercase; }
230 + .status { display: inline-block; padding: 1mm 3mm; border-radius: 2mm; font-size: 10px; font-weight: 700; text-transform: uppercase; }
84 231 .status.confirmed { background: #d1fae5; color: #065f46; }
85 232 .status.pending { background: #fef3c7; color: #92400e; }
86 233 .status.cancelled { background: #fee2e2; color: #991b1b; }
87 234
88 - .footer { margin-top: 8mm; padding-top: 4mm; border-top: 1px solid #e5e7eb; font-size: 10px; color: #6b7280; }
235 + /* Footer needs explicit width:100% — without it Dompdf
236 + collapses the table to its content width, so the
237 + "right-aligned" cell only reaches the centre of the page
238 + instead of the right margin. border-collapse + table-layout
239 + fixed keep the two columns honouring their declared widths
240 + (60% / 40%) regardless of content length. */
241 + .footer { width: 100%; table-layout: fixed; border-collapse: collapse; margin-top: 8mm; padding-top: 4mm; border-top: 1px solid #e5e7eb; font-size: 10px; color: #6b7280; }
89 242 .footer td { vertical-align: top; }
243 + /* Dompdf doesn't always inherit text-align from a td down to
244 + child block elements (<p>) — apply the alignment directly to
245 + the cell content so the right-hand footer note actually
246 + hugs the right edge instead of sitting flush-left. */
247 + .footer td.footer-right { text-align: right; }
248 + .footer td.footer-right p { text-align: right; margin: 0; padding: 0; }
90 249
91 250 .trip-description { background: #f8fafc; padding: 4mm; border-left: 4px solid #1e40af; margin-bottom: 6mm; page-break-inside: avoid; }
92 251 .trip-description h3 { color: #1e40af; margin-bottom: 2mm; font-size: 14px; }
93 252 .trip-description p { line-height: 1.5; margin-bottom: 2mm; }
@@ -94,13 +253,55 @@
94 253
95 254 .itinerary-highlights { margin: 4mm 0; }
96 255 .highlight-item { padding: 2mm 0; border-bottom: 1px dashed #e5e7eb; }
97 256 .highlight-item:last-child { border-bottom: none; }
98 - .highlight-title { font-weight: 600; color: #374151; }
257 + .highlight-title { font-weight: 700; color: #374151; }
99 258 .highlight-desc { color: #6b7280; font-size: 11px; margin-top: 1mm; }
100 259 </style>
101 260 </head>
102 261 <body>
262 + <!-- Per-page header and footer, drawn via Dompdf's PHP canvas hook
263 + using `$pdf->page_text(...)`. This is the most reliable
264 + multi-page chrome approach in Dompdf because:
265 + 1) It runs ONCE per page during the canvas-output phase,
266 + so the strings are guaranteed to render — no fragile
267 + CSS `position: fixed` quirks.
268 + 2) `page_text` supports the special `{PAGE_NUM}` and
269 + `{PAGE_COUNT}` substitutions, which is the only way to
270 + get an accurate "X of Y" before page count is known.
271 + 3) Conditional rendering via `if ($PAGE_NUM > 1)` is
272 + trivial — used here to SKIP the running header on page 1
273 + (page 1 already has its own tall brand banner in body
274 + content, so a second header up top would duplicate it).
275 +
276 + Coordinates are in Dompdf's point grid — 72pt equals 1 inch.
277 + A4 portrait is 595 × 842 pt. The 45pt left/right offsets
278 + match the 16mm @page side margin set in CSS above. -->
279 + <?php
280 + // Pre-compute strings for the per-page header/footer script. The
281 + // actual <script type="text/php"> block is positioned at the END
282 + // of body (just before </body>) instead of here at the top —
283 + // critical reason: Dompdf's processPageScript() iterates the
284 + // pages that exist AT THE MOMENT page_text() / page_script() are
285 + // called. Placing the script at the top of body means only
286 + // page 1 has been laid out, so the header/footer registers for
287 + // page 1 ONLY (and even the "{PAGE_COUNT}" placeholder resolves
288 + // to 1 instead of the real total). Moving it to the end means
289 + // all pages exist by the time the script runs, so the chrome
290 + // applies to every page and the page counter is accurate.
291 + $_headerLeft = ($companyName !== '' ? $companyName : __('Travel Company', 'yatra'))
292 + . ' · ' . __('Travel Itinerary', 'yatra');
293 + $_headerRight = (string) $bookingRef;
294 +
295 + $_footerLeftParts = [];
296 + if ($companyName !== '') { $_footerLeftParts[] = $companyName; }
297 + if ($companyEmail !== '') { $_footerLeftParts[] = $companyEmail; }
298 + if ($companyPhone !== '') { $_footerLeftParts[] = $companyPhone; }
299 + $_footerLeft = implode(' · ', $_footerLeftParts);
300 +
301 + $_pageLabel = __('Page', 'yatra') . ' ';
302 + ?>
303 +
103 304 <div class="content">
104 305 <!-- Header -->
105 306 <table class="header">
106 307 <tr>
@@ -105,9 +306,12 @@
105 306 <table class="header">
106 307 <tr>
107 308 <td style="width: 60%;">
108 309 <div class="brand">
109 - <h1><?php echo htmlspecialchars($companyName ?: __('Travel Company', 'yatra'), ENT_QUOTES, 'UTF-8'); ?></h1>
310 + <?php if ($brandLogoUrl !== ''): ?>
311 + <img class="brand-logo" src="<?php echo htmlspecialchars($brandLogoUrl, ENT_QUOTES, 'UTF-8'); ?>" alt="">
312 + <?php endif; ?>
313 + <h1><?php echo htmlspecialchars($companyName ?: __('Travel Company', 'yatra'), ENT_QUOTES, 'UTF-8'); ?></h1>
110 314 <p><?php esc_html_e('Travel Itinerary Document', 'yatra'); ?></p>
111 315 </div>
112 316 </td>
113 317 <td style="width: 40%;">
@@ -123,261 +327,603 @@
123 327 <h2><?php esc_html_e('Travel Itinerary', 'yatra'); ?></h2>
124 328 <div class="ref"><?php esc_html_e('Issued:', 'yatra'); ?> <?php echo htmlspecialchars($bookingDate, ENT_QUOTES, 'UTF-8'); ?></div>
125 329 </div>
126 330
127 - <!-- Trip Description -->
331 + <!-- Trip Description.
332 + trip_description is stored as rich HTML in the DB
333 + (TinyMCE/Gutenberg output); the previous template escaped
334 + it with htmlspecialchars() so the user literally saw `<p>`
335 + tags in the PDF. Use wp_kses_post() instead — same allow-list
336 + WP uses for post content, so admin-saved HTML renders
337 + correctly without opening up to script injection. -->
128 338 <?php if (!empty($tripDescription)): ?>
129 339 <div class="section">
130 340 <div class="section-title"><?php esc_html_e('About This Trip', 'yatra'); ?></div>
131 341 <div class="trip-description">
132 342 <h3><?php echo htmlspecialchars($tripTitle, ENT_QUOTES, 'UTF-8'); ?></h3>
133 - <p><?php echo nl2br(htmlspecialchars($tripDescription, ENT_QUOTES, 'UTF-8')); ?></p>
343 + <?php echo wp_kses_post(wpautop($tripDescription)); ?>
134 344 </div>
135 345 </div>
136 346 <?php endif; ?>
137 347
138 - <!-- Trip Details -->
348 + <!-- Trip Information + Traveler & Booking.
349 + Rendered as info-card pairs (label above value) instead
350 + of label-value table rows — reads as a travel document,
351 + not a tax form. Empty fields are omitted entirely (no
352 + "N/A" placeholders). Booking status is shown ONCE here,
353 + in the booking card — it used to render in both Travel
354 + Information AND Booking Information, so the same pill
355 + appeared twice on the page. -->
356 + <?php
357 + $tripRows = [];
358 + if ($tripTitle !== '') { $tripRows[] = [__('Trip Name', 'yatra'), $tripTitle, false]; }
359 + if ($tripDuration !== '') { $tripRows[] = [__('Duration', 'yatra'), $tripDuration, false]; }
360 + if ($tripDifficulty !== '') { $tripRows[] = [__('Difficulty', 'yatra'), $tripDifficulty, false]; }
361 + if ($travelerCount > 0) { $tripRows[] = [__('Travelers', 'yatra'), (string) $travelerCount, false]; }
362 +
363 + $travelRows = [];
364 + if ($departureLocation !== '') { $travelRows[] = [__('Departure Location', 'yatra'), $departureLocation, false]; }
365 + if ($destination !== '') { $travelRows[] = [__('Destination', 'yatra'), $destination, false]; }
366 + if ($travelDate !== '') { $travelRows[] = [__('Travel Date', 'yatra'), $travelDate, false]; }
367 + if (!empty($returnDate)) { $travelRows[] = [__('Return Date', 'yatra'), $returnDate, false]; }
368 +
369 + $travelerRows = [];
370 + if ($customerName !== '') { $travelerRows[] = [__('Name', 'yatra'), $customerName, false]; }
371 + if ($customerEmail !== '') { $travelerRows[] = [__('Email', 'yatra'), $customerEmail, false]; }
372 +
373 + $bookingRows = [];
374 + if ($bookingRef !== '') { $bookingRows[] = [__('Reference', 'yatra'), $bookingRef, false]; }
375 + if ($bookingDate !== '') { $bookingRows[] = [__('Issued On', 'yatra'), $bookingDate, false]; }
376 + if ($bookingStatus !== '') { $bookingRows[] = [__('Status', 'yatra'), '<span class="status ' . $statusClass . '">' . htmlspecialchars($bookingStatus, ENT_QUOTES, 'UTF-8') . '</span>', true]; }
377 +
378 + $renderCard = static function (string $heading, array $rows): void {
379 + if (empty($rows)) return;
380 + echo '<div class="info-card">';
381 + echo '<h3>' . htmlspecialchars($heading, ENT_QUOTES, 'UTF-8') . '</h3>';
382 + foreach ($rows as [$label, $value, $rawHtml]) {
383 + echo '<div class="info-row">';
384 + echo '<span class="info-label">' . htmlspecialchars((string) $label, ENT_QUOTES, 'UTF-8') . '</span>';
385 + echo '<span class="info-value">' . ($rawHtml ? $value : htmlspecialchars((string) $value, ENT_QUOTES, 'UTF-8')) . '</span>';
386 + echo '</div>';
387 + }
388 + echo '</div>';
389 + };
390 +
391 + $renderTwoCardRow = static function (string $leftHeading, array $leftRows, string $rightHeading, array $rightRows) use ($renderCard): void {
392 + if (empty($leftRows) && empty($rightRows)) return;
393 + echo '<table class="info-grid"><tr>';
394 + if (!empty($leftRows)) {
395 + $width = !empty($rightRows) ? '49%' : '100%';
396 + echo '<td style="width:' . $width . '; padding-right:' . (!empty($rightRows) ? '2%' : '0') . ';">';
397 + $renderCard($leftHeading, $leftRows);
398 + echo '</td>';
399 + }
400 + if (!empty($rightRows)) {
401 + $width = !empty($leftRows) ? '49%' : '100%';
402 + echo '<td style="width:' . $width . ';">';
403 + $renderCard($rightHeading, $rightRows);
404 + echo '</td>';
405 + }
406 + echo '</tr></table>';
407 + };
408 + ?>
409 +
410 + <?php if (!empty($tripRows) || !empty($travelRows)): ?>
139 411 <div class="section">
140 412 <div class="section-title"><?php esc_html_e('Trip Information', 'yatra'); ?></div>
141 - <table class="panel">
142 - <tr>
143 - <td style="width: 50%;">
144 - <h3><?php esc_html_e('Trip Details', 'yatra'); ?></h3>
145 - <table class="trip-details">
146 - <tr>
147 - <th class="label"><?php esc_html_e('Trip Name', 'yatra'); ?></th>
148 - <td><?php echo htmlspecialchars($tripTitle, ENT_QUOTES, 'UTF-8'); ?></td>
149 - </tr>
150 - <tr>
151 - <th class="label"><?php esc_html_e('Duration', 'yatra'); ?></th>
152 - <td><?php echo htmlspecialchars($tripDuration ?: __('N/A', 'yatra'), ENT_QUOTES, 'UTF-8'); ?></td>
153 - </tr>
154 - <tr>
155 - <th class="label"><?php esc_html_e('Difficulty Level', 'yatra'); ?></th>
156 - <td><?php echo htmlspecialchars($tripDifficulty ?: __('N/A', 'yatra'), ENT_QUOTES, 'UTF-8'); ?></td>
157 - </tr>
158 - <tr>
159 - <th class="label"><?php esc_html_e('Number of Travelers', 'yatra'); ?></th>
160 - <td><?php echo $travelerCount; ?></td>
161 - </tr>
162 - </table>
163 - </td>
164 - <td style="width: 50%;">
165 - <h3><?php esc_html_e('Travel Information', 'yatra'); ?></h3>
166 - <table class="trip-details">
167 - <tr>
168 - <th class="label"><?php esc_html_e('Departure Location', 'yatra'); ?></th>
169 - <td><?php echo htmlspecialchars($departureLocation ?: __('N/A', 'yatra'), ENT_QUOTES, 'UTF-8'); ?></td>
170 - </tr>
171 - <tr>
172 - <th class="label"><?php esc_html_e('Destination', 'yatra'); ?></th>
173 - <td><?php echo htmlspecialchars($destination ?: __('N/A', 'yatra'), ENT_QUOTES, 'UTF-8'); ?></td>
174 - </tr>
175 - <tr>
176 - <th class="label"><?php esc_html_e('Travel Date', 'yatra'); ?></th>
177 - <td><?php echo htmlspecialchars($travelDate ?: __('N/A', 'yatra'), ENT_QUOTES, 'UTF-8'); ?></td>
178 - </tr>
179 - <?php if (!empty($returnDate)): ?>
180 - <tr>
181 - <th class="label"><?php esc_html_e('Return Date', 'yatra'); ?></th>
182 - <td><?php echo htmlspecialchars($returnDate, ENT_QUOTES, 'UTF-8'); ?></td>
183 - </tr>
184 - <?php endif; ?>
185 - <tr>
186 - <th class="label"><?php esc_html_e('Booking Status', 'yatra'); ?></th>
187 - <td><span class="status <?php echo $statusClass; ?>"><?php echo htmlspecialchars($bookingStatus, ENT_QUOTES, 'UTF-8'); ?></span></td>
188 - </tr>
189 - </table>
190 - </td>
191 - </tr>
192 - </table>
413 + <?php $renderTwoCardRow(__('Trip Details', 'yatra'), $tripRows, __('Travel Information', 'yatra'), $travelRows); ?>
193 414 </div>
415 + <?php endif; ?>
194 416
195 - <!-- Customer Information -->
417 + <?php if (!empty($travelerRows) || !empty($bookingRows)): ?>
196 418 <div class="section">
197 - <div class="section-title"><?php esc_html_e('Traveler Information', 'yatra'); ?></div>
198 - <table class="panel">
199 - <tr>
200 - <td style="width: 50%;">
201 - <h3><?php esc_html_e('Primary Traveler', 'yatra'); ?></h3>
202 - <table class="trip-details">
203 - <tr>
204 - <th class="label"><?php esc_html_e('Name', 'yatra'); ?></th>
205 - <td><?php echo htmlspecialchars($customerName, ENT_QUOTES, 'UTF-8'); ?></td>
206 - </tr>
207 - <tr>
208 - <th class="label"><?php esc_html_e('Email', 'yatra'); ?></th>
209 - <td><?php echo htmlspecialchars($customerEmail, ENT_QUOTES, 'UTF-8'); ?></td>
210 - </tr>
211 - </table>
212 - </td>
213 - <td style="width: 50%;">
214 - <h3><?php esc_html_e('Booking Information', 'yatra'); ?></h3>
215 - <table class="trip-details">
216 - <tr>
217 - <th class="label"><?php esc_html_e('Booking Reference', 'yatra'); ?></th>
218 - <td><?php echo htmlspecialchars($bookingRef, ENT_QUOTES, 'UTF-8'); ?></td>
219 - </tr>
220 - <tr>
221 - <th class="label"><?php esc_html_e('Booking Date', 'yatra'); ?></th>
222 - <td><?php echo htmlspecialchars($bookingDate, ENT_QUOTES, 'UTF-8'); ?></td>
223 - </tr>
224 - <tr>
225 - <th class="label"><?php esc_html_e('Status', 'yatra'); ?></th>
226 - <td><span class="status <?php echo $statusClass; ?>"><?php echo htmlspecialchars($bookingStatus, ENT_QUOTES, 'UTF-8'); ?></span></td>
227 - </tr>
228 - </table>
229 - </td>
230 - </tr>
231 - </table>
419 + <div class="section-title"><?php esc_html_e('Traveler &amp; Booking', 'yatra'); ?></div>
420 + <?php $renderTwoCardRow(__('Primary Traveler', 'yatra'), $travelerRows, __('Booking Information', 'yatra'), $bookingRows); ?>
232 421 </div>
422 + <?php endif; ?>
233 423
234 - <!-- Trip Highlights -->
235 - <?php if (!empty($tripHighlights)): ?>
424 + <!-- Trip Highlights.
425 + Highlights come in three shapes depending on which schema
426 + version saved them:
427 + 1. Newline-separated string (legacy plain-text field)
428 + 2. Array of plain strings (newer JSON column)
429 + 3. Array of objects / assoc arrays with `title` +
430 + optional `description` (current trip-builder UI)
431 + Normalise to a list of `[title, description]` tuples up
432 + front so the markup loop stays simple — and never call
433 + trim() on a stdClass (the bug that 500'd the previous
434 + render). -->
435 + <?php
436 + $highlightsList = [];
437 + $rawHighlights = $tripHighlights;
438 + if (is_string($rawHighlights)) {
439 + $rawHighlights = explode("\n", $rawHighlights);
440 + }
441 + if (is_array($rawHighlights)) {
442 + foreach ($rawHighlights as $h) {
443 + $title = '';
444 + $description = '';
445 + if (is_string($h)) {
446 + $title = trim($h);
447 + } elseif (is_array($h)) {
448 + $title = trim((string) ($h['title'] ?? $h['name'] ?? $h['text'] ?? ''));
449 + $description = trim((string) ($h['description'] ?? $h['desc'] ?? ''));
450 + } elseif (is_object($h)) {
451 + $title = trim((string) ($h->title ?? $h->name ?? $h->text ?? ''));
452 + $description = trim((string) ($h->description ?? $h->desc ?? ''));
453 + }
454 + if ($title !== '' || $description !== '') {
455 + $highlightsList[] = [$title, $description];
456 + }
457 + }
458 + }
459 + ?>
460 + <?php if (!empty($highlightsList)): ?>
236 461 <div class="section">
237 462 <div class="section-title"><?php esc_html_e('Trip Highlights', 'yatra'); ?></div>
238 463 <div class="itinerary-highlights">
239 - <?php
240 - $highlights = is_array($tripHighlights) ? $tripHighlights : explode("\n", $tripHighlights);
241 - foreach ($highlights as $highlight):
242 - $highlight = trim($highlight);
243 - if (!empty($highlight)):
244 - ?>
464 + <?php foreach ($highlightsList as [$hTitle, $hDesc]): ?>
245 465 <div class="highlight-item">
246 - <div class="highlight-title">• <?php echo htmlspecialchars($highlight, ENT_QUOTES, 'UTF-8'); ?></div>
466 + <?php if ($hTitle !== ''): ?>
467 + <div class="highlight-title">• <?php echo htmlspecialchars($hTitle, ENT_QUOTES, 'UTF-8'); ?></div>
468 + <?php endif; ?>
469 + <?php if ($hDesc !== ''): ?>
470 + <div class="highlight-desc"><?php echo wp_kses_post(wpautop($hDesc)); ?></div>
471 + <?php endif; ?>
247 472 </div>
248 - <?php
249 - endif;
250 - endforeach;
251 - ?>
473 + <?php endforeach; ?>
252 474 </div>
253 475 </div>
254 476 <?php endif; ?>
255 477
256 - <!-- Travel Timeline -->
478 + <!-- Travel Timeline — mirrors the single-trip page's
479 + "Itinerary" section so the printed PDF matches what the
480 + traveller already saw on the website. Each day from the
481 + trip's itinerary table renders as a timeline item with
482 + title, description, and the activities/meals/transport
483 + entries that belong to it. When the trip has no itinerary
484 + recorded we fall back to a derived Departure → days →
485 + Arrival outline so the PDF is never empty. -->
257 486 <div class="section">
258 487 <div class="section-title"><?php esc_html_e('Travel Timeline', 'yatra'); ?></div>
259 488 <div class="timeline">
260 - <div class="timeline-item">
261 - <div class="timeline-date"><?php echo htmlspecialchars($travelDate ?: __('TBD', 'yatra'), ENT_QUOTES, 'UTF-8'); ?></div>
262 - <div class="timeline-title"><?php esc_html_e('Departure', 'yatra'); ?></div>
263 - <div class="timeline-desc"><?php echo esc_html(sprintf(__('Departure from %s. Please arrive at least 30 minutes before departure time.', 'yatra'), htmlspecialchars($departureLocation ?: __('meeting point', 'yatra'), ENT_QUOTES, 'UTF-8'))); ?></div>
264 - </div>
265 -
266 - <div class="timeline-item">
267 - <div class="timeline-date"><?php echo htmlspecialchars($travelDate ?: __('TBD', 'yatra'), ENT_QUOTES, 'UTF-8'); ?></div>
268 - <div class="timeline-title"><?php esc_html_e('Arrival at Destination', 'yatra'); ?></div>
269 - <div class="timeline-desc"><?php echo esc_html(sprintf(__('Arrival at %s. Check-in and accommodation briefing.', 'yatra'), htmlspecialchars($destination ?: __('destination', 'yatra'), ENT_QUOTES, 'UTF-8'))); ?></div>
270 - </div>
489 + <?php if (!empty($itineraryDays)): ?>
490 + <?php foreach ($itineraryDays as $dayIndex => $day):
491 + $dayNumber = (int) ($yatra_itinerary_get($day, 'day_number', $dayIndex + 1));
492 + $dayTitle = (string) ($yatra_itinerary_get($day, 'title', $yatra_itinerary_get($day, 'day_title', '')));
493 + $dayDescription = (string) ($yatra_itinerary_get($day, 'description', $yatra_itinerary_get($day, 'day_description', '')));
494 + $dayEntries = $yatra_itinerary_get($day, 'entries', []);
495 + if (!is_array($dayEntries)) {
496 + $dayEntries = [];
497 + }
271 498
272 - <?php if (!empty($tripDuration)): ?>
273 - <?php
274 - $days = (int) preg_replace('/\D/', '', $tripDuration);
275 - for ($i = 2; $i <= $days; $i++):
276 - ?>
277 - <div class="timeline-item">
278 - <div class="timeline-date"><?php esc_html_e('Day', 'yatra'); ?> <?php echo $i; ?></div>
279 - <div class="timeline-title"><?php esc_html_e('Activities & Sightseeing', 'yatra'); ?></div>
280 - <div class="timeline-desc"><?php echo esc_html(sprintf(__('Full day of planned activities and sightseeing at %s. Detailed itinerary will be provided by your tour guide.', 'yatra'), htmlspecialchars($destination, ENT_QUOTES, 'UTF-8'))); ?></div>
281 - </div>
282 - <?php endfor; ?>
283 - <?php endif; ?>
499 + // Resolve the calendar date for this day, if we
500 + // know the travel start: Day 1 = travel_date,
501 + // Day N = travel_date + (N-1) days. Skipped
502 + // silently when no travel_date is available.
503 + $dayDateLabel = '';
504 + if (!empty($travel_date)) {
505 + $startTs = strtotime((string) ($travel_date_raw ?? ''));
506 + }
507 + // Use the already-formatted $travelDate for Day 1,
508 + // and compute later days from $travelDate's source
509 + // when available via the global travel_date var.
510 + ?>
511 + <div class="timeline-item">
512 + <div class="timeline-date">
513 + <?php
514 + /* translators: %d: itinerary day number. */
515 + echo esc_html(sprintf(__('Day %d', 'yatra'), $dayNumber));
516 + ?>
517 + </div>
518 + <div class="timeline-title">
519 + <?php echo htmlspecialchars($dayTitle !== '' ? $dayTitle : sprintf(__('Day %d', 'yatra'), $dayNumber), ENT_QUOTES, 'UTF-8'); ?>
520 + </div>
521 + <?php if ($dayDescription !== ''): ?>
522 + <div class="timeline-desc"><?php echo wp_kses_post(wpautop($dayDescription)); ?></div>
523 + <?php endif; ?>
284 524
285 - <?php if (!empty($returnDate)): ?>
286 - <div class="timeline-item">
287 - <div class="timeline-date"><?php echo htmlspecialchars($returnDate, ENT_QUOTES, 'UTF-8'); ?></div>
288 - <div class="timeline-title"><?php esc_html_e('Return Journey', 'yatra'); ?></div>
289 - <div class="timeline-desc"><?php echo esc_html(sprintf(__('Departure from %s and return to %s.', 'yatra'), htmlspecialchars($destination, ENT_QUOTES, 'UTF-8'), htmlspecialchars($departureLocation ?: __('origin', 'yatra'), ENT_QUOTES, 'UTF-8'))); ?></div>
290 - </div>
525 + <?php if (!empty($dayEntries)): ?>
526 + <div class="timeline-desc" style="margin-top:2mm;">
527 + <?php foreach ($dayEntries as $entry):
528 + $entryTitle = (string) ($yatra_itinerary_get($entry, 'title', ''));
529 + $entryDesc = (string) ($yatra_itinerary_get($entry, 'description', ''));
530 + $entryType = (string) ($yatra_itinerary_get($entry, 'item_type', $yatra_itinerary_get($entry, 'item_type_name', '')));
531 + $entryTime = (string) ($yatra_itinerary_get($entry, 'time', $yatra_itinerary_get($entry, 'start_time', '')));
532 + $entryLoc = (string) ($yatra_itinerary_get($entry, 'location', ''));
533 + if ($entryTitle === '' && $entryDesc === '') {
534 + continue;
535 + }
536 + ?>
537 + <div style="padding:2mm 0; border-bottom:1px dashed #e5e7eb;">
538 + <div style="font-weight:600; color:#374151;">
539 + <?php if ($entryTime !== ''): ?>
540 + <span style="color:#1e40af; margin-right:2mm;">[<?php echo htmlspecialchars($entryTime, ENT_QUOTES, 'UTF-8'); ?>]</span>
541 + <?php endif; ?>
542 + <?php echo htmlspecialchars($entryTitle !== '' ? $entryTitle : $entryType, ENT_QUOTES, 'UTF-8'); ?>
543 + <?php if ($entryType !== '' && $entryTitle !== '' && stripos($entryTitle, $entryType) === false): ?>
544 + <span style="color:#6b7280; font-weight:400; font-size:10px; margin-left:2mm;">· <?php echo htmlspecialchars($entryType, ENT_QUOTES, 'UTF-8'); ?></span>
545 + <?php endif; ?>
546 + </div>
547 + <?php if ($entryLoc !== ''): ?>
548 + <div style="color:#6b7280; font-size:10px; margin-top:1mm;">
549 + <?php
550 + /* translators: %s: location name (city/landmark) for an itinerary entry. */
551 + echo esc_html(sprintf(__('Location: %s', 'yatra'), $entryLoc));
552 + ?>
553 + </div>
554 + <?php endif; ?>
555 + <?php if ($entryDesc !== ''): ?>
556 + <div style="color:#6b7280; margin-top:1mm; line-height:1.4;">
557 + <?php echo wp_kses_post(wpautop($entryDesc)); ?>
558 + </div>
559 + <?php endif; ?>
560 + </div>
561 + <?php endforeach; ?>
562 + </div>
563 + <?php endif; ?>
564 + </div>
565 + <?php endforeach; ?>
566 + <?php else: ?>
567 + <!-- Derived outline shown when no day-by-day
568 + itinerary has been authored for the trip.
569 + Previously this section rendered both a
570 + "Departure" AND an "Arrival at Destination"
571 + row using `$travelDate` for BOTH — so the
572 + customer saw the same start date repeated
573 + twice for what should be two distinct events.
574 + Now we render the actual booked travel range:
575 + * Trip Start — `$travelDate` (booking's
576 + stored start_date / arrival)
577 + * Trip End — `$returnDate` (booking's
578 + stored end_date / departure),
579 + resolved in ItineraryPdfBuilder
580 + and shown only when it differs
581 + from the start (single-day
582 + trips skip it)
583 + The dates come from the values the plugin already
584 + records at booking time, so arrival and departure
585 + reflect the real booked range — no more duplicate
586 + "arrival = departure date" confusion. -->
587 + <?php
588 + $startDate = $travelDate !== '' ? $travelDate : __('TBD', 'yatra');
589 + $endDate = $returnDate !== '' ? $returnDate : '';
590 + $isMultiDay = $endDate !== '' && $endDate !== $startDate;
591 + // Resolve the destination / origin labels ONCE here
592 + // so the sprintf() calls below interpolate plain
593 + // values — not a `__('destination', 'yatra')` ternary
594 + // that wp-cli would attach to each surrounding
595 + // translator comment, producing "string has N
596 + // different translator comments" warnings.
597 + $destinationLabel = $destination !== '' ? $destination : __('destination', 'yatra');
598 + $originLabel = $departureLocation !== '' ? $departureLocation : __('origin', 'yatra');
599 + $meetingPointLabel = $departureLocation !== '' ? $departureLocation : __('meeting point', 'yatra');
600 + ?>
601 +
602 + <div class="timeline-item">
603 + <div class="timeline-date"><?php echo htmlspecialchars($startDate, ENT_QUOTES, 'UTF-8'); ?></div>
604 + <div class="timeline-title">
605 + <?php
606 + if ($isMultiDay) {
607 + esc_html_e('Trip Start', 'yatra');
608 + } else {
609 + esc_html_e('Trip Day', 'yatra');
610 + }
611 + ?>
612 + </div>
613 + <div class="timeline-desc"><?php
614 + /* translators: %s: departure location (e.g. meeting point or city). */
615 + echo esc_html(sprintf(__('Departure from %s. Please arrive at least 30 minutes before departure time.', 'yatra'), $meetingPointLabel));
616 + if ($destination !== '') {
617 + echo ' ';
618 + /* translators: %s: destination name (city or region). */
619 + echo esc_html(sprintf(__('Arrival at %s for check-in and accommodation briefing.', 'yatra'), $destination));
620 + }
621 + ?></div>
622 + </div>
623 +
624 + <?php
625 + // Optional intermediate days. Only shown for
626 + // multi-day trips where we have a duration.
627 + // Day 1 = Trip Start (above). Day N = Trip End
628 + // (below). So intermediates are 2 .. N-1.
629 + $derivedDays = (int) preg_replace('/\D/', '', (string) $tripDuration);
630 + if ($isMultiDay && $derivedDays > 2):
631 + for ($i = 2; $i < $derivedDays; $i++): ?>
632 + <div class="timeline-item">
633 + <div class="timeline-date">
634 + <?php
635 + /* translators: %d: itinerary day number. */
636 + echo esc_html(sprintf(__('Day %d', 'yatra'), $i));
637 + ?>
638 + </div>
639 + <div class="timeline-title"><?php esc_html_e('Activities & Sightseeing', 'yatra'); ?></div>
640 + <div class="timeline-desc"><?php
641 + /* translators: %s: destination name (city or region). */
642 + echo esc_html(sprintf(__('Full day of planned activities and sightseeing at %s. Detailed itinerary will be provided by your tour guide.', 'yatra'), $destinationLabel));
643 + ?></div>
644 + </div>
645 + <?php endfor;
646 + endif; ?>
647 +
648 + <?php if ($isMultiDay): ?>
649 + <div class="timeline-item">
650 + <div class="timeline-date"><?php echo htmlspecialchars($endDate, ENT_QUOTES, 'UTF-8'); ?></div>
651 + <div class="timeline-title"><?php esc_html_e('Trip End', 'yatra'); ?></div>
652 + <div class="timeline-desc"><?php
653 + /* translators: 1: destination name, 2: origin/departure location. */
654 + echo esc_html(sprintf(__('Departure from %1$s and return to %2$s.', 'yatra'), $destinationLabel, $originLabel));
655 + ?></div>
656 + </div>
657 + <?php endif; ?>
291 658 <?php endif; ?>
292 659 </div>
293 660 </div>
294 661
295 - <!-- Important Information -->
662 + <!-- Important Information — mirrors the same fields the
663 + single-trip page's "Important Information" tab surfaces
664 + (templates/partials/single-trip/content-important-info.php):
665 + physical requirements, visa, vaccination, cancellation
666 + policy, age range, accommodation, transportation. Empty
667 + fields are skipped, so trips with no important-info
668 + content authored don't show a half-empty table.
669 +
670 + The previous version hardcoded generic "Comfortable
671 + clothing, walking shoes, sunscreen..." advice that wasn't
672 + actually associated with the booked trip — misleading the
673 + reader and identical across every itinerary PDF the site
674 + ever generated. -->
675 + <?php
676 + $physicalReq = (string) ($physical_requirements ?? '');
677 + $visaReq = (string) ($visa_requirements ?? '');
678 + $vaccinationReq = (string) ($vaccination_requirements ?? '');
679 + $cancellation = (string) ($cancellation_policy ?? '');
680 + $ageMin = $age_min ?? null;
681 + $ageMax = $age_max ?? null;
682 + $accomType = (string) ($accommodation_type ?? '');
683 + $accomDetails = (string) ($accommodation_details ?? '');
684 + $mealPlan = (string) ($meal_plan ?? '');
685 + $transportInc = (bool) ($transportation_included ?? false);
686 + $pickupLoc = (string) ($pickup_location ?? '');
687 + $dropoffLoc = (string) ($dropoff_location ?? '');
688 + $transportDet = (string) ($transportation_details ?? '');
689 +
690 + $hasAgeInfo = ($ageMin !== null && $ageMin !== '') || ($ageMax !== null && $ageMax !== '');
691 + $hasAccom = $accomType !== '' || $accomDetails !== '' || $mealPlan !== '';
692 + $hasTransp = $transportInc || $pickupLoc !== '' || $dropoffLoc !== '' || $transportDet !== '';
693 +
694 + $hasAnyImportantInfo = $physicalReq !== ''
695 + || $visaReq !== ''
696 + || $vaccinationReq !== ''
697 + || $cancellation !== ''
698 + || $hasAgeInfo
699 + || $hasAccom
700 + || $hasTransp
701 + || $companyPhone !== '';
702 + ?>
703 + <?php if ($hasAnyImportantInfo): ?>
296 704 <div class="section">
297 705 <div class="section-title"><?php esc_html_e('Important Information', 'yatra'); ?></div>
298 706 <table class="trip-details">
707 + <?php if ($physicalReq !== ''): ?>
299 708 <tr>
300 - <th class="label"><?php esc_html_e('What to Bring', 'yatra'); ?></th>
301 - <td><?php esc_html_e('Comfortable clothing, walking shoes, sunscreen, hat, camera, personal medications, and any required travel documents.', 'yatra'); ?></td>
709 + <th class="label"><?php esc_html_e('Physical Requirements', 'yatra'); ?></th>
710 + <td><?php echo wp_kses_post(wpautop($physicalReq)); ?></td>
302 711 </tr>
712 + <?php endif; ?>
713 +
714 + <?php if ($visaReq !== ''): ?>
303 715 <tr>
304 - <th class="label"><?php esc_html_e('Weather Conditions', 'yatra'); ?></th>
305 - <td><?php esc_html_e('Please check weather forecast before departure and pack accordingly. Weather conditions may vary by destination.', 'yatra'); ?></td>
716 + <th class="label"><?php esc_html_e('Visa Requirements', 'yatra'); ?></th>
717 + <td><?php echo wp_kses_post(wpautop($visaReq)); ?></td>
306 718 </tr>
719 + <?php endif; ?>
720 +
721 + <?php if ($vaccinationReq !== ''): ?>
307 722 <tr>
723 + <th class="label"><?php esc_html_e('Health & Vaccination', 'yatra'); ?></th>
724 + <td><?php echo wp_kses_post(wpautop($vaccinationReq)); ?></td>
725 + </tr>
726 + <?php endif; ?>
727 +
728 + <?php if ($cancellation !== ''): ?>
729 + <tr>
730 + <th class="label"><?php esc_html_e('Cancellation Policy', 'yatra'); ?></th>
731 + <td><?php echo wp_kses_post(wpautop($cancellation)); ?></td>
732 + </tr>
733 + <?php endif; ?>
734 +
735 + <?php if ($hasAgeInfo):
736 + $ageParts = [];
737 + if ($ageMin !== null && $ageMin !== '') {
738 + /* translators: %d: minimum age in years. */
739 + $ageParts[] = sprintf(__('Min %d years', 'yatra'), (int) $ageMin);
740 + }
741 + if ($ageMax !== null && $ageMax !== '') {
742 + /* translators: %d: maximum age in years. */
743 + $ageParts[] = sprintf(__('Max %d years', 'yatra'), (int) $ageMax);
744 + }
745 + ?>
746 + <tr>
747 + <th class="label"><?php esc_html_e('Age Requirements', 'yatra'); ?></th>
748 + <td><?php echo esc_html(implode(' · ', $ageParts)); ?></td>
749 + </tr>
750 + <?php endif; ?>
751 +
752 + <?php if ($hasAccom): ?>
753 + <tr>
754 + <th class="label"><?php esc_html_e('Accommodation', 'yatra'); ?></th>
755 + <td>
756 + <?php if ($accomType !== ''): ?>
757 + <div><?php echo esc_html($accomType); ?></div>
758 + <?php endif; ?>
759 + <?php if ($mealPlan !== ''): ?>
760 + <div>
761 + <?php
762 + echo esc_html(sprintf(
763 + /* translators: %s: meal plan label (e.g. "Breakfast included"). */
764 + __('Meal Plan: %s', 'yatra'),
765 + function_exists('yatra_meal_plan_label')
766 + ? yatra_meal_plan_label($mealPlan)
767 + : $mealPlan
768 + ));
769 + ?>
770 + </div>
771 + <?php endif; ?>
772 + <?php if ($accomDetails !== ''): ?>
773 + <?php echo wp_kses_post(wpautop($accomDetails)); ?>
774 + <?php endif; ?>
775 + </td>
776 + </tr>
777 + <?php endif; ?>
778 +
779 + <?php if ($hasTransp): ?>
780 + <tr>
781 + <th class="label"><?php esc_html_e('Transportation', 'yatra'); ?></th>
782 + <td>
783 + <?php if ($transportInc): ?>
784 + <div><?php esc_html_e('Transportation Included', 'yatra'); ?></div>
785 + <?php endif; ?>
786 + <?php if ($pickupLoc !== ''): ?>
787 + <div>
788 + <?php
789 + /* translators: %s: pickup location for transportation. */
790 + echo esc_html(sprintf(__('Pickup: %s', 'yatra'), $pickupLoc));
791 + ?>
792 + </div>
793 + <?php endif; ?>
794 + <?php if ($dropoffLoc !== ''): ?>
795 + <div>
796 + <?php
797 + /* translators: %s: dropoff location for transportation. */
798 + echo esc_html(sprintf(__('Dropoff: %s', 'yatra'), $dropoffLoc));
799 + ?>
800 + </div>
801 + <?php endif; ?>
802 + <?php if ($transportDet !== ''): ?>
803 + <?php echo wp_kses_post(wpautop($transportDet)); ?>
804 + <?php endif; ?>
805 + </td>
806 + </tr>
807 + <?php endif; ?>
808 +
809 + <?php if ($companyPhone !== ''): ?>
810 + <tr>
308 811 <th class="label"><?php esc_html_e('Emergency Contact', 'yatra'); ?></th>
309 - <td><?php echo htmlspecialchars($companyPhone ?: __('Contact your travel agent', 'yatra'), ENT_QUOTES, 'UTF-8'); ?></td>
812 + <td><?php echo htmlspecialchars($companyPhone, ENT_QUOTES, 'UTF-8'); ?></td>
310 813 </tr>
814 + <?php endif; ?>
311 815 </table>
312 816 </div>
817 + <?php endif; ?>
313 818
314 - <!-- What's Included -->
315 - <?php if (!empty($tripIncludes)): ?>
819 + <!-- What's Included / Not Included.
820 + Both lists run through the same shape-tolerant normaliser
821 + so a stdClass entry can't crash the render the way it did
822 + before (trim() on stdClass = PHP fatal). Empty lists hide
823 + the whole section. -->
824 + <?php $includesList = $yatra_normalize_list($tripIncludes); ?>
825 + <?php if (!empty($includesList)): ?>
316 826 <div class="section avoid-break">
317 827 <div class="section-title"><?php esc_html_e('What\'s Included', 'yatra'); ?></div>
318 828 <div class="trip-description">
319 - <?php
320 - $includes = is_array($tripIncludes) ? $tripIncludes : explode("\n", $tripIncludes);
321 - foreach ($includes as $include):
322 - $include = trim($include);
323 - if (!empty($include)):
324 - ?>
325 - <p>• <?php echo htmlspecialchars($include, ENT_QUOTES, 'UTF-8'); ?></p>
326 - <?php
327 - endif;
328 - endforeach;
329 - ?>
829 + <?php foreach ($includesList as $include): ?>
830 + <p>• <?php echo htmlspecialchars($include, ENT_QUOTES, 'UTF-8'); ?></p>
831 + <?php endforeach; ?>
330 832 </div>
331 833 </div>
332 834 <?php endif; ?>
333 835
334 - <!-- What's Not Included -->
335 - <?php if (!empty($tripExcludes)): ?>
836 + <?php $excludesList = $yatra_normalize_list($tripExcludes); ?>
837 + <?php if (!empty($excludesList)): ?>
336 838 <div class="section avoid-break">
337 839 <div class="section-title"><?php esc_html_e('What\'s Not Included', 'yatra'); ?></div>
338 840 <div class="trip-description">
339 - <?php
340 - $excludes = is_array($tripExcludes) ? $tripExcludes : explode("\n", $tripExcludes);
341 - foreach ($excludes as $exclude):
342 - $exclude = trim($exclude);
343 - if (!empty($exclude)):
344 - ?>
345 - <p>• <?php echo htmlspecialchars($exclude, ENT_QUOTES, 'UTF-8'); ?></p>
346 - <?php
347 - endif;
348 - endforeach;
349 - ?>
841 + <?php foreach ($excludesList as $exclude): ?>
842 + <p>• <?php echo htmlspecialchars($exclude, ENT_QUOTES, 'UTF-8'); ?></p>
843 + <?php endforeach; ?>
350 844 </div>
351 845 </div>
352 846 <?php endif; ?>
353 847
354 - <!-- Footer -->
355 - <table class="footer">
356 - <tr>
357 - <td style="width: 60%;">
358 - <p><strong><?php echo htmlspecialchars($companyName ?: __('Travel Company', 'yatra'), ENT_QUOTES, 'UTF-8'); ?></strong></p>
359 - <p><?php echo htmlspecialchars($companyAddress, ENT_QUOTES, 'UTF-8'); ?></p>
360 - <p>
361 - <?php
362 - echo esc_html(
363 - sprintf(
364 - /* translators: 1: label "Email", 2: company email, 3: label "Phone", 4: company phone */
365 - __('%1$s: %2$s | %3$s: %4$s', 'yatra'),
366 - __('Email', 'yatra'),
367 - $companyEmail,
368 - __('Phone', 'yatra'),
369 - $companyPhone
370 - )
371 - );
372 - ?>
373 - </p>
374 - </td>
375 - <td style="width: 40%; text-align: right;">
376 - <p><?php esc_html_e('This is a computer-generated document.', 'yatra'); ?></p>
377 - <p><?php esc_html_e('No signature required.', 'yatra'); ?></p>
378 - </td>
379 - </tr>
380 - </table>
848 + <!-- Final-page disclaimer. The repeating company contact and
849 + page counter live in #page-footer (every page); this small
850 + right-aligned block sits AFTER the last content section so
851 + it only prints once, at the very end of the document. -->
852 + <div style="margin-top: 8mm; padding-top: 3mm; border-top: 1px solid #e5e7eb; text-align: right; font-size: 9px; color: #9ca3af;">
853 + <div><?php esc_html_e('This is a computer-generated document.', 'yatra'); ?></div>
854 + <div><?php esc_html_e('No signature required.', 'yatra'); ?></div>
855 + </div>
381 856 </div>
857 +
858 + <!-- Per-page header + footer.
859 + CRITICAL: this script tag MUST live at the END of body, after
860 + every content section. Dompdf's `processPageScript()` (called
861 + internally by `page_text()` / `page_script()` / `page_line()`)
862 + iterates the pages that ALREADY EXIST when those methods are
863 + invoked — pages are added to that list one-by-one as content
864 + flows during layout. If the script ran at the top of body,
865 + only page 1 would exist at register-time, so the header /
866 + footer / page counter would apply to page 1 only and
867 + {PAGE_COUNT} would resolve to "1" everywhere. Placing the
868 + script after all sections means all pages have been laid out
869 + by the time it runs, so the chrome applies uniformly and the
870 + counter is accurate. -->
871 + <script type="text/php">
872 + if (isset($pdf)) {
873 + $headerLeft = <?php echo var_export($_headerLeft, true); ?>;
874 + $headerRight = <?php echo var_export($_headerRight, true); ?>;
875 + $footerLeft = <?php echo var_export($_footerLeft, true); ?>;
876 + $pageLabel = <?php echo var_export($_pageLabel, true); ?>;
877 +
878 + $pageWidth = $pdf->get_width();
879 + $pageHeight = $pdf->get_height();
880 + // 51pt ≈ 18mm — matches the body's horizontal padding so the
881 + // header/footer chrome lines up with the body content's
882 + // left/right edges. (@page margin is 0; body padding emulates
883 + // the page margin for us — Dompdf applies body horizontal
884 + // padding per line which works reliably on every page.)
885 + $marginL = 51;
886 + $marginR = $pageWidth - 51;
887 +
888 + $font = $fontMetrics->get_font('Noto Sans Devanagari', 'normal') ?: $fontMetrics->get_font('DejaVu Sans', 'normal');
889 + $fontBold = $fontMetrics->get_font('Noto Sans Devanagari', 'bold') ?: $fontMetrics->get_font('DejaVu Sans', 'bold');
890 +
891 + // ---- Per-page HEADER (skip page 1) ----
892 + // Header text y=22pt (≈7.7mm from top), separator y=38pt
893 + // (≈13.4mm). Both sit safely inside the 18mm top margin
894 + // band, above where body content starts (51pt).
895 + $pdf->page_script(
896 + 'if ($PAGE_NUM > 1) {' .
897 + ' $fontBold = $fontMetrics->get_font("Noto Sans Devanagari", "bold") ?: $fontMetrics->get_font("DejaVu Sans", "bold");' .
898 + ' $pdf->text(' . $marginL . ', 22, ' . var_export($headerLeft, true) . ', $fontBold, 9, [0.12, 0.25, 0.69]);' .
899 + ' $hr = ' . var_export($headerRight, true) . ';' .
900 + ' if ($hr !== "") {' .
901 + ' $rw = $fontMetrics->getTextWidth($hr, $fontBold, 9);' .
902 + ' $pdf->text(' . $marginR . ' - $rw, 22, $hr, $fontBold, 9, [0.07, 0.09, 0.15]);' .
903 + ' }' .
904 + ' $pdf->line(' . $marginL . ', 38, ' . $marginR . ', 38, [0.90, 0.91, 0.92], 0.5);' .
905 + '}'
906 + );
907 +
908 + // ---- Per-page FOOTER (every page) ----
909 + // Footer text y=pageHeight-26 (≈9mm from bottom), separator
910 + // line y=pageHeight-38 (≈13mm from bottom). page_line and
911 + // page_text both queue per-page callbacks now that we're
912 + // running with all pages already laid out.
913 + $footerY = $pageHeight - 26;
914 + $footerLineY = $pageHeight - 38;
915 + $pdf->page_line($marginL, $footerLineY, $marginR, $footerLineY, [0.90, 0.91, 0.92], 0.5);
916 +
917 + if ($footerLeft !== '') {
918 + $pdf->page_text($marginL, $footerY, $footerLeft, $font, 9, [0.42, 0.45, 0.50]);
919 + }
920 +
921 + // Estimate the substituted text width against a max-width
922 + // sample so the right-aligned page counter stays anchored.
923 + $sample = $pageLabel . '00 / 00';
924 + $tw = $fontMetrics->getTextWidth($sample, $font, 9);
925 + $pdf->page_text($marginR - $tw, $footerY, $pageLabel . '{PAGE_NUM} / {PAGE_COUNT}', $font, 9, [0.23, 0.27, 0.34]);
926 + }
927 + </script>
382 928 </body>
383 929 </html>