PluginProbe
Yatra – Travel Booking & Tour Operator Software / 3.0.15
Yatra – Travel Booking & Tour Operator Software v3.0.15
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 2.0.10 All 83 releases
yatra / templates / pdf / itinerary.php

itinerary.php in Yatra – Travel Booking & Tour Operator Software 3.0.15, at templates/pdf/itinerary.php

930 lines 50.3 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 $companyName = (string) ($company_name ?? '');
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 }));
16 $companyEmail = (string) ($company_email ?? '');
17 $companyPhone = (string) ($company_phone ?? '');
18
19 $customerName = (string) ($customer_name ?? '');
20 $customerEmail = (string) ($customer_email ?? '');
21
22 $bookingRef = (string) ($booking_ref ?? '');
23 $bookingDate = (string) ($booking_date ?? '');
24 $bookingStatus = (string) ($booking_status ?? '');
25 $statusClass = (string) ($status_class ?? 'pending');
26
27 $tripTitle = (string) ($trip_title ?? '');
28 $tripDescription = (string) ($trip_description ?? '');
29 $tripDuration = (string) ($trip_duration ?? '');
30 $tripDifficulty = (string) ($trip_difficulty ?? '');
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 ?? '';
39
40 $travelDate = (string) ($travel_date ?? '');
41 $returnDate = (string) ($return_date ?? '');
42
43 $currencySymbol = (string) ($currency_symbol ?? '');
44 $totalAmount = (string) ($total_amount ?? '0.00');
45 $amountPaid = (string) ($amount_paid ?? '0.00');
46 $amountDue = (string) ($amount_due ?? '0.00');
47
48 $travelerCount = (int) ($traveler_count ?? 1);
49 $departureLocation = (string) ($departure_location ?? '');
50 $destination = (string) ($destination ?? '');
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
114 ?><!DOCTYPE html>
115 <html lang="en">
116 <head>
117 <meta charset="UTF-8">
118 <meta name="viewport" content="width=device-width, initial-scale=1.0">
119 <title><?php echo esc_html__('Travel Itinerary', 'yatra'); ?> - <?php echo htmlspecialchars($bookingRef, ENT_QUOTES, 'UTF-8'); ?></title>
120 <style>
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 }
141 * { margin: 0; padding: 0; box-sizing: border-box; }
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; }
164
165 .page-break { page-break-before: always; }
166 .avoid-break { page-break-inside: avoid; }
167
168 .header { width: 100%; border-collapse: collapse; }
169 .header td { vertical-align: top; }
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; }
172 .brand h1 { font-size: 18px; font-weight: 700; margin: 0; }
173 .brand p { font-size: 11px; margin-top: 4px; }
174
175 .itinerary-number { background: #f3f4f6; padding: 4mm 6mm; border-bottom: 2px solid #1e40af; margin-top: 2mm; }
176 .itinerary-number h2 { font-size: 16px; font-weight: 700; color: #1e40af; margin: 0; }
177 .itinerary-number .ref { font-size: 13px; color: #6b7280; margin-top: 2px; }
178
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; }
194
195 .panel { width: 100%; border: 1px solid #e5e7eb; border-collapse: collapse; margin-top: 6mm; margin-bottom: 6mm; }
196 .panel td { padding: 5mm 5mm; vertical-align: top; }
197 .panel h3 { font-size: 11px; text-transform: uppercase; color: #6b7280; margin-bottom: 8px; letter-spacing: 0.5px; }
198 .muted { color: #6b7280; }
199
200 .trip-details { width: 100%; border-collapse: collapse; margin-bottom: 6mm; }
201 .trip-details th { background: #f9fafb; font-weight: 700; text-align: left; padding: 3mm 4mm; border: 1px solid #e5e7eb; }
202 .trip-details td { padding: 3mm 4mm; border: 1px solid #e5e7eb; }
203 .trip-details .label { font-weight: 700; color: #6b7280; width: 30%; }
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
221 .timeline { margin: 4mm 0; }
222 .timeline-item { position: relative; padding-left: 8mm; margin-bottom: 6mm; page-break-inside: avoid; }
223 .timeline-item:before { content: ''; position: absolute; left: 0; top: 6px; width: 8px; height: 8px; background: #1e40af; border-radius: 50%; }
224 .timeline-item:after { content: ''; position: absolute; left: 3px; top: 14px; width: 2px; height: calc(100% - 8px); background: #e5e7eb; }
225 .timeline-item:last-child:after { display: none; }
226 .timeline-date { font-weight: 700; color: #1e40af; margin-bottom: 1mm; }
227 .timeline-title { font-weight: 700; margin-bottom: 1mm; }
228 .timeline-desc { color: #6b7280; line-height: 1.4; }
229
230 .status { display: inline-block; padding: 1mm 3mm; border-radius: 2mm; font-size: 10px; font-weight: 700; text-transform: uppercase; }
231 .status.confirmed { background: #d1fae5; color: #065f46; }
232 .status.pending { background: #fef3c7; color: #92400e; }
233 .status.cancelled { background: #fee2e2; color: #991b1b; }
234
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; }
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; }
249
250 .trip-description { background: #f8fafc; padding: 4mm; border-left: 4px solid #1e40af; margin-bottom: 6mm; page-break-inside: avoid; }
251 .trip-description h3 { color: #1e40af; margin-bottom: 2mm; font-size: 14px; }
252 .trip-description p { line-height: 1.5; margin-bottom: 2mm; }
253
254 .itinerary-highlights { margin: 4mm 0; }
255 .highlight-item { padding: 2mm 0; border-bottom: 1px dashed #e5e7eb; }
256 .highlight-item:last-child { border-bottom: none; }
257 .highlight-title { font-weight: 700; color: #374151; }
258 .highlight-desc { color: #6b7280; font-size: 11px; margin-top: 1mm; }
259 </style>
260 </head>
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
304 <div class="content">
305 <!-- Header -->
306 <table class="header">
307 <tr>
308 <td style="width: 60%;">
309 <div class="brand">
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>
314 <p><?php esc_html_e('Travel Itinerary Document', 'yatra'); ?></p>
315 </div>
316 </td>
317 <td style="width: 40%;">
318 <div class="brand" style="background: #374151; text-align: right;">
319 <p style="font-size: 10px; margin-bottom: 2px;"><?php esc_html_e('Booking Reference', 'yatra'); ?></p>
320 <p style="font-size: 14px; font-weight: 700;"><?php echo htmlspecialchars($bookingRef ?: __('N/A', 'yatra'), ENT_QUOTES, 'UTF-8'); ?></p>
321 </div>
322 </td>
323 </tr>
324 </table>
325
326 <div class="itinerary-number">
327 <h2><?php esc_html_e('Travel Itinerary', 'yatra'); ?></h2>
328 <div class="ref"><?php esc_html_e('Issued:', 'yatra'); ?> <?php echo htmlspecialchars($bookingDate, ENT_QUOTES, 'UTF-8'); ?></div>
329 </div>
330
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. -->
338 <?php if (!empty($tripDescription)): ?>
339 <div class="section">
340 <div class="section-title"><?php esc_html_e('About This Trip', 'yatra'); ?></div>
341 <div class="trip-description">
342 <h3><?php echo htmlspecialchars($tripTitle, ENT_QUOTES, 'UTF-8'); ?></h3>
343 <?php echo wp_kses_post(wpautop($tripDescription)); ?>
344 </div>
345 </div>
346 <?php endif; ?>
347
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)): ?>
411 <div class="section">
412 <div class="section-title"><?php esc_html_e('Trip Information', 'yatra'); ?></div>
413 <?php $renderTwoCardRow(__('Trip Details', 'yatra'), $tripRows, __('Travel Information', 'yatra'), $travelRows); ?>
414 </div>
415 <?php endif; ?>
416
417 <?php if (!empty($travelerRows) || !empty($bookingRows)): ?>
418 <div class="section">
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); ?>
421 </div>
422 <?php endif; ?>
423
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)): ?>
461 <div class="section">
462 <div class="section-title"><?php esc_html_e('Trip Highlights', 'yatra'); ?></div>
463 <div class="itinerary-highlights">
464 <?php foreach ($highlightsList as [$hTitle, $hDesc]): ?>
465 <div class="highlight-item">
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; ?>
472 </div>
473 <?php endforeach; ?>
474 </div>
475 </div>
476 <?php endif; ?>
477
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. -->
486 <div class="section">
487 <div class="section-title"><?php esc_html_e('Travel Timeline', 'yatra'); ?></div>
488 <div class="timeline">
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 }
498
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; ?>
524
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; ?>
658 <?php endif; ?>
659 </div>
660 </div>
661
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): ?>
704 <div class="section">
705 <div class="section-title"><?php esc_html_e('Important Information', 'yatra'); ?></div>
706 <table class="trip-details">
707 <?php if ($physicalReq !== ''): ?>
708 <tr>
709 <th class="label"><?php esc_html_e('Physical Requirements', 'yatra'); ?></th>
710 <td><?php echo wp_kses_post(wpautop($physicalReq)); ?></td>
711 </tr>
712 <?php endif; ?>
713
714 <?php if ($visaReq !== ''): ?>
715 <tr>
716 <th class="label"><?php esc_html_e('Visa Requirements', 'yatra'); ?></th>
717 <td><?php echo wp_kses_post(wpautop($visaReq)); ?></td>
718 </tr>
719 <?php endif; ?>
720
721 <?php if ($vaccinationReq !== ''): ?>
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>
811 <th class="label"><?php esc_html_e('Emergency Contact', 'yatra'); ?></th>
812 <td><?php echo htmlspecialchars($companyPhone, ENT_QUOTES, 'UTF-8'); ?></td>
813 </tr>
814 <?php endif; ?>
815 </table>
816 </div>
817 <?php endif; ?>
818
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)): ?>
826 <div class="section avoid-break">
827 <div class="section-title"><?php esc_html_e('What\'s Included', 'yatra'); ?></div>
828 <div class="trip-description">
829 <?php foreach ($includesList as $include): ?>
830 <p> <?php echo htmlspecialchars($include, ENT_QUOTES, 'UTF-8'); ?></p>
831 <?php endforeach; ?>
832 </div>
833 </div>
834 <?php endif; ?>
835
836 <?php $excludesList = $yatra_normalize_list($tripExcludes); ?>
837 <?php if (!empty($excludesList)): ?>
838 <div class="section avoid-break">
839 <div class="section-title"><?php esc_html_e('What\'s Not Included', 'yatra'); ?></div>
840 <div class="trip-description">
841 <?php foreach ($excludesList as $exclude): ?>
842 <p> <?php echo htmlspecialchars($exclude, ENT_QUOTES, 'UTF-8'); ?></p>
843 <?php endforeach; ?>
844 </div>
845 </div>
846 <?php endif; ?>
847
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>
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>
928 </body>
929 </html>
930