Tickets

List, filter, paginate and export a user's tickets with <u-ticketable-list>. Templates are plain HTML — the SDK stamps one copy per ticket and fills in <ticketable-value> placeholders, so you keep full control over markup and styling. The same list also powers peer-to-peer ticket transfers.

View this guide as Markdown

Ticket cards

A card layout driven by a <template> element. ticketable-value reads any field from the ticket — including nested metadata with fallback defaults — while ticketable-conditional and unidy-attr let the markup react to the data.

  • <template> rendering — one card stamped per ticket, your markup
  • Nested metadata — name="metadata.category" with default values
  • Conditional content — VIP badge and wallet export only when the data says so
  • Export — PDF download and Apple Wallet (pkpass)
<u-signed-in>
  <u-ticketable-list ticketable-type="ticket" limit="2" container-class="grid gap-4">
    <template>
      <div class="rounded-lg border border-border bg-white p-5 shadow-sm">
        <div class="flex items-start justify-between gap-4">
          <div>
            <h3 class="text-lg font-bold">
              <ticketable-value name="title"></ticketable-value>
            </h3>
            <p class="mt-1 text-sm text-text-light">
              <ticketable-value name="starts_at" date-format="dd.MM.yyyy HH:mm"></ticketable-value>

              <ticketable-value name="ends_at" date-format="dd.MM.yyyy"></ticketable-value>
            </p>
            <p class="mt-2 font-semibold text-primary">
              <ticketable-value name="price" format="{{value}}" default="Free"></ticketable-value>
            </p>
            <!-- Nested metadata with a default value -->
            <p class="text-xs text-text-muted">
              Category: <ticketable-value name="metadata.category" default="General"
              ></ticketable-value>
            </p>
          </div>

          <!-- Conditional rendering based on ticket data -->
          <ticketable-conditional when="metadata.vip">
            <span class="rounded-full bg-warning-bg px-2 py-0.5 text-xs font-bold text-warning-text"
              >VIP</span
            >
          </ticketable-conditional>
        </div>

        <div class="mt-4 flex flex-wrap gap-2">
          <!-- Dynamic attributes from ticket data -->
          <a
            unidy-attr
            unidy-attr-href="{{button_cta_url}}"
            class="btn btn-primary !min-h-0 !px-3 !py-1.5 text-sm">
            View details
          </a>
          <u-ticketable-export
            format="pdf"
            class-name="px-3 py-1.5 rounded-lg text-sm font-medium bg-dark text-white hover:bg-dark-lighter transition-colors cursor-pointer disabled:opacity-50">
            Download PDF
          </u-ticketable-export>
          <!-- Wallet export only when the ticket supports it -->
          <ticketable-conditional when="exportable_to_wallet">
            <u-ticketable-export
              format="pkpass"
              class-name="px-3 py-1.5 rounded-lg text-sm font-medium bg-black text-white transition-colors cursor-pointer disabled:opacity-50">
              Add to Wallet
            </u-ticketable-export>
          </ticketable-conditional>
        </div>
      </div>
    </template>

    <!-- shown when the user has no tickets -->
    <p slot="empty" class="py-6 text-center text-sm text-text-light">No tickets yet.</p>
  </u-ticketable-list>
</u-signed-in>

<u-signed-in not>
  <p class="text-text-light">
    Sign in on the <a href="/auth" class="text-primary underline">Auth page</a> to see your tickets.
  </p>
</u-signed-in>
Live demo

No tickets yet.

Sign in on the Auth page to see your tickets.

Table view with pagination and filtering

The target attribute renders rows into an existing element (here: a tbody), which makes tables easy. Pagination is provided by u-pagination-button/-page, and changing the filter attribute makes the list re-fetch — no custom data plumbing.

  • target rendering — rows go into your own table structure
  • Pagination — prev/next buttons and page indicator components
  • Live filtering — set the filter attribute; the SDK re-fetches
  • Skeleton loading — skeleton-all-text renders placeholders while loading
<u-signed-in>
  <div class="mb-3 flex items-center justify-end">
    <select id="state-filter" class="rounded-lg border border-border bg-white px-3 py-2 text-sm">
      <option value="">All states</option>
      <option value="active">Active</option>
      <option value="inactive">Inactive</option>
    </select>
  </div>

  <!-- target renders rows into the tbody; skeleton rows show while loading -->
  <u-ticketable-list
    id="tickets-list"
    ticketable-type="ticket"
    target="#tickets-table-body"
    limit="5"
    skeleton-all-text="true">
    <div class="overflow-x-auto">
      <table class="min-w-full rounded-lg border border-border bg-white">
        <thead
          class="bg-background-light text-left text-xs uppercase tracking-wider text-text-muted">
          <tr>
            <th class="px-4 py-3">Title</th>
            <th class="px-4 py-3">Starts</th>
            <th class="px-4 py-3">Price</th>
            <th class="px-4 py-3">Actions</th>
          </tr>
        </thead>
        <tbody id="tickets-table-body" class="divide-y divide-border"></tbody>
      </table>
    </div>

    <div class="mt-4 flex items-center gap-2">
      <u-pagination-button
        direction="prev"
        class-name="px-3 py-2 rounded-lg border border-border bg-white text-sm cursor-pointer hover:bg-background-light disabled:opacity-50 disabled:cursor-not-allowed">
      </u-pagination-button>
      <u-pagination-page class-name="px-3 py-2 text-sm"></u-pagination-page>
      <u-pagination-button
        direction="next"
        class-name="px-3 py-2 rounded-lg border border-border bg-white text-sm cursor-pointer hover:bg-background-light disabled:opacity-50 disabled:cursor-not-allowed">
      </u-pagination-button>
    </div>

    <!-- shown when the user has no tickets -->
    <p slot="empty" class="py-6 text-center text-sm text-text-light">No tickets yet.</p>

    <template>
      <tr class="transition-colors hover:bg-background-light">
        <td class="whitespace-nowrap px-4 py-3 font-medium">
          <ticketable-value name="title"></ticketable-value>
        </td>
        <td class="whitespace-nowrap px-4 py-3 text-sm text-text-light">
          <ticketable-value name="starts_at" date-format="yyyy-MM-dd HH:mm"></ticketable-value>
        </td>
        <td class="whitespace-nowrap px-4 py-3 font-semibold text-primary">
          <ticketable-value name="price" default="—"></ticketable-value>
        </td>
        <td class="whitespace-nowrap px-4 py-3">
          <u-ticketable-export
            format="pdf"
            class-name="px-3 py-1.5 rounded-lg text-sm font-medium bg-dark text-white hover:bg-dark-lighter transition-colors cursor-pointer disabled:opacity-50">
            PDF
          </u-ticketable-export>
        </td>
      </tr>
    </template>
  </u-ticketable-list>

  <script is:inline>
    // Update the list's filter attribute; the SDK re-fetches automatically
    document.getElementById("state-filter").addEventListener("change", (event) => {
      const list = document.getElementById("tickets-list");
      list.setAttribute("filter", event.target.value ? `state=${event.target.value}` : "");
    });
  </script>
</u-signed-in>

<u-signed-in not>
  <p class="text-text-light">
    Sign in on the <a href="/auth" class="text-primary underline">Auth page</a> to see the table view.
  </p>
</u-signed-in>
Live demo
Title Starts Price Actions

No tickets yet.

Sign in on the Auth page to see the table view.

Transfer a ticket

Drop a u-ticket-transfer-form into a ticket <template> and u-ticketable-list stamps each ticket's id onto it — no wiring. The user types a recipient email and the SDK creates a pending transfer offer.

  • u-ticket-transfer-form — email input + submit, styled via *-class-name
  • Auto ticket-id — the ticket list stamps the id when nested
  • Inline feedback — error-class-name / success-class-name outputs
<u-signed-in>
  <!-- A u-ticket-transfer-form nested inside a ticket template needs no
       ticket-id: u-ticketable-list stamps each ticket's id onto it. -->
  <u-ticketable-list ticketable-type="ticket" limit="3" container-class="grid gap-4">
    <template>
      <div class="rounded-lg border border-border bg-white p-5 shadow-sm">
        <h3 class="text-lg font-bold">
          <ticketable-value name="title"></ticketable-value>
        </h3>
        <p class="mt-1 mb-4 text-sm text-text-light">
          <ticketable-value name="starts_at" date-format="dd.MM.yyyy HH:mm"></ticketable-value>
        </p>
        <u-ticket-transfer-form
          class-name="flex flex-wrap items-start gap-2"
          input-class-name="flex-1 min-w-48 rounded-lg border border-border bg-white px-3.5 py-2.5 text-base focus:border-primary focus:outline-none"
          button-class-name="btn btn-primary !min-h-0 !px-4 !py-2.5 text-sm"
          error-class-name="mt-1 w-full text-sm text-danger"
          success-class-name="mt-1 w-full text-sm text-success-text"></u-ticket-transfer-form>
      </div>
    </template>

    <p slot="empty" class="py-6 text-center text-sm text-text-light">No tickets to transfer.</p>
  </u-ticketable-list>
</u-signed-in>

<u-signed-in not>
  <p class="text-text-light">
    Sign in on the <a href="/auth" class="text-primary underline">Auth page</a> to transfer a ticket.
  </p>
</u-signed-in>
Live demo

No tickets to transfer.

Sign in on the Auth page to transfer a ticket.

Incoming & outgoing transfers

u-ticket-transfer-list renders pending offers per direction with its own <transfer-value> template. u-ticket-transfer-action stamps the transfer token automatically, so accept/decline/cancel buttons need no data plumbing.

  • direction="incoming|outgoing" — one component, two views
  • <transfer-value> — ticket.title, sender_email, expires_at, …
  • accept / decline / cancel — u-ticket-transfer-action with auto token
<u-signed-in>
  <div class="grid gap-8">
    <div>
      <h3 class="mb-3 text-sm font-semibold text-text-muted uppercase">Incoming</h3>
      <u-ticket-transfer-list direction="incoming" container-class="grid gap-3" skeleton-count="2">
        <template>
          <div
            class="flex flex-wrap items-start justify-between gap-3 rounded-lg border border-border bg-white p-4 shadow-sm">
            <div>
              <p class="font-bold">
                <transfer-value name="ticket.title"></transfer-value>
              </p>
              <p class="mt-1 text-sm text-text-light">
                From <transfer-value name="sender_email"></transfer-value> · expires
                <transfer-value name="expires_at" date-format="dd.MM.yyyy HH:mm"></transfer-value>
              </p>
            </div>
            <div class="flex gap-2">
              <u-ticket-transfer-action
                action="accept"
                class-name="btn btn-primary !min-h-0 !px-3 !py-1.5 text-sm">
                Accept
              </u-ticket-transfer-action>
              <u-ticket-transfer-action
                action="decline"
                class-name="btn btn-outline !min-h-0 !px-3 !py-1.5 text-sm">
                Decline
              </u-ticket-transfer-action>
            </div>
          </div>
        </template>
        <p slot="empty" class="text-sm text-text-light">No incoming transfers.</p>
      </u-ticket-transfer-list>
    </div>

    <div>
      <h3 class="mb-3 text-sm font-semibold text-text-muted uppercase">Outgoing</h3>
      <u-ticket-transfer-list direction="outgoing" container-class="grid gap-3" skeleton-count="2">
        <template>
          <div
            class="flex flex-wrap items-start justify-between gap-3 rounded-lg border border-border bg-white p-4 shadow-sm">
            <div>
              <p class="font-bold">
                <transfer-value name="ticket.title"></transfer-value>
              </p>
              <p class="mt-1 text-sm text-text-light">
                To <transfer-value name="recipient_email"></transfer-value> · expires
                <transfer-value name="expires_at" date-format="dd.MM.yyyy HH:mm"></transfer-value>
              </p>
            </div>
            <u-ticket-transfer-action
              action="cancel"
              class-name="btn btn-outline !min-h-0 !px-3 !py-1.5 text-sm">
              Cancel
            </u-ticket-transfer-action>
          </div>
        </template>
        <p slot="empty" class="text-sm text-text-light">No outgoing transfers.</p>
      </u-ticket-transfer-list>
    </div>
  </div>
</u-signed-in>

<u-signed-in not>
  <p class="text-text-light">
    Sign in on the <a href="/auth" class="text-primary underline">Auth page</a> to manage transfers.
  </p>
</u-signed-in>
Live demo

Incoming

No incoming transfers.

Outgoing

No outgoing transfers.

Sign in on the Auth page to manage transfers.

Create test tickets through the Admin API

This optional demo-only control sends the signed-in user's short-lived SDK token to a same-origin server route. The route validates that token, derives the user id and creates the ticket with server-only Admin API credentials.

  • Authenticated user only — the user id is derived from the SDK token
  • Server-side credentials — client id and secret never reach the browser
  • Create and clean up — add a test ticket and delete it again
<u-signed-in>
  <div class="rounded-lg border border-amber-200 bg-amber-50/40 p-5 space-y-4">
    <div class="flex flex-wrap items-center gap-2">
      <span
        class="text-xs font-semibold uppercase tracking-wider text-amber-700 bg-amber-100 border border-amber-200 px-2 py-0.5 rounded-full"
        >Demo Controls</span
      >
      <p class="text-sm text-text-light">
        Create a ticket through the Admin API for the signed-in user. Admin credentials stay
        server-side.
      </p>
    </div>
    <div class="flex flex-wrap items-center gap-3">
      <button id="create-demo-ticket" class="btn btn-primary !min-h-0 !px-4 !py-2 text-sm"
        >+ Create Demo Ticket</button
      >
      <button
        id="delete-demo-ticket"
        class="!min-h-0 !px-4 !py-2 text-sm rounded-lg border border-red-200 text-red-600 bg-white hover:bg-red-50 transition-colors cursor-pointer disabled:opacity-40 disabled:cursor-not-allowed"
        disabled>Delete Last</button
      >
      <span id="demo-ticket-status" class="text-xs text-text-muted" role="status"></span>
    </div>
    <p class="text-xs text-text-muted">
      Reload after creating or deleting to refresh the SDK list above.
    </p>
  </div>
</u-signed-in>

<u-signed-in not>
  <p class="text-sm text-text-light italic">Sign in to use the Admin API demo controls.</p>
</u-signed-in>

<script type="module">
  import { Auth } from "@unidy.io/sdk";

  const createButton = document.getElementById("create-demo-ticket");
  const deleteButton = document.getElementById("delete-demo-ticket");
  const status = document.getElementById("demo-ticket-status");
  let lastId = null;

  function setStatus(message, failed = false) {
    status.textContent = message;
    status.className = `text-xs ${failed ? "text-red-500" : "text-green-600"}`;
  }

  async function authenticatedFetch(url, options = {}) {
    const auth = await Auth.getInstance();
    const token = await auth.getToken();
    if (typeof token !== "string") throw new Error("Please sign in again.");
    return fetch(url, {
      ...options,
      headers: { ...options.headers, Authorization: `Bearer ${token}` },
    });
  }

  createButton?.addEventListener("click", async () => {
    createButton.disabled = true;
    setStatus("Creating…");
    try {
      const response = await authenticatedFetch("/api/demo/tickets", { method: "POST" });
      const data = await response.json();
      if (!response.ok) throw new Error(data.error || "Create failed");
      lastId = data.id;
      deleteButton.disabled = !lastId;
      setStatus(`Created${data.title ? `: ${data.title}` : ""}.`);
    } catch (error) {
      setStatus(error instanceof Error ? error.message : "Create failed", true);
    } finally {
      createButton.disabled = false;
    }
  });

  deleteButton?.addEventListener("click", async () => {
    if (!lastId) return;
    deleteButton.disabled = true;
    setStatus("Deleting…");
    try {
      const response = await authenticatedFetch(
        `/api/demo/tickets?id=${encodeURIComponent(lastId)}`,
        { method: "DELETE" }
      );
      const data = await response.json();
      if (!response.ok) throw new Error(data.error || "Delete failed");
      lastId = null;
      setStatus("Deleted.");
    } catch (error) {
      setStatus(error instanceof Error ? error.message : "Delete failed", true);
      deleteButton.disabled = false;
    }
  });
</script>
Live demo
Demo Controls

Create a ticket through the Admin API for the signed-in user. Admin credentials stay server-side.

Reload after creating or deleting to refresh the SDK list above.

Sign in to use the Admin API demo controls.