PluginProbe
Yatra – Travel Booking & Tour Operator Software / 3.0.9
Yatra – Travel Booking & Tour Operator Software v3.0.9
3.0.16 3.0.15 3.0.14 3.0.14.1 3.0.14.2 3.0.12 3.0.13 3.0.11 3.0.10 3.0.9 3.0.8 3.0.7 3.0.6 3.0.5 3.0.5.1 3.0.4 3.0.3 3.0.2.9 3.0.2.7 3.0.2.8 3.0.2.6 trunk 1.0.0 2.0.0 2.0.1 All 84 releases
yatra / templates / partials / single-trip / ai-chat-widget.php

ai-chat-widget.php in Yatra – Travel Booking & Tour Operator Software 3.0.9, at templates/partials/single-trip/ai-chat-widget.php

1,022 lines 41.5 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Public-facing "Chat with AI" widget — slot directly under the
4 * Enquiry button on the single-trip page when the operator has the AI
5 * module on AND has flipped the `yatra_ai_trip_chat_enabled` toggle.
6 *
7 * The widget is intentionally self-contained: button + slide-over
8 * panel + inline JS in one partial. That sidesteps the build pipeline
9 * (no admin React bundle on the public page) and keeps the widget
10 * cheap to render. The chat itself routes through the REST endpoint
11 * /wp-json/yatra/v1/ai/trip-chat/{trip_id} which carries the rate
12 * limits + BYOK key.
13 *
14 * Visual language matches the rest of the single-trip sidebar:
15 * - Yatra blue brand color (`--yatra-primary`, fallback #3b82f6)
16 * - Rounded corners, soft shadows
17 * - Same button silhouette as `.yatra-booking-button` so the chat
18 * trigger feels like a peer-CTA, not a foreign widget.
19 *
20 * Required vars (set by the caller before include):
21 * $trip — Yatra trip object (must expose getId() + getName())
22 */
23
24 if (!defined('ABSPATH')) {
25 exit;
26 }
27
28 // Two gates — module must be on AND the public chat toggle must be on.
29 // We deliberately keep both checks server-side; an operator who flips
30 // the module off without unticking the chat flag should still result in
31 // no widget rendered. ModuleManager stores modules as an array of
32 // `{slug: ai_assistant, ...}` objects, NOT a hyphen-keyed map, so we
33 // must call its isModuleEnabled() helper rather than indexing by slug.
34 $yatra_ai_module_on = false;
35 if (\class_exists('\\Yatra\\Core\\Modules\\ModuleManager')) {
36 $yatra_ai_module_on = \Yatra\Core\Modules\ModuleManager::isModuleEnabled('ai_assistant');
37 }
38
39 $yatra_ai_chat_flag = (bool) get_option('yatra_ai_trip_chat_enabled', false);
40
41 // WP_DEBUG-only HTML-comment breadcrumb so the operator can `View Source`
42 // and see exactly which gate stopped the widget. Production sites never
43 // see this — when the feature is off the page is byte-identical to before.
44 if (defined('WP_DEBUG') && WP_DEBUG && (!$yatra_ai_module_on || !$yatra_ai_chat_flag)) {
45 echo '<!-- yatra-ai-chat-widget: module_on=' . ($yatra_ai_module_on ? '1' : '0')
46 . ' chat_flag=' . ($yatra_ai_chat_flag ? '1' : '0') . ' -->';
47 }
48
49 if (!$yatra_ai_module_on || !$yatra_ai_chat_flag) {
50 return;
51 }
52
53 if (!isset($trip)) {
54 if (defined('WP_DEBUG') && WP_DEBUG) {
55 echo '<!-- yatra-ai-chat-widget: $trip missing -->';
56 }
57 return;
58 }
59
60 // Accept either the Trip object (with getId/getName) or a raw post-id
61 // integer or stdClass with ID field — the widget is rendered from
62 // multiple branches in content-sidebar and the calling shape isn't
63 // perfectly uniform.
64 $yatra_chat_trip_id = 0;
65 $yatra_chat_trip_name = '';
66 if (\is_object($trip)) {
67 if (\method_exists($trip, 'getId')) {
68 $yatra_chat_trip_id = (int) $trip->getId();
69 } elseif (isset($trip->id)) {
70 $yatra_chat_trip_id = (int) $trip->id;
71 } elseif (isset($trip->ID)) {
72 $yatra_chat_trip_id = (int) $trip->ID;
73 }
74 // The Yatra Trip class exposes the display title via getTitle();
75 // older snippets used getName(). Try both, then fall through to
76 // raw stdClass / WP_Post property names.
77 if (\method_exists($trip, 'getTitle')) {
78 $yatra_chat_trip_name = (string) $trip->getTitle();
79 } elseif (\method_exists($trip, 'getName')) {
80 $yatra_chat_trip_name = (string) $trip->getName();
81 } elseif (isset($trip->title)) {
82 $yatra_chat_trip_name = (string) $trip->title;
83 } elseif (isset($trip->name)) {
84 $yatra_chat_trip_name = (string) $trip->name;
85 } elseif (isset($trip->post_title)) {
86 $yatra_chat_trip_name = (string) $trip->post_title;
87 }
88 } elseif (\is_numeric($trip)) {
89 $yatra_chat_trip_id = (int) $trip;
90 }
91
92 if ($yatra_chat_trip_id <= 0) {
93 if (defined('WP_DEBUG') && WP_DEBUG) {
94 echo '<!-- yatra-ai-chat-widget: trip id resolution failed -->';
95 }
96 return;
97 }
98 if ($yatra_chat_trip_name === '') {
99 $yatra_chat_trip_name = sprintf(
100 /* translators: %d: trip post ID. */
101 __('Trip #%d', 'yatra'),
102 $yatra_chat_trip_id
103 );
104 }
105
106 $yatra_chat_rest = esc_url_raw(rest_url('yatra/v1/ai/trip-chat/' . $yatra_chat_trip_id));
107 $yatra_chat_widget_id = 'yatra-ai-chat-' . $yatra_chat_trip_id;
108 ?>
109
110 <button
111 type="button"
112 class="yatra-ai-chat-trigger"
113 data-yatra-ai-chat-trigger="<?php echo esc_attr($yatra_chat_widget_id); ?>"
114 aria-controls="<?php echo esc_attr($yatra_chat_widget_id); ?>"
115 aria-expanded="false"
116 >
117 <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
118 <path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z"/>
119 </svg>
120 <span><?php esc_html_e('Ask AI about this trip', 'yatra'); ?></span>
121 <span class="yatra-ai-chat-badge"><?php esc_html_e('AI', 'yatra'); ?></span>
122 </button>
123
124 <div
125 class="yatra-ai-chat-panel"
126 id="<?php echo esc_attr($yatra_chat_widget_id); ?>"
127 role="dialog"
128 aria-modal="false"
129 aria-labelledby="<?php echo esc_attr($yatra_chat_widget_id); ?>-title"
130 hidden
131 >
132 <div class="yatra-ai-chat-panel-header">
133 <div class="yatra-ai-chat-panel-title-wrap">
134 <div class="yatra-ai-chat-panel-avatar" aria-hidden="true">
135 <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
136 <path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z"/>
137 </svg>
138 </div>
139 <div class="yatra-ai-chat-panel-title-text">
140 <div class="yatra-ai-chat-panel-title" id="<?php echo esc_attr($yatra_chat_widget_id); ?>-title">
141 <?php esc_html_e('Trip Assistant', 'yatra'); ?>
142 </div>
143 <div class="yatra-ai-chat-panel-subtitle">
144 <span class="yatra-ai-chat-online-dot" aria-hidden="true"></span>
145 <?php echo esc_html(sprintf(
146 /* translators: %s: trip name. */
147 __('Online · ask about %s', 'yatra'),
148 $yatra_chat_trip_name
149 )); ?>
150 </div>
151 </div>
152 </div>
153 <button type="button" class="yatra-ai-chat-close" data-yatra-ai-chat-close aria-label="<?php esc_attr_e('Close chat', 'yatra'); ?>">
154 <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
155 <line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
156 </svg>
157 </button>
158 </div>
159
160 <div class="yatra-ai-chat-messages" data-yatra-ai-chat-messages>
161 <div class="yatra-ai-chat-msg yatra-ai-chat-msg-assistant">
162 <?php echo esc_html(sprintf(
163 /* translators: %s = trip name */
164 __("Hi! I can help with questions about %s — itinerary, what to pack, difficulty, best season. For bookings or quotes, please use the enquiry button above.", 'yatra'),
165 $yatra_chat_trip_name
166 )); ?>
167 </div>
168 <?php
169 // Suggestion chips — give the visitor a starting point so the
170 // empty input isn't intimidating. Click → fills the textarea +
171 // submits.
172 $yatra_chat_suggestions = [
173 __('What\'s included?', 'yatra'),
174 __('What should I pack?', 'yatra'),
175 __('Is it kid-friendly?', 'yatra'),
176 __('Best season to go?', 'yatra'),
177 ];
178 ?>
179 <div class="yatra-ai-chat-suggestions" data-yatra-ai-chat-suggestions>
180 <?php foreach ($yatra_chat_suggestions as $sug): ?>
181 <button type="button" class="yatra-ai-chat-suggestion" data-yatra-ai-chat-suggestion>
182 <?php echo esc_html($sug); ?>
183 </button>
184 <?php endforeach; ?>
185 </div>
186 </div>
187
188 <?php
189 // Intentionally a <div>, not a <form>. The booking sidebar already
190 // wraps everything in a <form>, and the HTML spec forbids nested
191 // forms — the browser silently elides the inner one, which left
192 // panel.querySelector('[data-yatra-ai-chat-form]') returning null
193 // and crashing the script's node check. We handle "submit" via a
194 // button click + Enter keydown instead.
195 ?>
196 <div class="yatra-ai-chat-input-row" data-yatra-ai-chat-form>
197 <textarea
198 class="yatra-ai-chat-input"
199 placeholder="<?php esc_attr_e('Type your question…', 'yatra'); ?>"
200 rows="1"
201 maxlength="1000"
202 data-yatra-ai-chat-textarea
203 ></textarea>
204 <button type="button" class="yatra-ai-chat-send" data-yatra-ai-chat-send aria-label="<?php esc_attr_e('Send message', 'yatra'); ?>">
205 <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
206 <path d="M22 2L11 13"/>
207 <path d="M22 2l-7 20-4-9-9-4 20-7z"/>
208 </svg>
209 </button>
210 </div>
211 <div class="yatra-ai-chat-disclaimer">
212 <?php esc_html_e('AI replies may have inaccuracies. Confirm details via enquiry.', 'yatra'); ?>
213 </div>
214 </div>
215
216 <style id="yatra-ai-chat-style-<?php echo esc_attr($yatra_chat_widget_id); ?>">
217 /* -- Chat widget — matches Yatra public-page palette ------------------ */
218 .yatra-ai-chat-trigger {
219 display: flex; align-items: center; justify-content: center; gap: 8px;
220 width: 100%; margin-top: 10px;
221 padding: 12px 14px;
222 background: #fff;
223 color: var(--yatra-primary, #3b82f6);
224 border: 1.5px solid var(--yatra-primary, #3b82f6);
225 border-radius: 10px; cursor: pointer;
226 font: inherit; font-size: 0.92rem; font-weight: 600;
227 transition: background .15s ease, color .15s ease, box-shadow .15s ease;
228 }
229 .yatra-ai-chat-trigger:hover {
230 background: color-mix(in srgb, var(--yatra-primary, #3b82f6) 8%, white);
231 box-shadow: 0 2px 8px color-mix(in srgb, var(--yatra-primary, #3b82f6) 22%, transparent);
232 }
233 .yatra-ai-chat-trigger svg { color: var(--yatra-primary, #3b82f6); flex-shrink: 0; }
234 .yatra-ai-chat-badge {
235 margin-left: 4px; padding: 2px 7px; border-radius: 999px;
236 background: color-mix(in srgb, var(--yatra-primary, #3b82f6) 14%, white);
237 color: var(--yatra-primary, #3b82f6);
238 font-size: 0.65rem; font-weight: 700; letter-spacing: 0.5px; text-transform: uppercase;
239 }
240
241 /* -- Slide-over panel ------------------------------------------------- */
242 .yatra-ai-chat-panel {
243 position: fixed; right: 20px; bottom: 20px;
244 width: 380px; max-width: calc(100vw - 40px);
245 height: 580px; max-height: calc(100vh - 40px);
246 background: #fff;
247 border-radius: 16px;
248 box-shadow:
249 0 20px 50px rgba(15, 23, 42, 0.18),
250 0 6px 14px rgba(15, 23, 42, 0.08);
251 display: flex; flex-direction: column; overflow: hidden;
252 z-index: 999999;
253 font: 14px/1.4 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
254 "Helvetica Neue", Arial, sans-serif;
255 border: 1px solid rgba(15, 23, 42, 0.08);
256 /* Smooth entrance */
257 animation: yatra-ai-chat-slide-up .22s ease-out;
258 }
259 .yatra-ai-chat-panel[hidden] { display: none; }
260 @keyframes yatra-ai-chat-slide-up {
261 from { opacity: 0; transform: translateY(12px); }
262 to { opacity: 1; transform: translateY(0); }
263 }
264
265 /* Header */
266 .yatra-ai-chat-panel-header {
267 padding: 14px 16px;
268 background: var(--yatra-primary, #3b82f6);
269 color: #fff;
270 display: flex; align-items: center; justify-content: space-between; gap: 10px;
271 }
272 .yatra-ai-chat-panel-title-wrap { display: flex; gap: 10px; align-items: center; flex: 1; min-width: 0; }
273 .yatra-ai-chat-panel-avatar {
274 width: 32px; height: 32px; border-radius: 10px;
275 background: rgba(255, 255, 255, 0.2);
276 display: flex; align-items: center; justify-content: center; flex-shrink: 0;
277 }
278 .yatra-ai-chat-panel-title-text { min-width: 0; flex: 1; }
279 .yatra-ai-chat-panel-title {
280 font-weight: 600; font-size: 0.95rem; line-height: 1.2;
281 }
282 .yatra-ai-chat-panel-subtitle {
283 display: flex; align-items: center; gap: 6px;
284 font-size: 0.72rem; opacity: 0.92; margin-top: 2px;
285 white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
286 }
287 .yatra-ai-chat-online-dot {
288 width: 7px; height: 7px; border-radius: 50%;
289 background: #4ade80; box-shadow: 0 0 0 3px rgba(74, 222, 128, 0.3);
290 flex-shrink: 0;
291 }
292 .yatra-ai-chat-close {
293 background: rgba(255, 255, 255, 0.16); border: 0; color: #fff;
294 width: 30px; height: 30px; border-radius: 8px; cursor: pointer;
295 display: flex; align-items: center; justify-content: center;
296 transition: background .12s ease;
297 padding: 0;
298 }
299 .yatra-ai-chat-close:hover { background: rgba(255, 255, 255, 0.28); }
300
301 /* Messages list */
302 .yatra-ai-chat-messages {
303 flex: 1; overflow-y: auto;
304 padding: 16px 14px 8px;
305 background: #f8fafc;
306 display: flex; flex-direction: column; gap: 10px;
307 scrollbar-width: thin;
308 scrollbar-color: #cbd5e1 transparent;
309 }
310 .yatra-ai-chat-messages::-webkit-scrollbar { width: 6px; }
311 .yatra-ai-chat-messages::-webkit-scrollbar-thumb { background: #cbd5e1; border-radius: 3px; }
312 .yatra-ai-chat-msg {
313 padding: 10px 13px; border-radius: 14px; max-width: 86%;
314 line-height: 1.5; word-wrap: break-word;
315 font-size: 0.875rem;
316 display: flex; flex-direction: column; gap: 3px;
317 }
318 .yatra-ai-chat-msg-text { white-space: pre-wrap; }
319 .yatra-ai-chat-msg-time {
320 align-self: flex-end;
321 font-size: 0.65rem;
322 opacity: 0.65;
323 letter-spacing: 0.2px;
324 font-variant-numeric: tabular-nums;
325 margin-top: 1px;
326 }
327 .yatra-ai-chat-msg-visitor .yatra-ai-chat-msg-time { color: #e0e7ff; }
328 .yatra-ai-chat-msg-assistant .yatra-ai-chat-msg-time { color: #94a3b8; }
329 .yatra-ai-chat-msg-error .yatra-ai-chat-msg-time { color: #b91c1c; }
330 .yatra-ai-chat-msg-assistant {
331 background: #fff;
332 border: 1px solid #e2e8f0;
333 align-self: flex-start;
334 color: #1e293b;
335 border-bottom-left-radius: 4px;
336 }
337 .yatra-ai-chat-msg-visitor {
338 background: var(--yatra-primary, #3b82f6);
339 color: #fff;
340 align-self: flex-end;
341 border-bottom-right-radius: 4px;
342 }
343 .yatra-ai-chat-msg-error {
344 background: #fef2f2; border: 1px solid #fecaca; color: #991b1b;
345 align-self: flex-start;
346 font-size: 0.8rem;
347 }
348
349 /* Typing indicator */
350 .yatra-ai-chat-typing {
351 align-self: flex-start;
352 background: #fff; border: 1px solid #e2e8f0;
353 border-radius: 14px; border-bottom-left-radius: 4px;
354 padding: 12px 14px;
355 display: flex; gap: 4px; align-items: center;
356 }
357 .yatra-ai-chat-typing-dot {
358 width: 6px; height: 6px; border-radius: 50%;
359 background: #94a3b8;
360 animation: yatra-ai-chat-bounce 1.2s infinite ease-in-out;
361 }
362 .yatra-ai-chat-typing-dot:nth-child(2) { animation-delay: 0.15s; }
363 .yatra-ai-chat-typing-dot:nth-child(3) { animation-delay: 0.3s; }
364 @keyframes yatra-ai-chat-bounce {
365 0%, 60%, 100% { transform: translateY(0); opacity: 0.5; }
366 30% { transform: translateY(-4px); opacity: 1; }
367 }
368
369 /* Suggestion chips */
370 .yatra-ai-chat-suggestions {
371 display: flex; flex-wrap: wrap; gap: 6px;
372 margin-top: 2px;
373 }
374 .yatra-ai-chat-suggestion {
375 background: #fff;
376 border: 1px solid color-mix(in srgb, var(--yatra-primary, #3b82f6) 25%, transparent);
377 color: var(--yatra-primary, #3b82f6);
378 font: inherit; font-size: 0.78rem; font-weight: 500;
379 padding: 6px 11px; border-radius: 999px;
380 cursor: pointer;
381 transition: background .12s ease, border-color .12s ease;
382 }
383 .yatra-ai-chat-suggestion:hover {
384 background: color-mix(in srgb, var(--yatra-primary, #3b82f6) 10%, white);
385 border-color: var(--yatra-primary, #3b82f6);
386 }
387
388 /* Input row */
389 .yatra-ai-chat-input-row {
390 padding: 10px 12px;
391 border-top: 1px solid #e5e7eb;
392 display: flex; gap: 8px; align-items: flex-end;
393 background: #fff;
394 }
395 .yatra-ai-chat-input {
396 flex: 1;
397 padding: 10px 12px;
398 border: 1px solid #cbd5e1;
399 border-radius: 10px;
400 resize: none;
401 font: inherit;
402 font-size: 0.875rem;
403 line-height: 1.4;
404 max-height: 100px; min-height: 40px;
405 outline: none;
406 color: #1e293b;
407 background: #fff;
408 transition: border-color .12s ease, box-shadow .12s ease;
409 }
410 .yatra-ai-chat-input::placeholder { color: #94a3b8; }
411 .yatra-ai-chat-input:focus {
412 border-color: var(--yatra-primary, #3b82f6);
413 box-shadow: 0 0 0 3px color-mix(in srgb, var(--yatra-primary, #3b82f6) 18%, transparent);
414 }
415 .yatra-ai-chat-send {
416 background: var(--yatra-primary, #3b82f6);
417 color: #fff;
418 border: 0;
419 border-radius: 10px;
420 width: 40px; height: 40px;
421 display: flex; align-items: center; justify-content: center;
422 cursor: pointer; flex-shrink: 0;
423 transition: background .12s ease, transform .08s ease;
424 }
425 .yatra-ai-chat-send:hover:not(:disabled) {
426 background: var(--yatra-primary-dark, #2563eb);
427 }
428 .yatra-ai-chat-send:active:not(:disabled) { transform: scale(0.96); }
429 .yatra-ai-chat-send:disabled { background: #cbd5e1; cursor: not-allowed; }
430
431 /* Price summary card — mirrors the regular booking page's pricing
432 breakdown (subtotal → discounts → taxes → total → due now) so the
433 visitor sees the same numbers before the chat fires the create
434 call. Shown as a chat bubble in the assistant column. */
435 .yatra-ai-chat-price-card {
436 align-self: flex-start;
437 background: #fff;
438 border: 1px solid #e2e8f0;
439 border-radius: 14px; border-bottom-left-radius: 4px;
440 padding: 12px 14px;
441 width: 86%; max-width: 86%;
442 font-size: 0.84rem;
443 }
444 .yatra-ai-chat-price-card-title {
445 font-weight: 600;
446 font-size: 0.78rem;
447 text-transform: uppercase;
448 letter-spacing: 0.5px;
449 color: #475569;
450 margin-bottom: 8px;
451 padding-bottom: 6px;
452 border-bottom: 1px solid #f1f5f9;
453 }
454 .yatra-ai-chat-price-card-rows { display: flex; flex-direction: column; gap: 4px; }
455 .yatra-ai-chat-price-row {
456 display: flex; justify-content: space-between; gap: 8px;
457 color: #1e293b;
458 }
459 .yatra-ai-chat-price-row-label { color: #475569; }
460 .yatra-ai-chat-price-row-value { font-weight: 500; font-variant-numeric: tabular-nums; }
461 .yatra-ai-chat-price-discount .yatra-ai-chat-price-row-value { color: #15803d; }
462 .yatra-ai-chat-price-total {
463 margin-top: 6px;
464 padding-top: 6px;
465 border-top: 1px dashed #cbd5e1;
466 font-weight: 600;
467 }
468 .yatra-ai-chat-price-total .yatra-ai-chat-price-row-label,
469 .yatra-ai-chat-price-total .yatra-ai-chat-price-row-value {
470 color: #0f172a;
471 font-size: 0.95rem;
472 }
473 .yatra-ai-chat-price-due .yatra-ai-chat-price-row-value {
474 color: var(--yatra-primary, #3b82f6);
475 }
476
477 /* Booking auto-create loader (shown between AI reply and confirmation
478 message while the POST is in-flight). Uses the same bouncing dots
479 as the typing indicator so it feels continuous. */
480 .yatra-ai-chat-booking-creating {
481 align-self: flex-start;
482 background: #fff;
483 border: 1px solid #e2e8f0;
484 border-radius: 14px; border-bottom-left-radius: 4px;
485 padding: 10px 13px;
486 display: flex; gap: 4px; align-items: center;
487 color: #475569; font-size: 0.78rem;
488 }
489 .yatra-ai-chat-booking-creating-text { color: #475569; }
490
491 /* Success bubble after a booking is created — same chat-bubble shape
492 as the assistant messages but with a green accent border and a
493 prominent "View confirmation" link. */
494 .yatra-ai-chat-msg-success {
495 border-color: #86efac;
496 background: #f0fdf4;
497 color: #14532d;
498 }
499 .yatra-ai-chat-msg-link {
500 display: inline-block;
501 margin-top: 6px;
502 color: var(--yatra-primary, #3b82f6);
503 font-weight: 600;
504 text-decoration: none;
505 font-size: 0.82rem;
506 }
507 .yatra-ai-chat-msg-link:hover { text-decoration: underline; }
508
509 .yatra-ai-chat-disclaimer {
510 padding: 7px 14px 10px;
511 background: #fff;
512 font-size: 0.68rem;
513 color: #94a3b8;
514 text-align: center;
515 border-top: 1px solid #f1f5f9;
516 }
517
518 @media (max-width: 480px) {
519 .yatra-ai-chat-panel {
520 right: 0; bottom: 0; left: 0;
521 width: 100%; max-width: 100%;
522 height: 92vh; max-height: 92vh;
523 border-radius: 16px 16px 0 0;
524 border-bottom: 0;
525 }
526 }
527 </style>
528
529 <script>
530 (function () {
531 'use strict';
532
533 // Single endpoint — the backend BookingAgent handles tool calls,
534 // pricing previews, and the booking POST internally. The frontend
535 // is now a thin transport: send message, render reply.
536 var WIDGET_ID = '<?php echo esc_js($yatra_chat_widget_id); ?>';
537 var REST_URL = '<?php echo esc_js($yatra_chat_rest); ?>';
538 var NONCE = '<?php echo esc_js(wp_create_nonce('wp_rest')); ?>';
539 var SESSION_KEY = 'yatra_ai_chat_session_<?php echo esc_js($yatra_chat_trip_id); ?>';
540 var HISTORY_KEY = 'yatra_ai_chat_history_<?php echo esc_js($yatra_chat_trip_id); ?>';
541 var I18N = {
542 thinking: '<?php echo esc_js(__('Thinking…', 'yatra')); ?>',
543 chatFailed: '<?php echo esc_js(__('Chat failed. Please try again.', 'yatra')); ?>',
544 noReply: '<?php echo esc_js(__('No reply received.', 'yatra')); ?>',
545 networkError: '<?php echo esc_js(__('Network error. Please try again.', 'yatra')); ?>',
546 priceSummaryTitle: '<?php echo esc_js(__('Price summary', 'yatra')); ?>',
547 priceSubtotal: '<?php echo esc_js(__('Subtotal', 'yatra')); ?>',
548 priceGroupDiscount: '<?php echo esc_js(__('Group discount', 'yatra')); ?>',
549 priceCouponDiscount: '<?php echo esc_js(__('Coupon', 'yatra')); ?>',
550 priceTaxes: '<?php echo esc_js(__('Taxes', 'yatra')); ?>',
551 priceTotal: '<?php echo esc_js(__('Total', 'yatra')); ?>',
552 priceDueNow: '<?php echo esc_js(__('Due now', 'yatra')); ?>',
553 bookingConfirmed: '<?php echo esc_js(__('Booking confirmed!', 'yatra')); ?>',
554 bookingReference: '<?php echo esc_js(__('Reference:', 'yatra')); ?>',
555 viewConfirmation: '<?php echo esc_js(__('View confirmation', 'yatra')); ?>'
556 };
557
558 // Wait until the DOM is parsed enough to find our nodes. The
559 // partial outputs the markup right above this script, but if the
560 // page injects extra content via a hook between them we still want
561 // to be safe.
562 function init() {
563 var trigger = document.querySelector('[data-yatra-ai-chat-trigger="' + WIDGET_ID + '"]');
564 var panel = document.getElementById(WIDGET_ID);
565 if (!trigger || !panel) return;
566 if (trigger.getAttribute('data-yatra-ai-chat-init') === '1') return;
567 trigger.setAttribute('data-yatra-ai-chat-init', '1');
568
569 var messagesEl = panel.querySelector('[data-yatra-ai-chat-messages]');
570 var formEl = panel.querySelector('[data-yatra-ai-chat-form]');
571 var textareaEl = panel.querySelector('[data-yatra-ai-chat-textarea]');
572 var sendEl = panel.querySelector('[data-yatra-ai-chat-send]');
573 var closeEl = panel.querySelector('[data-yatra-ai-chat-close]');
574 var suggestionsEl = panel.querySelector('[data-yatra-ai-chat-suggestions]');
575
576 // Defensive — if ANY required node is missing, log + bail
577 // instead of throwing a fatal addEventListener error.
578 if (!messagesEl || !formEl || !textareaEl || !sendEl || !closeEl) {
579 if (window.console) {
580 console.warn('Yatra AI chat: required nodes missing', {
581 messagesEl: !!messagesEl, formEl: !!formEl,
582 textareaEl: !!textareaEl, sendEl: !!sendEl, closeEl: !!closeEl
583 });
584 }
585 return;
586 }
587
588 var sessionId = '';
589 try {
590 sessionId = window.localStorage.getItem(SESSION_KEY) || '';
591 if (!sessionId) {
592 sessionId = 'sess-' + Math.random().toString(36).slice(2, 10) + '-' + Date.now().toString(36);
593 window.localStorage.setItem(SESSION_KEY, sessionId);
594 }
595 } catch (e) {
596 sessionId = 'sess-' + Math.random().toString(36).slice(2, 10) + '-' + Date.now().toString(36);
597 }
598
599 var history = [];
600 try {
601 var raw = window.localStorage.getItem(HISTORY_KEY);
602 if (raw) {
603 var parsed = JSON.parse(raw);
604 if (Array.isArray(parsed)) history = parsed.slice(-12);
605 }
606 } catch (e) { /* ignore */ }
607
608 // Replay any prior conversation. We hide the suggestions chips
609 // once the visitor has interacted at least once, so don't show
610 // them on a returning chat. Each turn carries an optional
611 // timestamp so re-renders match the original send time;
612 // missing timestamps (older history written before timestamps
613 // were added) get an empty time label rather than "now".
614 if (history.length > 0 && suggestionsEl) {
615 suggestionsEl.style.display = 'none';
616 }
617 history.forEach(function (turn) {
618 renderMessage(
619 turn.role === 'assistant' ? 'assistant' : 'visitor',
620 String(turn.content || ''),
621 Number(turn.ts) || 0
622 );
623 });
624 scrollToBottom();
625
626 function openPanel() {
627 panel.removeAttribute('hidden');
628 trigger.setAttribute('aria-expanded', 'true');
629 setTimeout(function () { try { textareaEl.focus(); } catch (e) {} }, 30);
630 }
631 function closePanel() {
632 panel.setAttribute('hidden', '');
633 trigger.setAttribute('aria-expanded', 'false');
634 }
635
636 trigger.addEventListener('click', function () {
637 if (panel.hasAttribute('hidden')) openPanel();
638 else closePanel();
639 });
640 closeEl.addEventListener('click', closePanel);
641
642 document.addEventListener('keydown', function (e) {
643 if (e.key === 'Escape' && !panel.hasAttribute('hidden')) closePanel();
644 });
645
646 // Suggestion chip click → send immediately. Cleaner than just
647 // filling the textarea since the visitor's intent is obvious.
648 if (suggestionsEl) {
649 suggestionsEl.addEventListener('click', function (e) {
650 var btn = e.target.closest('[data-yatra-ai-chat-suggestion]');
651 if (!btn) return;
652 var text = (btn.textContent || '').trim();
653 if (!text) return;
654 suggestionsEl.style.display = 'none';
655 sendVisitorMessage(text);
656 });
657 }
658
659 // Auto-grow textarea up to max-height.
660 textareaEl.addEventListener('input', function () {
661 textareaEl.style.height = 'auto';
662 textareaEl.style.height = Math.min(100, textareaEl.scrollHeight) + 'px';
663 });
664
665 textareaEl.addEventListener('keydown', function (e) {
666 // Enter → submit, Shift+Enter → newline. We don't use a
667 // <form> element (HTML disallows nested forms and this
668 // widget lives inside the booking form), so there's no
669 // submit event to dispatch — just call the handler.
670 if (e.key === 'Enter' && !e.shiftKey) {
671 e.preventDefault();
672 submitForm();
673 }
674 });
675
676 // The "send" button is type="button" — wire a click listener
677 // instead of relying on form submission.
678 sendEl.addEventListener('click', function (e) {
679 e.preventDefault();
680 submitForm();
681 });
682
683 function submitForm() {
684 var message = (textareaEl.value || '').trim();
685 if (!message) return;
686 if (sendEl.disabled) return;
687 textareaEl.value = '';
688 textareaEl.style.height = 'auto';
689 if (suggestionsEl) suggestionsEl.style.display = 'none';
690 sendVisitorMessage(message);
691 }
692
693 /**
694 * Append a chat bubble with an optional inline timestamp. Pass
695 * ts=0 (or omit) for "no timestamp" — useful when replaying
696 * pre-timestamp history. Otherwise we format as locale time
697 * (HH:MM), so an early-morning vs late-evening conversation
698 * is easy to distinguish at a glance without going as
699 * granular as seconds.
700 */
701 function renderMessage(role, content, ts) {
702 var div = document.createElement('div');
703 div.className = 'yatra-ai-chat-msg yatra-ai-chat-msg-' + (role === 'assistant' ? 'assistant' : 'visitor');
704
705 var textWrap = document.createElement('span');
706 textWrap.className = 'yatra-ai-chat-msg-text';
707 textWrap.textContent = content;
708 div.appendChild(textWrap);
709
710 var when = (typeof ts === 'number' && ts > 0) ? ts : Date.now();
711 var timeEl = document.createElement('span');
712 timeEl.className = 'yatra-ai-chat-msg-time';
713 timeEl.textContent = formatTime(when);
714 div.appendChild(timeEl);
715
716 messagesEl.appendChild(div);
717 }
718
719 function formatTime(ts) {
720 try {
721 return new Date(ts).toLocaleTimeString([], {
722 hour: '2-digit', minute: '2-digit'
723 });
724 } catch (e) {
725 var d = new Date(ts);
726 var h = d.getHours();
727 var m = d.getMinutes();
728 return (h < 10 ? '0' + h : h) + ':' + (m < 10 ? '0' + m : m);
729 }
730 }
731
732 function renderError(text) {
733 var div = document.createElement('div');
734 div.className = 'yatra-ai-chat-msg yatra-ai-chat-msg-error';
735
736 var textWrap = document.createElement('span');
737 textWrap.className = 'yatra-ai-chat-msg-text';
738 textWrap.textContent = text;
739 div.appendChild(textWrap);
740
741 var timeEl = document.createElement('span');
742 timeEl.className = 'yatra-ai-chat-msg-time';
743 timeEl.textContent = formatTime(Date.now());
744 div.appendChild(timeEl);
745
746 messagesEl.appendChild(div);
747 }
748
749 function showTyping() {
750 var div = document.createElement('div');
751 div.className = 'yatra-ai-chat-typing';
752 div.setAttribute('data-yatra-typing', '1');
753 div.setAttribute('aria-label', I18N.thinking);
754 div.innerHTML =
755 '<span class="yatra-ai-chat-typing-dot"></span>' +
756 '<span class="yatra-ai-chat-typing-dot"></span>' +
757 '<span class="yatra-ai-chat-typing-dot"></span>';
758 messagesEl.appendChild(div);
759 scrollToBottom();
760 return div;
761 }
762
763 function scrollToBottom() {
764 messagesEl.scrollTop = messagesEl.scrollHeight;
765 }
766
767 function persistHistory() {
768 try {
769 window.localStorage.setItem(HISTORY_KEY, JSON.stringify(history.slice(-12)));
770 } catch (e) { /* ignore */ }
771 }
772
773 /* The previous design had a `parseAction()` sentinel parser, a
774 booking-summary fetch, and a booking-create POST all in the
775 frontend. The agent rewrite moved every one of those to the
776 server (BookingAgent + BookingTools); the JS is now a thin
777 transport. Anything the visitor used to see as a separate
778 bubble (price breakdown, "Booking received! Reference: …")
779 is now emitted as part of the assistant's text reply by the
780 agent, and rendered through the regular renderMessage path. */
781 /**
782 * Render the structured pricing breakdown card from a
783 * lookup_pricing tool result. Mirrors the rows the regular
784 * booking page's pricing-summary.php shows (per-category
785 * subtotals → subtotal → discounts → taxes → total → due now).
786 * Only rows with non-zero values are rendered so a simple
787 * flat-rate trip gets a clean two-row card rather than five
788 * "$0.00" rows.
789 */
790 function renderPricingCard(data) {
791 if (!data || typeof data !== 'object') return;
792 var card = document.createElement('div');
793 card.className = 'yatra-ai-chat-price-card';
794
795 var title = document.createElement('div');
796 title.className = 'yatra-ai-chat-price-card-title';
797 title.textContent = I18N.priceSummaryTitle;
798 card.appendChild(title);
799
800 var rows = document.createElement('div');
801 rows.className = 'yatra-ai-chat-price-card-rows';
802
803 // Per-category breakdown ("Adult × 2", "Child × 1")
804 if (Array.isArray(data.category_breakdown)) {
805 data.category_breakdown.forEach(function (cat) {
806 if (!cat) return;
807 var label = (cat.label || '') + ' × ' + (cat.count || 0);
808 var amount = cat.subtotal_formatted
809 || (typeof cat.subtotal === 'number' ? cat.subtotal.toFixed(2) : '');
810 if (amount) appendPriceRow(rows, label, amount);
811 });
812 }
813
814 // Subtotal
815 if (data.subtotal_formatted || data.subtotal) {
816 appendPriceRow(rows, I18N.priceSubtotal, data.subtotal_formatted || data.subtotal);
817 }
818
819 // Group discount (only when non-zero)
820 if (data.group_discount && data.group_discount.amount && parseFloat(data.group_discount.amount) > 0) {
821 appendPriceRow(rows,
822 data.group_discount.label || I18N.priceGroupDiscount,
823 '- ' + (data.group_discount.amount_formatted || data.group_discount.amount),
824 'yatra-ai-chat-price-discount');
825 }
826
827 // Coupon
828 if (data.coupon_discount && data.coupon_discount.amount && parseFloat(data.coupon_discount.amount) > 0) {
829 appendPriceRow(rows,
830 data.coupon_discount.label || I18N.priceCouponDiscount,
831 '- ' + (data.coupon_discount.amount_formatted || data.coupon_discount.amount),
832 'yatra-ai-chat-price-discount');
833 }
834
835 // Tax breakdown — one row per tax rate
836 if (Array.isArray(data.tax_breakdown)) {
837 data.tax_breakdown.forEach(function (tax) {
838 if (!tax) return;
839 appendPriceRow(rows,
840 (tax.label || I18N.priceTaxes) + (tax.rate ? ' (' + tax.rate + '%)' : ''),
841 tax.amount_formatted || tax.amount || '');
842 });
843 }
844
845 // Total
846 if (data.total_amount_formatted || data.total_amount) {
847 appendPriceRow(rows, I18N.priceTotal,
848 data.total_amount_formatted || data.total_amount,
849 'yatra-ai-chat-price-total');
850 }
851
852 // Due now — only when it differs from total (deposit / partial)
853 if (data.amount_due && data.total_amount
854 && parseFloat(data.amount_due) !== parseFloat(data.total_amount)) {
855 appendPriceRow(rows, I18N.priceDueNow,
856 data.amount_due_formatted || data.amount_due,
857 'yatra-ai-chat-price-due');
858 }
859
860 card.appendChild(rows);
861 messagesEl.appendChild(card);
862 }
863
864 function appendPriceRow(parent, label, value, extraCls) {
865 var row = document.createElement('div');
866 row.className = 'yatra-ai-chat-price-row' + (extraCls ? ' ' + extraCls : '');
867 var l = document.createElement('span');
868 l.className = 'yatra-ai-chat-price-row-label';
869 l.textContent = String(label);
870 var v = document.createElement('span');
871 v.className = 'yatra-ai-chat-price-row-value';
872 v.textContent = String(value);
873 row.appendChild(l);
874 row.appendChild(v);
875 parent.appendChild(row);
876 }
877
878 /**
879 * Render the booking-confirmation success bubble. Always
880 * includes a real <a> link to the confirmation page rather
881 * than relying on the model to write the URL into prose where
882 * it wouldn't be clickable. Persists to history so a reload
883 * keeps the success visible.
884 */
885 function renderBookingSuccess(booking) {
886 if (!booking || typeof booking !== 'object') return;
887 var ref = String(booking.reference || booking.booking_id || '').trim();
888 var url = String(booking.confirmation_url || '').trim();
889 if (ref === '' && url === '') return; // nothing useful to show
890
891 var now = Date.now();
892 var div = document.createElement('div');
893 div.className = 'yatra-ai-chat-msg yatra-ai-chat-msg-assistant yatra-ai-chat-msg-success';
894
895 var textWrap = document.createElement('span');
896 textWrap.className = 'yatra-ai-chat-msg-text';
897
898 var line1 = document.createElement('div');
899 line1.style.fontWeight = '600';
900 line1.textContent = I18N.bookingConfirmed;
901 textWrap.appendChild(line1);
902
903 if (ref) {
904 var line2 = document.createElement('div');
905 line2.textContent = I18N.bookingReference + ' ' + ref;
906 textWrap.appendChild(line2);
907 }
908 if (url) {
909 var link = document.createElement('a');
910 link.href = url;
911 link.target = '_blank';
912 link.rel = 'noopener noreferrer';
913 link.className = 'yatra-ai-chat-msg-link';
914 link.textContent = I18N.viewConfirmation + ' →';
915 textWrap.appendChild(link);
916 }
917 div.appendChild(textWrap);
918
919 var timeEl = document.createElement('span');
920 timeEl.className = 'yatra-ai-chat-msg-time';
921 timeEl.textContent = formatTime(now);
922 div.appendChild(timeEl);
923
924 messagesEl.appendChild(div);
925
926 // Persist a textual summary so a refresh still shows it.
927 var summary = I18N.bookingConfirmed
928 + (ref ? ' ' + I18N.bookingReference + ' ' + ref : '')
929 + (url ? ' (' + url + ')' : '');
930 history.push({ role: 'assistant', content: summary, ts: now });
931 persistHistory();
932 }
933
934 function sendVisitorMessage(message) {
935 var now = Date.now();
936 renderMessage('visitor', message, now);
937 history.push({ role: 'visitor', content: message, ts: now });
938 persistHistory();
939 scrollToBottom();
940
941 sendEl.disabled = true;
942 var typingEl = showTyping();
943
944 fetch(REST_URL, {
945 method: 'POST',
946 credentials: 'same-origin',
947 headers: {
948 'Content-Type': 'application/json',
949 'X-WP-Nonce': NONCE
950 },
951 body: JSON.stringify({
952 message: message,
953 session_id: sessionId,
954 history: history.slice(-6).map(function (h) {
955 return {
956 role: h.role === 'assistant' ? 'assistant' : 'visitor',
957 content: String(h.content || '').slice(0, 600)
958 };
959 })
960 })
961 }).then(function (res) {
962 return res.json().then(function (body) {
963 return { ok: res.ok, status: res.status, body: body };
964 });
965 }).then(function (r) {
966 if (typingEl && typingEl.parentNode) typingEl.parentNode.removeChild(typingEl);
967 if (!r.ok) {
968 var msg = (r.body && r.body.message) ? r.body.message : I18N.chatFailed;
969 renderError(msg);
970 scrollToBottom();
971 return;
972 }
973 var reply = (r.body && r.body.reply) ? String(r.body.reply) : '';
974 if (!reply) {
975 renderError(I18N.noReply);
976 return;
977 }
978
979 // The agent has already done all the work (tool calls,
980 // pricing, booking commit) before the response came
981 // back. We render three pieces in order:
982 // 1. The text reply as a normal chat bubble.
983 // 2. (optional) A price-breakdown card, if the agent
984 // called lookup_pricing — same subtotal/discount/
985 // tax/total rows the regular booking page shows,
986 // not whatever the model summarized in prose.
987 // 3. (optional) A booking-success bubble with a real
988 // <a> link to the confirmation page, if the agent
989 // called submit_booking and got a booking_id.
990 // Both artifacts are extracted server-side from the
991 // tool trace and shipped as structured JSON, so we
992 // don't have to regex-parse the assistant's prose.
993 var assistantNow = Date.now();
994 renderMessage('assistant', reply, assistantNow);
995 history.push({ role: 'assistant', content: reply, ts: assistantNow });
996 persistHistory();
997
998 if (r.body && r.body.pricing) {
999 renderPricingCard(r.body.pricing);
1000 }
1001 if (r.body && r.body.booking) {
1002 renderBookingSuccess(r.body.booking);
1003 }
1004 scrollToBottom();
1005 }).catch(function () {
1006 if (typingEl && typingEl.parentNode) typingEl.parentNode.removeChild(typingEl);
1007 renderError(I18N.networkError);
1008 }).then(function () {
1009 sendEl.disabled = false;
1010 try { textareaEl.focus(); } catch (e) {}
1011 });
1012 }
1013 }
1014
1015 if (document.readyState === 'loading') {
1016 document.addEventListener('DOMContentLoaded', init);
1017 } else {
1018 init();
1019 }
1020 })();
1021 </script>
1022