{% extends "base.html" %} {% from '_upsell.html' import crown_badge, max_badge, beta_pill %} {% block title %}Ticket #{{ provider_thread_id or ticket.id }}{% endblock %} {% block content %}
{# ============== Left: conversation column ============== #}
{# -- Sticky header bar (tier 1) -- Mobile: only this slim row pins under the top nav (see media query below); the meta block (#ticket-header-meta) scrolls away so the thread gets the vertical space back. Desktop (lg+): the column is its own scroll container, both blocks are always visible — the lg:border-b-0 / lg:pb-0 keep the two divs rendering as one box. #}
{# Row 1: back, subject, ID, external link … refresh #}
{# Hidden below lg: the top nav already injects a mobile back button (nav_mobile_back) #}

{{ ticket.thread.subject }}

#{{ provider_thread_id or ticket.id }} {# Mobile status chip — the meta block (with #status-select) scrolls away, so the pinned bar gets a tappable chip to change status while deep in the thread. Colors mirror the inbox badges; JS keeps it in sync with the select via setTicketStatusUI (app.js). #} {% set _chip_cls = { 'open': 'bg-yellow-100 dark:bg-yellow-500/10 text-yellow-800 dark:text-yellow-300', 'in_progress': 'bg-blue-100 dark:bg-blue-500/10 text-blue-800 dark:text-blue-300', 'waiting_customer': 'bg-orange-100 dark:bg-orange-500/10 text-orange-800 dark:text-orange-300', 'answered': 'bg-teal-100 dark:bg-teal-500/10 text-teal-800 dark:text-teal-300', 'closed': 'bg-green-100 dark:bg-green-500/10 text-green-800 dark:text-green-300' } %} {% set _chip_lbl = {'open':_('Open'),'in_progress':_('In Progress'),'waiting_customer':_('Waiting'),'answered':_('Answered'),'closed':_('Closed')} %}
{% if provider_thread_id %} {% endif %}
{% if ticket.thread.customer_hash %} {% endif %}
{# Mobile overflow menu — the icon buttons above are hidden below lg (duplicated in the bottom quick-action bar or rarely used); the non-duplicated ones live here so nothing is lost on mobile. #}
{# -- Header meta block (tier 2): product, site, status, badges -- Not sticky on mobile: triage info is read once at the top of the ticket, so it scrolls away with the page instead of permanently eating viewport. On lg+ it is always visible (column scrolls). #}
{# Row 1b: product icon + name + customer website #} {% if ticket.thread.product_name or ticket.thread.customer_website %} {# ml-8 aligns under the subject past the back arrow — desktop only; the back arrow is hidden below lg so the indent would be orphaned #}
{% if product and product.product_type == 'business_care' %}
BC
{% elif product and product.pico_url %} {% endif %} {% if ticket.thread.product_name %} {{ ticket.thread.product_name }} {% endif %} {% if ticket.thread.customer_website %} {% set ws_href = ticket.thread.customer_website if ticket.thread.customer_website.startswith('http') else 'https://' + ticket.thread.customer_website %} {% if site_check and site_check.is_online == true %} {% set tip = _('Online') %} {% if site_check.error_message %}{% set tip = 'Online (' ~ site_check.http_status ~ ' — ' ~ site_check.error_message ~ ')' %} {% elif site_check.http_status %}{% set tip = 'Online (' ~ site_check.http_status ~ ', ' ~ (site_check.response_time_ms or '?') ~ 'ms)' %}{% endif %} {% elif site_check and site_check.is_online == false %} {% set tip = _('Offline') %} {% if site_check.error_message %}{% set tip = 'Offline — ' ~ site_check.error_message %}{% endif %} {% endif %} {{ ticket.thread.customer_website | truncate(32) }} {% endif %} {% if customer_thread_count and customer_thread_count > 0 and ticket.thread.customer_hash %} {% endif %}
{% endif %} {# -- Second row: controls + info -- #}
{# Support contract status — only show if we have actual support data (zen_type or has_support) #} {% if ticket.thread.zen_type or ticket.thread.has_support is not none %} {% if ticket.thread.has_support %} verified {{ ticket.thread.support_type or _('Support Active') }} {% elif ticket.thread.is_support_expired %} error {{ _('Support Expired') }} {% else %} check_circle {{ _('Support Active') }} {% endif %} {% endif %} {# Intent / sentiment badge from latest AI classification #} {% if suggestions %} {% set _latest_s = suggestions|sort(attribute='created_at', reverse=True)|first %} {% set _intent = (_latest_s.classification_json|fromjson).get('intent_category', '') %} {% if _intent %} {% set _intent_map = { 'angry': ['red', 'sentiment_very_dissatisfied', _('Angry')], 'escalation': ['red', 'priority_high', _('Escalation')], 'complaint': ['orange', 'feedback', _('Complaint')], 'refund': ['orange', 'currency_exchange', _('Refund')], 'bug': ['amber', 'bug_report', _('Bug')], 'question': ['blue', 'help', _('Question')], 'config': ['blue', 'settings', _('Config')], 'help': ['blue', 'support_agent', _('Help')], 'feature_request': ['purple', 'lightbulb', _('Feature Request')], 'thanks_solved': ['green', 'check_circle', _('Thanks / Solved')] } %} {% set _style = _intent_map.get(_intent, ['gray', 'label', _intent | replace('_', ' ') | title]) %} {{ _style[1] }} {{ _style[2] }} {% endif %} {% endif %} {# Customer language — manual override (any language, not limited to what support knows) #}
{# Wrapper hides these below lg (duplicated in the mobile bottom bar / overflow menu); JS toggles .hidden on the buttons themselves, so the breakpoint gate must live on this wrapper, not on the buttons. #}
{# -- Thread Summary / TL;DR -- shown when no segments, otherwise integrated into topic overlay #} {% if thread_summary and segments|length <= 1 %}
summarize {{ _('Thread Summary') }}
{{ thread_summary }}
{% endif %} {# -- Review request banner -- Shown whenever a genuine thanks was detected on a ticket we replied to (the review-request tag), regardless of status. Removed by replying, "Already Sent", or "Not now"; re-armed by the next genuine thanks. #} {% if is_review_request %}
sentiment_very_satisfied

{{ _('Happy customer — time for a review request!') }}

{% trans %}This customer sent a thank-you on a closed ticket. Reply to clear the Addons "unanswered" counter and earn a positive rating.{% endtrans %}

{% endif %} {# -- Conversation thread -- #}
{# Floating topic overlay — single pill, updated via JS #} {% if segments|length > 1 %}
{# Expandable per-topic summary panel under the pill #}
{% endif %} {% set ns = namespace(prev_seg=-1) %} {% for msg in messages %} {% set cur_seg = message_segment_map.get(msg.id, -1) %} {% if cur_seg != ns.prev_seg and cur_seg >= 0 %} {# Inline topic marker (not sticky — observed by JS) #}
topic {{ segments[cur_seg].label if cur_seg < segments|length else _('Topic') }}
{% set ns.prev_seg = cur_seg %} {% endif %} {% if scroll_to_message_id and msg.id == scroll_to_message_id %}
{{ _('Your last reply') }}
{% endif %} {% set outbound = msg.direction == 'outbound' %}
{# Avatar #}
{{ 'C' if not outbound else 'A' }}
{# Bubble + hover actions #} {# min-w-0: without it, unbreakable content (long URLs) props up the flex min-width and the row overflows — the hover/translate action buttons get clipped off-screen on mobile. #}
{# Bubble #}
{{ msg.author_display }} {% if msg.is_auto_response %}{{ _('Auto') }}{% endif %} {{ msg.created_at|localdt('%Y-%m-%d %H:%M') }}
{% if msg.body_raw %}{{ msg.body_raw | safe }}{% else %}{{ msg.body_text | e | replace('\n', '
') | safe }}{% endif %}
{# Translation result area — pre-populated from cache when available #} {% set cached_tr = cached_translations.get(msg.id) %}
{% if cached_tr %}{{ cached_tr | safe }}{% endif %}
{# Attachments #} {% if msg.attachments %}
{% for att in msg.attachments %} {% set ext = att.filename.rsplit('.', 1)[-1].lower() if '.' in att.filename else '' %} {% if ext in ('jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp', 'svg') %} {% set att_icon = 'image' %} {% set att_color = 'bg-blue-50 hover:bg-blue-100 text-blue-700 border-blue-200 dark:bg-blue-500/10 dark:hover:bg-blue-500/20 dark:text-blue-400 dark:border-blue-500/30' %} {% set att_label = _('Image') %} {% elif ext == 'pdf' %} {% set att_icon = 'picture_as_pdf' %} {% set att_color = 'bg-red-50 hover:bg-red-100 text-red-700 border-red-200 dark:bg-red-500/10 dark:hover:bg-red-500/20 dark:text-red-400 dark:border-red-500/30' %} {% set att_label = 'PDF' %} {% elif ext in ('zip', 'rar', '7z', 'tar', 'gz') %} {% set att_icon = 'folder_zip' %} {% set att_color = 'bg-amber-50 hover:bg-amber-100 text-amber-700 border-amber-200 dark:bg-amber-500/10 dark:hover:bg-amber-500/20 dark:text-amber-400 dark:border-amber-500/30' %} {% set att_label = ext.upper() %} {% elif ext in ('doc', 'docx', 'odt', 'txt') %} {% set att_icon = 'description' %} {% set att_color = 'bg-indigo-50 hover:bg-indigo-100 text-indigo-700 border-indigo-200 dark:bg-indigo-500/10 dark:hover:bg-indigo-500/20 dark:text-indigo-400 dark:border-indigo-500/30' %} {% set att_label = ext.upper() %} {% else %} {% set att_icon = 'attach_file' %} {% set att_color = 'bg-gray-100 hover:bg-gray-200 text-gray-700 border-gray-200 dark:bg-gray-700 dark:hover:bg-gray-600 dark:text-gray-300 dark:border-gray-600' %} {% set att_label = ext.upper() if ext else _('File') %} {% endif %} {% set att_url = url_for('attachment_view', attachment_id=att.id) %} {% if ext in ('jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp', 'svg', 'pdf', 'txt') %} {# tojson emits a JS literal in DOUBLE quotes and escapes ' < > & as \uXXXX, leaving " raw — so it is only safe inside a SINGLE-quoted attribute. (`| e` alone emitted ', which the attribute parser decoded back into a real quote inside the JS literal, which is the bug this replaces.) #} {{ att_icon }} {{ att_label }} .{{ ext }} {% else %} {{ att_icon }} {{ att_label }} .{{ ext }} {% endif %} {% endfor %}
{% endif %}
{# Hover action bar. The column stretches to the bubble height and the buttons stick to the top of #message-thread inside it, so on a long message they stay reachable instead of sitting in the middle of the bubble, far off-screen. z-10 keeps them under the topic overlay. #}
{% if has_feature('ai_translate') %} {% else %} {% endif %} {% if known_languages | length > 1 %}
{% endif %}
{% endfor %}
{# -- Reply bar -- #}
{# Mobile FAB: opens the bottom-sheet side panel (Credentials, Notes, Topics, Tags). Anchored to the reply bar so it always floats just above the composer. The toggle/close JS (toggleAISidebar) predates this button — the trigger was never rendered, leaving the panel unreachable on mobile. #} {# Mobile-only quick-action bar — the ticket header scrolls away on mobile, so the per-ticket actions (skip to last message, mark read/unread, next ticket) are surfaced here, riding with the fixed reply bar. Hidden at lg+ where the header is permanently pinned. The .mobile-ticket-actions class is excluded from the "expand composer on click" handler below. #}
{# Mobile: full-width tap target replaces the minimized editor #} {# Minimize/expand toggle #} {# Desktop collapsed state — a single cohesive card replacing the disjointed bare AI button + plain editor. Visible only when reply-bar.minimized on lg+. Mobile uses #reply-open-btn instead (see @media rule below). #} {# Tool strip — compact icon buttons that expand panels on click #}
{% if id_product %} {% endif %} {% if product_has_release %} {% endif %}
{# Collapsible: Quick Reply panel #} {% if id_product %} {% endif %} {# Collapsible: Product URL builder panel #} {# AI Toolbar #}
{% if not has_feature('ai_classify') or not has_feature('ai_draft') %} {# Write Reply locked entirely — shouldn't happen given matrix, but guard #} {% endif %} {# Proofread, Shorter, Context, Check — Premium+ (ai_polish) only #} {% if has_feature('ai_polish') %} {% else %} {# Free plan: show locked versions with crown badge #} {% endif %}
{# AI instruction row (shown when Write reply is clicked) #} {# AI result panel #} {# Editor — full width #}
{% if org_plan == 'free' %}

{% trans %}Free plan: 1 attachment per reply.{% endtrans %}

{% endif %} {# Drag-and-drop overlay #}
{% if reply_signature %}
draw {{ _('click to preview signature') }}
{% endif %} {# Draft translation result panel #}
{# Action bar — horizontal below editor #} {% set _translate_lang = customer_language or preferred_language %}
{# Attach #} {# Translate — with dropdown #} {% if has_feature('ai_translate') %}
{% set _all_common_langs = ['ar','ca','cs','da','de','el','en','es','fi','fr','he','hr','hu','id','it','ja','ko','nl','no','pl','pt','ro','ru','sk','sv','tr','uk','zh'] %}
{% else %} {# Translate locked — Free plan #} {% endif %}
{# Send — right-aligned #}
{# Quill.js WYSIWYG Editor #} {# Paste image confirmation dialog #} {# ── Drag-and-drop on reply bar ── #}
{# ============== Right sidebar ============== #} {# Mobile scrim behind the bottom sheet — tap to close (sheet covers 30vh→bottom) #}
{# ── Credential Modal ── #} {# Credential panel — non-blocking slide-out, no overlay #} {% if product_has_release %} {% endif %} {% if id_product %} {% endif %} {# ── Attachment Lightbox Modal ── #} {# ── Customer threads popup (lazy-loaded, inbox-style) ── #} {% if has_feature('export_conversation') %} {# ── Export conversation (Markdown) modal — MAX plan ── #} {% endif %} {% endblock %} {% block nav_mobile_back %} arrow_back {% endblock %}