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.
View this guide as MarkdownTicket 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> 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> | Title | Starts | Price | Actions |
|---|
No tickets yet.
Sign in on the Auth page to see the table view.
Admin API: Create & Delete
Demo-only section: two buttons that call a server-side proxy (Astro API route) to create and delete tickets for the signed-in user via the Unidy Admin API with client_credentials. Credentials never touch the browser.
- client_credentials flow — Admin API token fetched server-side
- getUnidyClient().profile.get() — headless JS API to get user ID
- POST /api/demo/tickets — creates a test ticket for the current user
- DELETE /api/demo/tickets?id=… — removes the last created ticket
<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">Creates and deletes test tickets via the Admin API (server-side, credentials never exposed).</p>
</div>
<div class="flex flex-wrap items-center gap-3">
<button id="create-ticket-btn" class="btn btn-primary !min-h-0 !px-4 !py-2 text-sm">
+ Create Demo Ticket
</button>
<button
id="delete-ticket-btn"
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>
<button
id="delete-all-ticket-btn"
class="!min-h-0 !px-4 !py-2 text-sm rounded-lg border border-red-300 text-red-700 bg-red-50 hover:bg-red-100 transition-colors cursor-pointer">
Delete All
</button>
<span id="ticket-demo-status" class="text-xs text-text-muted"></span>
</div>
<p class="text-xs text-text-muted">After creating, reload the page to see the ticket appear in the list above.</p>
</div>
</u-signed-in>
<u-signed-in not>
<p class="text-sm text-text-light italic">Sign in to use demo controls.</p>
</u-signed-in>
<script type="module">
import { getUnidyClient, profileState } from "https://cdn.jsdelivr.net/npm/@unidy.io/sdk@1.9.0/dist/sdk/index.esm.js";
const createBtn = document.getElementById("create-ticket-btn");
const deleteBtn = document.getElementById("delete-ticket-btn");
const deleteAllBtn = document.getElementById("delete-all-ticket-btn");
const statusEl = document.getElementById("ticket-demo-status");
let lastId = null;
let lastUserId = null;
function setStatus(msg, error = false) {
if (!statusEl) return;
statusEl.textContent = msg;
statusEl.className = `text-xs ${error ? "text-red-500" : "text-green-600"}`;
}
async function getUserEmail() {
const [err, profile] = await getUnidyClient().profile.get();
const email = profile?.email?.value ?? profileState?.profile?.email;
if (err || !email) {
const detail = err ? (typeof err === "string" ? err : (err.message || err.code || JSON.stringify(err))) : "no email in profile";
throw new Error(`Could not get user email: ${detail}`);
}
return email;
}
createBtn?.addEventListener("click", async () => {
createBtn.disabled = true;
setStatus("Creating…");
try {
const email = await getUserEmail();
const res = await fetch("/api/demo/tickets", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email }),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || "Create failed");
lastId = data.id;
lastUserId = data.user_id;
if (deleteBtn) deleteBtn.disabled = false;
setStatus(`Created: "${data.title}" — ID ${data.id.slice(0, 8)}…`);
} catch (e) {
setStatus(e.message, true);
} finally {
if (createBtn) createBtn.disabled = false;
}
});
deleteBtn?.addEventListener("click", async () => {
if (!lastId) return;
deleteBtn.disabled = true;
setStatus("Deleting…");
try {
const res = await fetch(`/api/demo/tickets?id=${lastId}&user_id=${lastUserId}`, { method: "DELETE" });
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.error || "Delete failed");
}
lastId = null;
setStatus("Deleted. Reload to see the list update.");
} catch (e) {
setStatus(e.message, true);
if (deleteBtn) deleteBtn.disabled = false;
}
});
deleteAllBtn?.addEventListener("click", async () => {
deleteAllBtn.disabled = true;
setStatus("Deleting all…");
try {
const email = await getUserEmail();
const res = await fetch(`/api/demo/tickets?all=true&email=${encodeURIComponent(email)}`, { method: "DELETE" });
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || "Delete all failed");
lastId = null;
if (deleteBtn) deleteBtn.disabled = true;
setStatus(`Deleted ${data.deleted ?? "all"} tickets. Reload to update.`);
} catch (e) {
setStatus(e.message, true);
} finally {
if (deleteAllBtn) deleteAllBtn.disabled = false;
}
});
</script> Creates and deletes test tickets via the Admin API (server-side, credentials never exposed).
After creating, reload the page to see the ticket appear in the list above.
Sign in to use demo controls.