indie-web-dashboard/SKILL.md
Build admin panels and analytics dashboards for indie iOS apps. Covers Next.js dashboard setup, authentication, data visualization, user management, and real-time metrics.
npx skillsauth add abanoub-ashraf/manus-skills-import indie-web-dashboardInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
4 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
Build admin panels and analytics dashboards for indie iOS apps. Covers Next.js dashboard setup, authentication, data visualization, user management, and real-time metrics.
Framework: Next.js 14+ (App Router)
UI Library: shadcn/ui + Tailwind CSS
Charts: Recharts or Tremor
Tables: TanStack Table
Auth: NextAuth.js or Clerk
Database: Prisma + PostgreSQL (Supabase/Neon)
Deployment: Vercel
app/
├── (auth)/
│ ├── login/
│ │ └── page.tsx
│ └── layout.tsx
├── (dashboard)/
│ ├── layout.tsx # Dashboard shell
│ ├── page.tsx # Overview/home
│ ├── users/
│ │ ├── page.tsx # Users list
│ │ └── [id]/
│ │ └── page.tsx # User detail
│ ├── analytics/
│ │ └── page.tsx # Analytics
│ ├── subscriptions/
│ │ └── page.tsx # Subscriptions
│ └── settings/
│ └── page.tsx # Settings
components/
├── ui/ # shadcn components
├── dashboard/
│ ├── Sidebar.tsx
│ ├── Header.tsx
│ ├── StatsCard.tsx
│ ├── Charts/
│ │ ├── LineChart.tsx
│ │ ├── BarChart.tsx
│ │ └── PieChart.tsx
│ └── Tables/
│ ├── UsersTable.tsx
│ └── DataTable.tsx
lib/
├── auth.ts
├── db.ts
└── utils.ts
npm install next-auth @auth/prisma-adapter
// lib/auth.ts
import NextAuth from 'next-auth'
import GitHub from 'next-auth/providers/github'
import Google from 'next-auth/providers/google'
import Credentials from 'next-auth/providers/credentials'
import { PrismaAdapter } from '@auth/prisma-adapter'
import { prisma } from './db'
import bcrypt from 'bcryptjs'
export const { handlers, auth, signIn, signOut } = NextAuth({
adapter: PrismaAdapter(prisma),
providers: [
GitHub({
clientId: process.env.GITHUB_ID!,
clientSecret: process.env.GITHUB_SECRET!,
}),
Google({
clientId: process.env.GOOGLE_ID!,
clientSecret: process.env.GOOGLE_SECRET!,
}),
Credentials({
name: 'credentials',
credentials: {
email: { label: 'Email', type: 'email' },
password: { label: 'Password', type: 'password' },
},
async authorize(credentials) {
if (!credentials?.email || !credentials?.password) {
return null
}
const user = await prisma.user.findUnique({
where: { email: credentials.email as string },
})
if (!user || !user.hashedPassword) {
return null
}
const isValid = await bcrypt.compare(
credentials.password as string,
user.hashedPassword
)
if (!isValid) {
return null
}
return {
id: user.id,
email: user.email,
name: user.name,
role: user.role,
}
},
}),
],
callbacks: {
async jwt({ token, user }) {
if (user) {
token.role = user.role
}
return token
},
async session({ session, token }) {
if (session.user) {
session.user.id = token.sub!
session.user.role = token.role as string
}
return session
},
},
pages: {
signIn: '/login',
},
})
// app/api/auth/[...nextauth]/route.ts
import { handlers } from '@/lib/auth'
export const { GET, POST } = handlers
// middleware.ts
import { auth } from '@/lib/auth'
import { NextResponse } from 'next/server'
export default auth((req) => {
const isLoggedIn = !!req.auth
const isOnDashboard = req.nextUrl.pathname.startsWith('/dashboard')
const isOnAuth = req.nextUrl.pathname.startsWith('/login')
if (isOnDashboard && !isLoggedIn) {
return NextResponse.redirect(new URL('/login', req.nextUrl))
}
if (isOnAuth && isLoggedIn) {
return NextResponse.redirect(new URL('/dashboard', req.nextUrl))
}
return NextResponse.next()
})
export const config = {
matcher: ['/((?!api|_next/static|_next/image|favicon.ico).*)'],
}
// app/(dashboard)/layout.tsx
import { auth } from '@/lib/auth'
import { redirect } from 'next/navigation'
import { Sidebar } from '@/components/dashboard/Sidebar'
import { Header } from '@/components/dashboard/Header'
export default async function DashboardLayout({
children,
}: {
children: React.ReactNode
}) {
const session = await auth()
if (!session) {
redirect('/login')
}
return (
<div className="flex h-screen bg-gray-100">
<Sidebar />
<div className="flex flex-1 flex-col overflow-hidden">
<Header user={session.user} />
<main className="flex-1 overflow-y-auto p-6">
{children}
</main>
</div>
</div>
)
}
// components/dashboard/Sidebar.tsx
'use client'
import Link from 'next/link'
import { usePathname } from 'next/navigation'
import {
HomeIcon,
UsersIcon,
ChartBarIcon,
CreditCardIcon,
CogIcon,
} from '@heroicons/react/24/outline'
const navigation = [
{ name: 'Overview', href: '/', icon: HomeIcon },
{ name: 'Users', href: '/users', icon: UsersIcon },
{ name: 'Analytics', href: '/analytics', icon: ChartBarIcon },
{ name: 'Subscriptions', href: '/subscriptions', icon: CreditCardIcon },
{ name: 'Settings', href: '/settings', icon: CogIcon },
]
export function Sidebar() {
const pathname = usePathname()
return (
<aside className="hidden w-64 flex-shrink-0 border-r border-gray-200 bg-white lg:block">
<div className="flex h-16 items-center px-6">
<span className="text-xl font-bold text-gray-900">AppName</span>
</div>
<nav className="mt-6 px-3">
{navigation.map((item) => {
const isActive = pathname === item.href ||
(item.href !== '/' && pathname.startsWith(item.href))
return (
<Link
key={item.name}
href={item.href}
className={`flex items-center gap-x-3 rounded-lg px-3 py-2 text-sm font-medium ${
isActive
? 'bg-blue-50 text-blue-600'
: 'text-gray-700 hover:bg-gray-50'
}`}
>
<item.icon className="h-5 w-5" />
{item.name}
</Link>
)
})}
</nav>
</aside>
)
}
// components/dashboard/Header.tsx
'use client'
import { Fragment } from 'react'
import { Menu, Transition } from '@headlessui/react'
import { signOut } from 'next-auth/react'
import { User } from 'next-auth'
interface HeaderProps {
user: User
}
export function Header({ user }: HeaderProps) {
return (
<header className="flex h-16 items-center justify-between border-b border-gray-200 bg-white px-6">
<div>
{/* Search or breadcrumbs */}
</div>
<Menu as="div" className="relative">
<Menu.Button className="flex items-center gap-x-2">
<img
src={user.image || '/default-avatar.png'}
alt=""
className="h-8 w-8 rounded-full"
/>
<span className="text-sm font-medium text-gray-700">{user.name}</span>
</Menu.Button>
<Transition
as={Fragment}
enter="transition ease-out duration-100"
enterFrom="transform opacity-0 scale-95"
enterTo="transform opacity-100 scale-100"
leave="transition ease-in duration-75"
leaveFrom="transform opacity-100 scale-100"
leaveTo="transform opacity-0 scale-95"
>
<Menu.Items className="absolute right-0 z-10 mt-2 w-48 origin-top-right rounded-md bg-white py-1 shadow-lg ring-1 ring-black ring-opacity-5">
<Menu.Item>
{({ active }) => (
<button
onClick={() => signOut()}
className={`block w-full px-4 py-2 text-left text-sm ${
active ? 'bg-gray-100' : ''
}`}
>
Sign out
</button>
)}
</Menu.Item>
</Menu.Items>
</Transition>
</Menu>
</header>
)
}
// components/dashboard/StatsCard.tsx
import { ArrowUpIcon, ArrowDownIcon } from '@heroicons/react/24/solid'
interface StatsCardProps {
title: string
value: string | number
change?: number
changeLabel?: string
icon?: React.ComponentType<{ className?: string }>
}
export function StatsCard({
title,
value,
change,
changeLabel,
icon: Icon
}: StatsCardProps) {
const isPositive = change && change > 0
const isNegative = change && change < 0
return (
<div className="rounded-lg bg-white p-6 shadow-sm">
<div className="flex items-center justify-between">
<p className="text-sm font-medium text-gray-500">{title}</p>
{Icon && <Icon className="h-5 w-5 text-gray-400" />}
</div>
<p className="mt-2 text-3xl font-semibold text-gray-900">{value}</p>
{change !== undefined && (
<div className="mt-2 flex items-center text-sm">
{isPositive && (
<ArrowUpIcon className="h-4 w-4 text-green-500" />
)}
{isNegative && (
<ArrowDownIcon className="h-4 w-4 text-red-500" />
)}
<span className={`ml-1 ${
isPositive ? 'text-green-600' :
isNegative ? 'text-red-600' :
'text-gray-500'
}`}>
{Math.abs(change)}%
</span>
{changeLabel && (
<span className="ml-1 text-gray-500">{changeLabel}</span>
)}
</div>
)}
</div>
)
}
// app/(dashboard)/page.tsx
import { StatsCard } from '@/components/dashboard/StatsCard'
import { UsersIcon, CreditCardIcon, ArrowTrendingUpIcon } from '@heroicons/react/24/outline'
import { getStats } from '@/lib/data'
export default async function DashboardPage() {
const stats = await getStats()
return (
<div>
<h1 className="text-2xl font-bold text-gray-900">Dashboard</h1>
<div className="mt-6 grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-4">
<StatsCard
title="Total Users"
value={stats.totalUsers.toLocaleString()}
change={12.5}
changeLabel="from last month"
icon={UsersIcon}
/>
<StatsCard
title="Active Subscriptions"
value={stats.activeSubscriptions.toLocaleString()}
change={8.2}
changeLabel="from last month"
icon={CreditCardIcon}
/>
<StatsCard
title="MRR"
value={`$${stats.mrr.toLocaleString()}`}
change={15.3}
changeLabel="from last month"
icon={ArrowTrendingUpIcon}
/>
<StatsCard
title="Churn Rate"
value={`${stats.churnRate}%`}
change={-2.1}
changeLabel="from last month"
/>
</div>
{/* Charts section */}
<div className="mt-8 grid grid-cols-1 gap-6 lg:grid-cols-2">
{/* Add charts here */}
</div>
</div>
)
}
// components/dashboard/Charts/LineChart.tsx
'use client'
import {
LineChart as RechartsLine,
Line,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
} from 'recharts'
interface DataPoint {
date: string
value: number
}
interface LineChartProps {
data: DataPoint[]
title: string
valuePrefix?: string
}
export function LineChart({ data, title, valuePrefix = '' }: LineChartProps) {
return (
<div className="rounded-lg bg-white p-6 shadow-sm">
<h3 className="text-lg font-medium text-gray-900">{title}</h3>
<div className="mt-4 h-72">
<ResponsiveContainer width="100%" height="100%">
<RechartsLine data={data}>
<CartesianGrid strokeDasharray="3 3" stroke="#E5E7EB" />
<XAxis
dataKey="date"
stroke="#9CA3AF"
fontSize={12}
/>
<YAxis
stroke="#9CA3AF"
fontSize={12}
tickFormatter={(value) => `${valuePrefix}${value}`}
/>
<Tooltip
contentStyle={{
backgroundColor: '#fff',
border: '1px solid #E5E7EB',
borderRadius: '8px',
}}
formatter={(value: number) => [`${valuePrefix}${value}`, title]}
/>
<Line
type="monotone"
dataKey="value"
stroke="#3B82F6"
strokeWidth={2}
dot={false}
activeDot={{ r: 4 }}
/>
</RechartsLine>
</ResponsiveContainer>
</div>
</div>
)
}
// components/dashboard/Charts/BarChart.tsx
'use client'
import {
BarChart as RechartsBar,
Bar,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
} from 'recharts'
interface DataPoint {
name: string
value: number
}
interface BarChartProps {
data: DataPoint[]
title: string
}
export function BarChart({ data, title }: BarChartProps) {
return (
<div className="rounded-lg bg-white p-6 shadow-sm">
<h3 className="text-lg font-medium text-gray-900">{title}</h3>
<div className="mt-4 h-72">
<ResponsiveContainer width="100%" height="100%">
<RechartsBar data={data}>
<CartesianGrid strokeDasharray="3 3" stroke="#E5E7EB" />
<XAxis dataKey="name" stroke="#9CA3AF" fontSize={12} />
<YAxis stroke="#9CA3AF" fontSize={12} />
<Tooltip
contentStyle={{
backgroundColor: '#fff',
border: '1px solid #E5E7EB',
borderRadius: '8px',
}}
/>
<Bar dataKey="value" fill="#3B82F6" radius={[4, 4, 0, 0]} />
</RechartsBar>
</ResponsiveContainer>
</div>
</div>
)
}
npm install @tanstack/react-table
// components/dashboard/Tables/DataTable.tsx
'use client'
import {
ColumnDef,
flexRender,
getCoreRowModel,
getPaginationRowModel,
getSortedRowModel,
getFilteredRowModel,
useReactTable,
SortingState,
} from '@tanstack/react-table'
import { useState } from 'react'
import { ChevronUpIcon, ChevronDownIcon } from '@heroicons/react/24/outline'
interface DataTableProps<TData, TValue> {
columns: ColumnDef<TData, TValue>[]
data: TData[]
searchKey?: string
}
export function DataTable<TData, TValue>({
columns,
data,
searchKey,
}: DataTableProps<TData, TValue>) {
const [sorting, setSorting] = useState<SortingState>([])
const [globalFilter, setGlobalFilter] = useState('')
const table = useReactTable({
data,
columns,
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
getSortedRowModel: getSortedRowModel(),
getFilteredRowModel: getFilteredRowModel(),
onSortingChange: setSorting,
onGlobalFilterChange: setGlobalFilter,
state: {
sorting,
globalFilter,
},
})
return (
<div>
{/* Search */}
{searchKey && (
<div className="mb-4">
<input
type="text"
placeholder="Search..."
value={globalFilter ?? ''}
onChange={(e) => setGlobalFilter(e.target.value)}
className="w-full max-w-sm rounded-lg border border-gray-300 px-4 py-2 text-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
/>
</div>
)}
{/* Table */}
<div className="overflow-hidden rounded-lg border border-gray-200 bg-white">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
{table.getHeaderGroups().map((headerGroup) => (
<tr key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<th
key={header.id}
className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500"
>
{header.isPlaceholder ? null : (
<div
className={`flex items-center gap-x-2 ${
header.column.getCanSort() ? 'cursor-pointer select-none' : ''
}`}
onClick={header.column.getToggleSortingHandler()}
>
{flexRender(
header.column.columnDef.header,
header.getContext()
)}
{{
asc: <ChevronUpIcon className="h-4 w-4" />,
desc: <ChevronDownIcon className="h-4 w-4" />,
}[header.column.getIsSorted() as string] ?? null}
</div>
)}
</th>
))}
</tr>
))}
</thead>
<tbody className="divide-y divide-gray-200 bg-white">
{table.getRowModel().rows.map((row) => (
<tr key={row.id} className="hover:bg-gray-50">
{row.getVisibleCells().map((cell) => (
<td key={cell.id} className="whitespace-nowrap px-6 py-4 text-sm text-gray-900">
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
{/* Pagination */}
<div className="mt-4 flex items-center justify-between">
<div className="text-sm text-gray-500">
Page {table.getState().pagination.pageIndex + 1} of{' '}
{table.getPageCount()}
</div>
<div className="flex gap-x-2">
<button
onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
className="rounded-lg border border-gray-300 px-3 py-1 text-sm disabled:opacity-50"
>
Previous
</button>
<button
onClick={() => table.nextPage()}
disabled={!table.getCanNextPage()}
className="rounded-lg border border-gray-300 px-3 py-1 text-sm disabled:opacity-50"
>
Next
</button>
</div>
</div>
</div>
)
}
// components/dashboard/Tables/UsersTable.tsx
'use client'
import { ColumnDef } from '@tanstack/react-table'
import { DataTable } from './DataTable'
import { formatDistanceToNow } from 'date-fns'
interface User {
id: string
email: string
name: string | null
status: 'active' | 'inactive' | 'churned'
plan: 'free' | 'pro' | 'enterprise'
createdAt: Date
}
const columns: ColumnDef<User>[] = [
{
accessorKey: 'email',
header: 'Email',
},
{
accessorKey: 'name',
header: 'Name',
cell: ({ row }) => row.original.name || '—',
},
{
accessorKey: 'status',
header: 'Status',
cell: ({ row }) => {
const status = row.original.status
const colors = {
active: 'bg-green-100 text-green-800',
inactive: 'bg-yellow-100 text-yellow-800',
churned: 'bg-red-100 text-red-800',
}
return (
<span className={`rounded-full px-2 py-1 text-xs font-medium ${colors[status]}`}>
{status}
</span>
)
},
},
{
accessorKey: 'plan',
header: 'Plan',
cell: ({ row }) => (
<span className="capitalize">{row.original.plan}</span>
),
},
{
accessorKey: 'createdAt',
header: 'Joined',
cell: ({ row }) => formatDistanceToNow(row.original.createdAt, { addSuffix: true }),
},
]
interface UsersTableProps {
users: User[]
}
export function UsersTable({ users }: UsersTableProps) {
return <DataTable columns={columns} data={users} searchKey="email" />
}
// app/(dashboard)/users/page.tsx
import { prisma } from '@/lib/db'
import { UsersTable } from '@/components/dashboard/Tables/UsersTable'
export default async function UsersPage() {
const users = await prisma.user.findMany({
orderBy: { createdAt: 'desc' },
take: 100,
})
return (
<div>
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold text-gray-900">Users</h1>
<div className="text-sm text-gray-500">
{users.length} total users
</div>
</div>
<div className="mt-6">
<UsersTable users={users} />
</div>
</div>
)
}
// app/(dashboard)/users/[id]/page.tsx
import { prisma } from '@/lib/db'
import { notFound } from 'next/navigation'
import { formatDistanceToNow, format } from 'date-fns'
interface UserPageProps {
params: { id: string }
}
export default async function UserPage({ params }: UserPageProps) {
const user = await prisma.user.findUnique({
where: { id: params.id },
include: {
subscriptions: true,
sessions: {
take: 10,
orderBy: { createdAt: 'desc' },
},
},
})
if (!user) {
notFound()
}
return (
<div>
<h1 className="text-2xl font-bold text-gray-900">{user.name || user.email}</h1>
<div className="mt-6 grid grid-cols-1 gap-6 lg:grid-cols-3">
{/* User Info */}
<div className="rounded-lg bg-white p-6 shadow-sm lg:col-span-2">
<h2 className="text-lg font-medium text-gray-900">User Information</h2>
<dl className="mt-4 grid grid-cols-2 gap-4">
<div>
<dt className="text-sm font-medium text-gray-500">Email</dt>
<dd className="mt-1 text-gray-900">{user.email}</dd>
</div>
<div>
<dt className="text-sm font-medium text-gray-500">Joined</dt>
<dd className="mt-1 text-gray-900">
{format(user.createdAt, 'PPP')}
</dd>
</div>
<div>
<dt className="text-sm font-medium text-gray-500">Status</dt>
<dd className="mt-1">
<span className="rounded-full bg-green-100 px-2 py-1 text-xs font-medium text-green-800">
Active
</span>
</dd>
</div>
<div>
<dt className="text-sm font-medium text-gray-500">Plan</dt>
<dd className="mt-1 capitalize text-gray-900">
{user.subscriptions[0]?.plan || 'Free'}
</dd>
</div>
</dl>
</div>
{/* Actions */}
<div className="rounded-lg bg-white p-6 shadow-sm">
<h2 className="text-lg font-medium text-gray-900">Actions</h2>
<div className="mt-4 space-y-3">
<button className="w-full rounded-lg border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50">
Send Email
</button>
<button className="w-full rounded-lg border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50">
Reset Password
</button>
<button className="w-full rounded-lg border border-red-300 px-4 py-2 text-sm font-medium text-red-700 hover:bg-red-50">
Delete User
</button>
</div>
</div>
</div>
{/* Recent Sessions */}
<div className="mt-6 rounded-lg bg-white p-6 shadow-sm">
<h2 className="text-lg font-medium text-gray-900">Recent Sessions</h2>
<div className="mt-4">
<table className="min-w-full divide-y divide-gray-200">
<thead>
<tr>
<th className="py-3 text-left text-xs font-medium uppercase text-gray-500">
Device
</th>
<th className="py-3 text-left text-xs font-medium uppercase text-gray-500">
Last Active
</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200">
{user.sessions.map((session) => (
<tr key={session.id}>
<td className="py-3 text-sm text-gray-900">iOS App</td>
<td className="py-3 text-sm text-gray-500">
{formatDistanceToNow(session.createdAt, { addSuffix: true })}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
)
}
// app/(dashboard)/analytics/page.tsx
import { LineChart } from '@/components/dashboard/Charts/LineChart'
import { BarChart } from '@/components/dashboard/Charts/BarChart'
import { getAnalytics } from '@/lib/data'
export default async function AnalyticsPage() {
const analytics = await getAnalytics()
return (
<div>
<h1 className="text-2xl font-bold text-gray-900">Analytics</h1>
{/* Date Range Selector */}
<div className="mt-4 flex items-center gap-x-4">
<select className="rounded-lg border border-gray-300 px-3 py-2 text-sm">
<option value="7d">Last 7 days</option>
<option value="30d">Last 30 days</option>
<option value="90d">Last 90 days</option>
</select>
</div>
{/* Charts */}
<div className="mt-6 grid grid-cols-1 gap-6 lg:grid-cols-2">
<LineChart
title="Daily Active Users"
data={analytics.dau}
/>
<LineChart
title="Revenue"
data={analytics.revenue}
valuePrefix="$"
/>
<BarChart
title="Downloads by Platform"
data={analytics.downloadsByPlatform}
/>
<BarChart
title="Subscriptions by Plan"
data={analytics.subscriptionsByPlan}
/>
</div>
</div>
)
}
// prisma/schema.prisma
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
model User {
id String @id @default(cuid())
email String @unique
name String?
hashedPassword String?
image String?
role String @default("user")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
accounts Account[]
sessions Session[]
subscriptions Subscription[]
}
model Account {
id String @id @default(cuid())
userId String
type String
provider String
providerAccountId String
refresh_token String?
access_token String?
expires_at Int?
token_type String?
scope String?
id_token String?
session_state String?
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([provider, providerAccountId])
}
model Session {
id String @id @default(cuid())
sessionToken String @unique
userId String
expires DateTime
createdAt DateTime @default(now())
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
}
model Subscription {
id String @id @default(cuid())
userId String
plan String // free, pro, enterprise
status String // active, canceled, expired
originalTransactionId String? @unique
expiresAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
}
model AnalyticsEvent {
id String @id @default(cuid())
event String
userId String?
metadata Json?
createdAt DateTime @default(now())
@@index([event, createdAt])
}
// app/api/stats/route.ts
import { auth } from '@/lib/auth'
import { prisma } from '@/lib/db'
import { NextResponse } from 'next/server'
export async function GET() {
const session = await auth()
if (!session) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const [totalUsers, activeSubscriptions, monthlyRevenue] = await Promise.all([
prisma.user.count(),
prisma.subscription.count({
where: { status: 'active' },
}),
prisma.subscription.aggregate({
where: {
status: 'active',
plan: { in: ['pro', 'enterprise'] },
},
_count: true,
}),
])
// Calculate MRR (simplified)
const mrr = activeSubscriptions * 9.99 // Adjust based on your pricing
return NextResponse.json({
totalUsers,
activeSubscriptions,
mrr,
churnRate: 3.2, // Calculate from actual data
})
}
# Database
DATABASE_URL="postgresql://..."
# NextAuth
NEXTAUTH_URL="http://localhost:3000"
NEXTAUTH_SECRET="your-secret-key"
# OAuth Providers
GITHUB_ID="..."
GITHUB_SECRET="..."
GOOGLE_ID="..."
GOOGLE_SECRET="..."
□ Database migrated (prisma migrate deploy)
□ Environment variables set in Vercel
□ Admin user created
□ OAuth providers configured
□ Rate limiting on API routes
□ Error monitoring (Sentry)
□ SSL enabled
development
Design principles for building polished, native-feeling SwiftUI apps and widgets. Use this skill when creating or modifying SwiftUI views, iOS widgets (WidgetKit), or any native Apple UI. Ensures proper spacing, typography, colors, and widget implementations that look and feel like quality apps rather than AI-generated slop.
data-ai
Design and implement SwiftUI views, components, and app architecture. Use when creating new SwiftUI views, implementing MVVM/TCA patterns, managing state with @Observable, @State, @Binding, or @Environment, designing navigation flows, or structuring iOS app architecture. Triggers on SwiftUI, view model, state management, navigation, coordinator pattern.
development
Implement, review, or improve SwiftUI animations and transitions. Use when adding implicit or explicit animations with withAnimation, configuring spring animations (.smooth, .snappy, .bouncy), building phase or keyframe animations with PhaseAnimator/KeyframeAnimator, creating hero transitions with matchedGeometryEffect or matchedTransitionSource, adding SF Symbol effects (bounce, pulse, variableColor, breathe, rotate, wiggle), implementing custom Transition or CustomAnimation types, or ensuring animations respect accessibilityReduceMotion.
testing
Audit SwiftUI views for accessibility (iOS + macOS) with patch-ready fixes