PluginProbe
Yatra – Travel Booking & Tour Operator Software / 3.0.11
Yatra – Travel Booking & Tour Operator Software v3.0.11
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
yatra / templates / pdf / itinerary.php

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

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