@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.
--}}
{{ __('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. --}}
{{-- 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. --}}
{{ __('PDF preview not supported inline.') }}
{{ __('Open in a new tab or download to view.') }}
@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. --}}
@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. --}}
@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)
{{ __('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
@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. --}}
{{-- Reship modal — full-screen overlay so it isn't cramped by
the 17rem side panel. Rendered as a sibling of the aside
so `fixed inset-0 z-50` covers the entire viewport. All
content is read from local Shopify data; only the Confirm
button calls Shopify, via ShopifyReshipService. --}}
@if ($reshipOpen && $activeOrder)
@php
$modalItems = $lineItemsDetailed;
$modalAddress = $address;
$modalOrderName = $orderSummary['order_name'] ?? '#'.$orderSummary['shopify_order_id'];
$selectedTotal = collect($reshipQuantities)->sum();
$hasSelection = $selectedTotal > 0;
// Helm stock map keyed by variant_id (string). Null
// value = "Unknown". Resolved via HelmStockResolver
// (default NullHelmStockResolver returns null for
// everything). No Shopify call to determine stock.
$stockMap = $this->stockMapForActiveOrder;
@endphp
{{-- Teleport the modal to so it escapes any
ancestor that might create a containing block for
`fixed` (transform / filter / contain / etc on the
conversation root or Flux's main panel). Without
the teleport the overlay was rendering INSIDE the
narrow right-side panel instead of over the whole
viewport. Alpine handles the move on render and
cleans up when the modal closes. --}}
{{-- Modal header --}}
{{ __('Create reship order') }}
{{-- Modal body (scrollable) --}}
{{ __('Selected items will be created as a free Shopify replacement order linked to') }}
{{ $modalOrderName }}.
{{-- Disabled placeholders for future product search /
custom item. These ship inert in this phase. --}}
{{-- Items section — card list (not a table).
Each row breathes: image, multi-line text,
premium qty stepper, line total, clear. --}}
{{ __('Items') }}
{{ __('Quantities default to 0 — reship only what was wrong.') }}
@foreach ($modalItems as $li)
@php
$qty = (int) ($reshipQuantities[$li['id']] ?? 0);
$maxQty = (int) ($li['quantity'] ?? 0);
$stock = $stockMap[$li['variant_id'] ?? ''] ?? null;
@endphp
{{-- Single horizontal flex line. The earlier 12-col
grid was forcing the SKU + stock + qty stepper
to wrap onto extra rows, blowing the modal up
tall enough to scroll off screen. This layout
keeps each item to ONE line: image, title
block, stock pill, qty stepper, line total,
trash. Each segment has `shrink-0` so the qty
stepper never collapses or wraps. --}}
@endif
{{-- Title + condensed meta row.
Title clamped to 1 line so the row height
stays fixed. Variant + SKU joined into a
single meta line in a normal-case font —
not the previous uppercase tracking-wide
which read as oversized. --}}
@endif
{{-- Note card removed — operator-visible internal
notes can be added directly from the
conversation composer. The reship action
still backfills a sensible default note on
Shopify's draft order; see
Conversation::confirmReship for the default.
--}}
{{-- Tags card --}}
@endif
@endif
{{-- =====================================================
CANCEL MODAL — 5-state UI driven by $cancelPreflightBlockReason
(a string from App\Actions\Enums\PreflightBlockReason).
Teleported to body so it escapes any ancestor's containing
block, same pattern as the reship modal above.
===================================================== --}}
@if ($cancelModalOpen)
@endif
{{-- Line items with per-line qty steppers. Default
is "all selected at full original quantity".
Reducing any line below original reroutes to
the refund modal on confirm. --}}
@if (! empty($cancelSelections))
{{ __('Resolve the order state in Helm first, then verify in Shopify. No automatic refund offered here to avoid accidentally refunding borderline progress.') }}
@endif
{{-- Generic fallback when none of the above match --}}
@if (! $stateA && ! $stateB && ! $stateC && ! $stateD && ! $stateE)
{{ $cancelPreflightSummary ?: __('Cancel is not available for this order.') }}
@endif
{{-- =====================================================
REFUND MODAL — reship-style per-line picker. Opened
directly via the order panel button or via the cancel
modal's partial-tick reroute (selections pre-populated).
===================================================== --}}
@if ($refundModalOpen)
@endif
{{-- =====================================================
EDIT ADDRESS MODAL (Slice C)
Five-state machine driven by `editAddressPreflightBlockReason`:
- canProceed=true → State A (form)
- OrderAlreadyShipped → State B
- HelmLookupAmbiguous / HelmLookupFailed → State C
- HelmLookupNotFound → State D
- AddressNotEditable → State E
Manual channel_order_id lookup is offered in C and D so
the operator can disambiguate without leaving the modal.
===================================================== --}}
@if ($editAddressModalOpen)
@php
$eaReason = $editAddressPreflightBlockReason;
$eaStateA = $editAddressPreflightCanProceed;
// Spec change: fulfilled orders are NO LONGER hard-blocked
// by the modal. Old States B (OrderAlreadyShipped) + E
// (AddressNotEditable) are merged into the new
// "fulfilled warning" variant of State A. The boolean
// here is sourced from the preflight context the action
// surfaces — `requires_fulfilled_confirm = true`.
$eaFulfilledWarning = $eaStateA && ! empty($editAddressPreflightContext['requires_fulfilled_confirm']);
$eaStateC = in_array($eaReason, [
\App\Actions\Enums\PreflightBlockReason::HelmLookupAmbiguous->value,
\App\Actions\Enums\PreflightBlockReason::HelmLookupFailed->value,
], true);
$eaStateD = $eaReason === \App\Actions\Enums\PreflightBlockReason::HelmLookupNotFound->value;
@endphp
{{ __('Edit shipping address') }}
{{-- STATE A: editable form (handles both
pre-fulfilment and fulfilled-warning
variants — the latter shows a red warning
+ a "I understand the shipped parcel won't
change" checkbox that must be ticked before
Save will run). --}}
@if ($eaStateA || $editAddressManualConfirmedChannelOrderId)
@if ($eaFulfilledWarning)
{{ __('This order is already fulfilled.') }}
{{ __('Updating the address will only update the saved order/customer information in ClearDesk only and will not change the shipped parcel.') }}
{{ __('Edit the fields you want to change. Untouched fields are left alone on both Helm and Shopify.') }}
@endif
{{-- Operator-facing note about Helm field-name uncertainty.
Confirmed Helm fields are phone_one, shipping_address_line_one,
shipping_address_postcode (per operator example). Others are
best-guesses derived from the import-side read field names; if
your Helm deployment uses different keys, the 422 path in the
action will surface that and a log entry will name the field. --}}
{{ __('Heads up:') }}
{{ __('Helm field mapping is partly inferred. If Helm rejects the update, no Shopify change is attempted — the toast and your storage/logs will name the field that needs adjusting.') }}
@if (! empty($editAddressPreflightWarnings))
@foreach ($editAddressPreflightWarnings as $warning)
{{ $warning }}
@endforeach
@endif
@endif
{{-- States B + E (hard-block on shipped /
status-ineligible) were removed in the
cleanup-pass spec change — both cases now
route through State A's
`eaFulfilledWarning` variant above. --}}
{{-- STATE C: Helm lookup ambiguous OR failed → manual channel_order_id --}}
@if ($eaStateC && ! $editAddressManualConfirmedChannelOrderId)
{{ __('Helm match is ambiguous or unreachable.') }}
{{ $editAddressPreflightSummary }}
@foreach ($editAddressPreflightBlockers as $blocker)
{{ $editAddressPreflightSummary ?: __('Address edit is not available for this order.') }}
@endif
{{ __('Back') }}
@if ($eaStateA || $editAddressManualConfirmedChannelOrderId)
@php
// Save is disabled on the fulfilled-warning
// path until the operator ticks the
// explicit confirm checkbox. Defence in
// depth — the action layer also refuses
// without `confirm_fulfilled_override`,
// so even a forged payload can't slip
// through.
$eaSaveDisabled = $eaFulfilledWarning && ! $editAddressConfirmFulfilledOverride;
@endphp
{{ $eaFulfilledWarning ? __('Save (ClearDesk only)') : __('Save address') }}
@endif
@endif
{{-- =====================================================
OPERATIONAL MACRO WARNING — Push 2G.
Fires when the operator applies a macro whose attached
action is operational (cancel / refund / edit_address /
reship / generate_return_label). The macro NEVER runs
the action — it stages a warning with three explicit
choices. Macros are no longer a path to silent
destructive mutations.
Open / close state is gated on
`$pendingOperationalMacroId !== null`. Closed by every
resolution method via `clearOperationalMacroWarningState`.
===================================================== --}}
@if ($pendingOperationalMacroId !== null)
@include('livewire.tickets._operational-macro-warning-modal')
@endif
{{-- =====================================================
RETURN LABEL MODAL — Push 2D operator picker.
Opens when the operator clicks Run on the
`generate_return_label` suggested-action chip.
Pure UI / no API calls until `confirmReturnLabel` fires.
Teleported to body so it escapes any ancestor
containing block, same pattern as the other modals.
===================================================== --}}
@if ($returnLabelOpen)
@include('livewire.tickets._return-label-modal')
@endif