Support System — Frontend Implementation Guide
This guide contains everything needed to implement the support module in the Next.js frontend. It covers all GraphQL operations, recommended page structure, component breakdown, and real-time subscription handling.
Prerequisites
The backend is fully deployed with:
- 11 queries, 17 mutations, 2 subscriptions
User.isSupportAgentfield resolver (use to conditionally show support portal nav)downloadUrlfield resolver on attachments (auto-generates S3 presigned URLs)
Page Structure
User-facing pages go under app/(shared)/support/. Agent and admin pages go under app/(admin)/admin/support/. Gate access using user.isSupportAgent or user.isSuperAdmin.
GraphQL Operations
Fragments
Define reusable fragments in graphql/operations/support/fragments.ts:
fragment TicketUserFields on TicketUser {
id
firstName
lastName
email
profileImageUrl
}
fragment SupportTicketFields on SupportTicketOutput {
id
ticketNumber
subject
description
category
priority
status
createdById
createdBy { ...TicketUserFields }
assignedToId
assignedTo { ...TicketUserFields }
testerId
tester { ...TicketUserFields }
companyId
lastActivityAt
firstResponseAt
resolvedAt
closedAt
createdAt
updatedAt
}
fragment TicketAttachmentFields on TicketAttachmentOutput {
id
messageId
filename
contentType
sizeBytes
objectPath
downloadUrl
createdAt
}
fragment TicketMessageFields on TicketMessageOutput {
id
ticketId
senderId
sender { ...TicketUserFields }
senderRole
content
isInternalNote
attachments { ...TicketAttachmentFields }
createdAt
updatedAt
}
fragment TicketAuditLogFields on TicketAuditLogOutput {
id
ticketId
actorId
actor { ...TicketUserFields }
action
previousValue
newValue
metadata
createdAt
}
fragment FaqEntryFields on FaqEntryOutput {
id
question
answer
category
tags
sourceTicketId
status
createdById
publishedAt
viewCount
helpfulCount
createdAt
updatedAt
}
Queries
User Queries
# My tickets (paginated, filterable by status)
query MyTickets($input: MyTicketsInput) {
myTickets(input: $input) {
items { ...SupportTicketFields }
total
page
limit
}
}
# Single ticket with messages (user view — no internal notes)
query MyTicketDetail($ticketId: ID!) {
myTicketDetail(ticketId: $ticketId) {
ticket { ...SupportTicketFields }
messages { ...TicketMessageFields }
auditLog { ...TicketAuditLogFields }
}
}
# Unread count for badge
query MyUnreadTicketCount {
myUnreadTicketCount {
count
}
}
# Published FAQs (public-facing)
query PublishedFaqs($input: PublishedFaqsInput) {
publishedFaqs(input: $input) {
items { ...FaqEntryFields }
total
page
limit
}
}
Support Agent Queries
# All tickets (agent dashboard)
query SupportTickets($input: SupportTicketsInput!) {
supportTickets(input: $input) {
items { ...SupportTicketFields }
total
page
limit
}
}
# Ticket detail with ALL messages (including internal notes) + audit log
query SupportTicketDetail($ticketId: ID!) {
supportTicketDetail(ticketId: $ticketId) {
ticket { ...SupportTicketFields }
messages { ...TicketMessageFields }
auditLog { ...TicketAuditLogFields }
}
}
# Dashboard statistics
query SupportDashboardStats {
supportDashboardStats {
totalOpen
totalInProgress
totalWaitingOnCustomer
totalResolved
totalClosed
avgFirstResponseTime
avgResolutionTime
ticketsByCategory { category count }
ticketsByPriority { priority count }
}
}
# All FAQs (including drafts/archived)
query SupportFaqs($input: SupportFaqsInput) {
supportFaqs(input: $input) {
items { ...FaqEntryFields }
total
page
limit
}
}
Admin Queries
# Full overview with agent performance
query AdminSupportOverview {
adminSupportOverview {
totalOpen
totalInProgress
totalWaitingOnCustomer
totalResolved
totalClosed
avgFirstResponseTime
avgResolutionTime
ticketsByCategory { category count }
ticketsByPriority { priority count }
agentPerformance {
agentId
agentName
assignedCount
resolvedCount
avgResponseTime
avgResolutionTime
}
}
}
# List support agents (for dropdowns — accessible to support agents)
query ListSupportAgents {
listSupportAgents {
id
firstName
lastName
email
profileImageUrl
}
}
# List support agents with performance metrics (super admin only)
query AdminListSupportAgents {
adminListSupportAgents {
id
firstName
lastName
email
profileImageUrl
assignedTicketCount
resolvedTicketCount
}
}
# Audit log (filterable)
query AdminSupportAuditLog($input: AdminSupportAuditLogInput) {
adminSupportAuditLog(input: $input) {
items { ...TicketAuditLogFields }
total
page
limit
}
}
Mutations
User Mutations
# Create new ticket
mutation CreateSupportTicket($input: CreateSupportTicketInput!) {
createSupportTicket(input: $input) {
...SupportTicketFields
}
}
# Send message on own ticket
mutation SendTicketMessage($input: SendTicketMessageInput!) {
sendTicketMessage(input: $input) {
...TicketMessageFields
}
}
# Close own ticket
mutation CloseMyTicket($ticketId: ID!) {
closeMyTicket(ticketId: $ticketId) {
...SupportTicketFields
}
}
# Reopen own ticket
mutation ReopenMyTicket($ticketId: ID!) {
reopenMyTicket(ticketId: $ticketId) {
...SupportTicketFields
}
}
# Remove an attachment from a sent ticket message.
# Despite living on the user resolver, the service authorizes
# the ticket creator OR support agents OR super admins, so this
# same operation powers the X button in both /support and the
# /support-portal ticket detail.
mutation RemoveTicketAttachment($attachmentId: ID!) {
removeTicketAttachment(attachmentId: $attachmentId) {
ticketId
attachmentId
messageId # null when the parent message was also deleted
messageDeleted
}
}
# Mark FAQ as helpful (idempotent — no-op if already voted)
mutation MarkFaqHelpful($id: ID!) {
markFaqHelpful(id: $id) {
id
helpfulCount
}
}
Support Agent Mutations
# Assign ticket to agent
mutation AssignTicket($ticketId: ID!, $agentId: ID!) {
assignTicket(ticketId: $ticketId, agentId: $agentId) {
...SupportTicketFields
}
}
# Set / change / clear the tester (pass null to clear)
mutation AssignTester($ticketId: ID!, $testerId: ID) {
assignTester(ticketId: $ticketId, testerId: $testerId) {
...SupportTicketFields
}
}
# Update status
mutation UpdateTicketStatus($ticketId: ID!, $status: TicketStatus!) {
updateTicketStatus(ticketId: $ticketId, status: $status) {
...SupportTicketFields
}
}
# Update priority
mutation UpdateTicketPriority($ticketId: ID!, $priority: TicketPriority!) {
updateTicketPriority(ticketId: $ticketId, priority: $priority) {
...SupportTicketFields
}
}
# Update category
mutation UpdateTicketCategory($ticketId: ID!, $category: TicketCategory!) {
updateTicketCategory(ticketId: $ticketId, category: $category) {
...SupportTicketFields
}
}
# Agent reply (visible to customer)
mutation SendSupportReply($input: SendSupportReplyInput!) {
sendSupportReply(input: $input) {
...TicketMessageFields
}
}
# Internal note (hidden from customer)
mutation AddInternalNote($input: AddInternalNoteInput!) {
addInternalNote(input: $input) {
...TicketMessageFields
}
}
# Create FAQ entry
mutation CreateFaqEntry($input: CreateFaqInput!) {
createFaqEntry(input: $input) {
...FaqEntryFields
}
}
# Update FAQ entry
mutation UpdateFaqEntry($id: ID!, $input: UpdateFaqInput!) {
updateFaqEntry(id: $id, input: $input) {
...FaqEntryFields
}
}
# Generate FAQ suggestion from ticket
mutation GenerateFaqSuggestion($ticketId: ID!) {
generateFaqSuggestion(ticketId: $ticketId) {
question
answer
tags
}
}
Admin Mutations
# Assign support role to user
mutation AdminAssignSupportRole($userId: ID!) {
adminAssignSupportRole(userId: $userId) {
success
message
}
}
# Revoke support role
mutation AdminRevokeSupportRole($userId: ID!) {
adminRevokeSupportRole(userId: $userId) {
success
message
}
}
# Delete FAQ (admin only)
mutation DeleteFaqEntry($id: ID!) {
deleteFaqEntry(id: $id) {
success
message
}
}
Subscriptions
# User: updates on own tickets
subscription MyTicketUpdated($ticketId: ID) {
myTicketUpdated(ticketId: $ticketId) {
ticket { ...SupportTicketFields }
newMessage { ...TicketMessageFields }
type
}
}
# Agent: all ticket events
subscription SupportTicketEvent {
supportTicketEvent {
ticket { ...SupportTicketFields }
type
}
}
Input Types Reference
CreateSupportTicketInput
| Field | Type | Required | Notes |
|---|---|---|---|
subject | String | Yes | Max 500 chars |
description | String | Yes | Full description |
category | TicketCategory | Yes | Dropdown select |
priority | TicketPriority | No | Defaults to MEDIUM |
testerId | ID | No | Optional — pre-assign the user who will verify the fix. Omit (do not send null) when unset; the backend @IsUUID validator rejects literal null |
SendTicketMessageInput / SendSupportReplyInput / AddInternalNoteInput
| Field | Type | Required |
|---|---|---|
ticketId | ID | Yes |
content | String | Yes |
MyTicketsInput
| Field | Type | Required | Notes |
|---|---|---|---|
status | TicketStatus | No | Filter by status |
page | Int | No | Default 1 |
limit | Int | No | Default 12 |
SupportTicketsInput
| Field | Type | Required | Notes |
|---|---|---|---|
status | TicketStatus | No | Filter |
priority | TicketPriority | No | Filter |
category | TicketCategory | No | Filter |
assignedToId | ID | No | Filter by agent |
search | String | No | Subject search (case-insensitive) |
sortBy | TicketSortBy | No | CREATED_AT / LAST_ACTIVITY / PRIORITY |
sortOrder | SupportSortOrder | No | ASC / DESC |
page | Int | No | Default 1 |
limit | Int | No | Default 12 |
CreateFaqInput
| Field | Type | Required | Notes |
|---|---|---|---|
question | String | Yes | |
answer | String | Yes | |
category | TicketCategory | Yes | |
tags | [String] | No | |
sourceTicketId | ID | No | Link to originating ticket |
status | FaqStatus | No | Default DRAFT |
UpdateFaqInput
| Field | Type | Required |
|---|---|---|
question | String | No |
answer | String | No |
category | TicketCategory | No |
tags | [String] | No |
status | FaqStatus | No |
AdminSupportAuditLogInput
| Field | Type | Required | Notes |
|---|---|---|---|
ticketId | ID | No | Filter by ticket |
actorId | ID | No | Filter by actor |
action | String | No | Filter by action type |
dateFrom | DateTime | No | Start date |
dateTo | DateTime | No | End date |
page | Int | No | Default 1 |
limit | Int | No | Default 30 |
Component Breakdown
User-Facing Pages
/support — My Tickets
┌─────────────────────────────────────────┐
│ My Support Tickets [New Ticket] │
│ │
│ Filter: [All ▾] [Status ▾] │
│ │
│ ┌─────────────────────────────────────┐ │
│ │ SUP-00042 ● OPEN HIGH │ │
│ │ "Cannot upload CV files" │ │
│ │ Technical · 2 hours ago │ │
│ ├─────────────────────────────────────┤ │
│ │ SUP-00039 ● RESOLVED MEDIUM │ │
│ │ "Billing question about credits" │ │
│ │ Billing · 3 days ago │ │
│ └─────────────────────────────────────┘ │
│ │
│ Page 1 of 3 [< Prev] [Next >] │
└─────────────────────────────────────────┘
Components:
SupportTicketList— paginated list with status/priority chipsTicketCard— single ticket row (ticketNumber, subject, category, priority, status, relative time)TicketStatusChip— color-coded status badgeTicketPriorityChip— color-coded priority badge
Data: myTickets query with MyTicketsInput
/support/[ticketId] — Ticket Detail
┌─────────────────────────────────────────┐
│ ← Back SUP-00042 ● OPEN HIGH │
│ "Cannot upload CV files" │
│ Technical · Created 2 hours ago │
│ Assigned to: Jane Smith │
│ │
│ ┌─────────────────────────────────────┐ │
│ │ [You] 2h ago │ │
│ │ I'm getting an error when I try... │ │
│ │ │ │
│ │ [Support — Jane] 1h ago │ │
│ │ Could you share a screenshot? │ │
│ │ │ │
│ │ [You] 30m ago │ │
│ │ Here's the screenshot... │ │
│ │ 📎 error-screenshot.png (240 KB) │ │
│ └─────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────┐ │
│ │ Type your message... [Send] │ │
│ └─────────────────────────────────────┘ │
│ │
│ [Close Ticket] [Reopen Ticket] │
└─────────────────────────────────────────┘
Components:
TicketHeader— ticket info bar (number, status, priority, category, timestamps)TicketMessageThread— scrollable message listTicketMessageBubble— individual message (distinguish USER vs SUPPORT vs SYSTEM bysenderRole)TicketMessageInput— compose + sendTicketActions— close/reopen buttons (based on current status)
Data: myTicketDetail query + myTicketUpdated subscription
The user-facing myTicketDetail query automatically filters out messages with isInternalNote: true. The frontend does not need to filter these — the backend handles it.
Each rendered attachment chip in TicketConversation overlays a hover-revealed (always-visible on touch) TWButton X. Clicking it opens a TWConfirmDialog (danger variant) and on confirm calls the useRemoveTicketAttachment() hook, which fires removeTicketAttachment and refetches both MY_TICKET_DETAIL and SUPPORT_TICKET_DETAIL so the UI updates everywhere. Authorization is enforced server-side, so the X is shown to all viewers; non-permitted clicks fail with a FORBIDDEN toast.
/support/new — Create Ticket
┌─────────────────────────────────────────┐
│ Create Support Ticket │
│ │
│ Subject: [________________________] │
│ Category: [Select category ▾] │
│ Priority: [Medium ▾] │
│ Description: │
│ ┌─────────────────────────────────────┐ │
│ │ │ │
│ │ │ │
│ └─────────────────────────────────────┘ │
│ │
│ [Cancel] [Submit Ticket] │
└─────────────────────────────────────────┘
Data: createSupportTicket mutation. On success, redirect to /support/{newTicketId}.
/support/faq — FAQ Browser
┌─────────────────────────────────────────┐
│ Help Center │
│ │
│ Search: [________________________] │
│ Category: [All ▾] │
│ │
│ ▸ How do I reset my password? │
│ Account · 👍 12 │
│ │
│ ▾ How are credits calculated? │
│ Credits are deducted based on the │
│ AI model used and token count... │
│ Billing · 👍 8 │
│ [Was this helpful? 👍] │
│ │
│ ▸ Why is my CV not parsing? │
│ Technical · 👍 5 │
└─────────────────────────────────────────┘
Components:
FaqList— searchable accordion listFaqItem— expandable Q&A card with helpful buttonFaqCategoryFilter— category dropdown
Data: publishedFaqs query + markFaqHelpful mutation
Support Agent Portal
/admin/support — Agent Dashboard
┌──────────────────────────────────────────────────┐
│ Support Dashboard │
│ │
│ ┌────────┐ ┌────────┐ ┌────────┐ ┌────────┐ │
│ │ Open │ │ In Prog│ │ Waiting│ │ Resolved│ │
│ │ 12 │ │ 5 │ │ 3 │ │ 8 │ │
│ └────────┘ └────────┘ └────────┘ └────────┘ │
│ │
│ Avg First Response: 24m Avg Resolution: 4.2h │
│ │
│ ┌────────────────────────────────────────────┐ │
│ │ Filters: [Status▾] [Priority▾] [Agent▾] │ │
│ │ Search: [_________________________] │ │
│ │ Sort: [Last Activity ▾] [DESC ▾] │ │
│ ├────────────────────────────────────────────┤ │
│ │ SUP-00042 · OPEN · HIGH · Unassigned │ │
│ │ "Cannot upload CV files" · Technical │ │
│ │ Created by: John Doe · 2h ago │ │
│ │ [Assign to me] [View] │ │
│ ├────────────────────────────────────────────┤ │
│ │ SUP-00041 · IN_PROGRESS · MEDIUM │ │
│ │ "Credit balance discrepancy" · Billing │ │
│ │ Assigned to: Jane Smith · 5h ago │ │
│ └────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────┘
Components:
SupportDashboard— stat cards + ticket listSupportStatsCards— open/in-progress/waiting/resolved countsSupportTicketTable— filterable, sortable ticket tableSupportTicketFilters— status, priority, category, agent, search, sort
Data: supportDashboardStats + supportTickets queries + supportTicketEvent subscription
/admin/support/[ticketId] — Agent Ticket Detail
Same as user ticket detail, plus:
- Internal notes (messages with
isInternalNote: true— render with distinct yellow/amber background) - Status/priority/category change controls
- Agent assignment dropdown
- Audit log timeline
- "Generate FAQ" button
Components (additional to user view):
InternalNoteInput— textarea for internal notes (separate from public reply)TicketSidebar— status, priority, category dropdowns + assign agent + assign tester + metadataAuditLogTimeline— chronological audit trailGenerateFaqButton— callsgenerateFaqSuggestion, opens pre-filled FAQ creation form
Data: supportTicketDetail query (includes all messages + audit log) + listSupportAgents query (populates both "Assigned To" and "Tester" dropdowns)
Mutations used:
sendSupportReply— public replyaddInternalNote— internal noteupdateTicketStatus/updateTicketPriority/updateTicketCategoryassignTicket— set the support agentassignTester— set / change / clear the tester (passnullto clear)generateFaqSuggestion
Super Admin Pages
/admin/support/overview — Full Overview
Everything in the agent dashboard, plus:
- Agent performance table (assignedCount, resolvedCount, avgResponseTime, avgResolutionTime)
Data: adminSupportOverview query
/admin/support/agents — Agent Management
| Agent | Assigned | Resolved | Actions | |
|---|---|---|---|---|
| Jane Smith | jane@... | 15 | 42 | [Revoke] |
| Bob Lee | bob@... | 8 | 23 | [Revoke] |
Add Agent: User search → adminAssignSupportRole
Remove Agent: adminRevokeSupportRole
Data: adminListSupportAgents query (includes performance metrics, super admin only)
/admin/support/audit — Audit Log
Filterable table of all support actions across all tickets.
Data: adminSupportAuditLog query
Tester Field
Every ticket can optionally reference a tester — the user responsible for verifying that a reported bug is actually fixed (or that a delivered feature behaves correctly). This is intentionally separate from the support agent (assignedToId) so the person who works on the fix is not the person who signs it off.
Where it appears
| Surface | File | Behavior |
|---|---|---|
NewTicketForm (user + portal create flow) | components/support/NewTicketForm.tsx | Optional avatar-button picker (emerald accent to differentiate from the blue Assignee picker). End users fetch the candidate list via useAvailableSupportAgents() (open to any auth user); the support portal injects the agents list via the agents prop. The submitted testerId is omitted from the payload when empty — never send literal null because CreateSupportTicketInput.testerId is validated with @IsUUID(). |
TicketDetailView sidebar (support portal) | components/support/portal/TicketDetailView.tsx | TWSelect labelled "Tester" right under "Assigned To". Includes an "Unassigned" option that calls assignTester(ticketId, null) to clear. Wraps with withNotifyPrompt() so the agent is offered to send the ticket creator an [Update] Tester set to ... reply. |
TicketQueue table | components/support/portal/TicketQueue.tsx | New "Tester" line in the People column under "Agent". Filterable via the ?testerIsMe=true URL param (handled in both filter passes — tickets and ticketsBeforeStatusTab). |
TicketBoardView Kanban cards | components/support/portal/TicketBoardView.tsx | Tester name shown above the assignee/time footer only when set, to keep cards uncluttered. |
SupportDashboard rows | components/support/portal/SupportDashboard.tsx | New "Testing" stat tab (count = open tickets where testerId === user.id) with priority/status breakdown, plus a 🧪 Tester chip in compact ticket rows. |
TicketAuditLogView | components/support/portal/TicketAuditLogView.tsx | New TESTER_CHANGED action config (🧪 emoji, primary color) renders "Tester set to <name>" or "Tester cleared" in the timeline. |
SupportPortalSidebar nav | components/support/layout/SupportPortalSidebar.tsx | New "Testing" nav link routing to /support-portal/tickets?testerIsMe=true, with active-state highlighting. The "Client Tickets" active check excludes both assignedToMe=true and testerIsMe=true. |
Hook
import { useSupportMutations } from "@hooks/useSupport"
const { assignTester } = useSupportMutations()
// Set
await assignTester(ticketId, "user-uuid")
// Clear
await assignTester(ticketId, null)
The wrapper shows a success toast ("Tester Assigned" or "Tester Cleared") and refetches via the standard mutation cache.
Real-Time Integration
User Ticket Page
Subscribe to myTicketUpdated when viewing a ticket detail:
// In /support/[ticketId] page
const { data } = useSubscription(MY_TICKET_UPDATED, {
variables: { ticketId },
onData: ({ data }) => {
const event = data.data?.myTicketUpdated;
if (!event) return;
if (event.type === 'NEW_MESSAGE' && event.newMessage) {
// Append new message to thread
// Refetch ticket to get updated status
}
if (event.type === 'STATUS_CHANGED') {
// Update ticket status badge
}
},
});
Unread Badge
Poll myUnreadTicketCount in the navbar (similar to notification badge). Suggested interval: 30 seconds.
const { data } = useQuery(MY_UNREAD_TICKET_COUNT, {
pollInterval: 30000,
});
// Show badge on support nav item when count > 0
Agent Dashboard
Subscribe to supportTicketEvent to get real-time updates:
const { data } = useSubscription(SUPPORT_TICKET_EVENT, {
onData: ({ data }) => {
const event = data.data?.supportTicketEvent;
if (!event) return;
if (event.type === 'NEW_TICKET') {
// Show toast: "New ticket: SUP-00043"
// Refetch ticket list
}
if (event.type === 'ASSIGNED') {
// Refetch if assigned to current agent
}
},
});
UI Conventions
Dynamic Status Rendering
Ticket status names, labels, and colors live in the TicketStatusConfig table (see Overview → TicketStatus). The frontend must not hardcode label or color maps — admins can rename, add, or disable statuses at runtime.
Fetch the current config via the useTicketStatusConfigs() hook (lib/hooks/useSupport.ts). It returns:
{
configs: TicketStatusConfig[], // full list sorted by sortOrder
statusOptions: {value, label}[], // ready for dropdowns
getStatusLabel(name: string): string, // label by name, falls back to name
getStatusColor(name: string): string, // hex color by name, defaults #6B7280
loading, error,
}
Pure helpers in lib/utils/supportStatus.ts for places that can't use a React hook:
| Helper | Purpose |
|---|---|
resolveStatusLabel(status, configs) | config.label → humanized fallback |
resolveStatusColor(status, configs) | config.color → neutral gray fallback |
humanizeStatus(raw) | "WAITING_ON_CUSTOMER" → "Waiting On Customer" for toast/notification text with no hook context |
findWaitingStatusConfig(configs) | returns the config flagged isWaiting && !isTerminal && !isResolved — used for the dashboard Testing tab so renames propagate |
TicketStatus type is string (widened 2026-04-17, PR #644). Don't narrow it — admins can introduce new statuses.
Current default palette (derived from TicketStatusConfig.color, kept here for designer reference only — the UI sources these dynamically):
| name | label | color |
|---|---|---|
| OPEN | Open | #3B82F6 |
| IN_PROGRESS | In Progress | #F59E0B |
| WAITING_ON_CUSTOMER | Waiting on Customer | #6B7280 |
| TEST | Test | #ae00ff |
| RESOLVED | Resolved | #10B981 |
| CLOSED | Closed | #EF4444 |
Internal board default-hidden statuses
components/support/portal/TicketQueue.tsx exports DEFAULT_HIDDEN_STATUSES = ["CLOSED"]. Agents can hide additional statuses via the board's visibility toggle; the selection persists in localStorage (key support-ticket-hidden-statuses).
Migration (2026-04-23): WAITING_ON_CUSTOMER was previously hidden by default. On first load after the fix, loadHiddenStatuses() strips WAITING_ON_CUSTOMER from any legacy persisted list and sets support-ticket-hidden-statuses-migration-v1 = "1" so the migration runs at most once per browser. Agents who had explicitly hidden other statuses keep their choices; the migration only removes WAITING_ON_CUSTOMER. Paired with the backend change that stopped sendSupportReply from auto-setting the status — see Support Overview → Auto-Transitions.
Priority Colors
| Priority | Color | Tailwind |
|---|---|---|
| LOW | Gray | bg-gray-100 text-gray-600 |
| MEDIUM | Blue | bg-blue-100 text-blue-700 |
| HIGH | Orange | bg-orange-100 text-orange-700 |
| URGENT | Red | bg-red-100 text-red-700 |
Message Styling
| Sender Role | Alignment | Background |
|---|---|---|
| USER | Right | bg-primary-100 (user's own messages) |
| SUPPORT | Left | bg-default-100 |
| SYSTEM | Center | bg-warning-50 italic text-sm |
| Internal Note | Left | bg-amber-50 border-l-4 border-amber-400 |
Category Icons
| Category | Suggested Icon |
|---|---|
| GENERAL | HelpCircle |
| BILLING | CreditCard |
| TECHNICAL | Wrench |
| ACCOUNT | UserCircle |
| FEATURE_REQUEST | Lightbulb |
| BUG_REPORT | Bug |
Responsive Layout
The support portal is fully responsive with mobile-first design using the same patterns as the AdminSidebar.
Sidebar (Mobile Drawer)
Files: components/support/layout/SupportPortalSidebar.tsx, SupportPortalLayout.tsx
| Viewport | Behavior |
|---|---|
| Desktop (>= 768px) | Static w-60 sidebar, always visible |
| Mobile (< 768px) | Hidden. Hamburger button in a mobile top bar toggles a slide-in drawer (fixed w-64, z-50) with backdrop overlay |
The pattern mirrors components/admin/layout/AdminSidebar.tsx:
- Props:
mobileOpen,onMobileClose - Desktop:
hidden md:flex w-60 - Mobile drawer:
fixed inset-y-0 left-0 z-50 w-64 md:hiddenwithtranslate-xtransition - Backdrop:
fixed inset-0 z-40 bg-black/50 md:hidden - Nav clicks call
onMobileClose()to auto-close the drawer
Ticket Detail (Right Sidebar)
File: components/support/portal/TicketDetailView.tsx
| Viewport | Behavior |
|---|---|
| Desktop (>= 768px) | Side-by-side layout: conversation + w-72 right sidebar |
| Mobile (< 768px) | Right sidebar hidden. A collapsible "Ticket Details" toggle panel appears below the conversation (max-h-[60vh], scrollable) |
The sidebar content (SLA metrics, status/priority/category selects, metadata, action buttons) is extracted into a shared variable rendered in both the desktop sidebar and the mobile collapsible panel.
Ticket Queue
File: components/support/portal/TicketQueue.tsx
- Search input:
w-full sm:w-64(full width on mobile) - Header:
flex-wrap gap-2to stack title and actions - Status tabs:
overflow-x-autofor horizontal scroll - Padding:
p-3 sm:p-6
FAQ Management Table
File: app/support-portal/faqs/page.tsx
- Filters: stack vertically on mobile (
flex-col sm:flex-row), inputsw-full sm:w-64 - Table:
overflow-x-autowithmin-w-[500px] - Progressive column hiding: Category
hidden sm:table-cell, Views/Helpfulhidden md:table-cell, Updatedhidden lg:table-cell
File Structure
graphql/operations/support/
├── fragments.ts # All support fragments
├── queries.ts # All queries
├── mutations.ts # All mutations
└── subscriptions.ts # Both subscriptions
components/support/
├── layout/
│ ├── SupportPortalLayout.tsx # Main layout (sidebar + mobile top bar + content)
│ ├── SupportPortalSidebar.tsx # Sidebar with mobile drawer pattern
│ └── SupportPortalGuard.tsx # Access control (isSupportAgent || isSuperAdmin)
├── portal/
│ ├── SupportDashboard.tsx # Dashboard with stat cards + charts
│ ├── TicketQueue.tsx # Ticket list/board with filters, bulk actions
│ ├── TicketBoardView.tsx # Kanban board view for tickets
│ ├── TicketDetailView.tsx # Full ticket detail with responsive right sidebar
│ ├── TicketAuditLogView.tsx # Audit trail timeline
│ ├── TicketResolveFaqModal.tsx # FAQ creation modal on resolve/close
│ └── TicketStatusSettings.tsx # Custom status configuration
├── TicketList.tsx # Ticket card list (shared)
├── TicketConversation.tsx # Message thread with composer
├── TicketStatusChip.tsx # Status badge
├── TicketPriorityChip.tsx # Priority badge
├── NewTicketForm.tsx # Create ticket form
├── FaqCard.tsx # FAQ accordion card
├── FaqList.tsx # FAQ list with filters
└── SupportDrawer.tsx # Quick-access support modal
app/support-portal/
├── layout.tsx # Route layout (guard + SupportPortalLayout)
├── page.tsx # Agent dashboard
├── tickets/
│ ├── page.tsx # Ticket queue (list/board)
│ └── [ticketId]/page.tsx # Ticket detail
├── faqs/page.tsx # FAQ management (CRUD table)
└── settings/page.tsx # Status configuration (super admin)
app/(shared)/support/
├── page.tsx # My tickets list (user-facing)
├── [ticketId]/page.tsx # Ticket detail + messages
└── faq/page.tsx # Published FAQ browser
app/admin/support/
└── page.tsx # Admin overview (stats, agents, audit)
Implementation Order
Phase 1 — User-Facing (MVP)
- GraphQL operations (fragments, queries, mutations, subscriptions)
/support— My tickets list/support/new— Create ticket/support/[ticketId]— Ticket detail with messages- Unread badge in navbar
/support/faq— FAQ browser
Phase 2 — Support Agent Portal
/admin/support— Dashboard with stats + ticket list/admin/support/[ticketId]— Full ticket detail with internal notes, sidebar controls, audit log- Real-time subscription on agent dashboard
/admin/support/faq— FAQ management CRUD
Phase 3 — Super Admin
/admin/support/overview— Stats + agent performance/admin/support/agents— Agent role management/admin/support/audit— Full audit log
Navigation Integration
Add support link to the main navigation:
- User nav: "Support" link →
/support(always visible) - Admin nav: "Support Portal" link →
/admin/support(visible whenuser.isSupportAgent || user.isSuperAdmin) - Unread badge: Poll
myUnreadTicketCountand show count on the "Support" nav item
Query isSupportAgent on the User type to conditionally render agent nav:
query Me {
me {
id
isSuperAdmin
isSupportAgent # Use this to show/hide support portal link
}
}
Related
- Support System Overview — Data model, lifecycle, enums, backend architecture
- Messaging System — Similar real-time pattern with WebSocket subscriptions
- Notifications — Unread badge polling pattern reference