@php $ticket = $this->ticket; @endphp {{-- Conversation pane internals = main column + right context column. Strict horizontal flex, no responsive breakpoints — the workspace is desktop-first. Every column declares its width discipline: - root has `flex-1 min-h-0 min-w-0 overflow-hidden` so long subjects/messages don't expand the page, AND so the height chain stays flex-driven (not percentage-driven). `flex-1` plus `align-items: stretch` (default) makes the root fill its flex-container parent in both axes without relying on `h-full` propagating through every ancestor. - main column has `flex-1 min-w-0 min-h-0` so it shrinks correctly in both axes. - right context column has `w-[17rem] shrink-0` so it never gets squashed by long content next to it. Inside main: - header (shrink-0) stays pinned at top. - timeline (flex-1 min-h-0 overflow-y-auto) scrolls. - composer (shrink-0) stays pinned at bottom. Dark mode: explicit `dark:bg-zinc-950` on the root so the pane is dark even before the inner header / timeline / composer paint their own backgrounds, and so any gap during streaming render doesn't flash white. --}}
@if ($ticket === null) {{-- Empty / not-found state. Fills the whole pane. --}}
{{ __('Ticket not available') }} {{ __('The ticket may have been deleted, reassigned to a different workspace, or never existed.') }}
@else {{-- ─── Main conversation column ──────────────────────── --}} {{-- Header / timeline / composer are SIBLINGS here. Only the timeline gets `flex-1 overflow-y-auto`; header + composer stay `shrink-0`. The combination of `h-full` (explicit height from parent) + `min-h-0` (allow flex-1 child to shrink) + `flex-col overflow-hidden` is what makes the timeline the actual scroll container instead of the page body. --}}
{{-- Header — compact: tighter padding, smaller gap between the subject and the badge row. --}}
{{ $ticket->subject }}
{{-- Status dropdown — clickable badge that lets agents change ticket status at any time. Each option carries the literal status string in its wire:click; server-side validation enforces the enum. --}} {{ ucfirst($ticket->status->value) }} @foreach (\App\Enums\TicketStatus::cases() as $status) {{ ucfirst($status->value) }} @endforeach {{ ucfirst($ticket->priority->value) }} {{ ucfirst($ticket->source->value) }} @if ($ticket->store) {{ $ticket->store->name }} @endif {{-- Assignee dropdown — sits alongside the status dropdown. Each menu entry's wire:click hardcodes the user id (or null for unassign), so the server only ever sees a primitive. Membership is verified server-side. --}} @if ($ticket->assignedUser) {{ $ticket->assignedUser->name }} @else {{ __('Unassigned') }} @endif @if ($ticket->assigned_user_id !== auth()->id()) {{ __('Assign to me') }} @endif @if ($ticket->assigned_user_id !== null) {{ __('Unassign') }} @endif @foreach ($this->availableAssignees as $member) {{ $member->name }} @endforeach
@php $attachments = $this->ticketAttachments; @endphp @if ($attachments->isNotEmpty()) @php // Build a JS-ready list of attachments for // the Alpine lightbox. Names are // intentionally short — `n`, `m`, `s`, `i`, // `p`, `d`, `c` — to keep the data // attribute small while still readable. $attachmentJs = $attachments->map(fn ($a) => [ 'i' => $a->id, 'n' => $a->original_filename, 'm' => $a->mime_type, 's' => $a->humanSize(), 'p' => route('attachments.show', ['attachment' => $a->id]), 'd' => route('attachments.show', ['attachment' => $a->id, 'disposition' => 'attachment']), 'isImage' => $a->isImage(), 'isPdf' => $a->isPdf(), 'c' => (bool) $a->uploaded_by_customer, ])->values()->all(); @endphp {{-- Attachment strip + lightbox modal. Strip: thumbnail row between header and timeline. Click a thumbnail → opens the lightbox at that index. Linear-style UX: contained, not page-replacing. Lightbox: full-viewport modal with prev/next arrows (only when a sibling exists), download button, optional open-in-new-tab, close on X / Escape / backdrop click. Keyboard nav (only active when the modal is open — guarded by `lightboxOpen` — so background hotkeys aren't hijacked): Left → previous attachment Right → next attachment Esc → close The whole thing lives in one Alpine scope so the strip + modal share `index` state — clicking a different thumbnail re-points the open modal too. --}}
{{-- Strip header --}}
{{ trans_choice('{1} 1 attachment|[2,*] :count attachments', $attachments->count(), ['count' => $attachments->count()]) }}
{{-- Thumbnail strip. Clicking a card opens the lightbox at its index. --}}
@foreach ($attachments as $i => $attachment) @php $previewUrl = route('attachments.show', ['attachment' => $attachment->id]); $isImage = $attachment->isImage(); $isPdf = $attachment->isPdf(); @endphp @endforeach
{{-- Lightbox modal. Rendered once at the end of the strip; `x-show="lightboxOpen"` keeps the DOM tree but hides it until the operator clicks a thumbnail. Backdrop click closes (x-on:click on the outer); the inner card stops propagation so clicks inside the modal don't dismiss it. --}}
{{-- Prev arrow — anchored left, only when a sibling exists. --}} {{-- Next arrow — anchored right. --}} {{-- Modal card. Stops click propagation so clicks inside don't trigger the backdrop's close handler. --}}
{{-- Header: filename + meta + close --}}
·
{{-- Preview body. --}}
{{-- Footer: action buttons. --}}
@endif {{-- Timeline — only this region scrolls. `x-ref="timeline"` is the target for the scroll-to-bottom helper on the root component. Background matches the conversation main column (`bg-white dark:bg-zinc-950`) so the scrolling surface reads as a single panel rather than a lighter band between header and composer. --}}
@forelse ($this->timelineMessages as $message) @php $isInternal = $message->direction === \App\Enums\MessageDirection::Internal; $isInbound = $message->direction === \App\Enums\MessageDirection::Inbound; $isOutbound = $message->direction === \App\Enums\MessageDirection::Outbound; $deliveryState = $isOutbound ? $message->deliveryState() : null; @endphp
@if ($isInbound) {{ $ticket->customer?->name ?: $ticket->customer?->email ?: __('Customer') }} @else {{ $message->author?->name ?? __('System') }} @endif · {{ ucfirst($message->direction->value) }} {{-- Delivery-state badge for outbound rows. Operator-only signal: tells the agent whether the reply actually reached the customer (sent), is awaiting retry (queued), or permanently failed (failed). Never rendered for inbound / internal messages. The 'sent' badge is intentionally rendered too — gives a positive confirmation in the timeline. --}} @if ($deliveryState !== null) {{ $deliveryState->label() }} @endif · {{ $message->created_at->diffForHumans() }}
{{-- Preserve paragraph breaks safely: `e()` escapes any HTML the body contains, then `nl2br()` adds
tags after each newline. This is robust against CSS resets that can trip up `whitespace-pre-wrap`, and applies the same way to internal notes, outbound replies, and future inbound messages. --}}
{!! nl2br(e($message->body)) !!}
@empty
{{ __('No messages on this ticket yet. Add an internal note below.') }}
@endforelse
{{-- Composer — pinned to bottom of main column. The composer sits flush against the dark page bg so the timeline overlap looks intentional, not like a card floating in a brighter band. --}}
@php $tabBase = 'flex items-center gap-1.5 px-3 py-2 text-sm font-medium border-b-2 -mb-px transition'; $tabActive = 'border-zinc-900 text-zinc-900 dark:border-zinc-100 dark:text-zinc-100'; $tabIdle = 'border-transparent text-zinc-500 hover:text-zinc-700 dark:text-zinc-400 dark:hover:text-zinc-200'; @endphp {{-- Macros pill — third tab, sits naturally next to Public reply (no `ml-auto` push to the right edge). Operator feedback: detached placement felt jarring; this places it immediately after Public reply so the dropdown anchors under the button itself. Dropdown width: `min-w-[280px] max-w-[360px]` — the previous `w-80` was being squeezed by the parent flex layout to roughly the button's intrinsic width (~100px), making the typeahead unreadable. Min-width keeps it usable at all viewport widths; max-width stops a long macro name forcing the panel over the order list. Alignment: `left-0 top-full` so the dropdown opens directly below the Macros button's left edge — visually attached. --}}
{{-- VISIBLE DROPDOWN PANEL. ───────────────────────────────────────── Width + height are set via INLINE STYLE instead of Tailwind classes. Why: - inline styles bypass any Tailwind JIT / build-pipeline issue that would silently drop `w-80` / `min-w-[320px]` from the bundle, - inline styles win against any unrelated CSS the global / Flux UI sheet might apply, - the operator can confirm the values in DevTools instantly. `max-height: 235px` is the hard operator-specified cap. No min() / viewport-calc estimates: previous attempts left the panel hanging off the bottom, so the cap is now a flat number. `overflow-y-auto` is on this same panel — scroll lives on the actual visible element the operator sees clip, not on an unrelated inner wrapper. `data-test="macro-dropdown-panel"` is the assertion anchor for the regression test. --}}
@forelse ($this->availableMacros as $row) @empty
{{ __('No macros match.') }}
@endforelse
{{-- No staging banner. ─────────────────────────────────────────────── Operator-confirmed direction: composer macros are quick canned replies. Picking one inserts the rendered reply directly into the composer and silently applies any internal-note / status / assignee side-effects. No "Apply" intermediate step, no reason picker (composer macros never ask reasons — that lives in operational modals). If apply fails (e.g. soft-deleted mid-flight) the operator sees a Flux toast instead of an ugly inline banner. --}} @if ($composerMode === 'internal') {{-- rows=7 gives the operator roughly 90px more vertical typing space than rows=3 — addresses the "cramped under the pending-macro banner" feedback without redesigning the composer layout. Same bump on the public-reply form below for parity. --}}
{{ __('Visible only to your team') }} {{ __('Add note') }}
@else @php $customerEmail = $ticket->customer?->email; @endphp @if ($customerEmail === null || $customerEmail === '') {{-- No customer email on the ticket — public reply requires a recipient. We surface this clearly instead of letting the user type a reply that can't be delivered. --}}
{{ __('No customer email on this ticket') }}
{{ __('Link a customer (with an email address) to send a public reply. You can still add internal notes.') }}
@else @php // Pending outbound attachments staged by macro-suggested // actions (today: attach_file_template). Surface them // above the reply textarea so the operator can SEE // what will be attached on send — agreed UX from the // operator: "reinforce trust — this macro includes // the denial form attachment". // // Query is fixed-budget per ticket (no N+1) and goes // through AccountScope, so foreign-tenant rows stay // invisible. $pendingAttachments = $ticket ? \App\Models\TicketAttachment::query() ->where('ticket_id', $ticket->id) ->where('pending_outbound', true) ->orderBy('id') ->get() : collect(); @endphp {{-- Suggested action chip rendered after applyMacro populates $suggestedAction. NEVER auto-runs — "Run" / "Dismiss" are the only paths. --}} @if ($suggestedAction !== null)
{{ $suggestedAction['label'] }} @if ($suggestedAction['prefill_summary'] !== '') {{ $suggestedAction['prefill_summary'] }} @endif @if (! empty($suggestedAction['applied'])) {{ __('Done') }} @endif
{{ __('Suggested by macro') }}: {{ $suggestedAction['macro_name'] }}
@if ($suggestedAction['block_reason'] !== '')
{{ $suggestedAction['block_reason'] }}
@endif
@if (empty($suggestedAction['applied'])) {{ __('Run') }} @endif @php // Pick honest button copy. // - Chip with a staged attachment (only // attach_file_template auto-stages on // apply, and `applied=true` is the // signal) → "Remove attachment". The // dismiss handler ALSO deletes the // pending_outbound row + the file on // disk, so "Hide" is misleading. // - Otherwise (blocked stub, fresh // non-deterministic chip) → "Dismiss". $dismissLabel = $suggestedAction['action_type'] === 'attach_file_template' && ! empty($suggestedAction['applied']) ? __('Remove attachment') : __('Dismiss'); @endphp {{ $dismissLabel }}
@endif {{-- Pending outbound attachments (staged by AttachFileTemplateAction). One chip per file so the operator can verify what's queued before clicking Send AND remove the staged file with a single click if they change their mind. The X button calls removePendingAttachment on the Livewire component which deletes the on-disk file + force-deletes the TicketAttachment row (only when pending_outbound is still true). --}} @if ($pendingAttachments->isNotEmpty())
{{ __('Attaching on send:') }} @foreach ($pendingAttachments as $pending) {{ $pending->original_filename }} @endforeach
@endif
{{ __('Replying to') }}: {{ $customerEmail }} {{ __('Send reply') }}
@endif @endif
{{-- ─── Right context column ──────────────────────────── --}} {{-- Explicit `h-full` + `min-h-0` for the same reason the main column has them — the aside is a flex item in the conversation root (row), so without `h-full` its height relies on stretched-flex propagation which is unreliable across browsers. SCROLL OWNERSHIP: the aside itself is `overflow-hidden`, NOT `overflow-y-auto`. The ONE scroll container in this column is the inner Shopify orders list (see further down — `context-shopify-order-list-items`). The fixed- height upper blocks (customer, badge, store, integration) keep their natural heights and the orders list absorbs the remaining vertical space via `flex-1 min-h-0`. This was changed back from `overflow-y-auto` after an operator hit the squashed-cards bug on tickets with 19+ orders: when the whole aside scrolled, the flex-col engine compressed every card below its natural height before the inner wrapper's `max-h-[40rem]` could engage. Pinning scroll to the orders block keeps the cards at full height and gives the operator a familiar local scroll affordance instead of a sidebar-wide scroll. Background is explicit `bg-zinc-50 dark:bg-zinc-950` so the panel is dark in dark mode regardless of what its parent paints. --}}
{{-- ─── Suggested macros (AI Stage A scaffold) ────────── Reads from `MacroSuggestionService` (Null binding today → always empty). Renders an empty-state placeholder. When the embedding / LLM tiers land, this card surfaces the top-N macros without changing markup. --}}
{{ __('Suggested macros') }}
@php $aiSuggestions = $this->suggestedMacroEntries; @endphp @if (empty($aiSuggestions))
{{ __('No AI suggestions available yet.') }}
@else @endif
{{ __('Store') }}
{{ $ticket->store?->name ?? __('Unassigned') }}
{{-- Return-label status card. Renders only when a ReturnLabel row exists for this ticket; otherwise the partial is a no-op. Read-only — polling + auto-reship live entirely server-side. --}} @include('livewire.tickets._return-label-card') {{-- Shopify orders + actions panel. Reads only local `shopify_customers` / `shopify_orders` — NEVER hits Shopify during rendering. Only the explicit Reship confirm action calls Shopify (see ShopifyReshipService). Layout: a Gorgias-style card with line items, totals, shipping, and address. Past orders below render as compact cards in a scrollable strip. Reship opens a full-screen modal rather than an inline panel. --}} @php // ───────────────────────────────────────────────── // Performance note (do not regress). // // This @php block runs on EVERY Livewire roundtrip // — every click on an order card, action button, // status dropdown. Keep it cheap. // // Cheap-on-every-render: // - $shopifyCtx (zero queries) // - $activeOrder (one query, cached // on the computed) // - $sidebarCards (TWO queries flat // regardless of how // many orders exist) // // Heavy (raw_payload decode + image-cache query): // - $orderSummary, $lineItemsDetailed, $totals, // $shipping, $address — only needed when the // slide-out is OPEN, the cancel/refund/ // edit-address/reship modal is open, or any // reship draft preflight is being computed. // // Computing them only on demand drops every // closed-slide-out render's cost by one full // detailedLineItems() query plus a payload decode. // ───────────────────────────────────────────────── $shopifyCtx = $this->shopifyContext; $activeOrder = $this->activeShopifyOrder; $sidebarCards = $shopifyCtx ? $shopifyCtx->sidebarOrderCards() : collect(); // Demand-gate the heavy active-order data. Each of // these surfaces consumes the variables; if NONE of // them is currently visible, we skip the work. $orderDetailNeeded = $shopifyCtx && $activeOrder && ( $orderSlideoutOpen || $cancelModalOpen || $refundModalOpen || $editAddressModalOpen || $reshipOpen ); $orderSummary = $orderDetailNeeded ? $shopifyCtx->summary($activeOrder) : null; $lineItemsDetailed = $orderDetailNeeded ? $shopifyCtx->detailedLineItems($activeOrder) : []; $totals = $orderDetailNeeded ? $shopifyCtx->totals($activeOrder) : null; $shipping = $orderDetailNeeded ? $shopifyCtx->shippingSummary($activeOrder) : null; $address = $orderDetailNeeded ? $shopifyCtx->shippingAddress($activeOrder) : null; @endphp {{-- Compact order list in the right sidebar. Clicking any row opens the 500px slide-out detail panel anchored to the right. Replaces the old fat active-order card that bulked out this sidebar. --}} {{-- Full Shopify orders list — uses the same card structure as the legacy "past orders" strip (Shopify icon, order name, date, product thumbnails, line-item summary, status badges, total + count footer). Every card is a single button that opens the slide-out detail panel. No "Use this order" / "Open" CTA copy — the whole card is the click target. --}} @if ($sidebarCards->isNotEmpty()) {{-- Order list container. ───────────────────────────────────────────────── This is the ONLY block in the right sidebar that should scroll. The aside above sets `min-h-0` and can grow to fill the viewport; the customer card, badge, store row and integration block all keep their natural heights via `shrink-0`-friendly content. This section gets `flex-1 min-h-0` so it absorbs the remaining vertical space, and the inner items wrapper owns `overflow-y-auto`. ───────────────────────────────────────────────── Without `flex-1 min-h-0` the flex-col aside would compress this container when the total content height exceeds the viewport, squashing every card into a few pixels (the bug the operator hit with 19-order tickets). `shrink-0` on each card below is the belt-and-braces guard. ───────────────────────────────────────────────── `min-w-0` + `overflow-hidden` on the outer block still defend against a long product title pushing the sidebar wider — that horizontal-overflow rule is independent of the vertical-scroll rule. ───────────────────────────────────────────────── Perf: the loop body iterates pre-baked card objects from `sidebarOrderCards()`. NO per-row `detailedLineItems` / `lineItemSummary` calls happen here — those used to fire 1+N queries per render. The card object already carries thumbnails, summary titles, totals, badges. --}}
{{ __('Shopify orders') }} {{ $sidebarCards->count() }}
{{-- Items wrapper owns the scrollbar. `flex-1 min-h-0` so it takes whatever vertical space the outer block has left after the header row. `overflow-y-auto` engages the scrollbar once the cards' natural heights exceed the pane. No `max-h-…` cap any more — the cap was preventing the operator from seeing the list at full height on tall viewports, and (combined with the missing `shrink-0` on cards) was the immediate cause of the squashed-card bug. --}}
@foreach ($sidebarCards as $row) @php // All per-row data is already on the // pre-baked card. No service calls, // no raw_payload decode, no image // cache query — see the @php block // up top for the perf rationale. $rowIsSelected = $activeOrder && (int) $activeOrder->id === (int) $row->id && $orderSlideoutOpen; // Defensive: some legacy / draft / // cancelled orders end up with no // reconstructible line_items in // raw_payload. Footer count still // resolves to >0 for those. Without // a fallback the card looks broken. $rowHasItemsButNoDetails = empty($row->thumbnails) && ($row->summary_total_quantity > 0 || $row->summary_overflow > 0); @endphp {{-- Whole card = clickable @endforeach
@endif {{-- Slide-out detail panel. ==================================================== - x-teleport="body": render at level, so the panel is not clipped or repositioned by the right sidebar's `overflow-y-auto` / flex constraints. Animates as a screen-edge overlay over the whole conversation column. - ALWAYS rendered (no @if gate on the wrapper) so the `translate-x` transition can slide in/out smoothly. When closed the panel sits at `translate-x-full` (off-screen right) with `pointer-events-none` so it can't intercept clicks behind it. - `wire:key` is STABLE (no order id in the key). Previously the key embedded `$activeOrder->id`, which made Livewire treat the slide-out as a different element on every order-card click — triggering an `x-teleport` re-run and leaving stale body-level fragments that intercepted future clicks ("buttons stop responding"). The slide-out is a SINGLE panel whose content swaps; the DOM node identity must stay stable. - `wire:init` is NOT on this wrapper any more. It lives on the outermost Conversation root so it fires once per component mount only. Putting it on a re-keyed element caused image hydration to re-fire on every order click and blocked the worker for ~2s each time. ==================================================== --}}