| @@ -44,8 +44,10 @@ | ||
| 44 | 44 | public const EVENT_BOOKING_CANCELLED = 'booking.cancelled'; |
| 45 | 45 | public const EVENT_BOOKING_COMPLETED = 'booking.completed'; |
| 46 | 46 | public const EVENT_BOOKING_EXPIRED = 'booking.expired'; |
| 47 | 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'; | |
| 48 | 50 | public const EVENT_PAYMENT_REMINDER = 'payment.reminder'; |
| 49 | 51 | public const EVENT_REMINDER_TRIP = 'reminder.trip'; |
| 50 | 52 | public const EVENT_ENQUIRY_CREATED = 'enquiry.created'; |
| 51 | 53 | public const EVENT_ENQUIRY_RESPONDED = 'enquiry.responded'; |
| @@ -51,8 +53,14 @@ | ||
| 51 | 53 | public const EVENT_ENQUIRY_RESPONDED = 'enquiry.responded'; |
| 52 | 54 | public const EVENT_REVIEW_REQUEST = 'marketing.review_request'; |
| 53 | 55 | public const EVENT_CONSENT_REQUESTED = 'consent.requested'; |
| 54 | 56 | public const EVENT_ACCOUNT_EMAIL_VERIFICATION = 'account.email_verification'; |
| 57 | + // Account email change (CustomerService): the request goes to the NEW | |
| 58 | + // address with a confirmation link; the "changed" security notice goes to | |
| 59 | + // the OLD address. Pro seeds templates on these keys, so they must be | |
| 60 | + // registered here or the template editor rejects them ("Invalid event key"). | |
| 61 | + public const EVENT_ACCOUNT_EMAIL_CHANGE_REQUEST = 'account.email_change_request'; | |
| 62 | + public const EVENT_ACCOUNT_EMAIL_CHANGED = 'account.email_changed'; | |
| 55 | 63 | public const EVENT_SCHEDULED_PAYMENT_REMINDER = 'scheduled.payment.reminder'; |
| 56 | 64 | public const EVENT_SCHEDULED_PAYMENT_SUCCEEDED = 'scheduled.payment.succeeded'; |
| 57 | 65 | public const EVENT_SCHEDULED_PAYMENT_FAILED = 'scheduled.payment.failed'; |
| 58 | 66 | public const EVENT_BOOKING_ABANDONED_RECOVERY = 'booking.abandoned_recovery'; |
| @@ -61,8 +69,15 @@ | ||
| 61 | 69 | * Every event that resolves a `variablesFromBooking()`-derived |
| 62 | 70 | * booking context. Booking-context tags inherit this list so |
| 63 | 71 | * the per-event whitelist stays in sync as events evolve. |
| 64 | 72 | */ |
| 73 | + /** Customer-account emails: share the customer + intro/footer tags. */ | |
| 74 | + private const ACCOUNT_CONTEXT_EVENTS = [ | |
| 75 | + self::EVENT_ACCOUNT_EMAIL_VERIFICATION, | |
| 76 | + self::EVENT_ACCOUNT_EMAIL_CHANGE_REQUEST, | |
| 77 | + self::EVENT_ACCOUNT_EMAIL_CHANGED, | |
| 78 | + ]; | |
| 79 | + | |
| 65 | 80 | private const BOOKING_CONTEXT_EVENTS = [ |
| 66 | 81 | self::EVENT_BOOKING_CREATED, |
| 67 | 82 | self::EVENT_BOOKING_CONFIRMED, |
| 68 | 83 | self::EVENT_BOOKING_CANCELLED, |
| @@ -68,8 +83,9 @@ | ||
| 68 | 83 | self::EVENT_BOOKING_CANCELLED, |
| 69 | 84 | self::EVENT_BOOKING_COMPLETED, |
| 70 | 85 | self::EVENT_BOOKING_EXPIRED, |
| 71 | 86 | self::EVENT_PAYMENT_RECEIVED, |
| 87 | + self::EVENT_PAYMENT_PARTIAL_RECEIVED, | |
| 72 | 88 | self::EVENT_PAYMENT_REMINDER, |
| 73 | 89 | self::EVENT_REMINDER_TRIP, |
| 74 | 90 | self::EVENT_REVIEW_REQUEST, |
| 75 | 91 | self::EVENT_SCHEDULED_PAYMENT_REMINDER, |
| @@ -198,12 +214,10 @@ | ||
| 198 | 214 | 'sample' => 'John Doe', |
| 199 | 215 | 'events' => array_merge( |
| 200 | 216 | $bookingContextEvents, |
| 201 | 217 | $enquiryContextEvents, |
| 202 | - [ | |
| 203 | - self::EVENT_ACCOUNT_EMAIL_VERIFICATION, | |
| 204 | - self::EVENT_BOOKING_ABANDONED_RECOVERY, | |
| 205 | - ] | |
| 218 | + self::ACCOUNT_CONTEXT_EVENTS, | |
| 219 | + [self::EVENT_BOOKING_ABANDONED_RECOVERY] | |
| 206 | 220 | ), |
| 207 | 221 | ], |
| 208 | 222 | 'customer_first_name' => [ |
| 209 | 223 | 'key' => 'customer_first_name', |
| @@ -212,9 +226,9 @@ | ||
| 212 | 226 | 'category' => self::CATEGORY_CUSTOMER, |
| 213 | 227 | 'sample' => 'John', |
| 214 | 228 | 'events' => array_merge( |
| 215 | 229 | $bookingContextEvents, |
| 216 | - [self::EVENT_ACCOUNT_EMAIL_VERIFICATION] | |
| 230 | + self::ACCOUNT_CONTEXT_EVENTS | |
| 217 | 231 | ), |
| 218 | 232 | ], |
| 219 | 233 | 'customer_last_name' => [ |
| 220 | 234 | 'key' => 'customer_last_name', |
| @@ -232,12 +246,10 @@ | ||
| 232 | 246 | 'sample' => '[email protected]', |
| 233 | 247 | 'events' => array_merge( |
| 234 | 248 | $bookingContextEvents, |
| 235 | 249 | $enquiryContextEvents, |
| 236 | - [ | |
| 237 | - self::EVENT_ACCOUNT_EMAIL_VERIFICATION, | |
| 238 | - self::EVENT_BOOKING_ABANDONED_RECOVERY, | |
| 239 | - ] | |
| 250 | + self::ACCOUNT_CONTEXT_EVENTS, | |
| 251 | + [self::EVENT_BOOKING_ABANDONED_RECOVERY] | |
| 240 | 252 | ), |
| 241 | 253 | ], |
| 242 | 254 | 'customer_phone' => [ |
| 243 | 255 | 'key' => 'customer_phone', |
| @@ -491,9 +503,9 @@ | ||
| 491 | 503 | 'label' => 'Payment Amount (formatted)', |
| 492 | 504 | 'description' => 'Amount of the specific payment with currency.', |
| 493 | 505 | 'category' => self::CATEGORY_PAYMENT, |
| 494 | 506 | 'sample' => '$500.00', |
| 495 | - 'events' => [self::EVENT_PAYMENT_RECEIVED, self::EVENT_PAYMENT_REMINDER], | |
| 507 | + 'events' => [self::EVENT_PAYMENT_RECEIVED, self::EVENT_PAYMENT_PARTIAL_RECEIVED, self::EVENT_PAYMENT_REMINDER], | |
| 496 | 508 | ], |
| 497 | 509 | 'payment_method' => [ |
| 498 | 510 | 'key' => 'payment_method', |
| 499 | 511 | 'label' => 'Payment Method', |
| @@ -499,9 +511,9 @@ | ||
| 499 | 511 | 'label' => 'Payment Method', |
| 500 | 512 | 'description' => 'Instrument label (e.g. Card, Bank Transfer).', |
| 501 | 513 | 'category' => self::CATEGORY_PAYMENT, |
| 502 | 514 | 'sample' => 'Credit Card', |
| 503 | - 'events' => [self::EVENT_PAYMENT_RECEIVED, self::EVENT_PAYMENT_REMINDER], | |
| 515 | + 'events' => [self::EVENT_PAYMENT_RECEIVED, self::EVENT_PAYMENT_PARTIAL_RECEIVED, self::EVENT_PAYMENT_REMINDER], | |
| 504 | 516 | ], |
| 505 | 517 | 'transaction_id' => [ |
| 506 | 518 | 'key' => 'transaction_id', |
| 507 | 519 | 'label' => 'Transaction ID', |
| @@ -756,20 +768,29 @@ | ||
| 756 | 768 | // --------------------------------------------------------- |
| 757 | 769 | 'verification_link' => [ |
| 758 | 770 | 'key' => 'verification_link', |
| 759 | 771 | 'label' => 'Verification Link', |
| 760 | - 'description' => 'Magic link the customer opens to verify their email.', | |
| 772 | + 'description' => 'Magic link the customer opens to verify their email, or to confirm a requested new address.', | |
| 761 | 773 | 'category' => self::CATEGORY_ACCOUNT, |
| 762 | 774 | 'sample' => $verificationSampleLink, |
| 763 | - 'events' => [self::EVENT_ACCOUNT_EMAIL_VERIFICATION], | |
| 775 | + // Not offered for the "changed" notice — that email carries no link. | |
| 776 | + 'events' => [self::EVENT_ACCOUNT_EMAIL_VERIFICATION, self::EVENT_ACCOUNT_EMAIL_CHANGE_REQUEST], | |
| 764 | 777 | ], |
| 778 | + 'new_email' => [ | |
| 779 | + 'key' => 'new_email', | |
| 780 | + 'label' => 'New Email Address', | |
| 781 | + 'description' => 'The address the customer asked to switch their account to.', | |
| 782 | + 'category' => self::CATEGORY_ACCOUNT, | |
| 783 | + 'sample' => '[email protected]', | |
| 784 | + 'events' => [self::EVENT_ACCOUNT_EMAIL_CHANGE_REQUEST, self::EVENT_ACCOUNT_EMAIL_CHANGED], | |
| 785 | + ], | |
| 765 | 786 | 'intro_paragraph' => [ |
| 766 | 787 | 'key' => 'intro_paragraph', |
| 767 | 788 | 'label' => 'Intro Paragraph', |
| 768 | - 'description' => 'Opening sentence (registration / resend variant).', | |
| 789 | + 'description' => 'Opening sentence, set by the sender for each account email (verification, change request, changed notice).', | |
| 769 | 790 | 'category' => self::CATEGORY_ACCOUNT, |
| 770 | 791 | 'sample' => 'Thank you for registering. Click the button in this email to verify your address.', |
| 771 | - 'events' => [self::EVENT_ACCOUNT_EMAIL_VERIFICATION], | |
| 792 | + 'events' => self::ACCOUNT_CONTEXT_EVENTS, | |
| 772 | 793 | ], |
| 773 | 794 | 'footer_note' => [ |
| 774 | 795 | 'key' => 'footer_note', |
| 775 | 796 | 'label' => 'Footer Note', |
| @@ -775,9 +796,9 @@ | ||
| 775 | 796 | 'label' => 'Footer Note', |
| 776 | 797 | 'description' => 'Disclaimer for unintended recipients.', |
| 777 | 798 | 'category' => self::CATEGORY_ACCOUNT, |
| 778 | 799 | 'sample' => 'If you did not create an account, you can ignore this email.', |
| 779 | - 'events' => [self::EVENT_ACCOUNT_EMAIL_VERIFICATION], | |
| 800 | + 'events' => self::ACCOUNT_CONTEXT_EVENTS, | |
| 780 | 801 | ], |
| 781 | 802 | 'expiry_notice_html' => [ |
| 782 | 803 | 'key' => 'expiry_notice_html', |
| 783 | 804 | 'label' => 'Expiry Notice (HTML)', |
| @@ -818,8 +839,14 @@ | ||
| 818 | 839 | 'events' => [self::EVENT_BOOKING_ABANDONED_RECOVERY], |
| 819 | 840 | ], |
| 820 | 841 | ]; |
| 821 | 842 | |
| 843 | + // Dynamically expose every enabled Contact/Emergency booking-form field — | |
| 844 | + // including custom fields an operator adds — so they're discoverable and | |
| 845 | + // usable as email variables. Values are resolved at send time by | |
| 846 | + // BookingEmailRichMergeTags (contact_/emergency_ prefixes). | |
| 847 | + $catalog = array_merge($catalog, self::bookingFormFieldDefinitions($bookingContextEvents)); | |
| 848 | + | |
| 822 | 849 | /** |
| 823 | 850 | * Filter the email merge-tag catalogue so integrations (Channel |
| 824 | 851 | * Manager, WhatsApp, custom modules) can append their own tags. |
| 825 | 852 | * |
| @@ -827,8 +854,87 @@ | ||
| 827 | 854 | */ |
| 828 | 855 | return function_exists('apply_filters') |
| 829 | 856 | ? (array) apply_filters('yatra_email_merge_tag_definitions', $catalog) |
| 830 | 857 | : $catalog; |
| 858 | + } | |
| 859 | + | |
| 860 | + /** | |
| 861 | + * Build merge-tag definitions from the live booking-form config so dynamic | |
| 862 | + * (and custom) Contact/Emergency fields surface in the email editor. Only | |
| 863 | + * enabled fields in enabled sections are included; existing canonical tags | |
| 864 | + * are never overwritten. | |
| 865 | + * | |
| 866 | + * @param array<int,string> $events | |
| 867 | + * @return array<string, array<string,mixed>> | |
| 868 | + */ | |
| 869 | + private static function bookingFormFieldDefinitions(array $events): array | |
| 870 | + { | |
| 871 | + // Custom/dynamic booking-form fields are a Pro-module feature. When the | |
| 872 | + // Dynamic Form Field module is off the form is fixed, so we don't surface | |
| 873 | + // these extra tags — free installs keep their existing tag list unchanged. | |
| 874 | + if (!function_exists('apply_filters') || !apply_filters('yatra_dynamic_form_field_enabled', false)) { | |
| 875 | + return []; | |
| 876 | + } | |
| 877 | + if (!function_exists('yatra_get_booking_form_config')) { | |
| 878 | + return []; | |
| 879 | + } | |
| 880 | + | |
| 881 | + $config = yatra_get_booking_form_config(); | |
| 882 | + if (!is_array($config)) { | |
| 883 | + return []; | |
| 884 | + } | |
| 885 | + | |
| 886 | + $sections = [ | |
| 887 | + 'contact_form' => ['prefix' => 'contact_', 'category' => self::CATEGORY_CUSTOMER], | |
| 888 | + 'emergency_contact_form' => ['prefix' => 'emergency_', 'category' => self::CATEGORY_BOOKING], | |
| 889 | + ]; | |
| 890 | + | |
| 891 | + $defs = []; | |
| 892 | + foreach ($sections as $sectionKey => $meta) { | |
| 893 | + $section = $config[$sectionKey] ?? null; | |
| 894 | + if (!is_array($section) || (isset($section['enabled']) && !$section['enabled'])) { | |
| 895 | + continue; | |
| 896 | + } | |
| 897 | + // Fields the section can ask on ANY trip: the global list plus every | |
| 898 | + // per-trip condition's list (Pro) — a field that only a "Trekking" | |
| 899 | + // version of the form asks still needs its merge tag. | |
| 900 | + $fields = is_array($section['fields'] ?? null) ? $section['fields'] : []; | |
| 901 | + foreach ((array) ($section['conditions'] ?? []) as $condition) { | |
| 902 | + if (is_array($condition) && is_array($condition['fields'] ?? null)) { | |
| 903 | + $fields = array_merge($fields, $condition['fields']); | |
| 904 | + } | |
| 905 | + } | |
| 906 | + foreach ($fields as $field) { | |
| 907 | + if (!is_array($field) || empty($field['enabled']) || empty($field['id'])) { | |
| 908 | + continue; | |
| 909 | + } | |
| 910 | + // Text blocks are display-only content, not inputs — they hold no | |
| 911 | + // booking value, so they must not become email merge tags. | |
| 912 | + if (($field['type'] ?? '') === 'text_block') { | |
| 913 | + continue; | |
| 914 | + } | |
| 915 | + $id = sanitize_key((string) $field['id']); | |
| 916 | + if ($id === '') { | |
| 917 | + continue; | |
| 918 | + } | |
| 919 | + $tagKey = $meta['prefix'] . $id; | |
| 920 | + if (isset($defs[$tagKey])) { | |
| 921 | + continue; | |
| 922 | + } | |
| 923 | + $label = (string) ($field['label'] ?? ucwords(str_replace('_', ' ', $id))); | |
| 924 | + $defs[$tagKey] = [ | |
| 925 | + 'key' => $tagKey, | |
| 926 | + 'label' => $label, | |
| 927 | + /* translators: %s: booking form field label. */ | |
| 928 | + 'description' => sprintf(__('Booking form field: %s', 'yatra'), $label), | |
| 929 | + 'category' => $meta['category'], | |
| 930 | + 'sample' => '', | |
| 931 | + 'events' => $events, | |
| 932 | + ]; | |
| 933 | + } | |
| 934 | + } | |
| 935 | + | |
| 936 | + return $defs; | |
| 831 | 937 | } |
| 832 | 938 | |
| 833 | 939 | /** |
| 834 | 940 | * Return tag definitions grouped by category, optionally filtered to |