PluginProbe
Yatra – Travel Booking & Tour Operator Software / 3.0.8
Yatra – Travel Booking & Tour Operator Software v3.0.8
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.8, at templates/pdf/itinerary.php

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