| 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 & 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 |
|