CRM on Nuxt 4, MySQL 8 and Clerk
Type-checked against the real SDKs, migration applied to a live MySQL 8, connection clients load-tested, then tracked for upstream drift and re-verified when it moves. How we verify
session validation runs in server components and route handlers, not at the edge
What you're getting
Nuxt 4 (framework mode) — full-stack Vue SSR: client under app/ (Vite), server under server/ (Nitro), API as server/api/*.post.ts Nitro route handlers.
MySQL 8 via Drizzle ORM and the mysql2 driver.
Clerk — hosted identity (sign-in UI, sessions, user management) mounted via middleware + provider.
Sales CRM — companies, contacts, named pipelines, deal-stage tracking, and an append-only activity trail.
Setup
bun add nuxt vue drizzle-orm mysql2 @clerk/nextjsDATABASE_URLMySQL connection string (mysql://…)NEXT_PUBLIC_CLERK_PUBLISHABLE_KEYCLERK_SECRET_KEYCLERK_WEBHOOK_SECRETsvix secret that verifies Clerk webhook signaturesApply the schema with bunx drizzle-kit push
Initialization
Database client
import { drizzle } from "drizzle-orm/mysql2";
import mysql from "mysql2/promise";
// ponytail: single module-level pool; the runtime + mysql2's pool handle concurrency,
// so no globalThis singleton dance needed.
const pool = mysql.createPool(process.env.DATABASE_URL!);
export const db = drizzle({ client: pool });// Clerk on Nuxt 4 is a Nuxt MODULE: registering "@clerk/nuxt" auto-injects
// <ClerkProvider>, the Vue components (<SignIn/>, <UserButton/>), and a server plugin that
// attaches the Clerk Auth object to event.context.auth — no manual provider wiring.
// ponytail: Clerk is hosted — set NUXT_PUBLIC_CLERK_PUBLISHABLE_KEY (read client-side by the
// module) and NUXT_CLERK_SECRET_KEY (read server-side by clerkMiddleware) in the env; the
// module picks them up automatically, so nothing here references them.
// ponytail: Nuxt provides defineNuxtConfig as a global at runtime; import it explicitly from
// "nuxt/config" so this config type-checks under standalone tsc.
import { defineNuxtConfig } from "nuxt/config";
export default defineNuxtConfig({
modules: ["@clerk/nuxt"],
});// Clerk route protection as a Nitro server middleware — the Nuxt 4 analog of
// Next's clerkMiddleware proxy + auth.protect(). clerkMiddleware() wraps a handler that runs
// on every request with the Clerk Auth object on event.context.auth; here we guard the SaaS
// app surface and redirect signed-out users to the hosted sign-in.
// ponytail: Nuxt AUTO-IMPORTS the h3 helpers at runtime; we import getRequestURL + sendRedirect
// EXPLICITLY from h3 (Nitro's engine, a nuxt dep) so this type-checks under standalone tsc.
import { clerkMiddleware } from "@clerk/nuxt/server";
import { getRequestURL, sendRedirect, type H3Event } from "h3";
// ponytail: guard the SaaS app surface; widen the prefixes per app-type.
const PROTECTED_PREFIXES = ["/dashboard", "/settings"];
export default clerkMiddleware((event) => {
// clerkMiddleware injects Clerk's Auth object on event.context.auth (see @clerk/nuxt module).
const { isAuthenticated } = event.context.auth();
// ponytail: @clerk/nuxt bundles its OWN h3 copy, so clerkMiddleware types `event` against that
// copy; re-narrow to the top-level h3 H3Event (the SAME runtime object) so the explicitly
// imported getRequestURL/sendRedirect type-check standalone — only the compiled d.ts differ.
const h3Event = event as unknown as H3Event;
const { pathname } = getRequestURL(h3Event);
if (!isAuthenticated && PROTECTED_PREFIXES.some((prefix) => pathname.startsWith(prefix))) {
return sendRedirect(h3Event, "/sign-in", 302);
}
});Sales CRM schema: companies, contacts, pipelines, deals & activities
Companies & contactsaccount records (companies de-duped on domain) and the contacts that belong to them, with nullable company FK to support unattached leads
Pipelines & dealsnamed sales pipelines and the opportunities (deals) moving through them, each carrying a contact FK, an owner FK to Better Auth user, and amount stored as integer cents
Deal stage trackingstage column on deals enforced by CHECK constraint with an index that powers pipeline-board grouping by stage
Activity interaction logappend-only calls/emails/notes logged against a deal by a Better Auth actor, forming the per-deal audit timeline
// === file: server/db/schema.ts ===
import { relations, sql } from "drizzle-orm";
import {
bigint,
check,
index,
mysqlTable,
text,
timestamp,
varchar,
} from "drizzle-orm/mysql-core";
// Better Auth owns identity; we only reference its `user` table by id.
import { user } from "./auth-schema";
export type DealStage = "lead" | "qualified" | "won" | "lost";
export type ActivityType = "call" | "email" | "note";
/** Account record: the organization a set of contacts belongs to. */
export const companies = mysqlTable(
"companies",
{
id: varchar("id", { length: 36 }).primaryKey(),
name: text("name").notNull(),
// Primary web domain — opaque key we de-dupe accounts on.
domain: varchar("domain", { length: 255 }),
createdAt: timestamp("created_at").notNull().defaultNow(),
},
(t) => [index("idx_company_domain").on(t.domain)],
);
/** A person we sell to. Company is nullable (a lead can exist before we know
* their employer); owner is the Better Auth user accountable for the contact. */
export const contacts = mysqlTable(
"contacts",
{
id: varchar("id", { length: 36 }).primaryKey(),
// Nullable: unattached leads get a company once qualified.
companyId: varchar("company_id", { length: 36 }).references(() => companies.id, {
onDelete: "set null",
}),
email: text("email").notNull(),
name: text("name"),
// Better Auth's user.id is text — match it as varchar(255).
ownerId: varchar("owner_id", { length: 255 })
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
createdAt: timestamp("created_at").notNull().defaultNow(),
},
(t) => [
index("idx_contact_company").on(t.companyId),
index("idx_contact_owner").on(t.ownerId),
],
);
/** Named sales pipeline deals move through (e.g. Inbound, Enterprise). */
export const pipelines = mysqlTable("pipelines", {
id: varchar("id", { length: 36 }).primaryKey(),
name: text("name").notNull(),
createdAt: timestamp("created_at").notNull().defaultNow(),
});
/** An open/closed opportunity against a contact, in a pipeline, owned by a
* Better Auth user. amount in integer cents (no float money). */
export const deals = mysqlTable(
"deals",
{
id: varchar("id", { length: 36 }).primaryKey(),
contactId: varchar("contact_id", { length: 36 })
.notNull()
.references(() => contacts.id, { onDelete: "cascade" }),
pipelineId: varchar("pipeline_id", { length: 36 })
.notNull()
.references(() => pipelines.id),
ownerId: varchar("owner_id", { length: 255 })
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
title: text("title").notNull(),
// ponytail: money as integer cents — no float, no numeric type churn.
amountCents: bigint("amount_cents", { mode: "number" })
.notNull()
.default(0),
stage: varchar("stage", { length: 32 }).$type<DealStage>().notNull().default("lead"),
closeDate: timestamp("close_date"),
createdAt: timestamp("created_at").notNull().defaultNow(),
},
(t) => [
// Drives the pipeline board "deals grouped by stage" query.
index("idx_deal_stage").on(t.stage),
index("idx_deal_contact").on(t.contactId),
check(
"deals_stage_check",
sql`${t.stage} in ('lead','qualified','won','lost')`,
),
],
);
/** Append-only interaction trail against a deal, logged by a Better Auth user. */
export const activities = mysqlTable(
"activities",
{
id: varchar("id", { length: 36 }).primaryKey(),
dealId: varchar("deal_id", { length: 36 })
.notNull()
.references(() => deals.id, { onDelete: "cascade" }),
// Who logged the interaction.
actorId: varchar("actor_id", { length: 255 })
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
type: varchar("type", { length: 32 }).$type<ActivityType>().notNull().default("note"),
body: text("body"),
occurredAt: timestamp("occurred_at").notNull().defaultNow(),
},
(t) => [
// Drives the per-deal timeline (most-recent-first).
index("idx_activity_deal_time").on(t.dealId, t.occurredAt),
check(
"activities_type_check",
sql`${t.type} in ('call','email','note')`,
),
],
);
export const companiesRelations = relations(companies, ({ many }) => ({
contacts: many(contacts),
}));
export const contactsRelations = relations(contacts, ({ one, many }) => ({
company: one(companies, {
fields: [contacts.companyId],
references: [companies.id],
}),
owner: one(user, { fields: [contacts.ownerId], references: [user.id] }),
deals: many(deals),
}));
export const pipelinesRelations = relations(pipelines, ({ many }) => ({
deals: many(deals),
}));
export const dealsRelations = relations(deals, ({ one, many }) => ({
contact: one(contacts, {
fields: [deals.contactId],
references: [contacts.id],
}),
pipeline: one(pipelines, {
fields: [deals.pipelineId],
references: [pipelines.id],
}),
owner: one(user, { fields: [deals.ownerId], references: [user.id] }),
activities: many(activities),
}));
export const activitiesRelations = relations(activities, ({ one }) => ({
deal: one(deals, { fields: [activities.dealId], references: [deals.id] }),
actor: one(user, { fields: [activities.actorId], references: [user.id] }),
}));
Verified identity sync (Clerk)
// === file: server/db/auth-schema.ts ===
import { mysqlTable, text, timestamp, varchar } from "drizzle-orm/mysql-core";
// Local mirror of Clerk identity — the FK target app-type schemas reference as user.
// id = Clerk's user id (varchar(255), matching the app-type user_id FKs), so existing
// user_id foreign keys resolve once the sync runs. This IS the auth-schema slot for Clerk cells.
export const user = mysqlTable("user", {
id: varchar("id", { length: 255 }).primaryKey(), // = Clerk user id
email: text("email"),
firstName: text("first_name"),
lastName: text("last_name"),
imageUrl: text("image_url"),
updatedAt: timestamp("updated_at"), // staleness key (Clerk updated_at)
createdAt: timestamp("created_at").notNull().defaultNow(),
});
import { and, eq, isNull, lt, or } from "drizzle-orm";
import { user } from "~~/server/db/auth-schema";
export type ClerkUserEvent = {
type: string;
data: {
id: string;
email_addresses?: { email_address: string }[];
first_name?: string | null;
last_name?: string | null;
image_url?: string | null;
updated_at?: number;
};
};
// Idempotent + concurrency-safe sync of a Clerk user into the local user table (MySQL).
// Keyed on id (= Clerk id, the PK); the staleness guard lives in the UPDATE WHERE. MySQL has no
// RETURNING — affectedRows tells us what happened. The insert path catches the unique-key race (a
// concurrent delivery of the same NEW user) as an idempotent no-op, rethrowing every other error.
// user.deleted removes the row.
export async function recordClerkEvent(
// ponytail: loosely typed Drizzle client so the emitted core stays portable.
db: any,
event: ClerkUserEvent,
): Promise<{ changed: boolean }> {
const d = event.data;
if (event.type === "user.deleted") {
const deleted = await db.delete(user).where(eq(user.id, d.id));
return { changed: deleted[0].affectedRows > 0 };
}
const email = d.email_addresses?.[0]?.email_address ?? null;
const eventAt = new Date(d.updated_at ?? 0);
const fields = {
email,
firstName: d.first_name ?? null,
lastName: d.last_name ?? null,
imageUrl: d.image_url ?? null,
updatedAt: eventAt,
};
const updated = await db
.update(user)
.set(fields)
.where(and(eq(user.id, d.id), or(isNull(user.updatedAt), lt(user.updatedAt, eventAt))));
if (updated[0].affectedRows > 0) return { changed: true };
const [existing] = await db.select({ id: user.id }).from(user).where(eq(user.id, d.id)).limit(1);
if (existing) return { changed: false };
// The row doesn't exist yet — insert it. A concurrent delivery of the SAME new user can win the
// race between our SELECT and this INSERT; the PK then rejects ours (ER_DUP_ENTRY) — an idempotent
// no-op, NOT a change. Any other error is real: rethrow so the webhook fails loud and Clerk retries.
try {
await db.insert(user).values({ id: d.id, ...fields });
return { changed: true };
} catch (err: any) {
if ((err?.cause?.code ?? err?.code) === "ER_DUP_ENTRY") return { changed: false };
throw err;
}
}// Clerk identity webhook for Nuxt 4 (Nitro server route). The .post.ts
// suffix binds this handler to POST /api/webhooks/clerk. Clerk webhooks are svix: verify
// the signature, then hand the event to the idempotent recordClerkEvent.
// ponytail: Nuxt AUTO-IMPORTS the h3 helpers at runtime; we import defineEventHandler /
// readRawBody / getHeaders / createError EXPLICITLY from h3 (Nitro's engine, a nuxt dep) so
// this handler type-checks under standalone tsc — the one idiom trade for verifiability.
import { createError, defineEventHandler, getHeaders, readRawBody } from "h3";
import { Webhook } from "svix";
import { db } from "~~/server/lib/db";
import { recordClerkEvent, type ClerkUserEvent } from "~~/server/lib/identity/record";
const USER_EVENTS = new Set(["user.created", "user.updated", "user.deleted"]);
export default defineEventHandler(async (event) => {
const secret = process.env.CLERK_WEBHOOK_SECRET;
if (!secret) throw createError({ statusCode: 500, statusMessage: "Server misconfigured" });
const raw = await readRawBody(event);
if (!raw) throw createError({ statusCode: 400, statusMessage: "Missing body" });
// getHeaders → Partial<Record<…, string | undefined>>; svix reads only the svix-* string
// headers, so widen to the Record<string, string> its verify() signature expects.
const headers = getHeaders(event) as Record<string, string>;
let clerkEvent: ClerkUserEvent;
try {
clerkEvent = new Webhook(secret).verify(raw, headers) as ClerkUserEvent;
} catch {
throw createError({ statusCode: 403, statusMessage: "Invalid signature" });
}
if (USER_EVENTS.has(clerkEvent.type)) await recordClerkEvent(db, clerkEvent);
return { ok: true };
});Deploy targets
import { drizzle } from "drizzle-orm/mysql2";
import mysql from "mysql2/promise";
// Serverless: a small pool per short-lived instance — many instances × a big pool exhausts MySQL.
export const pool = mysql.createPool({ uri: process.env.DATABASE_URL!, connectionLimit: 2 });
export const db = drizzle({ client: pool });
import { drizzle } from "drizzle-orm/mysql2";
import mysql from "mysql2/promise";
// Long-running process: a real, reused pool (mysql2 manages idle recycling).
export const pool = mysql.createPool({ uri: process.env.DATABASE_URL!, connectionLimit: 10 });
export const db = drizzle({ client: pool });
import { drizzle } from "drizzle-orm/planetscale-serverless";
import { Client } from "@planetscale/database";
// Edge/Workers have NO TCP sockets, so mysql2 cannot run here. PlanetScale's HTTP driver
// speaks MySQL over fetch — the client that works on Workers (the MySQL analog of Neon's HTTP driver).
const client = new Client({ url: process.env.DATABASE_URL! });
export const db = drizzle({ client });
The app UI

<script setup lang="ts">
import { computed } from "vue"
import {
EllipsisVerticalIcon,
CircleUserRoundIcon,
CreditCardIcon,
BellIcon,
LogOutIcon,
} from "lucide-vue-next"
import { useUser, useClerk } from "@clerk/nuxt/composables"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { SidebarMenu, SidebarMenuButton, SidebarMenuItem, useSidebar } from "@/components/ui/sidebar"
import { Skeleton } from "@/components/ui/skeleton"
// Clerk variant of the sidebar-footer user menu — the SAME shell as the Better Auth overlay, wired to
// Clerk's client session (useUser) + hosted sign-out (useClerk). Composables imported explicitly from
// @clerk/nuxt/composables (auto-imported at runtime) so the SFC type-checks standalone under vue-tsc.
const { isMobile } = useSidebar()
const { user, isLoaded } = useUser()
const clerk = useClerk()
const email = computed(() => user.value?.primaryEmailAddress?.emailAddress ?? "")
const initials = computed(() => email.value.slice(0, 2).toUpperCase())
const imageUrl = computed(() => user.value?.imageUrl)
async function handleSignOut() {
await clerk.value?.signOut()
navigateTo("/sign-in")
}
</script>
<template>
<SidebarMenu>
<SidebarMenuItem>
<div v-if="!isLoaded" class="flex items-center gap-2 p-2">
<Skeleton class="size-8 rounded-lg" />
<Skeleton class="h-4 w-28 rounded-md" />
</div>
<DropdownMenu v-else-if="user">
<DropdownMenuTrigger as-child>
<SidebarMenuButton
size="lg"
class="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
>
<Avatar class="size-8 rounded-lg grayscale">
<AvatarImage v-if="imageUrl" :src="imageUrl" :alt="email" />
<AvatarFallback class="rounded-lg">{{ initials }}</AvatarFallback>
</Avatar>
<div class="grid flex-1 text-left text-sm leading-tight">
<span class="truncate font-medium">{{ email }}</span>
</div>
<EllipsisVerticalIcon class="ml-auto size-4" />
</SidebarMenuButton>
</DropdownMenuTrigger>
<DropdownMenuContent
class="min-w-56 rounded-lg"
:side="isMobile ? 'bottom' : 'right'"
align="end"
:side-offset="4"
>
<DropdownMenuLabel class="p-0 font-normal">
<div class="flex items-center gap-2 px-1 py-1.5 text-left text-sm">
<Avatar class="size-8 rounded-lg">
<AvatarImage v-if="imageUrl" :src="imageUrl" :alt="email" />
<AvatarFallback class="rounded-lg">{{ initials }}</AvatarFallback>
</Avatar>
<div class="grid flex-1 text-left text-sm leading-tight">
<span class="truncate font-medium">{{ email }}</span>
</div>
</div>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem>
<CircleUserRoundIcon />
Account
</DropdownMenuItem>
<DropdownMenuItem>
<CreditCardIcon />
Billing
</DropdownMenuItem>
<DropdownMenuItem>
<BellIcon />
Notifications
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuItem @click="handleSignOut">
<LogOutIcon />
Log out
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuItem>
</SidebarMenu>
</template><script setup lang="ts">
import AppSidebar from "@/components/app-sidebar.vue"
import SiteHeader from "@/components/site-header.vue"
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar"
// Authed app shell (Clerk overlay). Identical chrome to the Better Auth overlay — the @clerk/nuxt
// module auto-injects the ClerkProvider at the app root, so no provider wrapper is needed here. The
// auth gate lives in the route middleware (client) + the clerkMiddleware server middleware (SSR/API).
</script>
<template>
<SidebarProvider>
<AppSidebar />
<SidebarInset>
<SiteHeader />
<div class="flex flex-1 flex-col gap-4">
<slot />
</div>
</SidebarInset>
</SidebarProvider>
</template>import { useAuth } from "@clerk/nuxt/composables"
// Client-side route guard (Clerk overlay). The clerkMiddleware server middleware (init) is the true
// boundary on SSR/API; this bounces client-side SPA navigation for signed-out users. Pages opt in via
// definePageMeta({ middleware: "auth" }). Explicit composable import for standalone vue-tsc.
export default defineNuxtRouteMiddleware(() => {
const { isLoaded, isSignedIn } = useAuth()
if (isLoaded.value && !isSignedIn.value) {
return navigateTo("/sign-in")
}
})<script setup lang="ts">
import { SignIn } from "@clerk/nuxt/components"
// Clerk hosted sign-in. The @clerk/nuxt module renders the full flow; `layout: false` opts out of the
// dashboard shell. ponytail: explicit import from @clerk/nuxt/components (the module auto-imports it at
// runtime) so this SFC type-checks standalone under vue-tsc — the same trade the server code makes.
definePageMeta({ layout: false })
</script>
<template>
<div class="flex min-h-svh items-center justify-center p-4">
<SignIn sign-up-url="/sign-up" />
</div>
</template><script setup lang="ts">
import { SignUp } from "@clerk/nuxt/components"
// Clerk hosted sign-up. See sign-in.vue for the explicit-import note.
definePageMeta({ layout: false })
</script>
<template>
<div class="flex min-h-svh items-center justify-center p-4">
<SignUp sign-in-url="/sign-in" />
</div>
</template><script setup lang="ts">
// Nuxt root. Renders the active layout + page. Global styles pulled in via nuxt.config `css`.
</script>
<template>
<NuxtLayout>
<NuxtPage />
</NuxtLayout>
</template>@import "tailwindcss";
@import "tw-animate-css";
@import "shadcn/tailwind.css";
@custom-variant dark (&:is(.dark *));
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-sans);
--font-mono: var(--font-mono);
--font-heading: var(--font-sans);
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar: var(--sidebar);
--color-chart-5: var(--chart-5);
--color-chart-4: var(--chart-4);
--color-chart-3: var(--chart-3);
--color-chart-2: var(--chart-2);
--color-chart-1: var(--chart-1);
--color-ring: var(--ring);
--color-input: var(--input);
--color-border: var(--border);
--color-destructive: var(--destructive);
--color-accent-foreground: var(--accent-foreground);
--color-accent: var(--accent);
--color-muted-foreground: var(--muted-foreground);
--color-muted: var(--muted);
--color-secondary-foreground: var(--secondary-foreground);
--color-secondary: var(--secondary);
--color-primary-foreground: var(--primary-foreground);
--color-primary: var(--primary);
--color-popover-foreground: var(--popover-foreground);
--color-popover: var(--popover);
--color-card-foreground: var(--card-foreground);
--color-card: var(--card);
--radius-sm: calc(var(--radius) * 0.6);
--radius-md: calc(var(--radius) * 0.8);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) * 1.4);
--radius-2xl: calc(var(--radius) * 1.8);
--radius-3xl: calc(var(--radius) * 2.2);
--radius-4xl: calc(var(--radius) * 2.6);
}
:root {
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.87 0 0);
--chart-2: oklch(0.556 0 0);
--chart-3: oklch(0.439 0 0);
--chart-4: oklch(0.371 0 0);
--chart-5: oklch(0.269 0 0);
--radius: 0.625rem;
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--chart-1: oklch(0.87 0 0);
--chart-2: oklch(0.556 0 0);
--chart-3: oklch(0.439 0 0);
--chart-4: oklch(0.371 0 0);
--chart-5: oklch(0.269 0 0);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
html {
@apply font-sans;
}
}<script setup lang="ts">
import { BoxesIcon } from "lucide-vue-next"
import { cn } from "@/lib/utils"
// Reusable wordmark: a token-filled icon chip + the "Acme" wordmark. One brand source for the
// marketing nav/footer and the app sidebar header.
const props = defineProps<{ class?: string }>()
</script>
<template>
<span :class="cn('inline-flex items-center gap-2 font-semibold tracking-tight', props.class)">
<span class="flex size-6 items-center justify-center rounded-md bg-primary text-primary-foreground">
<BoxesIcon class="size-4" aria-hidden="true" />
</span>
<span class="text-base">Acme</span>
</span>
</template><script setup lang="ts">
import { SidebarTrigger } from "@/components/ui/sidebar"
// Shared app-shell header — archetype-neutral. Carries only the sidebar toggle; the current section
// is conveyed by the sidebar nav, not a hardcoded title.
</script>
<template>
<header class="flex h-12 shrink-0 items-center gap-2 border-b transition-[width,height] ease-linear">
<div class="flex w-full items-center gap-1 px-4 lg:gap-2 lg:px-6">
<SidebarTrigger class="-ml-1" />
</div>
</header>
</template><script setup lang="ts">
import { AvatarRoot } from "reka-ui"
import { cn } from "@/lib/utils"
const props = defineProps<{ class?: string }>()
</script>
<template>
<AvatarRoot data-slot="avatar" :class="cn('relative flex size-8 shrink-0 overflow-hidden rounded-full', props.class)">
<slot />
</AvatarRoot>
</template><script setup lang="ts">
import { AvatarFallback } from "reka-ui"
import { cn } from "@/lib/utils"
const props = defineProps<{ class?: string }>()
</script>
<template>
<AvatarFallback data-slot="avatar-fallback" :class="cn('bg-muted flex size-full items-center justify-center rounded-full', props.class)">
<slot />
</AvatarFallback>
</template><script setup lang="ts">
import { AvatarImage, type AvatarImageProps } from "reka-ui"
import { cn } from "@/lib/utils"
const props = defineProps<AvatarImageProps & { class?: string }>()
</script>
<template>
<AvatarImage data-slot="avatar-image" :src="props.src" :class="cn('aspect-square size-full', props.class)" />
</template>export { default as Avatar } from "./Avatar.vue"
export { default as AvatarImage } from "./AvatarImage.vue"
export { default as AvatarFallback } from "./AvatarFallback.vue"<script setup lang="ts">
import { Primitive, type PrimitiveProps } from "reka-ui"
import { cn } from "@/lib/utils"
import { badgeVariants, type BadgeVariants } from "."
interface Props extends PrimitiveProps {
variant?: BadgeVariants["variant"]
class?: string
}
const props = withDefaults(defineProps<Props>(), { as: "span" })
</script>
<template>
<Primitive data-slot="badge" :as="as" :as-child="asChild" :class="cn(badgeVariants({ variant }), props.class)">
<slot />
</Primitive>
</template>import { cva, type VariantProps } from "class-variance-authority"
export { default as Badge } from "./Badge.vue"
export const badgeVariants = cva(
"inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 gap-1 [&>svg]:size-3 transition-[color,box-shadow] overflow-hidden",
{
variants: {
variant: {
default: "border-transparent bg-primary text-primary-foreground",
secondary: "border-transparent bg-secondary text-secondary-foreground",
destructive: "border-transparent bg-destructive text-white",
outline: "text-foreground",
},
},
defaultVariants: { variant: "default" },
},
)
export type BadgeVariants = VariantProps<typeof badgeVariants><script setup lang="ts">
import { Primitive, type PrimitiveProps } from "reka-ui"
import { cn } from "@/lib/utils"
import { buttonVariants, type ButtonVariants } from "."
interface Props extends PrimitiveProps {
variant?: ButtonVariants["variant"]
size?: ButtonVariants["size"]
class?: string
}
const props = withDefaults(defineProps<Props>(), { as: "button" })
</script>
<template>
<Primitive
data-slot="button"
:as="as"
:as-child="asChild"
:class="cn(buttonVariants({ variant, size }), props.class)"
>
<slot />
</Primitive>
</template>import { cva, type VariantProps } from "class-variance-authority"
export { default as Button } from "./Button.vue"
export const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
destructive: "bg-destructive text-white shadow-xs hover:bg-destructive/90",
outline: "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground",
secondary: "bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9",
},
},
defaultVariants: { variant: "default", size: "default" },
},
)
export type ButtonVariants = VariantProps<typeof buttonVariants><script setup lang="ts">
import { cn } from "@/lib/utils"
const props = defineProps<{ class?: string }>()
</script>
<template>
<div data-slot="card" :class="cn('bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm', props.class)">
<slot />
</div>
</template><script setup lang="ts">
import { cn } from "@/lib/utils"
const props = defineProps<{ class?: string }>()
</script>
<template>
<div data-slot="card-action" :class="cn('col-start-2 row-span-2 row-start-1 self-start justify-self-end', props.class)">
<slot />
</div>
</template><script setup lang="ts">
import { cn } from "@/lib/utils"
const props = defineProps<{ class?: string }>()
</script>
<template>
<div data-slot="card-content" :class="cn('px-6', props.class)">
<slot />
</div>
</template><script setup lang="ts">
import { cn } from "@/lib/utils"
const props = defineProps<{ class?: string }>()
</script>
<template>
<div data-slot="card-description" :class="cn('text-muted-foreground text-sm', props.class)">
<slot />
</div>
</template><script setup lang="ts">
import { cn } from "@/lib/utils"
const props = defineProps<{ class?: string }>()
</script>
<template>
<div data-slot="card-footer" :class="cn('flex items-center px-6 [.border-t]:pt-6', props.class)">
<slot />
</div>
</template><script setup lang="ts">
import { cn } from "@/lib/utils"
const props = defineProps<{ class?: string }>()
</script>
<template>
<div data-slot="card-header" :class="cn('@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto]', props.class)">
<slot />
</div>
</template><script setup lang="ts">
import { cn } from "@/lib/utils"
const props = defineProps<{ class?: string }>()
</script>
<template>
<div data-slot="card-title" :class="cn('leading-none font-semibold', props.class)">
<slot />
</div>
</template>export { default as Card } from "./Card.vue"
export { default as CardHeader } from "./CardHeader.vue"
export { default as CardTitle } from "./CardTitle.vue"
export { default as CardDescription } from "./CardDescription.vue"
export { default as CardAction } from "./CardAction.vue"
export { default as CardContent } from "./CardContent.vue"
export { default as CardFooter } from "./CardFooter.vue"<script setup lang="ts">
import { DropdownMenuRoot, type DropdownMenuRootProps, type DropdownMenuRootEmits, useForwardPropsEmits } from "reka-ui"
const props = defineProps<DropdownMenuRootProps>()
const emits = defineEmits<DropdownMenuRootEmits>()
const forwarded = useForwardPropsEmits(props, emits)
</script>
<template>
<DropdownMenuRoot data-slot="dropdown-menu" v-bind="forwarded">
<slot />
</DropdownMenuRoot>
</template><script setup lang="ts">
import { DropdownMenuContent, DropdownMenuPortal, type DropdownMenuContentProps } from "reka-ui"
import { cn } from "@/lib/utils"
const props = withDefaults(defineProps<DropdownMenuContentProps & { class?: string }>(), { sideOffset: 4 })
</script>
<template>
<DropdownMenuPortal>
<DropdownMenuContent
data-slot="dropdown-menu-content"
:align="align"
:side="side"
:side-offset="sideOffset"
:class="cn('bg-popover text-popover-foreground z-50 max-h-(--reka-dropdown-menu-content-available-height) min-w-[8rem] origin-(--reka-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md', props.class)"
>
<slot />
</DropdownMenuContent>
</DropdownMenuPortal>
</template><script setup lang="ts">
import { DropdownMenuItem, type DropdownMenuItemProps } from "reka-ui"
import { cn } from "@/lib/utils"
const props = defineProps<DropdownMenuItemProps & { class?: string; inset?: boolean }>()
</script>
<template>
<DropdownMenuItem
data-slot="dropdown-menu-item"
:disabled="disabled"
:text-value="textValue"
:data-inset="inset ? '' : undefined"
:class="cn('focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*=size-])]:size-4', props.class)"
>
<slot />
</DropdownMenuItem>
</template><script setup lang="ts">
import { DropdownMenuLabel, type DropdownMenuLabelProps } from "reka-ui"
import { cn } from "@/lib/utils"
const props = defineProps<DropdownMenuLabelProps & { class?: string; inset?: boolean }>()
</script>
<template>
<DropdownMenuLabel
data-slot="dropdown-menu-label"
:data-inset="inset ? '' : undefined"
:class="cn('px-2 py-1.5 text-sm font-medium data-[inset]:pl-8', props.class)"
>
<slot />
</DropdownMenuLabel>
</template><script setup lang="ts">
import { DropdownMenuSeparator } from "reka-ui"
import { cn } from "@/lib/utils"
const props = defineProps<{ class?: string }>()
</script>
<template>
<DropdownMenuSeparator data-slot="dropdown-menu-separator" :class="cn('bg-border -mx-1 my-1 h-px', props.class)" />
</template>export { default as DropdownMenu } from "./DropdownMenu.vue"
export { default as DropdownMenuContent } from "./DropdownMenuContent.vue"
export { default as DropdownMenuItem } from "./DropdownMenuItem.vue"
export { default as DropdownMenuLabel } from "./DropdownMenuLabel.vue"
export { default as DropdownMenuSeparator } from "./DropdownMenuSeparator.vue"
export { DropdownMenuTrigger, DropdownMenuGroup, DropdownMenuPortal } from "reka-ui"<script setup lang="ts">
import { cn } from "@/lib/utils"
const props = defineProps<{ class?: string; type?: string }>()
const model = defineModel<string>()
</script>
<template>
<input
v-model="model"
data-slot="input"
:type="type"
:class="cn(
'file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground border-input flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
'focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]',
'aria-invalid:ring-destructive/20 aria-invalid:border-destructive',
props.class,
)"
>
</template>export { default as Input } from "./Input.vue"<script setup lang="ts">
import { Label, type LabelProps } from "reka-ui"
import { cn } from "@/lib/utils"
const props = defineProps<LabelProps & { class?: string }>()
</script>
<template>
<Label
data-slot="label"
:for="props.for"
:class="cn('flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50', props.class)"
>
<slot />
</Label>
</template>export { default as Label } from "./Label.vue"<script setup lang="ts">
import { Separator, type SeparatorProps } from "reka-ui"
import { cn } from "@/lib/utils"
const props = withDefaults(defineProps<SeparatorProps & { class?: string }>(), {
orientation: "horizontal",
decorative: true,
})
</script>
<template>
<Separator
data-slot="separator"
:orientation="orientation"
:decorative="decorative"
:class="cn('bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px', props.class)"
/>
</template>export { default as Separator } from "./Separator.vue"<script setup lang="ts">
import { cn } from "@/lib/utils"
import { useSidebar } from "."
const props = defineProps<{ class?: string }>()
const { open } = useSidebar()
</script>
<template>
<aside
data-slot="sidebar"
:data-state="open ? 'expanded' : 'collapsed'"
:class="cn(
'bg-sidebar text-sidebar-foreground flex h-svh flex-col border-r transition-[width] duration-200 ease-linear',
open ? 'w-64' : 'w-0 overflow-hidden border-r-0',
props.class,
)"
>
<slot />
</aside>
</template><script setup lang="ts">
import { cn } from "@/lib/utils"
const props = defineProps<{ class?: string }>()
</script>
<template>
<div data-slot="sidebar-content" :class="cn('flex min-h-0 flex-1 flex-col gap-2 overflow-auto', props.class)">
<slot />
</div>
</template><script setup lang="ts">
import { cn } from "@/lib/utils"
const props = defineProps<{ class?: string }>()
</script>
<template>
<div data-slot="sidebar-footer" :class="cn('flex flex-col gap-2 p-2', props.class)">
<slot />
</div>
</template><script setup lang="ts">
import { cn } from "@/lib/utils"
const props = defineProps<{ class?: string }>()
</script>
<template>
<div data-slot="sidebar-group" :class="cn('relative flex w-full min-w-0 flex-col p-2', props.class)">
<slot />
</div>
</template><script setup lang="ts">
import { cn } from "@/lib/utils"
const props = defineProps<{ class?: string }>()
</script>
<template>
<div data-slot="sidebar-group-content" :class="cn('w-full text-sm', props.class)">
<slot />
</div>
</template><script setup lang="ts">
import { cn } from "@/lib/utils"
const props = defineProps<{ class?: string }>()
</script>
<template>
<div data-slot="sidebar-group-label" :class="cn('text-sidebar-foreground/70 flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium', props.class)">
<slot />
</div>
</template><script setup lang="ts">
import { cn } from "@/lib/utils"
const props = defineProps<{ class?: string }>()
</script>
<template>
<div data-slot="sidebar-header" :class="cn('flex flex-col gap-2 p-2', props.class)">
<slot />
</div>
</template><script setup lang="ts">
import { cn } from "@/lib/utils"
const props = defineProps<{ class?: string }>()
</script>
<template>
<main data-slot="sidebar-inset" :class="cn('bg-background relative flex w-full flex-1 flex-col', props.class)">
<slot />
</main>
</template><script setup lang="ts">
import { cn } from "@/lib/utils"
const props = defineProps<{ class?: string }>()
</script>
<template>
<ul data-slot="sidebar-menu" :class="cn('flex w-full min-w-0 flex-col gap-1', props.class)">
<slot />
</ul>
</template><script setup lang="ts">
import { Primitive, type PrimitiveProps } from "reka-ui"
import { cn } from "@/lib/utils"
import { sidebarMenuButtonVariants, type SidebarMenuButtonVariants } from "."
interface Props extends PrimitiveProps {
variant?: SidebarMenuButtonVariants["variant"]
size?: SidebarMenuButtonVariants["size"]
isActive?: boolean
class?: string
}
const props = withDefaults(defineProps<Props>(), { as: "button" })
</script>
<template>
<Primitive
data-slot="sidebar-menu-button"
:as="as"
:as-child="asChild"
:data-active="isActive"
:class="cn(sidebarMenuButtonVariants({ variant, size }), props.class)"
>
<slot />
</Primitive>
</template><script setup lang="ts">
import { cn } from "@/lib/utils"
const props = defineProps<{ class?: string }>()
</script>
<template>
<li data-slot="sidebar-menu-item" :class="cn('group/menu-item relative', props.class)">
<slot />
</li>
</template><script setup lang="ts">
import { provide, ref } from "vue"
import { cn } from "@/lib/utils"
import { SIDEBAR_INJECTION_KEY } from "."
const props = withDefaults(defineProps<{ class?: string; defaultOpen?: boolean }>(), { defaultOpen: true })
const open = ref(props.defaultOpen)
const isMobile = ref(false)
function toggle() {
open.value = !open.value
}
provide(SIDEBAR_INJECTION_KEY, { open, isMobile, toggle })
</script>
<template>
<div
data-slot="sidebar-wrapper"
:data-state="open ? 'expanded' : 'collapsed'"
:class="cn('group/sidebar-wrapper flex min-h-svh w-full', props.class)"
>
<slot />
</div>
</template><script setup lang="ts">
import { PanelLeftIcon } from "lucide-vue-next"
import { Button } from "@/components/ui/button"
import { cn } from "@/lib/utils"
import { useSidebar } from "."
const props = defineProps<{ class?: string }>()
const { toggle } = useSidebar()
</script>
<template>
<Button
data-slot="sidebar-trigger"
variant="ghost"
size="icon"
:class="cn('size-7', props.class)"
@click="toggle"
>
<PanelLeftIcon />
<span class="sr-only">Toggle Sidebar</span>
</Button>
</template>import { inject, type InjectionKey, type Ref } from "vue"
import { cva, type VariantProps } from "class-variance-authority"
export { default as SidebarProvider } from "./SidebarProvider.vue"
export { default as Sidebar } from "./Sidebar.vue"
export { default as SidebarInset } from "./SidebarInset.vue"
export { default as SidebarTrigger } from "./SidebarTrigger.vue"
export { default as SidebarHeader } from "./SidebarHeader.vue"
export { default as SidebarContent } from "./SidebarContent.vue"
export { default as SidebarFooter } from "./SidebarFooter.vue"
export { default as SidebarGroup } from "./SidebarGroup.vue"
export { default as SidebarGroupLabel } from "./SidebarGroupLabel.vue"
export { default as SidebarGroupContent } from "./SidebarGroupContent.vue"
export { default as SidebarMenu } from "./SidebarMenu.vue"
export { default as SidebarMenuItem } from "./SidebarMenuItem.vue"
export { default as SidebarMenuButton } from "./SidebarMenuButton.vue"
// Sidebar state shared via provide/inject. ponytail: a desktop-first expand/collapse toggle — the
// working subset. No cookie persistence, no mobile Sheet, no collapsible-icon rail on this pass;
// add them (with the sheet/tooltip primitives) when an archetype needs them.
export interface SidebarContext {
open: Ref<boolean>
isMobile: Ref<boolean>
toggle: () => void
}
export const SIDEBAR_INJECTION_KEY: InjectionKey<SidebarContext> = Symbol("sidebar")
export function useSidebar(): SidebarContext {
const context = inject(SIDEBAR_INJECTION_KEY)
if (!context) throw new Error("useSidebar must be used within a <SidebarProvider>.")
return context
}
export const sidebarMenuButtonVariants = cva(
"peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-hidden transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground [&>svg]:size-4 [&>svg]:shrink-0",
{
variants: {
variant: {
default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
outline: "bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))]",
},
size: {
default: "h-8 text-sm",
lg: "h-12 text-sm group-data-[collapsible=icon]:p-0!",
},
},
defaultVariants: { variant: "default", size: "default" },
},
)
export type SidebarMenuButtonVariants = VariantProps<typeof sidebarMenuButtonVariants><script setup lang="ts">
import { cn } from "@/lib/utils"
const props = defineProps<{ class?: string }>()
</script>
<template>
<div data-slot="skeleton" :class="cn('bg-accent animate-pulse rounded-md', props.class)" />
</template>export { default as Skeleton } from "./Skeleton.vue"<script setup lang="ts">
import { cn } from "@/lib/utils"
const props = defineProps<{ class?: string }>()
</script>
<template>
<div data-slot="table-container" class="relative w-full overflow-x-auto">
<table data-slot="table" :class="cn('w-full caption-bottom text-sm', props.class)">
<slot />
</table>
</div>
</template><script setup lang="ts">
import { cn } from "@/lib/utils"
const props = defineProps<{ class?: string }>()
</script>
<template>
<tbody data-slot="table-body" :class="cn('[&_tr:last-child]:border-0', props.class)">
<slot />
</tbody>
</template><script setup lang="ts">
import { cn } from "@/lib/utils"
const props = defineProps<{ class?: string }>()
</script>
<template>
<td data-slot="table-cell" :class="cn('p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0', props.class)">
<slot />
</td>
</template><script setup lang="ts">
import { cn } from "@/lib/utils"
const props = defineProps<{ class?: string }>()
</script>
<template>
<th data-slot="table-head" :class="cn('text-muted-foreground h-10 px-2 text-left align-middle font-medium whitespace-nowrap [&:has([role=checkbox])]:pr-0', props.class)">
<slot />
</th>
</template><script setup lang="ts">
import { cn } from "@/lib/utils"
const props = defineProps<{ class?: string }>()
</script>
<template>
<thead data-slot="table-header" :class="cn('[&_tr]:border-b', props.class)">
<slot />
</thead>
</template><script setup lang="ts">
import { cn } from "@/lib/utils"
const props = defineProps<{ class?: string }>()
</script>
<template>
<tr data-slot="table-row" :class="cn('hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors', props.class)">
<slot />
</tr>
</template>export { default as Table } from "./Table.vue"
export { default as TableHeader } from "./TableHeader.vue"
export { default as TableBody } from "./TableBody.vue"
export { default as TableRow } from "./TableRow.vue"
export { default as TableHead } from "./TableHead.vue"
export { default as TableCell } from "./TableCell.vue"<script setup lang="ts">
import { cn } from "@/lib/utils"
const props = defineProps<{ class?: string }>()
const model = defineModel<string>()
</script>
<template>
<textarea
v-model="model"
data-slot="textarea"
:class="cn(
'border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 aria-invalid:border-destructive flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
props.class,
)"
/>
</template>export { default as Textarea } from "./Textarea.vue"import { type ClassValue, clsx } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}<script setup lang="ts">
import { ZapIcon, ShieldCheckIcon, BarChart3Icon } from "lucide-vue-next"
import Logo from "@/components/logo.vue"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
// Public landing. Self-contained marketing chrome (nav + hero + features + CTA + footer) so the
// dashboard archetype ships a real front door. ponytail: inline sections over a marketing-component
// tree on this pass — extract shared marketing/* if a second archetype needs the same chrome.
const features = [
{ icon: ZapIcon, title: "Ship faster", body: "Projects, analytics and access control wired together out of the box." },
{ icon: ShieldCheckIcon, title: "Secure by default", body: "Session-guarded routes and a real server-side auth boundary from line one." },
{ icon: BarChart3Icon, title: "See everything", body: "A dashboard that surfaces the metrics your team actually acts on." },
]
</script>
<template>
<div class="flex min-h-svh flex-col">
<header class="flex h-16 items-center justify-between border-b px-4 lg:px-8">
<Logo />
<nav class="flex items-center gap-2">
<Button as-child variant="ghost" size="sm">
<NuxtLink to="/sign-in">Sign in</NuxtLink>
</Button>
<Button as-child size="sm">
<NuxtLink to="/sign-up">Get started</NuxtLink>
</Button>
</nav>
</header>
<main class="flex-1">
<section class="mx-auto flex max-w-3xl flex-col items-center gap-6 px-4 py-24 text-center">
<h1 class="text-4xl font-semibold tracking-tight text-balance md:text-6xl">
One workspace for your team
</h1>
<p class="text-muted-foreground max-w-xl text-lg text-pretty">
Projects, analytics, and access control wired together so your team ships faster.
</p>
<div class="flex gap-3">
<Button as-child size="lg">
<NuxtLink to="/sign-up">Start for free</NuxtLink>
</Button>
<Button as-child size="lg" variant="outline">
<NuxtLink to="/sign-in">Sign in</NuxtLink>
</Button>
</div>
</section>
<section class="mx-auto grid max-w-5xl grid-cols-1 gap-4 px-4 pb-24 md:grid-cols-3">
<Card v-for="feature in features" :key="feature.title">
<CardHeader>
<component :is="feature.icon" class="text-primary size-6" />
<CardTitle class="mt-2">{{ feature.title }}</CardTitle>
<CardDescription>{{ feature.body }}</CardDescription>
</CardHeader>
<CardContent />
</Card>
</section>
</main>
<footer class="text-muted-foreground border-t px-4 py-8 text-center text-sm">
© 2026 Acme. All rights reserved.
</footer>
</div>
</template><script setup lang="ts">
import { LayoutDashboardIcon, FolderIcon, UsersIcon, Settings2Icon } from "lucide-vue-next"
import NavMain from "@/components/nav-main.vue"
import NavUser from "@/components/nav-user.vue"
import Logo from "@/components/logo.vue"
import {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarHeader,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
} from "@/components/ui/sidebar"
// Dashboard-archetype sidebar: brand header, primary nav, and the auth-wired user menu in the
// footer (NavUser rides the _auth overlay, so this same shell works for any auth provider).
const items = [
{ title: "Dashboard", url: "/dashboard", icon: LayoutDashboardIcon },
{ title: "Projects", url: "/dashboard/projects", icon: FolderIcon },
{ title: "Team", url: "/dashboard/team", icon: UsersIcon },
{ title: "Settings", url: "/dashboard/settings", icon: Settings2Icon },
]
</script>
<template>
<Sidebar>
<SidebarHeader>
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton as-child size="lg">
<NuxtLink to="/dashboard">
<Logo />
</NuxtLink>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarHeader>
<SidebarContent>
<NavMain :items="items" />
</SidebarContent>
<SidebarFooter>
<NavUser />
</SidebarFooter>
</Sidebar>
</template><script setup lang="ts">
import type { Component } from "vue"
import { CirclePlusIcon, MailIcon } from "lucide-vue-next"
import { Button } from "@/components/ui/button"
import {
SidebarGroup,
SidebarGroupContent,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
} from "@/components/ui/sidebar"
defineProps<{ items: { title: string; url: string; icon?: Component }[] }>()
</script>
<template>
<SidebarGroup>
<SidebarGroupContent class="flex flex-col gap-2">
<SidebarMenu>
<SidebarMenuItem class="flex items-center gap-2">
<SidebarMenuButton class="min-w-8 bg-primary text-primary-foreground duration-200 ease-linear hover:bg-primary/90 hover:text-primary-foreground active:bg-primary/90 active:text-primary-foreground">
<CirclePlusIcon />
<span>Quick Create</span>
</SidebarMenuButton>
<Button size="icon" variant="outline" class="size-8">
<MailIcon />
<span class="sr-only">Inbox</span>
</Button>
</SidebarMenuItem>
</SidebarMenu>
<SidebarMenu>
<SidebarMenuItem v-for="item in items" :key="item.title">
<SidebarMenuButton as-child>
<NuxtLink :to="item.url">
<component :is="item.icon" v-if="item.icon" />
<span>{{ item.title }}</span>
</NuxtLink>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
</template><script setup lang="ts">
import { TrendingUpIcon, TrendingDownIcon } from "lucide-vue-next"
import { Badge } from "@/components/ui/badge"
import { Card, CardAction, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"
// Four KPI cards — the dashboard archetype's headline metrics. Static placeholder figures; wire to
// real queries in the page loader. Same content as the Next/RR dashboard tree.
const cards = [
{ label: "Total Revenue", value: "$1,250.00", delta: "+12.5%", up: true, title: "Trending up this month", note: "Visitors for the last 6 months" },
{ label: "New Customers", value: "1,234", delta: "-20%", up: false, title: "Down 20% this period", note: "Acquisition needs attention" },
{ label: "Active Accounts", value: "45,678", delta: "+12.5%", up: true, title: "Strong user retention", note: "Engagement exceeds targets" },
{ label: "Growth Rate", value: "4.5%", delta: "+4.5%", up: true, title: "Steady performance increase", note: "Meets growth projections" },
]
</script>
<template>
<div class="grid grid-cols-1 gap-4 px-4 @xl/main:grid-cols-2 @5xl/main:grid-cols-4 lg:px-6">
<Card v-for="card in cards" :key="card.label" class="@container/card">
<CardHeader>
<CardDescription>{{ card.label }}</CardDescription>
<CardTitle class="text-2xl font-semibold tabular-nums @[250px]/card:text-3xl">
{{ card.value }}
</CardTitle>
<CardAction>
<Badge variant="outline">
<component :is="card.up ? TrendingUpIcon : TrendingDownIcon" />
{{ card.delta }}
</Badge>
</CardAction>
</CardHeader>
<CardFooter class="flex-col items-start gap-1.5 text-sm">
<div class="line-clamp-1 flex gap-2 font-medium">
{{ card.title }}
<component :is="card.up ? TrendingUpIcon : TrendingDownIcon" class="size-4" />
</div>
<div class="text-muted-foreground">{{ card.note }}</div>
</CardFooter>
</Card>
</div>
</template><script setup lang="ts">
import SectionCards from "@/components/section-cards.vue"
import { Badge } from "@/components/ui/badge"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
// Dashboard home. The `dashboard` layout (auth overlay) supplies the sidebar shell + gate; the
// `auth` route middleware guards client-side nav (the Nitro server middleware guards SSR/API).
definePageMeta({ layout: "dashboard", middleware: "auth" })
const invoices = [
{ id: "INV-001", customer: "Acme Corp", status: "Paid", amount: "$1,250.00" },
{ id: "INV-002", customer: "Globex", status: "Pending", amount: "$3,400.00" },
{ id: "INV-003", customer: "Initech", status: "Paid", amount: "$820.00" },
{ id: "INV-004", customer: "Umbrella", status: "Overdue", amount: "$5,600.00" },
{ id: "INV-005", customer: "Soylent", status: "Paid", amount: "$2,100.00" },
]
function statusVariant(status: string): "default" | "secondary" | "outline" {
if (status === "Paid") return "default"
if (status === "Pending") return "secondary"
return "outline"
}
</script>
<template>
<div class="flex flex-col gap-6 py-4 md:py-6">
<SectionCards />
<div class="px-4 lg:px-6">
<Card>
<CardHeader>
<CardTitle>Recent invoices</CardTitle>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>Invoice</TableHead>
<TableHead>Customer</TableHead>
<TableHead>Status</TableHead>
<TableHead class="text-right">Amount</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow v-for="invoice in invoices" :key="invoice.id">
<TableCell class="font-medium">{{ invoice.id }}</TableCell>
<TableCell>{{ invoice.customer }}</TableCell>
<TableCell>
<Badge :variant="statusVariant(invoice.status)">{{ invoice.status }}</Badge>
</TableCell>
<TableCell class="text-right tabular-nums">{{ invoice.amount }}</TableCell>
</TableRow>
</TableBody>
</Table>
</CardContent>
</Card>
</div>
</div>
</template>Email — verified (Resend)
<mjml lang="en">
<mj-head>
<mj-title>Welcome aboard</mj-title>
<mj-attributes>
<mj-all font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif" />
</mj-attributes>
</mj-head>
<mj-body background-color="#f4f4f5">
<mj-raw><div style="display:none;overflow:hidden;line-height:1px;opacity:0;max-height:0;max-width:0;">You're in — here's how to get started.</div></mj-raw>
<mj-section background-color="#ffffff" padding="32px" border-radius="8px">
<mj-column>
<mj-text color="#111827" font-size="22px" font-weight="700">Welcome, {{name}}!</mj-text>
<mj-text color="#374151" font-size="15px" line-height="24px">Thanks for signing up. Your account is ready to go.</mj-text>
<mj-text color="#374151" font-size="15px" line-height="24px">If you have any questions, just reply to this email — a real person reads it.</mj-text>
<mj-raw>
<table role="presentation" cellpadding="0" cellspacing="0" style="margin:24px 0;">
<tr>
<td style="border-radius:6px;background-color:#111827;" align="center">
<a href="{{ctaUrl}}" style="display:inline-block;padding:12px 20px;font-size:15px;font-weight:600;color:#ffffff;background-color:#111827;border-radius:6px;text-decoration:none;font-family:-apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif;">Go to your dashboard</a>
</td>
</tr>
</table>
</mj-raw>
<mj-divider border-color="#e5e7eb" />
<mj-text color="#4b5563" font-size="12px" line-height="18px">Acme Inc, 548 Market St PMB 12345, San Francisco, CA 94104</mj-text>
<mj-text color="#4b5563" font-size="12px" line-height="18px"><a href="{{unsubscribeUrl}}" style="color:#4b5563;text-decoration:underline;">Unsubscribe</a></mj-text>
</mj-column>
</mj-section>
</mj-body>
</mjml><mjml lang="en">
<mj-head>
<mj-title>You have a new notification</mj-title>
<mj-attributes>
<mj-all font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif" />
</mj-attributes>
</mj-head>
<mj-body background-color="#f4f4f5">
<mj-raw><div style="display:none;overflow:hidden;line-height:1px;opacity:0;max-height:0;max-width:0;">Something happened in your account.</div></mj-raw>
<mj-section background-color="#ffffff" padding="32px" border-radius="8px">
<mj-column>
<mj-text color="#111827" font-size="22px" font-weight="700">{{title}}</mj-text>
<mj-text color="#374151" font-size="15px" line-height="24px">Hi {{name}},</mj-text>
<mj-text color="#374151" font-size="15px" line-height="24px">{{message}}</mj-text>
<mj-raw>
<table role="presentation" cellpadding="0" cellspacing="0" style="margin:24px 0;">
<tr>
<td style="border-radius:6px;background-color:#111827;" align="center">
<a href="{{ctaUrl}}" style="display:inline-block;padding:12px 20px;font-size:15px;font-weight:600;color:#ffffff;background-color:#111827;border-radius:6px;text-decoration:none;font-family:-apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif;">View details</a>
</td>
</tr>
</table>
</mj-raw>
<mj-divider border-color="#e5e7eb" />
<mj-text color="#4b5563" font-size="12px" line-height="18px">Acme Inc, 548 Market St PMB 12345, San Francisco, CA 94104</mj-text>
<mj-text color="#4b5563" font-size="12px" line-height="18px"><a href="{{unsubscribeUrl}}" style="color:#4b5563;text-decoration:underline;">Unsubscribe</a></mj-text>
</mj-column>
</mj-section>
</mj-body>
</mjml>import { Resend } from "resend";
import mjml2html from "mjml";
// Constructed LAZILY, on first send. `new Resend(undefined)` THROWS ("Missing API key"),
// and a module-scope client makes that throw fire at IMPORT time — which is exactly when a
// framework build imports this file to collect page data. The result is that `next build`
// fails outright on any machine without RESEND_API_KEY set (CI, a fresh clone, a preview
// deploy that only holds runtime secrets). Deferring it means importing lib/email is always
// safe and a missing key fails at the send, where it is actionable.
let client: Resend | undefined;
function resendClient(): Resend {
return (client ??= new Resend(process.env.RESEND_API_KEY));
}
const FROM = process.env.EMAIL_FROM ?? "onboarding@resend.dev";
const WELCOME_MJML = `<mjml lang="en">
<mj-head>
<mj-title>Welcome aboard</mj-title>
<mj-attributes>
<mj-all font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif" />
</mj-attributes>
</mj-head>
<mj-body background-color="#f4f4f5">
<mj-raw><div style="display:none;overflow:hidden;line-height:1px;opacity:0;max-height:0;max-width:0;">You're in — here's how to get started.</div></mj-raw>
<mj-section background-color="#ffffff" padding="32px" border-radius="8px">
<mj-column>
<mj-text color="#111827" font-size="22px" font-weight="700">Welcome, {{name}}!</mj-text>
<mj-text color="#374151" font-size="15px" line-height="24px">Thanks for signing up. Your account is ready to go.</mj-text>
<mj-text color="#374151" font-size="15px" line-height="24px">If you have any questions, just reply to this email — a real person reads it.</mj-text>
<mj-raw>
<table role="presentation" cellpadding="0" cellspacing="0" style="margin:24px 0;">
<tr>
<td style="border-radius:6px;background-color:#111827;" align="center">
<a href="{{ctaUrl}}" style="display:inline-block;padding:12px 20px;font-size:15px;font-weight:600;color:#ffffff;background-color:#111827;border-radius:6px;text-decoration:none;font-family:-apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif;">Go to your dashboard</a>
</td>
</tr>
</table>
</mj-raw>
<mj-divider border-color="#e5e7eb" />
<mj-text color="#4b5563" font-size="12px" line-height="18px">Acme Inc, 548 Market St PMB 12345, San Francisco, CA 94104</mj-text>
<mj-text color="#4b5563" font-size="12px" line-height="18px"><a href="{{unsubscribeUrl}}" style="color:#4b5563;text-decoration:underline;">Unsubscribe</a></mj-text>
</mj-column>
</mj-section>
</mj-body>
</mjml>`;
const NOTIFICATION_MJML = `<mjml lang="en">
<mj-head>
<mj-title>You have a new notification</mj-title>
<mj-attributes>
<mj-all font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif" />
</mj-attributes>
</mj-head>
<mj-body background-color="#f4f4f5">
<mj-raw><div style="display:none;overflow:hidden;line-height:1px;opacity:0;max-height:0;max-width:0;">Something happened in your account.</div></mj-raw>
<mj-section background-color="#ffffff" padding="32px" border-radius="8px">
<mj-column>
<mj-text color="#111827" font-size="22px" font-weight="700">{{title}}</mj-text>
<mj-text color="#374151" font-size="15px" line-height="24px">Hi {{name}},</mj-text>
<mj-text color="#374151" font-size="15px" line-height="24px">{{message}}</mj-text>
<mj-raw>
<table role="presentation" cellpadding="0" cellspacing="0" style="margin:24px 0;">
<tr>
<td style="border-radius:6px;background-color:#111827;" align="center">
<a href="{{ctaUrl}}" style="display:inline-block;padding:12px 20px;font-size:15px;font-weight:600;color:#ffffff;background-color:#111827;border-radius:6px;text-decoration:none;font-family:-apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif;">View details</a>
</td>
</tr>
</table>
</mj-raw>
<mj-divider border-color="#e5e7eb" />
<mj-text color="#4b5563" font-size="12px" line-height="18px">Acme Inc, 548 Market St PMB 12345, San Francisco, CA 94104</mj-text>
<mj-text color="#4b5563" font-size="12px" line-height="18px"><a href="{{unsubscribeUrl}}" style="color:#4b5563;text-decoration:underline;">Unsubscribe</a></mj-text>
</mj-column>
</mj-section>
</mj-body>
</mjml>`;
// MJML has no built-in variable binding — templates carry {{token}} mail-merge
// placeholders (see templates-mjml.ts); substitute before compiling.
function fill(template: string, vars: Record<string, string>): string {
return template.replace(/\{\{(\w+)\}\}/g, (_match, key: string) => vars[key] ?? "");
}
async function renderMjml(template: string, vars: Record<string, string>): Promise<string> {
const { html, errors } = await mjml2html(fill(template, vars), { validationLevel: "soft" });
// The ambient mjml.d.ts below deliberately types errors as unknown[] (no upstream .d.ts to
// pin against); mjml's real runtime error objects carry .formattedMessage, so assert it here.
if (errors.length > 0) throw new Error(`mjml: ${errors.map((e) => (e as { formattedMessage: string }).formattedMessage).join("; ")}`);
return html;
}
export async function sendWelcome(to: string, vars: { name: string; ctaUrl: string; unsubscribeUrl: string }) {
const html = await renderMjml(WELCOME_MJML, vars);
return resendClient().emails.send({ from: FROM, to, subject: "Welcome aboard", html });
}
export async function sendNotification(to: string, vars: { name: string; title: string; message: string; ctaUrl: string; unsubscribeUrl: string }) {
const html = await renderMjml(NOTIFICATION_MJML, { ...vars, title: vars.title });
return resendClient().emails.send({ from: FROM, to, subject: vars.title, html });
}// mjml@5 ships no type declarations for its default export — this ambient module fills the gap.
declare module "mjml" {
export default function mjml2html(
src: string,
opts?: { validationLevel?: "strict" | "soft" | "skip" },
): Promise<{ html: string; errors: unknown[] }>;
}Decisions and compatibility
Client/server split: the DB client, Drizzle schema, records, and webhooks are server-side (server/). The `@/` alias is the client root (app/); server code reaches shared modules via Nuxt's `~~` rootDir alias (e.g. `~~/server/db/schema`).
The API layer is Nitro, Nuxt's server engine: endpoints are server/api/*.post.ts route handlers, and auth mounts as a Nitro catch-all that delegates to the auth library's framework-agnostic web handler.
Nuxt auto-imports components and composables at runtime, but the emitted server code imports h3 helpers (defineEventHandler, toWebRequest) EXPLICITLY — the one deliberate idiom trade so the handlers type-check under standalone tsc instead of relying on the auto-import magic.
Session gating runs in a Nitro server middleware (server/middleware/), which fires on every SSR and API request — the true security boundary, and a real server-side session check rather than a cookie-existence peek.
mysql2's pool multiplexes connections; drizzle-orm/mysql2 wraps it. One module-level pool is right for a serverless/edge app — the runtime and the pool handle concurrency.
MySQL has no row-level security: multi-tenant isolation is enforced in application code via the forOrg helper (src/lib/tenant.ts), not by the database. See the tenant-scoping section on SaaS pages.
Hosted: Clerk owns identity and does NOT create a local `user` table. Store `clerk_user_id` as text without a foreign key, or sync Clerk users into a local table via webhook before relying on FKs to `user`.
Route protection is clerkMiddleware() + auth.protect() in the Next proxy (getAuth() in a React Router loader, or event.context.auth() on Nuxt) — logged-out users bounce to Clerk's hosted sign-in, so there are no self-hosted auth pages to build or maintain.
Keeping the local mirror in sync is a webhook job: a svix-verified webhook route replays user.created / user.updated / user.deleted idempotently into the local `user` row, so app-type foreign keys to `user` resolve even though Clerk is the source of truth.
ClerkProvider (client) wraps the app so the hosted <SignIn/> / <UserButton/> components and hooks work; the publishable key is read client-side, while the secret key is only ever read server-side by clerkMiddleware.
Deal stage is text + CHECK ('lead','qualified','won','lost') on the deals table — new stages ship without an ALTER TYPE migration; the idx_deal_stage index drives the pipeline board's 'deals grouped by stage' query.
activities is append-only (no updates, no deletes cascaded from deal): the per-deal timeline is always a raw log, never a mutated summary, queried via idx_activity_deal_time (deal_id, occurred_at).
Clerk is a hosted identity provider and does not create a local `user` table. This schema's foreign keys to `user` assume a local identity table (as Better Auth provides). With Clerk, store `clerk_user_id` as a text column without a foreign key, or sync Clerk users into a local `users` table via webhook before relying on these FKs.
MySQL provides no row-level security. On MySQL, multi-tenant isolation is APP-ENFORCED via the forOrg helper (src/lib/tenant.ts), not database-enforced like Postgres RLS. Every org-scoped query MUST go through forOrg — a missed query leaks across tenants. Postgres cells enforce this in the database itself (RLS), so it holds even for a query that forgets to scope.
Recombine
Swap one axis and keep the rest. Every combination below is verified the same way.