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