Tendkit

Search documentation

Find a docs page

Get Started

Inbox and tickets

Mount SupportInbox and SupportTicket behind your own API and auth.

The screens never talk to the database. They call a SupportClient you implement on top of your own API, so your auth decides who's an agent. TanStack Query must be set up in your app (a QueryClientProvider above the screens).

Expose the API

Any API layer works: route handlers, server actions, oRPC, tRPC. Check the agent, then call the service:

app/api/support/route.ts
// app/api/support/[...path]/route.ts, or your oRPC/tRPC router: anything behind your auth.
import { support } from "@/lib/support";
import { requireAdmin } from "@/lib/auth";

export async function POST(request: Request) {
  const agent = await requireAdmin(request);
  const { action, input } = await request.json();
  const s = support();
  switch (action) {
    case "listTickets":
      return Response.json(
        await s.listTickets(
          {
            status: input.status,
            assigneeId: input.assignee === "me" ? agent.id : input.assignee,
            search: input.search,
            sort: input.status === "open" ? "waiting" : "recent",
            limit: input.pageSize,
            offset: (input.page - 1) * input.pageSize,
          },
          agent.id
        )
      );
    case "getTicket":
      return Response.json(await s.getTicket(input.id, agent.id));
    case "reply":
      return Response.json(await s.reply({ ...input, agentId: agent.id }));
    // …addNote, assign, setStatus, linkCustomer, retry, uploadAttachment, attachmentUrl
  }
}

Implement the client

app/admin/support/client.ts
"use client";

import type { SupportClient } from "@/components/support/client";

const call = (action: string, input?: unknown) =>
  fetch("/api/support", { method: "POST", body: JSON.stringify({ action, input }) }).then((r) => r.json());

export const supportClient: SupportClient = {
  listTickets: (query) => call("listTickets", query),
  counts: () => call("counts"),
  getTicket: (id) => call("getTicket", { id }),
  reply: (input) => call("reply", input),
  addNote: (input) => call("addNote", input),
  assign: (input) => call("assign", input),
  setStatus: (input) => call("setStatus", input),
  linkCustomer: (input) => call("linkCustomer", input),
  retry: (input) => call("retry", input),
  upload: ({ ticketId, file }) => uploadFile(ticketId, file),
  attachmentUrl: (input) => call("attachmentUrl", input),
  listAgents: () => call("listAgents"),
  searchCustomers: (query) => call("searchCustomers", { query }),
};

Dates may arrive as Date objects or ISO strings; the screens accept both.

Mount the screens

app/admin/support
// app/admin/support/layout.tsx
"use client";

import { SupportProvider } from "@/components/support/provider";
import { supportClient } from "./client";

export default function Layout({ children }: { children: React.ReactNode }) {
  const user = useCurrentUser();
  return (
    <SupportProvider
      client={supportClient}
      viewerId={user.id}
      inboxHref="/admin/support"
      ticketHref={(id) => `/admin/support/${id}`}
      customerPanel={(detail) => (detail.customer ? <AccountPanel id={detail.customer.id} /> : null)}
    >
      {children}
    </SupportProvider>
  );
}

// app/admin/support/page.tsx
export default function Page() {
  return <SupportInbox />;
}

// app/admin/support/[id]/page.tsx
export default function Page({ params }: { params: Promise<{ id: string }> }) {
  const { id } = use(params);
  return <SupportTicket ticketId={id} />;
}

Provider props

NameTypeDescription
client*SupportClientYour implementation, above.
viewerId*stringThe signed-in agent's id, as listAgents returns it.
inboxHref*stringWhere the ticket screen's back link goes.
ticketHref*(id) => stringThe URL of a ticket.
customerPanel(detail) => ReactNodeYour own view of the customer, shown next to every ticket: plan, orders, payments.
responseTargetMinutesnumberHow long a customer may wait before the wait turns red. It turns amber at three quarters.Default: 240
t(key, values) => stringTranslations. See below.Default: English
localestringFor dates and numbers.Default: "en"

Try the props

The demo lets you change the theme, the response target and the account panel live.

Translations

Every string lives in components/support/messages/en.json. With next-intl, add that file to your messages under a support namespace and pass useTranslations("support") as t. Without an i18n library, call createTranslator(messages) with your own copy. The screens support right-to-left languages.