PluginProbe
Yatra – Travel Booking & Tour Operator Software / 3.0.14.2
Yatra – Travel Booking & Tour Operator Software v3.0.14.2
3.0.15 3.0.14 3.0.14.1 3.0.14.2 3.0.12 3.0.13 3.0.11 3.0.10 3.0.9 3.0.8 3.0.7 3.0.6 3.0.5 3.0.5.1 3.0.4 3.0.3 3.0.2.9 3.0.2.7 3.0.2.8 3.0.2.6 trunk 1.0.0 2.0.0 2.0.1 2.0.10 All 83 releases
yatra / app / Services / EmailMergeTagRegistry.php

EmailMergeTagRegistry.php in Yatra – Travel Booking & Tour Operator Software 3.0.14.2, at app/Services/EmailMergeTagRegistry.php

1,015 lines 46.2 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Central registry of email merge tags.
4 *
5 * The single source of truth for every {{tag}} an operator can put inside
6 * an email subject or body. Defines the tag's label, description, category
7 * and sample value, plus which automation events resolve it at send time.
8 *
9 * Consumed by:
10 * - {@see \YatraPro\Modules\EmailAutomation\Services\EmailAutomationService}
11 * for the Template Editor's "Available Variables" sidebar and for
12 * preview-sample defaults — instead of maintaining a parallel catalogue.
13 * - {@see \YatraPro\Modules\EmailAutomation\Support\EmailAutomationEvents}
14 * to derive each event's variable whitelist, so adding a new tag to
15 * the registry automatically surfaces it on the events that resolve it.
16 *
17 * To add a new tag: append a row in {@see self::definitions()} with the
18 * event keys that actually inject the value, then make the renderer
19 * (`variablesFromBooking`, etc.) inject it. Both ends stay in sync.
20 *
21 * @package Yatra\Services
22 * @since 3.0.5
23 */
24
25 declare(strict_types=1);
26
27 namespace Yatra\Services;
28
29 final class EmailMergeTagRegistry
30 {
31 public const CATEGORY_GENERAL = 'general';
32 public const CATEGORY_CUSTOMER = 'customer';
33 public const CATEGORY_BOOKING = 'booking';
34 public const CATEGORY_PAYMENT = 'payment';
35 public const CATEGORY_SCHEDULED_PAYMENT = 'scheduled_payment';
36 public const CATEGORY_ENQUIRY = 'enquiry';
37 public const CATEGORY_TRIP_CONSENT = 'trip_consent';
38 public const CATEGORY_ACCOUNT = 'account';
39 public const CATEGORY_ABANDONED_RECOVERY = 'abandoned_recovery';
40 public const CATEGORY_REMINDER = 'reminder';
41
42 public const EVENT_BOOKING_CREATED = 'booking.created';
43 public const EVENT_BOOKING_CONFIRMED = 'booking.confirmed';
44 public const EVENT_BOOKING_CANCELLED = 'booking.cancelled';
45 public const EVENT_BOOKING_COMPLETED = 'booking.completed';
46 public const EVENT_BOOKING_EXPIRED = 'booking.expired';
47 public const EVENT_PAYMENT_RECEIVED = 'payment.received';
48 /** A payment landed but a balance is still outstanding (deposit / instalment). */
49 public const EVENT_PAYMENT_PARTIAL_RECEIVED = 'payment.partial_received';
50 public const EVENT_PAYMENT_REMINDER = 'payment.reminder';
51 public const EVENT_REMINDER_TRIP = 'reminder.trip';
52 public const EVENT_ENQUIRY_CREATED = 'enquiry.created';
53 public const EVENT_ENQUIRY_RESPONDED = 'enquiry.responded';
54 public const EVENT_REVIEW_REQUEST = 'marketing.review_request';
55 public const EVENT_CONSENT_REQUESTED = 'consent.requested';
56 public const EVENT_ACCOUNT_EMAIL_VERIFICATION = 'account.email_verification';
57 public const EVENT_SCHEDULED_PAYMENT_REMINDER = 'scheduled.payment.reminder';
58 public const EVENT_SCHEDULED_PAYMENT_SUCCEEDED = 'scheduled.payment.succeeded';
59 public const EVENT_SCHEDULED_PAYMENT_FAILED = 'scheduled.payment.failed';
60 public const EVENT_BOOKING_ABANDONED_RECOVERY = 'booking.abandoned_recovery';
61
62 /**
63 * Every event that resolves a `variablesFromBooking()`-derived
64 * booking context. Booking-context tags inherit this list so
65 * the per-event whitelist stays in sync as events evolve.
66 */
67 private const BOOKING_CONTEXT_EVENTS = [
68 self::EVENT_BOOKING_CREATED,
69 self::EVENT_BOOKING_CONFIRMED,
70 self::EVENT_BOOKING_CANCELLED,
71 self::EVENT_BOOKING_COMPLETED,
72 self::EVENT_BOOKING_EXPIRED,
73 self::EVENT_PAYMENT_RECEIVED,
74 self::EVENT_PAYMENT_PARTIAL_RECEIVED,
75 self::EVENT_PAYMENT_REMINDER,
76 self::EVENT_REMINDER_TRIP,
77 self::EVENT_REVIEW_REQUEST,
78 self::EVENT_SCHEDULED_PAYMENT_REMINDER,
79 self::EVENT_SCHEDULED_PAYMENT_SUCCEEDED,
80 self::EVENT_SCHEDULED_PAYMENT_FAILED,
81 ];
82
83 private const ENQUIRY_CONTEXT_EVENTS = [
84 self::EVENT_ENQUIRY_CREATED,
85 self::EVENT_ENQUIRY_RESPONDED,
86 ];
87
88 private const SCHEDULED_PAYMENT_EVENTS = [
89 self::EVENT_SCHEDULED_PAYMENT_REMINDER,
90 self::EVENT_SCHEDULED_PAYMENT_SUCCEEDED,
91 self::EVENT_SCHEDULED_PAYMENT_FAILED,
92 ];
93
94 /**
95 * Full merge-tag catalogue keyed by tag key.
96 *
97 * Each entry shape:
98 * - key (string) — the literal token, e.g. `booking_reference`
99 * - label (string) — human-readable label for the UI sidebar
100 * - description (string) — short hint shown under the label
101 * - category (string) — bucket for grouping in the sidebar
102 * - sample (string) — preview value when no real context is present
103 * - events (list|string) — automation events that inject this tag at
104 * send time. Use the literal '*' to mark
105 * event-independent tags (site_name, etc.).
106 *
107 * Filter `yatra_email_merge_tag_definitions` lets Pro modules / custom
108 * integrations append their own tags without editing this method.
109 *
110 * @return array<string, array{key:string,label:string,description:string,category:string,sample:string,events:list<string>|string}>
111 */
112 public static function definitions(): array
113 {
114 $previewTok = defined('YATRA_EMAIL_VERIFICATION_PREVIEW_TOKEN')
115 ? (string) YATRA_EMAIL_VERIFICATION_PREVIEW_TOKEN
116 : 'preview-verify-token';
117 $verificationSampleLink = function_exists('yatra_get_email_verification_url')
118 ? yatra_get_email_verification_url($previewTok)
119 : home_url('/?yatra_verify_email=' . rawurlencode($previewTok));
120
121 $dateFormat = function_exists('get_option') ? (string) get_option('date_format') : 'Y-m-d';
122 $sampleTravelDate = function_exists('date_i18n')
123 ? date_i18n($dateFormat, strtotime('+30 days') ?: time())
124 : date('Y-m-d', strtotime('+30 days') ?: time());
125 $sampleDueDate = function_exists('date_i18n')
126 ? date_i18n($dateFormat, strtotime('+14 days') ?: time())
127 : date('Y-m-d', strtotime('+14 days') ?: time());
128
129 $homeUrl = function_exists('home_url') ? home_url('/') : 'https://example.test/';
130 $adminUrl = function_exists('admin_url') ? admin_url('admin.php?page=yatra') : $homeUrl . 'wp-admin/admin.php?page=yatra';
131 $siteName = function_exists('get_bloginfo') ? (string) get_bloginfo('name') : 'Your Site';
132 $adminEmail = function_exists('get_option') ? (string) get_option('admin_email') : 'admin@example.test';
133
134 $bookingContextEvents = self::BOOKING_CONTEXT_EVENTS;
135 $enquiryContextEvents = self::ENQUIRY_CONTEXT_EVENTS;
136 $scheduledPaymentEvents = self::SCHEDULED_PAYMENT_EVENTS;
137
138 $catalog = [
139 // ---------------------------------------------------------
140 // General — available to every event because parseTemplate
141 // merges getDefaultVariables() over the caller's $variables.
142 // ---------------------------------------------------------
143 'site_name' => [
144 'key' => 'site_name',
145 'label' => 'Site Name',
146 'description' => 'Your website name (from WordPress Site Title).',
147 'category' => self::CATEGORY_GENERAL,
148 'sample' => $siteName !== '' ? $siteName : 'Your Site',
149 'events' => '*',
150 ],
151 'site_url' => [
152 'key' => 'site_url',
153 'label' => 'Site URL',
154 'description' => 'Your website home URL.',
155 'category' => self::CATEGORY_GENERAL,
156 'sample' => $homeUrl,
157 'events' => '*',
158 ],
159 'admin_email' => [
160 'key' => 'admin_email',
161 'label' => 'Admin Email',
162 'description' => 'Site administrator email address.',
163 'category' => self::CATEGORY_GENERAL,
164 'sample' => $adminEmail !== '' ? $adminEmail : 'admin@example.test',
165 'events' => '*',
166 ],
167 'admin_url' => [
168 'key' => 'admin_url',
169 'label' => 'Admin URL',
170 'description' => 'Link to the Yatra admin dashboard.',
171 'category' => self::CATEGORY_GENERAL,
172 'sample' => $adminUrl,
173 'events' => '*',
174 ],
175 'current_date' => [
176 'key' => 'current_date',
177 'label' => 'Current Date',
178 'description' => "Today's date formatted per the site's date format.",
179 'category' => self::CATEGORY_GENERAL,
180 'sample' => function_exists('date_i18n') ? date_i18n($dateFormat) : date($dateFormat),
181 'events' => '*',
182 ],
183 'current_year' => [
184 'key' => 'current_year',
185 'label' => 'Current Year',
186 'description' => 'Current four-digit year.',
187 'category' => self::CATEGORY_GENERAL,
188 'sample' => date('Y'),
189 'events' => '*',
190 ],
191
192 // ---------------------------------------------------------
193 // Customer — produced by variablesFromBooking + variablesFromEnquiry
194 // + the verification + consent + recovery senders.
195 // ---------------------------------------------------------
196 'customer_name' => [
197 'key' => 'customer_name',
198 'label' => 'Customer Name',
199 'description' => 'Full name (first + last) of the customer / enquirer.',
200 'category' => self::CATEGORY_CUSTOMER,
201 'sample' => 'John Doe',
202 'events' => array_merge(
203 $bookingContextEvents,
204 $enquiryContextEvents,
205 [
206 self::EVENT_ACCOUNT_EMAIL_VERIFICATION,
207 self::EVENT_BOOKING_ABANDONED_RECOVERY,
208 ]
209 ),
210 ],
211 'customer_first_name' => [
212 'key' => 'customer_first_name',
213 'label' => 'First Name',
214 'description' => 'Customer first name only.',
215 'category' => self::CATEGORY_CUSTOMER,
216 'sample' => 'John',
217 'events' => array_merge(
218 $bookingContextEvents,
219 [self::EVENT_ACCOUNT_EMAIL_VERIFICATION]
220 ),
221 ],
222 'customer_last_name' => [
223 'key' => 'customer_last_name',
224 'label' => 'Last Name',
225 'description' => 'Customer last name only.',
226 'category' => self::CATEGORY_CUSTOMER,
227 'sample' => 'Doe',
228 'events' => $bookingContextEvents,
229 ],
230 'customer_email' => [
231 'key' => 'customer_email',
232 'label' => 'Customer Email',
233 'description' => 'Customer email address.',
234 'category' => self::CATEGORY_CUSTOMER,
235 'sample' => 'john.doe@example.com',
236 'events' => array_merge(
237 $bookingContextEvents,
238 $enquiryContextEvents,
239 [
240 self::EVENT_ACCOUNT_EMAIL_VERIFICATION,
241 self::EVENT_BOOKING_ABANDONED_RECOVERY,
242 ]
243 ),
244 ],
245 'customer_phone' => [
246 'key' => 'customer_phone',
247 'label' => 'Customer Phone',
248 'description' => 'Customer phone number.',
249 'category' => self::CATEGORY_CUSTOMER,
250 'sample' => '+1 234 567 8900',
251 'events' => array_merge($bookingContextEvents, $enquiryContextEvents),
252 ],
253
254 // ---------------------------------------------------------
255 // Booking — core fields from variablesFromBooking + the rich
256 // tags appended by BookingEmailRichMergeTags::forBooking.
257 // ---------------------------------------------------------
258 'booking_reference' => [
259 'key' => 'booking_reference',
260 'label' => 'Booking Reference',
261 'description' => 'Customer-visible booking code (e.g. YTR-12345).',
262 'category' => self::CATEGORY_BOOKING,
263 'sample' => 'YTR-2024-001234',
264 'events' => array_merge(
265 $bookingContextEvents,
266 [
267 self::EVENT_CONSENT_REQUESTED,
268 self::EVENT_BOOKING_ABANDONED_RECOVERY,
269 ]
270 ),
271 ],
272 'booking_id' => [
273 'key' => 'booking_id',
274 'label' => 'Booking ID',
275 'description' => 'Internal numeric booking identifier.',
276 'category' => self::CATEGORY_BOOKING,
277 'sample' => '1234',
278 'events' => $bookingContextEvents,
279 ],
280 'booking_url' => [
281 'key' => 'booking_url',
282 'label' => 'Booking URL',
283 'description' => 'Link to view the booking in My Account.',
284 'category' => self::CATEGORY_BOOKING,
285 'sample' => $homeUrl . 'my-account/bookings/1234',
286 'events' => $bookingContextEvents,
287 ],
288 'booking_status' => [
289 'key' => 'booking_status',
290 'label' => 'Booking Status',
291 'description' => 'pending / confirmed / cancelled / completed.',
292 'category' => self::CATEGORY_BOOKING,
293 'sample' => 'confirmed',
294 'events' => $bookingContextEvents,
295 ],
296 'payment_status' => [
297 'key' => 'payment_status',
298 'label' => 'Payment Status',
299 'description' => 'unpaid / partial / paid / refunded.',
300 'category' => self::CATEGORY_BOOKING,
301 'sample' => 'paid',
302 'events' => $bookingContextEvents,
303 ],
304 'trip_name' => [
305 'key' => 'trip_name',
306 'label' => 'Trip Name',
307 'description' => 'Title of the trip the booking / enquiry is for.',
308 'category' => self::CATEGORY_BOOKING,
309 'sample' => 'Amazing Mountain Adventure',
310 'events' => array_merge(
311 $bookingContextEvents,
312 $enquiryContextEvents,
313 [
314 self::EVENT_CONSENT_REQUESTED,
315 self::EVENT_BOOKING_ABANDONED_RECOVERY,
316 ]
317 ),
318 ],
319 'trip_url' => [
320 'key' => 'trip_url',
321 'label' => 'Trip URL',
322 'description' => 'Public link to the trip detail page.',
323 'category' => self::CATEGORY_BOOKING,
324 'sample' => $homeUrl . 'trips/amazing-mountain-adventure',
325 'events' => array_merge($bookingContextEvents, $enquiryContextEvents),
326 ],
327 'travel_date' => [
328 'key' => 'travel_date',
329 'label' => 'Travel Date',
330 'description' => 'Departure date formatted per site settings.',
331 'category' => self::CATEGORY_BOOKING,
332 'sample' => $sampleTravelDate,
333 'events' => array_merge($bookingContextEvents, [self::EVENT_CONSENT_REQUESTED]),
334 ],
335 'travelers_count' => [
336 'key' => 'travelers_count',
337 'label' => 'Travelers Count',
338 'description' => 'Number of travelers on the booking.',
339 'category' => self::CATEGORY_BOOKING,
340 'sample' => '4',
341 'events' => $bookingContextEvents,
342 ],
343 'travelers_list' => [
344 'key' => 'travelers_list',
345 'label' => 'Travelers List (plain)',
346 'description' => 'Plain-text list of traveler names.',
347 'category' => self::CATEGORY_BOOKING,
348 'sample' => "John Doe\nJane Doe",
349 'events' => $bookingContextEvents,
350 ],
351 'travelers_list_html' => [
352 'key' => 'travelers_list_html',
353 'label' => 'Travelers List (HTML)',
354 'description' => 'HTML-formatted list of traveler names.',
355 'category' => self::CATEGORY_BOOKING,
356 'sample' => '<ul><li>John Doe</li><li>Jane Doe</li></ul>',
357 'events' => $bookingContextEvents,
358 ],
359 'traveler_custom_fields_html' => [
360 'key' => 'traveler_custom_fields_html',
361 'label' => 'Traveler Custom Fields (HTML)',
362 'description' => 'Dynamic Form Field answers per traveler, rendered as HTML.',
363 'category' => self::CATEGORY_BOOKING,
364 'sample' => '',
365 'events' => $bookingContextEvents,
366 ],
367 'booking_custom_fields_html' => [
368 'key' => 'booking_custom_fields_html',
369 'label' => 'Booking Custom Fields (HTML)',
370 'description' => 'Booking-level Dynamic Form Field answers as HTML.',
371 'category' => self::CATEGORY_BOOKING,
372 'sample' => '',
373 'events' => $bookingContextEvents,
374 ],
375 'special_requests' => [
376 'key' => 'special_requests',
377 'label' => 'Special Requests (plain)',
378 'description' => 'Customer-entered special requests text.',
379 'category' => self::CATEGORY_BOOKING,
380 'sample' => 'Vegetarian meals please.',
381 'events' => $bookingContextEvents,
382 ],
383 'special_requests_html' => [
384 'key' => 'special_requests_html',
385 'label' => 'Special Requests (HTML)',
386 'description' => 'Special requests with line breaks preserved.',
387 'category' => self::CATEGORY_BOOKING,
388 'sample' => 'Vegetarian meals please.',
389 'events' => $bookingContextEvents,
390 ],
391 'cancellation_reason' => [
392 'key' => 'cancellation_reason',
393 'label' => 'Cancellation Reason',
394 'description' => 'Reason recorded when the booking was cancelled.',
395 'category' => self::CATEGORY_BOOKING,
396 'sample' => 'Change of plans',
397 'events' => [self::EVENT_BOOKING_CANCELLED],
398 ],
399 'completion_date' => [
400 'key' => 'completion_date',
401 'label' => 'Completion Date',
402 'description' => 'Date the trip / booking was marked completed.',
403 'category' => self::CATEGORY_BOOKING,
404 'sample' => function_exists('date_i18n') ? date_i18n($dateFormat) : date($dateFormat),
405 'events' => [self::EVENT_BOOKING_COMPLETED, self::EVENT_REVIEW_REQUEST],
406 ],
407 'expiry_policy_note' => [
408 'key' => 'expiry_policy_note',
409 'label' => 'Expiry Policy Note',
410 'description' => 'Message shown when a booking auto-expires for non-payment.',
411 'category' => self::CATEGORY_BOOKING,
412 'sample' => 'This booking was automatically cancelled after the payment window expired.',
413 'events' => [self::EVENT_BOOKING_EXPIRED],
414 ],
415
416 // ---------------------------------------------------------
417 // Payment
418 // ---------------------------------------------------------
419 'total_amount_formatted' => [
420 'key' => 'total_amount_formatted',
421 'label' => 'Total Amount (formatted)',
422 'description' => 'Total cost with currency symbol — preferred over total_amount.',
423 'category' => self::CATEGORY_PAYMENT,
424 'sample' => '$2,500.00',
425 'events' => $bookingContextEvents,
426 ],
427 // Alias of `total_amount_formatted` — exposed for templates
428 // that use the short name. Renders identically (formatted
429 // with currency) so admins can pick whichever reads
430 // naturally in their copy.
431 'total_amount' => [
432 'key' => 'total_amount',
433 'label' => 'Total Amount',
434 'description' => 'Total cost with currency symbol (alias of total_amount_formatted).',
435 'category' => self::CATEGORY_PAYMENT,
436 'sample' => '$2,500.00',
437 'events' => $bookingContextEvents,
438 ],
439 'amount_due_formatted' => [
440 'key' => 'amount_due_formatted',
441 'label' => 'Amount Due (formatted)',
442 'description' => 'Remaining balance with currency symbol.',
443 'category' => self::CATEGORY_PAYMENT,
444 'sample' => '$2,000.00',
445 'events' => $bookingContextEvents,
446 ],
447 // Aliases for the remaining balance — same value, different
448 // common spellings. `{{balance_due}}` is the most common
449 // legacy spelling in customer-edited templates; `amount_due`
450 // (unformatted-looking name but actually formatted with
451 // currency) matches the booking record column.
452 'balance_due' => [
453 'key' => 'balance_due',
454 'label' => 'Balance Due',
455 'description' => 'Remaining balance with currency symbol (alias of amount_due_formatted).',
456 'category' => self::CATEGORY_PAYMENT,
457 'sample' => '$2,000.00',
458 'events' => $bookingContextEvents,
459 ],
460 'amount_due' => [
461 'key' => 'amount_due',
462 'label' => 'Amount Due',
463 'description' => 'Remaining balance with currency symbol (alias of amount_due_formatted).',
464 'category' => self::CATEGORY_PAYMENT,
465 'sample' => '$2,000.00',
466 'events' => $bookingContextEvents,
467 ],
468 'amount_paid' => [
469 'key' => 'amount_paid',
470 'label' => 'Amount Paid',
471 'description' => 'Total paid so far with currency symbol.',
472 'category' => self::CATEGORY_PAYMENT,
473 'sample' => '$500.00',
474 'events' => $bookingContextEvents,
475 ],
476 'amount_paid_formatted' => [
477 'key' => 'amount_paid_formatted',
478 'label' => 'Amount Paid (formatted)',
479 'description' => 'Total paid so far with currency symbol (alias of amount_paid).',
480 'category' => self::CATEGORY_PAYMENT,
481 'sample' => '$500.00',
482 'events' => $bookingContextEvents,
483 ],
484 'currency' => [
485 'key' => 'currency',
486 'label' => 'Currency',
487 'description' => 'ISO 4217 currency code (e.g. USD).',
488 'category' => self::CATEGORY_PAYMENT,
489 'sample' => 'USD',
490 'events' => $bookingContextEvents,
491 ],
492 'payment_amount_formatted' => [
493 'key' => 'payment_amount_formatted',
494 'label' => 'Payment Amount (formatted)',
495 'description' => 'Amount of the specific payment with currency.',
496 'category' => self::CATEGORY_PAYMENT,
497 'sample' => '$500.00',
498 'events' => [self::EVENT_PAYMENT_RECEIVED, self::EVENT_PAYMENT_PARTIAL_RECEIVED, self::EVENT_PAYMENT_REMINDER],
499 ],
500 'payment_method' => [
501 'key' => 'payment_method',
502 'label' => 'Payment Method',
503 'description' => 'Instrument label (e.g. Card, Bank Transfer).',
504 'category' => self::CATEGORY_PAYMENT,
505 'sample' => 'Credit Card',
506 'events' => [self::EVENT_PAYMENT_RECEIVED, self::EVENT_PAYMENT_PARTIAL_RECEIVED, self::EVENT_PAYMENT_REMINDER],
507 ],
508 'transaction_id' => [
509 'key' => 'transaction_id',
510 'label' => 'Transaction ID',
511 'description' => 'Gateway transaction reference for the payment.',
512 'category' => self::CATEGORY_PAYMENT,
513 'sample' => 'ch_3O8XYZabc123',
514 'events' => [self::EVENT_PAYMENT_RECEIVED],
515 ],
516 'payment_gateway' => [
517 'key' => 'payment_gateway',
518 'label' => 'Payment Gateway (slug)',
519 'description' => 'Internal gateway slug — stripe / paypal / razorpay etc.',
520 'category' => self::CATEGORY_PAYMENT,
521 'sample' => 'stripe',
522 'events' => $bookingContextEvents,
523 ],
524 'payment_gateway_label' => [
525 'key' => 'payment_gateway_label',
526 'label' => 'Payment Gateway (label)',
527 'description' => 'Human-readable gateway name — Stripe, PayPal etc.',
528 'category' => self::CATEGORY_PAYMENT,
529 'sample' => 'Stripe',
530 'events' => $bookingContextEvents,
531 ],
532 'payment_schedule' => [
533 'key' => 'payment_schedule',
534 'label' => 'Payment Schedule (slug)',
535 'description' => 'full / deposit / partial — raw value.',
536 'category' => self::CATEGORY_PAYMENT,
537 'sample' => 'deposit',
538 'events' => $bookingContextEvents,
539 ],
540 'payment_schedule_label' => [
541 'key' => 'payment_schedule_label',
542 'label' => 'Payment Schedule (label)',
543 'description' => 'Humanised schedule (e.g. Deposit, Full Payment).',
544 'category' => self::CATEGORY_PAYMENT,
545 'sample' => 'Deposit',
546 'events' => $bookingContextEvents,
547 ],
548 'due_date' => [
549 'key' => 'due_date',
550 'label' => 'Due Date',
551 'description' => 'Payment due date for reminders.',
552 'category' => self::CATEGORY_PAYMENT,
553 'sample' => $sampleDueDate,
554 'events' => [self::EVENT_PAYMENT_REMINDER],
555 ],
556
557 // ---------------------------------------------------------
558 // Scheduled payments (installments)
559 // ---------------------------------------------------------
560 'scheduled_amount_formatted' => [
561 'key' => 'scheduled_amount_formatted',
562 'label' => 'Scheduled Amount (formatted)',
563 'description' => 'Amount of the upcoming scheduled charge with currency.',
564 'category' => self::CATEGORY_SCHEDULED_PAYMENT,
565 'sample' => '$750.00',
566 'events' => $scheduledPaymentEvents,
567 ],
568 'scheduled_date_formatted' => [
569 'key' => 'scheduled_date_formatted',
570 'label' => 'Scheduled Date (formatted)',
571 'description' => 'When the next scheduled charge will run.',
572 'category' => self::CATEGORY_SCHEDULED_PAYMENT,
573 'sample' => $sampleDueDate,
574 'events' => $scheduledPaymentEvents,
575 ],
576 'payment_type_label' => [
577 'key' => 'payment_type_label',
578 'label' => 'Payment Type Label',
579 'description' => 'Humanised type (Deposit, Final, Installment 2 of 4 ...).',
580 'category' => self::CATEGORY_SCHEDULED_PAYMENT,
581 'sample' => 'Installment 2 of 4',
582 'events' => $scheduledPaymentEvents,
583 ],
584 'balance_after_formatted' => [
585 'key' => 'balance_after_formatted',
586 'label' => 'Balance After (formatted)',
587 'description' => 'Balance remaining after this charge succeeds.',
588 'category' => self::CATEGORY_SCHEDULED_PAYMENT,
589 'sample' => '$1,250.00',
590 'events' => [self::EVENT_SCHEDULED_PAYMENT_SUCCEEDED],
591 ],
592 'failure_reason' => [
593 'key' => 'failure_reason',
594 'label' => 'Failure Reason',
595 'description' => 'Provided by the gateway when a scheduled charge fails.',
596 'category' => self::CATEGORY_SCHEDULED_PAYMENT,
597 'sample' => 'Card declined',
598 'events' => [self::EVENT_SCHEDULED_PAYMENT_FAILED],
599 ],
600 'failure_intro_html' => [
601 'key' => 'failure_intro_html',
602 'label' => 'Failure Intro (HTML)',
603 'description' => 'Intro block for the payment-failure email body.',
604 'category' => self::CATEGORY_SCHEDULED_PAYMENT,
605 'sample' => '<p>We were unable to process your scheduled payment.</p>',
606 'events' => [self::EVENT_SCHEDULED_PAYMENT_FAILED],
607 ],
608 'failure_followup_html' => [
609 'key' => 'failure_followup_html',
610 'label' => 'Failure Follow-up (HTML)',
611 'description' => 'Closing block prompting the customer to update payment.',
612 'category' => self::CATEGORY_SCHEDULED_PAYMENT,
613 'sample' => '<p>Please update your payment method to avoid cancellation.</p>',
614 'events' => [self::EVENT_SCHEDULED_PAYMENT_FAILED],
615 ],
616
617 // ---------------------------------------------------------
618 // Reminder (trip & booking reminders)
619 // ---------------------------------------------------------
620 'days_until_trip' => [
621 'key' => 'days_until_trip',
622 'label' => 'Days Until Trip',
623 'description' => 'Days remaining until departure.',
624 'category' => self::CATEGORY_REMINDER,
625 'sample' => '30',
626 'events' => [self::EVENT_REMINDER_TRIP],
627 ],
628 'reminder_days' => [
629 'key' => 'reminder_days',
630 'label' => 'Reminder Days',
631 'description' => 'Configured number of days before the trip when the reminder fires.',
632 'category' => self::CATEGORY_REMINDER,
633 'sample' => '3',
634 'events' => [self::EVENT_REMINDER_TRIP],
635 ],
636 'reminder_extra_html' => [
637 'key' => 'reminder_extra_html',
638 'label' => 'Reminder Extra (HTML)',
639 'description' => 'Optional extra block appended to reminder emails (packing list, etc.).',
640 'category' => self::CATEGORY_REMINDER,
641 'sample' => '<p>Don\'t forget your passport and travel insurance.</p>',
642 'events' => [self::EVENT_REMINDER_TRIP],
643 ],
644 'review_url' => [
645 'key' => 'review_url',
646 'label' => 'Review URL',
647 'description' => 'Public link the customer opens to leave a review.',
648 'category' => self::CATEGORY_REMINDER,
649 'sample' => $homeUrl . 'trips/amazing-mountain-adventure#reviews',
650 'events' => [self::EVENT_REVIEW_REQUEST],
651 ],
652
653 // ---------------------------------------------------------
654 // Enquiry
655 // ---------------------------------------------------------
656 'enquiry_id' => [
657 'key' => 'enquiry_id',
658 'label' => 'Enquiry ID',
659 'description' => 'Internal numeric enquiry identifier.',
660 'category' => self::CATEGORY_ENQUIRY,
661 'sample' => '4567',
662 'events' => $enquiryContextEvents,
663 ],
664 'enquiry_date' => [
665 'key' => 'enquiry_date',
666 'label' => 'Enquiry Date',
667 'description' => 'When the enquiry was submitted.',
668 'category' => self::CATEGORY_ENQUIRY,
669 'sample' => function_exists('date_i18n') ? date_i18n($dateFormat) : date($dateFormat),
670 'events' => $enquiryContextEvents,
671 ],
672 'subject' => [
673 'key' => 'subject',
674 'label' => 'Subject',
675 'description' => 'Subject line the customer provided.',
676 'category' => self::CATEGORY_ENQUIRY,
677 'sample' => 'Question about Amazing Mountain Adventure',
678 'events' => $enquiryContextEvents,
679 ],
680 'message' => [
681 'key' => 'message',
682 'label' => 'Message',
683 'description' => 'Customer message body (sanitised, line breaks preserved).',
684 'category' => self::CATEGORY_ENQUIRY,
685 'sample' => 'I would like to know more about this trip. What is included in the package?',
686 'events' => $enquiryContextEvents,
687 ],
688 'original_message' => [
689 'key' => 'original_message',
690 'label' => 'Original Message',
691 'description' => 'First message in the enquiry thread (no line-break escaping).',
692 'category' => self::CATEGORY_ENQUIRY,
693 'sample' => 'I would like to know more about this trip.',
694 'events' => $enquiryContextEvents,
695 ],
696 'response' => [
697 'key' => 'response',
698 'label' => 'Response',
699 'description' => "Operator's typed reply (alias of response_message).",
700 'category' => self::CATEGORY_ENQUIRY,
701 'sample' => 'Thank you for your interest! The package includes accommodation, meals, and guided tours.',
702 'events' => [self::EVENT_ENQUIRY_RESPONDED],
703 ],
704 'response_message' => [
705 'key' => 'response_message',
706 'label' => 'Response Message',
707 'description' => "Operator's typed reply.",
708 'category' => self::CATEGORY_ENQUIRY,
709 'sample' => 'Thank you for your interest! The package includes accommodation, meals, and guided tours.',
710 'events' => [self::EVENT_ENQUIRY_RESPONDED],
711 ],
712 'response_date' => [
713 'key' => 'response_date',
714 'label' => 'Response Date',
715 'description' => 'When the reply was sent.',
716 'category' => self::CATEGORY_ENQUIRY,
717 'sample' => function_exists('date_i18n') ? date_i18n($dateFormat) : date($dateFormat),
718 'events' => [self::EVENT_ENQUIRY_RESPONDED],
719 ],
720
721 // ---------------------------------------------------------
722 // Trip consent (Pro)
723 // ---------------------------------------------------------
724 'recipient_name' => [
725 'key' => 'recipient_name',
726 'label' => 'Recipient Name',
727 'description' => 'Traveler receiving the consent email.',
728 'category' => self::CATEGORY_TRIP_CONSENT,
729 'sample' => 'Alex Traveler',
730 'events' => [self::EVENT_CONSENT_REQUESTED],
731 ],
732 'form_name' => [
733 'key' => 'form_name',
734 'label' => 'Consent Form Name',
735 'description' => 'Title of the consent form.',
736 'category' => self::CATEGORY_TRIP_CONSENT,
737 'sample' => 'Trip liability & release',
738 'events' => [self::EVENT_CONSENT_REQUESTED],
739 ],
740 'consent_link' => [
741 'key' => 'consent_link',
742 'label' => 'Consent Link',
743 'description' => 'URL to open and sign the form.',
744 'category' => self::CATEGORY_TRIP_CONSENT,
745 'sample' => $homeUrl . 'trip-consent/preview-token/',
746 'events' => [self::EVENT_CONSENT_REQUESTED],
747 ],
748 'consent_test_notice_html' => [
749 'key' => 'consent_test_notice_html',
750 'label' => 'Test Notice (HTML)',
751 'description' => 'Shown only on admin test sends.',
752 'category' => self::CATEGORY_TRIP_CONSENT,
753 'sample' => '',
754 'events' => [self::EVENT_CONSENT_REQUESTED],
755 ],
756
757 // ---------------------------------------------------------
758 // Account verification
759 // ---------------------------------------------------------
760 'verification_link' => [
761 'key' => 'verification_link',
762 'label' => 'Verification Link',
763 'description' => 'Magic link the customer opens to verify their email.',
764 'category' => self::CATEGORY_ACCOUNT,
765 'sample' => $verificationSampleLink,
766 'events' => [self::EVENT_ACCOUNT_EMAIL_VERIFICATION],
767 ],
768 'intro_paragraph' => [
769 'key' => 'intro_paragraph',
770 'label' => 'Intro Paragraph',
771 'description' => 'Opening sentence (registration / resend variant).',
772 'category' => self::CATEGORY_ACCOUNT,
773 'sample' => 'Thank you for registering. Click the button in this email to verify your address.',
774 'events' => [self::EVENT_ACCOUNT_EMAIL_VERIFICATION],
775 ],
776 'footer_note' => [
777 'key' => 'footer_note',
778 'label' => 'Footer Note',
779 'description' => 'Disclaimer for unintended recipients.',
780 'category' => self::CATEGORY_ACCOUNT,
781 'sample' => 'If you did not create an account, you can ignore this email.',
782 'events' => [self::EVENT_ACCOUNT_EMAIL_VERIFICATION],
783 ],
784 'expiry_notice_html' => [
785 'key' => 'expiry_notice_html',
786 'label' => 'Expiry Notice (HTML)',
787 'description' => 'Link-expiry messaging block (consent / verification emails).',
788 'category' => self::CATEGORY_ACCOUNT,
789 'sample' => '<strong>Security note:</strong> This link expires in 24 hours.',
790 'events' => [
791 self::EVENT_ACCOUNT_EMAIL_VERIFICATION,
792 self::EVENT_CONSENT_REQUESTED,
793 ],
794 ],
795
796 // ---------------------------------------------------------
797 // Abandoned booking recovery (Pro)
798 // ---------------------------------------------------------
799 'recovery_link' => [
800 'key' => 'recovery_link',
801 'label' => 'Recovery Link',
802 'description' => 'Resume the abandoned checkout from the customer email.',
803 'category' => self::CATEGORY_ABANDONED_RECOVERY,
804 'sample' => $homeUrl . 'checkout/recover/sample-token',
805 'events' => [self::EVENT_BOOKING_ABANDONED_RECOVERY],
806 ],
807 'recovery_reminder_label' => [
808 'key' => 'recovery_reminder_label',
809 'label' => 'Reminder Label',
810 'description' => 'Sequence-stage label (First, Second, Final).',
811 'category' => self::CATEGORY_ABANDONED_RECOVERY,
812 'sample' => 'First reminder',
813 'events' => [self::EVENT_BOOKING_ABANDONED_RECOVERY],
814 ],
815 'recovery_intro_html' => [
816 'key' => 'recovery_intro_html',
817 'label' => 'Intro Paragraph (HTML)',
818 'description' => 'Lead paragraph specific to each recovery email.',
819 'category' => self::CATEGORY_ABANDONED_RECOVERY,
820 'sample' => '<p>You\'re just one step away from booking your dream trip.</p>',
821 'events' => [self::EVENT_BOOKING_ABANDONED_RECOVERY],
822 ],
823 ];
824
825 // Dynamically expose every enabled Contact/Emergency booking-form field —
826 // including custom fields an operator adds — so they're discoverable and
827 // usable as email variables. Values are resolved at send time by
828 // BookingEmailRichMergeTags (contact_/emergency_ prefixes).
829 $catalog = array_merge($catalog, self::bookingFormFieldDefinitions($bookingContextEvents));
830
831 /**
832 * Filter the email merge-tag catalogue so integrations (Channel
833 * Manager, WhatsApp, custom modules) can append their own tags.
834 *
835 * @param array $catalog The tag definitions keyed by tag key.
836 */
837 return function_exists('apply_filters')
838 ? (array) apply_filters('yatra_email_merge_tag_definitions', $catalog)
839 : $catalog;
840 }
841
842 /**
843 * Build merge-tag definitions from the live booking-form config so dynamic
844 * (and custom) Contact/Emergency fields surface in the email editor. Only
845 * enabled fields in enabled sections are included; existing canonical tags
846 * are never overwritten.
847 *
848 * @param array<int,string> $events
849 * @return array<string, array<string,mixed>>
850 */
851 private static function bookingFormFieldDefinitions(array $events): array
852 {
853 // Custom/dynamic booking-form fields are a Pro-module feature. When the
854 // Dynamic Form Field module is off the form is fixed, so we don't surface
855 // these extra tags — free installs keep their existing tag list unchanged.
856 if (!function_exists('apply_filters') || !apply_filters('yatra_dynamic_form_field_enabled', false)) {
857 return [];
858 }
859 if (!function_exists('yatra_get_booking_form_config')) {
860 return [];
861 }
862
863 $config = yatra_get_booking_form_config();
864 if (!is_array($config)) {
865 return [];
866 }
867
868 $sections = [
869 'contact_form' => ['prefix' => 'contact_', 'category' => self::CATEGORY_CUSTOMER],
870 'emergency_contact_form' => ['prefix' => 'emergency_', 'category' => self::CATEGORY_BOOKING],
871 ];
872
873 $defs = [];
874 foreach ($sections as $sectionKey => $meta) {
875 $section = $config[$sectionKey] ?? null;
876 if (!is_array($section) || (isset($section['enabled']) && !$section['enabled'])) {
877 continue;
878 }
879 foreach (($section['fields'] ?? []) as $field) {
880 if (empty($field['enabled']) || empty($field['id'])) {
881 continue;
882 }
883 // Text blocks are display-only content, not inputs — they hold no
884 // booking value, so they must not become email merge tags.
885 if (($field['type'] ?? '') === 'text_block') {
886 continue;
887 }
888 $id = sanitize_key((string) $field['id']);
889 if ($id === '') {
890 continue;
891 }
892 $tagKey = $meta['prefix'] . $id;
893 if (isset($defs[$tagKey])) {
894 continue;
895 }
896 $label = (string) ($field['label'] ?? ucwords(str_replace('_', ' ', $id)));
897 $defs[$tagKey] = [
898 'key' => $tagKey,
899 'label' => $label,
900 /* translators: %s: booking form field label. */
901 'description' => sprintf(__('Booking form field: %s', 'yatra'), $label),
902 'category' => $meta['category'],
903 'sample' => '',
904 'events' => $events,
905 ];
906 }
907 }
908
909 return $defs;
910 }
911
912 /**
913 * Return tag definitions grouped by category, optionally filtered to
914 * those that resolve for the given automation event.
915 *
916 * When $eventKey is empty or unknown, returns the full catalogue so
917 * the operator never sees an empty sidebar.
918 *
919 * @return array<string, list<array{key:string,label:string,description:string}>>
920 */
921 public static function groupedForEvent(string $eventKey = ''): array
922 {
923 $eventKey = trim($eventKey);
924 $defs = self::definitions();
925
926 if ($eventKey !== '' && !self::eventHasAnyDefinitions($eventKey, $defs)) {
927 // Unknown event — return the full registry rather than nothing.
928 $eventKey = '';
929 }
930
931 $grouped = [];
932 foreach ($defs as $def) {
933 if ($eventKey !== '' && !self::tagAppliesToEvent($def, $eventKey)) {
934 continue;
935 }
936 $cat = $def['category'] ?? self::CATEGORY_GENERAL;
937 $grouped[$cat] = $grouped[$cat] ?? [];
938 $grouped[$cat][] = [
939 'key' => $def['key'],
940 'label' => $def['label'],
941 'description' => $def['description'],
942 ];
943 }
944
945 return $grouped;
946 }
947
948 /**
949 * Flat list of tag keys that resolve for the given event. Used by
950 * EmailAutomationEvents::definitions to derive each event's variable
951 * whitelist instead of hand-maintaining a parallel list.
952 *
953 * @return list<string>
954 */
955 public static function keysForEvent(string $eventKey): array
956 {
957 $eventKey = trim($eventKey);
958 if ($eventKey === '') {
959 return [];
960 }
961 $keys = [];
962 foreach (self::definitions() as $def) {
963 if (self::tagAppliesToEvent($def, $eventKey)) {
964 $keys[] = $def['key'];
965 }
966 }
967 return $keys;
968 }
969
970 /**
971 * Sample variable map for the in-editor preview pipeline. Includes
972 * every tag with a non-empty sample value — operators see realistic
973 * placeholders rather than the literal {{tag}} string.
974 *
975 * @return array<string, string>
976 */
977 public static function samples(): array
978 {
979 $out = [];
980 foreach (self::definitions() as $def) {
981 $out[$def['key']] = (string) ($def['sample'] ?? '');
982 }
983 return $out;
984 }
985
986 /**
987 * @param array{events:list<string>|string} $def
988 */
989 private static function tagAppliesToEvent(array $def, string $eventKey): bool
990 {
991 $events = $def['events'] ?? [];
992 if ($events === '*' || $events === ['*']) {
993 return true;
994 }
995 return is_array($events) && in_array($eventKey, $events, true);
996 }
997
998 /**
999 * @param array<string, array{events:list<string>|string}> $defs
1000 */
1001 private static function eventHasAnyDefinitions(string $eventKey, array $defs): bool
1002 {
1003 foreach ($defs as $def) {
1004 $events = $def['events'] ?? [];
1005 if ($events === '*' || $events === ['*']) {
1006 continue; // General tags don't qualify the event as "known".
1007 }
1008 if (is_array($events) && in_array($eventKey, $events, true)) {
1009 return true;
1010 }
1011 }
1012 return false;
1013 }
1014 }
1015