codexmachina
registry/nuxt-postgres-better-auth-lms-resend

LMS (learning platform) on Nuxt 4, Postgres (Neon) and Better Auth

verified 2026-08-17clsx2.1.1nuxt4.5.2vaul1.1.2resend6.18.1shadcn4.16.1sonner2.0.7postgres3.4.9radix-ui1.6.7recharts3.10.1better-auth1.6.29lucide-react1.28.0tailwind-merge3.6.0tw-animate-css1.4.0@tanstack/react-table8.21.3@neondatabase/serverless1.1.0class-variance-authority0.7.1

Type-checked against the real SDKs, migration applied to a live Postgres (Neon), connection clients load-tested, then tracked for upstream drift and re-verified when it moves. How we verify

request path
Browserrequest
fetch
Nuxt 4routing + proxy
verify
Better Authsession
query
Postgres (Neon)pooled

session validation runs in server components and route handlers, not at the edge

What you're getting

Nuxt 4

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.

Postgres (Neon)

Postgres on Neon via Drizzle ORM and the postgres-js driver.

Better Auth

Better Auth — self-hosted auth running inside your app against your Postgres (Drizzle adapter).

LMS (learning platform)

Learning platform — course catalog (courses → modules → lessons), enrollment join, and per-lesson learner progress tracking.

Setup

bun add nuxt vue drizzle-orm postgres better-auth
DATABASE_URLNeon pooled (-pooler) connection string
BETTER_AUTH_SECRETgenerate with `openssl rand -base64 32`
BETTER_AUTH_URLyour app's base URL

Apply the schema with bunx drizzle-kit push

Initialization

Database client

server/lib/db.ts
import { drizzle } from "drizzle-orm/postgres-js";
import postgres from "postgres";

// Neon pooled endpoint = PgBouncer transaction mode → prepared statements off.
// ponytail: single module-level client; the serverless runtime + PgBouncer do
// the pooling, so no custom pool/globalThis singleton dance needed.
const client = postgres(process.env.DATABASE_URL!, { prepare: false });

export const db = drizzle({ client });
import { boolean, pgTable, text, timestamp } from "drizzle-orm/pg-core";

export const user = pgTable("user", {
  id: text("id").primaryKey(),
  name: text("name").notNull(),
  email: text("email").notNull().unique(),
  emailVerified: boolean("email_verified").notNull().default(false),
  image: text("image"),
  createdAt: timestamp("created_at").notNull().defaultNow(),
  updatedAt: timestamp("updated_at").notNull().defaultNow(),
});

export const session = pgTable("session", {
  id: text("id").primaryKey(),
  expiresAt: timestamp("expires_at").notNull(),
  token: text("token").notNull().unique(),
  createdAt: timestamp("created_at").notNull().defaultNow(),
  updatedAt: timestamp("updated_at").notNull(),
  ipAddress: text("ip_address"),
  userAgent: text("user_agent"),
  userId: text("user_id")
    .notNull()
    .references(() => user.id, { onDelete: "cascade" }),
});

export const account = pgTable("account", {
  id: text("id").primaryKey(),
  accountId: text("account_id").notNull(),
  providerId: text("provider_id").notNull(),
  userId: text("user_id")
    .notNull()
    .references(() => user.id, { onDelete: "cascade" }),
  accessToken: text("access_token"),
  refreshToken: text("refresh_token"),
  idToken: text("id_token"),
  accessTokenExpiresAt: timestamp("access_token_expires_at"),
  refreshTokenExpiresAt: timestamp("refresh_token_expires_at"),
  scope: text("scope"),
  password: text("password"),
  createdAt: timestamp("created_at").notNull().defaultNow(),
  updatedAt: timestamp("updated_at").notNull(),
});

export const verification = pgTable("verification", {
  id: text("id").primaryKey(),
  identifier: text("identifier").notNull(),
  value: text("value").notNull(),
  expiresAt: timestamp("expires_at").notNull(),
  createdAt: timestamp("created_at").notNull().defaultNow(),
  updatedAt: timestamp("updated_at").notNull().defaultNow(),
});
// Better Auth instance (self-hosted, Nuxt 4).
import { betterAuth } from "better-auth";
import { drizzleAdapter } from "better-auth/adapters/drizzle";
// Reuse the SAME postgres-js/Drizzle client the db slice exported in
// server/lib/db.ts — Better Auth shares the pooled `DATABASE_URL` connection.
// Server code lives under server/ (Nitro); reach it via Nuxt's `~~` rootDir alias.
import { db } from "~~/server/lib/db";
import * as authSchema from "~~/server/db/auth-schema";
import { sendResetPassword, sendVerifyEmail } from "~~/server/lib/email";

export const auth = betterAuth({
  // Drizzle adapter over the shared client. The schema is passed HERE (not into
  // drizzle()) — it is the only consumer that needs it, so the db client stays
  // schema-less. ponytail: do NOT enable experimental.joins; it is the one option
  // that would make the adapter reach into db._.fullSchema.
  // ponytail: usePlural stays false (the default) — with it true Better Auth would
  // look for `sessions`/`accounts` and could bind to the analytics/ledger app-type tables.
  database: drizzleAdapter(db, { provider: "pg", schema: authSchema }),
  // Wired to the email fragment's send helpers: Better Auth calls these on signup
  // verification and forgot-password instead of leaving them unset.
  emailAndPassword: {
    enabled: true,
    async sendResetPassword({ user, url }) {
      await sendResetPassword(user.email, { name: user.name, ctaUrl: url, unsubscribeUrl: process.env.BETTER_AUTH_URL ?? url });
    },
  },
  emailVerification: {
    async sendVerificationEmail({ user, url }) {
      await sendVerifyEmail(user.email, { name: user.name, ctaUrl: url, unsubscribeUrl: process.env.BETTER_AUTH_URL ?? url });
    },
  },
  secret: process.env.BETTER_AUTH_SECRET,
  baseURL: process.env.BETTER_AUTH_URL,
});

export type Session = typeof auth.$Infer.Session;
// Better Auth mounted as a Nitro catch-all route. auth.handler is framework-
// agnostic — (Request) => Promise<Response> — and toWebRequest adapts the H3
// event into a web Request. The `[...all]` param catches every /api/auth/*
// sub-path Better Auth routes internally.
// ponytail: Nuxt AUTO-IMPORTS defineEventHandler/toWebRequest from Nitro/H3 at
// runtime; we import them EXPLICITLY from h3 (Nitro's engine, a nuxt dep) so this
// handler type-checks under standalone tsc — the one idiom trade for verifiability.
import { defineEventHandler, toWebRequest } from "h3";
import { auth } from "~~/lib/auth";

export default defineEventHandler((event) => auth.handler(toWebRequest(event)));
// Vue/Nuxt client binding. better-auth/vue exposes the framework-appropriate client
// (signIn / signUp / useSession as Vue refs) — the Vue analog of the React client.
import { createAuthClient } from "better-auth/vue";

export const authClient = createAuthClient();
// Session gate — the Nuxt analog of Next's Edge proxy / RR8's requireAuth. A Nitro
// server middleware runs on every SSR/API request; guard the app surface and do the
// REAL server-side session check (like RR8 — stronger than Next's cookie-existence peek).
// ponytail: this guards SSR loads + direct hits + API — the true security boundary. For
// client-side SPA navigation add an app/middleware/*.ts route middleware (authored with
// Nuxt auto-imports; NOT tsc-gated, since plain tsc can't resolve them — spec §6).
// Explicit h3 imports so this type-checks standalone (see the handler note above).
import { defineEventHandler, getRequestURL, sendRedirect, toWebRequest } from "h3";
import { auth } from "~~/lib/auth";

export default defineEventHandler(async (event) => {
  const { pathname } = getRequestURL(event);
  // ponytail: guard the SaaS app surface; widen the prefixes per app-type.
  if (!pathname.startsWith("/dashboard") && !pathname.startsWith("/settings")) return;
  const session = await auth.api.getSession({ headers: toWebRequest(event).headers });
  if (!session) return sendRedirect(event, "/sign-in", 302);
});

LMS schema: courses, modules, lessons, enrollments & progress

Courses & instructors

catalog root with slug unique index, status CHECK, and FK to the Better Auth instructor user

Modules & lessons

ordered curriculum units — modules by position within a course, lessons by position within a module with contentType CHECK

Enrollments

course↔student join with composite unique enforcing one enrollment per pair

Lesson progress tracking

per-learner, per-lesson state rows with status CHECK and composite unique on (studentId, lessonId)

src/db/schema.ts
// === file: server/db/schema.ts ===
import { relations, sql } from "drizzle-orm";
import {
  check,
  index,
  integer,
  pgTable,
  text,
  timestamp,
  unique,
  uuid,
} from "drizzle-orm/pg-core";
// Better Auth owns identity; we only reference its `user` table by id.
import { user } from "./auth-schema";

export type CourseStatus = "draft" | "published" | "archived";
export type LessonContentType = "video" | "text" | "quiz";
export type ProgressStatus = "not_started" | "in_progress" | "completed";

/** Catalog root. Each course has one instructor (a Better Auth user). */
export const courses = pgTable(
  "courses",
  {
    id: uuid("id").primaryKey().defaultRandom(),
    slug: text("slug").notNull().unique(),
    title: text("title").notNull(),
    // Better Auth's user.id is text — match it, don't recast.
    instructorId: text("instructor_id")
      .notNull()
      .references(() => user.id, { onDelete: "cascade" }),
    status: text("status")
      .$type<CourseStatus>()
      .notNull()
      .default("draft"),
    createdAt: timestamp("created_at", { withTimezone: true })
      .notNull()
      .defaultNow(),
  },
  (t) => [
    index("idx_course_slug").on(t.slug),
    index("idx_course_instructor").on(t.instructorId),
    check(
      "courses_status_check",
      sql`${t.status} in ('draft','published','archived')`,
    ),
  ],
);

/** Ordered sections within a course. position drives curriculum order. */
export const modules = pgTable(
  "modules",
  {
    id: uuid("id").primaryKey().defaultRandom(),
    courseId: uuid("course_id")
      .notNull()
      .references(() => courses.id, { onDelete: "cascade" }),
    title: text("title").notNull(),
    position: integer("position").notNull().default(0),
    createdAt: timestamp("created_at", { withTimezone: true })
      .notNull()
      .defaultNow(),
  },
  (t) => [index("idx_module_course").on(t.courseId, t.position)],
);

/** Leaf content unit. contentType selects how the lesson renders/plays. */
export const lessons = pgTable(
  "lessons",
  {
    id: uuid("id").primaryKey().defaultRandom(),
    moduleId: uuid("module_id")
      .notNull()
      .references(() => modules.id, { onDelete: "cascade" }),
    title: text("title").notNull(),
    position: integer("position").notNull().default(0),
    contentType: text("content_type")
      .$type<LessonContentType>()
      .notNull()
      .default("text"),
    createdAt: timestamp("created_at", { withTimezone: true })
      .notNull()
      .defaultNow(),
  },
  (t) => [
    index("idx_lesson_module").on(t.moduleId, t.position),
    check(
      "lessons_content_type_check",
      sql`${t.contentType} in ('video','text','quiz')`,
    ),
  ],
);

/** course <-> student join. The composite unique is the enrollment identity. */
export const enrollments = pgTable(
  "enrollments",
  {
    id: uuid("id").primaryKey().defaultRandom(),
    courseId: uuid("course_id")
      .notNull()
      .references(() => courses.id, { onDelete: "cascade" }),
    studentId: text("student_id")
      .notNull()
      .references(() => user.id, { onDelete: "cascade" }),
    enrolledAt: timestamp("enrolled_at", { withTimezone: true })
      .notNull()
      .defaultNow(),
  },
  (t) => [
    unique("enrollments_course_student_unique").on(t.courseId, t.studentId),
    // Drives the learner's "my courses" list.
    index("idx_enrollment_student").on(t.studentId),
  ],
);

/** Per-lesson learner state. The composite unique is one row per student+lesson. */
export const lessonProgress = pgTable(
  "lesson_progress",
  {
    id: uuid("id").primaryKey().defaultRandom(),
    lessonId: uuid("lesson_id")
      .notNull()
      .references(() => lessons.id, { onDelete: "cascade" }),
    studentId: text("student_id")
      .notNull()
      .references(() => user.id, { onDelete: "cascade" }),
    status: text("status")
      .$type<ProgressStatus>()
      .notNull()
      .default("not_started"),
    completedAt: timestamp("completed_at", { withTimezone: true }),
  },
  (t) => [
    unique("lesson_progress_student_lesson_unique").on(
      t.studentId,
      t.lessonId,
    ),
    // Drives the per-learner progress lookup (student + lesson).
    index("idx_progress_student_lesson").on(t.studentId, t.lessonId),
    check(
      "lesson_progress_status_check",
      sql`${t.status} in ('not_started','in_progress','completed')`,
    ),
  ],
);

export const coursesRelations = relations(courses, ({ one, many }) => ({
  instructor: one(user, {
    fields: [courses.instructorId],
    references: [user.id],
  }),
  modules: many(modules),
  enrollments: many(enrollments),
}));

export const modulesRelations = relations(modules, ({ one, many }) => ({
  course: one(courses, {
    fields: [modules.courseId],
    references: [courses.id],
  }),
  lessons: many(lessons),
}));

export const lessonsRelations = relations(lessons, ({ one, many }) => ({
  module: one(modules, {
    fields: [lessons.moduleId],
    references: [modules.id],
  }),
  progress: many(lessonProgress),
}));

export const enrollmentsRelations = relations(enrollments, ({ one }) => ({
  course: one(courses, {
    fields: [enrollments.courseId],
    references: [courses.id],
  }),
  student: one(user, {
    fields: [enrollments.studentId],
    references: [user.id],
  }),
}));

export const lessonProgressRelations = relations(lessonProgress, ({ one }) => ({
  lesson: one(lessons, {
    fields: [lessonProgress.lessonId],
    references: [lessons.id],
  }),
  student: one(user, {
    fields: [lessonProgress.studentId],
    references: [user.id],
  }),
}));

Deploy targets

✓ The right DB client for where you deploy: load-tested with concurrent queries against a live database. Edge needs the HTTP driver (no TCP); serverless needs a tiny pool.
src/lib/db.ts
import { drizzle } from "drizzle-orm/postgres-js";
import postgres from "postgres";

// Serverless: one connection per (short-lived) instance; Neon's pooler multiplexes.
export const sql = postgres(process.env.DATABASE_URL!, { prepare: false, max: 1 });
export const db = drizzle(sql);
import { drizzle } from "drizzle-orm/postgres-js";
import postgres from "postgres";

// Long-running process: a real, reused pool. Still prepare:false on the pooled endpoint.
export const sql = postgres(process.env.DATABASE_URL!, { prepare: false, max: 10, idle_timeout: 20 });
export const db = drizzle(sql);
import { neon } from "@neondatabase/serverless";
import { drizzle } from "drizzle-orm/neon-http";

// Edge/Workers have NO TCP sockets, so postgres-js cannot run here. Neon's HTTP
// driver speaks Postgres over fetch — the only client that works on Workers.
export const sql = neon(process.env.DATABASE_URL!);
export const db = drizzle(sql);

The app UI

Nuxt 4 listing starter — the catalog, rendered from the verified UI
Rendered from the verified starter · the catalog
A working auth flow and a protected app shell, type-checked against the same verified wiring above. This is what codexmachina create scaffolds on top of the official Nuxt 4 starter.
app/components/nav-user.vue
<script setup lang="ts">
import { computed } from "vue"
import {
  EllipsisVerticalIcon,
  CircleUserRoundIcon,
  CreditCardIcon,
  BellIcon,
  LogOutIcon,
} from "lucide-vue-next"
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"
import { authClient } from "~~/lib/auth-client"

// Sidebar-footer user menu wired to Better Auth's Vue client: reactive session (useSession) +
// hosted sign-out. The SAME shell every auth overlay reuses — only the client binding differs.
const { isMobile } = useSidebar()
const session = authClient.useSession()

const user = computed(() => session.value.data?.user)
const pending = computed(() => session.value.isPending)
const email = computed(() => user.value?.email ?? "")
const initials = computed(() => email.value.slice(0, 2).toUpperCase())

async function handleSignOut() {
  await authClient.signOut()
  navigateTo("/sign-in")
}
</script>
<template>
  <SidebarMenu>
    <SidebarMenuItem>
      <div v-if="pending" 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="user.image" :src="user.image" :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="user.image" :src="user.image" :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 (Better Auth overlay). SidebarProvider owns the collapse state; AppSidebar +
// SiteHeader frame the routed page (<slot />). The auth gate itself lives in the route middleware
// (client) + the Nitro server middleware (SSR/API) — the layout just composes the chrome.
</script>
<template>
  <SidebarProvider>
    <AppSidebar />
    <SidebarInset>
      <SiteHeader />
      <div class="flex flex-1 flex-col gap-4">
        <slot />
      </div>
    </SidebarInset>
  </SidebarProvider>
</template>
import { authClient } from "~~/lib/auth-client"

// Client-side route guard for the app surface. The Nitro server middleware (init) is the true
// security boundary on SSR/API; this bounces client-side SPA navigation for logged-out users
// before the protected page renders. Pages opt in via definePageMeta({ middleware: "auth" }).
export default defineNuxtRouteMiddleware(async () => {
  const { data } = await authClient.getSession()
  if (!data) {
    return navigateTo("/sign-in")
  }
})
<script setup lang="ts">
import { ref } from "vue"
import Logo from "@/components/logo.vue"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { authClient } from "~~/lib/auth-client"

// Better Auth email+password sign-in. `layout: false` opts out of the dashboard shell.
definePageMeta({ layout: false })

const email = ref("")
const password = ref("")
const error = ref("")
const loading = ref(false)

async function onSubmit() {
  loading.value = true
  error.value = ""
  const { error: err } = await authClient.signIn.email({ email: email.value, password: password.value })
  loading.value = false
  if (err) {
    error.value = err.message ?? "Sign in failed"
    return
  }
  navigateTo("/dashboard")
}
</script>
<template>
  <div class="flex min-h-svh items-center justify-center p-4">
    <Card class="w-full max-w-sm">
      <CardHeader>
        <Logo class="mb-2" />
        <CardTitle>Welcome back</CardTitle>
        <CardDescription>Sign in to your account to continue.</CardDescription>
      </CardHeader>
      <CardContent>
        <form class="flex flex-col gap-4" @submit.prevent="onSubmit">
          <div class="flex flex-col gap-2">
            <Label for="email">Email</Label>
            <Input id="email" v-model="email" type="email" placeholder="you@example.com" required />
          </div>
          <div class="flex flex-col gap-2">
            <Label for="password">Password</Label>
            <Input id="password" v-model="password" type="password" required />
          </div>
          <p v-if="error" class="text-destructive text-sm">{{ error }}</p>
          <Button type="submit" :disabled="loading" class="w-full">
            {{ loading ? "Signing in…" : "Sign in" }}
          </Button>
          <p class="text-muted-foreground text-center text-sm">
            No account?
            <NuxtLink to="/sign-up" class="text-foreground underline underline-offset-4">Sign up</NuxtLink>
          </p>
        </form>
      </CardContent>
    </Card>
  </div>
</template>
<script setup lang="ts">
import { ref } from "vue"
import Logo from "@/components/logo.vue"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { authClient } from "~~/lib/auth-client"

// Better Auth email+password sign-up.
definePageMeta({ layout: false })

const name = ref("")
const email = ref("")
const password = ref("")
const error = ref("")
const loading = ref(false)

async function onSubmit() {
  loading.value = true
  error.value = ""
  const { error: err } = await authClient.signUp.email({ name: name.value, email: email.value, password: password.value })
  loading.value = false
  if (err) {
    error.value = err.message ?? "Sign up failed"
    return
  }
  navigateTo("/dashboard")
}
</script>
<template>
  <div class="flex min-h-svh items-center justify-center p-4">
    <Card class="w-full max-w-sm">
      <CardHeader>
        <Logo class="mb-2" />
        <CardTitle>Create your account</CardTitle>
        <CardDescription>Start your workspace in seconds.</CardDescription>
      </CardHeader>
      <CardContent>
        <form class="flex flex-col gap-4" @submit.prevent="onSubmit">
          <div class="flex flex-col gap-2">
            <Label for="name">Name</Label>
            <Input id="name" v-model="name" required />
          </div>
          <div class="flex flex-col gap-2">
            <Label for="email">Email</Label>
            <Input id="email" v-model="email" type="email" placeholder="you@example.com" required />
          </div>
          <div class="flex flex-col gap-2">
            <Label for="password">Password</Label>
            <Input id="password" v-model="password" type="password" required />
          </div>
          <p v-if="error" class="text-destructive text-sm">{{ error }}</p>
          <Button type="submit" :disabled="loading" class="w-full">
            {{ loading ? "Creating account…" : "Sign up" }}
          </Button>
          <p class="text-muted-foreground text-center text-sm">
            Already have an account?
            <NuxtLink to="/sign-in" class="text-foreground underline underline-offset-4">Sign in</NuxtLink>
          </p>
        </form>
      </CardContent>
    </Card>
  </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 { LayoutGridIcon, SearchIcon, ShieldCheckIcon } 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 for the listing archetype (ecommerce / marketplace / job-board / lms / video).
// Self-contained marketing chrome; the catalog lives behind /dashboard.
const features = [
  { icon: LayoutGridIcon, title: "A catalog that scales", body: "Every listing in one grid, with the status and category your buyers scan for." },
  { icon: SearchIcon, title: "Findable by design", body: "Structured detail pages so each item is its own indexable, shareable page." },
  { icon: ShieldCheckIcon, title: "Trust built in", body: "Access control and a real auth boundary from line one, not bolted on later." },
]
</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">
          Your catalog, ready to browse
        </h1>
        <p class="text-muted-foreground max-w-xl text-lg text-pretty">
          List it, categorize it, and let people find exactly what they came for.
        </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">
      &copy; 2026 Acme. All rights reserved.
    </footer>
  </div>
</template>
<script setup lang="ts">
import { LayoutGridIcon, TagsIcon, ShoppingBagIcon, 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"

// Listing-archetype sidebar: brand header, catalog-shaped nav, and the auth-wired user menu (NavUser
// rides the _auth overlay — same shell across auth providers).
const items = [
  { title: "Catalog", url: "/dashboard", icon: LayoutGridIcon },
  { title: "Categories", url: "/dashboard/categories", icon: TagsIcon },
  { title: "Orders", url: "/dashboard/orders", icon: ShoppingBagIcon },
  { 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 {
  SidebarGroup,
  SidebarGroupContent,
  SidebarMenu,
  SidebarMenuButton,
  SidebarMenuItem,
} from "@/components/ui/sidebar"

// Content-archetype nav — a plain link list (no Quick Create rail; the "New entry" CTA lives on the
// entries page). Same shell primitives, content-shaped nav items.
defineProps<{ items: { title: string; url: string; icon?: Component }[] }>()
</script>
<template>
  <SidebarGroup>
    <SidebarGroupContent class="flex flex-col gap-2">
      <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 { ArrowLeftIcon, PackageIcon } from "lucide-vue-next"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Card, CardContent } from "@/components/ui/card"

// Listing detail (/dashboard/:id, behind the auth-gated dashboard layout). Static sample keyed on the
// route param — no data layer wired; a real app-type looks the item up by `id`.
definePageMeta({ layout: "dashboard", middleware: "auth" })

const route = useRoute()

const item = {
  title: "Aurora Starter Kit",
  category: "Essentials",
  status: "Active",
  reference: route.params.id,
  updated: "Updated 2 days ago",
  description:
    "Everything included to get a new listing off the ground: the base assets, the setup checklist, and a starter configuration you can adjust once it is live.",
}
</script>
<template>
  <div class="flex flex-col gap-6 p-4 lg:p-6">
    <Button as-child variant="ghost" size="icon" class="w-fit">
      <NuxtLink to="/dashboard" aria-label="Back to listings">
        <ArrowLeftIcon />
      </NuxtLink>
    </Button>

    <div class="grid gap-6 lg:grid-cols-[22rem_1fr] lg:items-start">
      <div class="from-primary/15 via-primary/5 to-card ring-foreground/10 relative flex aspect-[4/3] items-center justify-center overflow-hidden rounded-xl bg-gradient-to-br ring-1">
        <div aria-hidden="true" class="bg-primary/10 absolute -top-10 -right-10 size-40 rounded-full blur-3xl" />
        <span class="bg-background text-foreground ring-foreground/10 relative flex size-16 items-center justify-center rounded-2xl shadow-sm ring-1">
          <PackageIcon class="size-8" aria-hidden="true" />
        </span>
      </div>

      <div class="flex flex-col gap-6">
        <div class="flex flex-col gap-2">
          <div class="flex flex-wrap items-center gap-2">
            <Badge variant="secondary">{{ item.status }}</Badge>
            <span class="text-muted-foreground text-sm">{{ item.category }}</span>
          </div>
          <h1 class="text-2xl font-semibold tracking-tight">{{ item.title }}</h1>
          <p class="text-muted-foreground max-w-2xl text-pretty">{{ item.description }}</p>
        </div>

        <Card class="max-w-md gap-0 py-0">
          <CardContent class="grid grid-cols-2 gap-4 p-4 text-sm">
            <div class="flex flex-col gap-1">
              <span class="text-muted-foreground text-xs">Reference</span>
              <span class="font-medium">{{ item.reference }}</span>
            </div>
            <div class="flex flex-col gap-1">
              <span class="text-muted-foreground text-xs">Last updated</span>
              <span class="font-medium">{{ item.updated }}</span>
            </div>
          </CardContent>
        </Card>

        <div>
          <Button size="lg" class="h-11 px-6 text-base">Get started</Button>
        </div>
      </div>
    </div>
  </div>
</template>
<script setup lang="ts">
import type { Component } from "vue"
import {
  CompassIcon,
  KeyRoundIcon,
  LayersIcon,
  PackageIcon,
  ShieldCheckIcon,
  TicketIcon,
  TrendingUpIcon,
  WrenchIcon,
} from "lucide-vue-next"
import { Badge } from "@/components/ui/badge"
import { Card, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { cn } from "@/lib/utils"

// Listing archetype home (behind the auth-gated dashboard layout). Card grid shared by
// ecommerce/marketplace/job-board/lms/video-platform. ponytail: scaffold data, not persisted.
definePageMeta({ layout: "dashboard", middleware: "auth" })

type ListingStatus = "active" | "limited" | "new" | "archived"

type Listing = {
  slug: string
  title: string
  category: string
  status: ListingStatus
  meta: string
  icon: Component
  gradient: string
}

const gradients = [
  "from-primary/15 via-primary/5 to-card",
  "from-muted to-card",
  "from-primary/10 to-card",
  "from-muted/70 via-primary/5 to-card",
]

const listings: Listing[] = [
  { slug: "aurora-starter-kit", title: "Aurora Starter Kit", category: "Essentials", status: "active", meta: "Updated 2 days ago", icon: PackageIcon, gradient: gradients[0]! },
  { slug: "meridian-growth-plan", title: "Meridian Growth Plan", category: "Membership", status: "new", meta: "Added 3 days ago", icon: TrendingUpIcon, gradient: gradients[1]! },
  { slug: "northline-access-pass", title: "Northline Access Pass", category: "Access", status: "limited", meta: "12 spots left", icon: TicketIcon, gradient: gradients[2]! },
  { slug: "cobalt-creator-bundle", title: "Cobalt Creator Bundle", category: "Bundle", status: "active", meta: "Updated 1 week ago", icon: LayersIcon, gradient: gradients[3]! },
  { slug: "fieldstone-onboarding-kit", title: "Fieldstone Onboarding Kit", category: "Onboarding", status: "new", meta: "Added 5 hours ago", icon: CompassIcon, gradient: gradients[0]! },
  { slug: "harbor-team-license", title: "Harbor Team License", category: "License", status: "active", meta: "Updated 3 weeks ago", icon: KeyRoundIcon, gradient: gradients[1]! },
  { slug: "lumen-pro-toolkit", title: "Lumen Pro Toolkit", category: "Toolkit", status: "archived", meta: "Closed last month", icon: WrenchIcon, gradient: gradients[2]! },
  { slug: "vantage-full-access", title: "Vantage Full Access", category: "Access", status: "limited", meta: "4 spots left", icon: ShieldCheckIcon, gradient: gradients[3]! },
]

const statusLabel: Record<ListingStatus, string> = { active: "Active", limited: "Limited", new: "New", archived: "Archived" }
const statusVariant: Record<ListingStatus, "default" | "secondary" | "outline"> = {
  active: "secondary",
  limited: "outline",
  new: "default",
  archived: "outline",
}
</script>
<template>
  <div class="flex flex-col gap-6 p-4 lg:p-6">
    <div>
      <h1 class="text-2xl font-semibold tracking-tight">Listings</h1>
      <p class="text-muted-foreground text-sm">Everything currently live in your catalog.</p>
    </div>

    <div class="grid grid-cols-1 gap-4 sm:grid-cols-2 @5xl/main:grid-cols-4">
      <NuxtLink
        v-for="item in listings"
        :key="item.slug"
        :to="`/dashboard/${item.slug}`"
        class="group focus-visible:ring-ring focus-visible:ring-offset-background rounded-xl outline-none focus-visible:ring-2 focus-visible:ring-offset-2"
      >
        <Card class="h-full gap-0 py-0 transition-shadow group-hover:shadow-md">
          <div :class="cn('relative flex aspect-[16/10] items-center justify-center bg-gradient-to-br', item.gradient)">
            <div aria-hidden="true" class="bg-primary/10 absolute -top-8 -right-8 size-28 rounded-full blur-2xl" />
            <span class="bg-background text-foreground ring-foreground/10 relative flex size-12 items-center justify-center rounded-xl shadow-sm ring-1">
              <component :is="item.icon" class="size-6" aria-hidden="true" />
            </span>
          </div>
          <CardHeader class="gap-2 py-4">
            <div class="flex items-start justify-between gap-2">
              <CardTitle class="text-base leading-snug">{{ item.title }}</CardTitle>
              <Badge :variant="statusVariant[item.status]">{{ statusLabel[item.status] }}</Badge>
            </div>
            <CardDescription class="flex items-center justify-between gap-2 text-xs">
              <span>{{ item.category }}</span>
              <span>{{ item.meta }}</span>
            </CardDescription>
          </CardHeader>
        </Card>
      </NuxtLink>
    </div>
  </div>
</template>

Email — verified (Resend)

Audited with emailens — above our disclosed compatibility threshold across 21 email clients. Every template ships an unsubscribe link, a physical mailing address, and explicit lang/charset/contrast — the concrete set the audit checks for.
server/emails/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 emaila 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>
<mjml lang="en">
  <mj-head>
    <mj-title>Verify your email address</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;">Confirm your email to finish setting up 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">Verify your email</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">Click the button below to confirm this is your email address. This link expires in 24 hours.</mj-text>
        <mj-text color="#374151" font-size="15px" line-height="24px">If you didn't create an account, you can safely ignore this email.</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;">Verify email</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>Reset your password</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;">Reset your passwordthis link expires soon.</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">Reset your password</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">We received a request to reset your password. Click the button below to choose a new one. This link expires in 1 hour.</mj-text>
        <mj-text color="#374151" font-size="15px" line-height="24px">If you didn't request this, you can safely ignore this email — your password will not change.</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;">Reset password</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>`;

const VERIFY_EMAIL_MJML = `<mjml lang="en">
  <mj-head>
    <mj-title>Verify your email address</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;">Confirm your email to finish setting up 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">Verify your email</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">Click the button below to confirm this is your email address. This link expires in 24 hours.</mj-text>
        <mj-text color="#374151" font-size="15px" line-height="24px">If you didn't create an account, you can safely ignore this email.</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;">Verify email</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 RESET_PASSWORD_MJML = `<mjml lang="en">
  <mj-head>
    <mj-title>Reset your password</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;">Reset your password — this link expires soon.</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">Reset your password</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">We received a request to reset your password. Click the button below to choose a new one. This link expires in 1 hour.</mj-text>
        <mj-text color="#374151" font-size="15px" line-height="24px">If you didn't request this, you can safely ignore this email — your password will not change.</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;">Reset password</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 });
}

export async function sendVerifyEmail(to: string, vars: { name: string; ctaUrl: string; unsubscribeUrl: string }) {
  const html = await renderMjml(VERIFY_EMAIL_MJML, vars);
  return resendClient().emails.send({ from: FROM, to, subject: "Verify your email address", html });
}

export async function sendResetPassword(to: string, vars: { name: string; ctaUrl: string; unsubscribeUrl: string }) {
  const html = await renderMjml(RESET_PASSWORD_MJML, vars);
  return resendClient().emails.send({ from: FROM, to, subject: "Reset your password", 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

note

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`).

note

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.

note

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.

note

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.

note

prepare: false is mandatory — Neon's pooled endpoint is PgBouncer in transaction mode, where server-side prepared statements break across the pool.

note

Drizzle is paired here (not Prisma): Prisma's prepared-statement reliance is incompatible with transaction-mode pooling.

note

Self-hosted: Better Auth owns the user/session/account/verification tables. This stack emits them (db/auth-schema.ts) and hands them to the Drizzle adapter, so app-type schemas can foreign-key `user` directly.

note

Enrollments carry a composite unique on (courseId, studentId) — re-enrolling the same student in the same course is a constraint violation, not a duplicate row.

note

lessonProgress uses text + CHECK over pgEnum for status, keeping 'not_started'/'in_progress'/'completed' extensible without an ALTER TYPE migration.