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

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