Overview
The Auction Excellence admin dashboard is built with Next.js 16 and the App Router, using TypeScript for type safety. This guide covers everything you need to set up your development environment, understand the codebase architecture, and contribute to the admin dashboard.Next.js 16 + App Router
Server Components, Server Actions, and streaming with React 19
TypeScript
Full type safety with generated database types
shadcn/ui
Beautiful, accessible components built on Radix UI
Supabase
PostgreSQL database with RLS, Auth, and Storage
Prerequisites
Before you begin, ensure you have the following installed:| Tool | Version | Installation |
|---|---|---|
| Node.js | 20.x LTS | nodejs.org or nvm install 20 |
| pnpm | 9.x | npm install -g pnpm |
| Git | Latest | git-scm.com |
- macOS
- Windows
- Linux
Copy
# Install Homebrew if not already installed
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
# Install Node.js via nvm (recommended)
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.0/install.sh | bash
nvm install 20
nvm use 20
# Install pnpm
npm install -g pnpm
Copy
# Install Node.js from nodejs.org or via scoop
scoop install nodejs-lts
# Install pnpm
npm install -g pnpm
Copy
# Install Node.js via nvm
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.0/install.sh | bash
nvm install 20
nvm use 20
# Install pnpm
npm install -g pnpm
Getting Started
Repository Setup
1
Clone the Repository
Copy
git clone https://github.com/your-org/auction-excellence.git
cd auction-excellence/admin
2
Install Dependencies
Copy
pnpm install
3
Configure Environment Variables
Create a
.env.local file in the admin directory:Copy
NEXT_PUBLIC_SUPABASE_URL=https://your-project.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=your-anon-key
SUPABASE_SERVICE_ROLE_KEY=your-service-role-key
The
SUPABASE_SERVICE_ROLE_KEY bypasses RLS and should never be exposed to the client. Only use it in Server Actions and API routes.4
Start the Development Server
Copy
pnpm dev
http://localhost:3000.5
Access the Dashboard
Open http://localhost:3000 in your browser. You’ll be redirected to the login page if not authenticated.
Use
pnpm dev --turbo explicitly if Turbopack isn’t enabled by default. Turbopack provides significantly faster refresh times.Project Structure
The admin dashboard follows a feature-based structure with clear separation of concerns:Copy
admin/
├── src/
│ ├── app/ # Next.js App Router
│ │ ├── (auth)/ # Auth route group (login)
│ │ ├── (dashboard)/ # Protected dashboard routes
│ │ │ └── auctions/[auctionId]/
│ │ │ ├── page.tsx # Dashboard home
│ │ │ ├── locations/ # Location management
│ │ │ ├── members/ # Team management
│ │ │ ├── submissions/ # Lot submissions
│ │ │ ├── quality/ # Quality inspections
│ │ │ ├── problems/ # A3 problem reports
│ │ │ ├── chat/ # Chat management
│ │ │ ├── analytics/ # Charts and KPIs
│ │ │ ├── reports/ # Custom reports
│ │ │ └── settings/ # Configuration
│ │ ├── reports/shared/[token]/ # Public report sharing
│ │ └── layout.tsx # Root layout
│ ├── components/ # React components
│ │ ├── ui/ # shadcn/ui components
│ │ ├── layout/ # Header, Sidebar
│ │ ├── providers/ # Context providers
│ │ └── [feature]/ # Feature-specific components
│ ├── lib/
│ │ ├── actions/ # Server actions
│ │ │ └── __tests__/ # Action unit tests
│ │ ├── supabase/ # Supabase clients
│ │ └── utils.ts # Helper functions
│ ├── types/ # TypeScript interfaces
│ └── hooks/ # Custom React hooks
├── e2e/ # Playwright E2E tests
├── package.json
├── tsconfig.json
├── next.config.ts
├── vitest.config.ts
├── playwright.config.ts
└── biome.json
Key Directories
app/
app/
Next.js App Router pages and layouts:
- (auth)/ - Login page with unauthenticated layout
- (dashboard)/ - Protected routes requiring authentication
- auctions/[auctionId]/ - Multi-tenant auction-scoped pages
- reports/shared/[token]/ - Public report viewing
components/
components/
React components organized by domain:
- ui/ - shadcn/ui primitives:
Button,Card,Dialog,Table,Form - layout/ -
Header,Sidebar,PageHeader - providers/ -
AuctionProviderfor context - [feature]/ - Feature components:
locations/,members/,analytics/
lib/actions/
lib/actions/
Server Actions for all mutations and queries:
auctions.ts- Auction CRUD operationslocations.ts- Location managementmembers.ts- Team member managementanalytics.ts- Dashboard analytics queriesreports/- Report generation and sharing
lib/supabase/
lib/supabase/
Supabase client configuration:
server.ts- Server client with cookie-based authclient.ts- Browser client for client componentsindex.ts- Re-exports for convenience
Key Technologies
Next.js 16 + App Router
The dashboard uses Next.js 16 with the App Router for server-first development:Copy
// next.config.ts
import type { NextConfig } from 'next';
const nextConfig: NextConfig = {
images: {
remotePatterns: [
{
protocol: 'https',
hostname: '*.supabase.co',
pathname: '/storage/v1/object/public/**',
},
],
},
};
export default nextConfig;
- Server Components by default for optimal performance
- Server Actions for mutations without API routes
- Streaming and Suspense for progressive loading
- Route groups for layout organization
React 19
React 19 brings improved performance and new features:- Server Components - Components that run only on the server
- Actions - Async functions for form handling
- use() - New hook for reading resources in render
- Improved hydration - Better error handling and recovery
TypeScript Configuration
Strict TypeScript is enabled for maximum type safety:Copy
{
"compilerOptions": {
"strict": true,
"noUncheckedIndexedAccess": true,
"paths": {
"@/*": ["./src/*"]
}
}
}
Database types are auto-generated from Supabase and located in
src/types/database.ts. Run pnpm supabase:gen-types in the root directory to update them.Tailwind CSS 4.x
Styling uses Tailwind CSS with the new v4 configuration:Copy
/* app/globals.css */
@import "tailwindcss";
@theme {
--color-primary: #4F8EF7;
--color-primary-foreground: #ffffff;
--radius-lg: 0.5rem;
}
shadcn/ui Components
UI components are built on Radix UI primitives:Copy
// Using shadcn/ui components
import { Button } from '@/components/ui/button';
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
export function MyComponent() {
return (
<Card>
<CardHeader>
<CardTitle>Settings</CardTitle>
</CardHeader>
<CardContent>
<Input placeholder="Enter value..." />
<Button>Save</Button>
</CardContent>
</Card>
);
}
Add new shadcn/ui components with:
npx shadcn@latest add [component-name]App Router Patterns
Route Groups
Route groups organize routes without affecting the URL structure:Copy
app/
├── (auth)/ # Unauthenticated routes
│ ├── layout.tsx # Minimal layout (no sidebar)
│ └── login/
│ └── page.tsx
├── (dashboard)/ # Authenticated routes
│ ├── layout.tsx # Full layout (sidebar + header)
│ └── auctions/
│ └── [auctionId]/
│ └── page.tsx
Copy
// app/(auth)/layout.tsx
export default function AuthLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50">
{children}
</div>
);
}
Copy
// app/(dashboard)/layout.tsx
import { redirect } from 'next/navigation';
import { createClient } from '@/lib/supabase/server';
import { Sidebar } from '@/components/layout/sidebar';
import { Header } from '@/components/layout/header';
export default async function DashboardLayout({
children,
}: {
children: React.ReactNode;
}) {
const supabase = await createClient();
const { data: { user } } = await supabase.auth.getUser();
if (!user) {
redirect('/login');
}
return (
<div className="flex h-screen">
<Sidebar />
<div className="flex-1 flex flex-col">
<Header user={user} />
<main className="flex-1 overflow-y-auto p-6">
{children}
</main>
</div>
</div>
);
}
Server Components
Server Components are the default in the App Router:Copy
// app/(dashboard)/auctions/[auctionId]/locations/page.tsx
import { Suspense } from 'react';
import { getLocations } from '@/lib/actions/locations';
import { LocationsTable } from '@/components/locations/locations-table';
import { LocationsTableSkeleton } from '@/components/locations/locations-table-skeleton';
interface LocationsPageProps {
params: Promise<{ auctionId: string }>;
searchParams: Promise<{
search?: string;
status?: string;
page?: string;
}>;
}
// Separate async component for data fetching
async function LocationsContent({
auctionId,
searchParams,
}: {
auctionId: string;
searchParams: { search?: string; status?: string; page?: string };
}) {
const page = Number(searchParams.page) || 1;
const { locations, total, totalPages } = await getLocations({
auctionId,
search: searchParams.search,
status: searchParams.status,
page,
limit: 10,
});
if (total === 0 && !searchParams.search && !searchParams.status) {
return <EmptyState />;
}
return (
<LocationsTable
locations={locations}
totalPages={totalPages}
currentPage={page}
/>
);
}
export default async function LocationsPage({
params,
searchParams,
}: LocationsPageProps) {
const { auctionId } = await params;
const search = await searchParams;
return (
<div className="space-y-6">
<PageHeader
title="Locations"
action={<AddLocationButton auctionId={auctionId} />}
/>
<LocationsFilter />
<Suspense fallback={<LocationsTableSkeleton />}>
<LocationsContent auctionId={auctionId} searchParams={search} />
</Suspense>
</div>
);
}
In Next.js 15+,
params and searchParams are Promises. Always await them before use.Client Components
Mark components as client-side with the'use client' directive:
Copy
'use client';
import { useState, useTransition } from 'react';
import { Button } from '@/components/ui/button';
import { deleteLocation } from '@/lib/actions/locations';
import { toast } from 'sonner';
export function DeleteLocationButton({ locationId }: { locationId: string }) {
const [isPending, startTransition] = useTransition();
const handleDelete = () => {
startTransition(async () => {
const result = await deleteLocation(locationId);
if (result.success) {
toast.success('Location deleted');
} else {
toast.error(result.error);
}
});
};
return (
<Button
variant="destructive"
onClick={handleDelete}
disabled={isPending}
>
{isPending ? 'Deleting...' : 'Delete'}
</Button>
);
}
Streaming with Suspense
Use Suspense boundaries for progressive loading:Copy
import { Suspense } from 'react';
export default function DashboardPage() {
return (
<div className="grid grid-cols-2 gap-6">
<Suspense fallback={<ChartSkeleton />}>
<SubmissionChart />
</Suspense>
<Suspense fallback={<ChartSkeleton />}>
<QualityChart />
</Suspense>
<Suspense fallback={<TableSkeleton />}>
<RecentActivity />
</Suspense>
</div>
);
}
Server Actions
Server Actions are async functions that run on the server and can be called from client components.Creating Server Actions
Copy
// lib/actions/locations.ts
'use server';
import { revalidatePath } from 'next/cache';
import { z } from 'zod';
import { createServiceClient } from '@/lib/supabase/server';
// 1. Define validation schema
const createLocationSchema = z.object({
auction_id: z.string().uuid(),
name: z.string().min(2, 'Name must be at least 2 characters').max(100),
address: z.string().optional(),
capacity: z.number().int().positive().optional(),
is_active: z.boolean().default(true),
});
// 2. Define input/result types
export type CreateLocationInput = z.infer<typeof createLocationSchema>;
export interface CreateLocationResult {
success: boolean;
location?: Location;
error?: string;
}
// 3. Implement the action
export async function createLocation(
input: CreateLocationInput
): Promise<CreateLocationResult> {
// Validate input
const validation = createLocationSchema.safeParse(input);
if (!validation.success) {
const errors = validation.error.issues
.map((issue) => issue.message)
.join(', ');
return { success: false, error: errors };
}
// Use service client for admin operations
const supabase = createServiceClient();
try {
const { data, error } = await supabase
.from('locations')
.insert(validation.data)
.select()
.single();
if (error) {
// Handle specific error codes
if (error.code === '23505') {
return { success: false, error: 'A location with this name already exists' };
}
return { success: false, error: `Failed to create location: ${error.message}` };
}
// Revalidate the locations page
revalidatePath(`/auctions/${input.auction_id}/locations`);
return { success: true, location: data };
} catch (error) {
console.error('Unexpected error:', error);
return { success: false, error: 'An unexpected error occurred' };
}
}
Update Actions
Copy
// lib/actions/locations.ts
const updateLocationSchema = z.object({
id: z.string().uuid(),
name: z.string().min(2).max(100).optional(),
address: z.string().optional(),
capacity: z.number().int().positive().optional(),
is_active: z.boolean().optional(),
});
export type UpdateLocationInput = z.infer<typeof updateLocationSchema>;
export async function updateLocation(
auctionId: string,
input: UpdateLocationInput
): Promise<ActionResult<Location>> {
const validation = updateLocationSchema.safeParse(input);
if (!validation.success) {
return {
success: false,
error: validation.error.issues.map((i) => i.message).join(', '),
};
}
const supabase = createServiceClient();
const { data, error } = await supabase
.from('locations')
.update(validation.data)
.eq('id', input.id)
.eq('auction_id', auctionId) // Ensure multi-tenant isolation
.select()
.single();
if (error) {
if (error.code === 'PGRST116') {
return { success: false, error: 'Location not found' };
}
return { success: false, error: error.message };
}
revalidatePath(`/auctions/${auctionId}/locations`);
return { success: true, data };
}
Delete Actions
Copy
// lib/actions/locations.ts
export async function deleteLocation(
auctionId: string,
locationId: string
): Promise<ActionResult> {
const supabase = createServiceClient();
const { error } = await supabase
.from('locations')
.delete()
.eq('id', locationId)
.eq('auction_id', auctionId);
if (error) {
if (error.code === '23503') {
return {
success: false,
error: 'Cannot delete location with existing submissions',
};
}
return { success: false, error: error.message };
}
revalidatePath(`/auctions/${auctionId}/locations`);
return { success: true };
}
Query Actions
Copy
// lib/actions/locations.ts
export interface GetLocationsFilter {
auctionId: string;
search?: string;
status?: 'active' | 'inactive';
page?: number;
limit?: number;
}
export interface GetLocationsResult {
locations: LocationWithUserCount[];
total: number;
totalPages: number;
}
export async function getLocations(
filter: GetLocationsFilter
): Promise<GetLocationsResult> {
const supabase = createServiceClient();
const { auctionId, search, status, page = 1, limit = 10 } = filter;
let query = supabase
.from('locations')
.select('*, user_count:location_users(count)', { count: 'exact' })
.eq('auction_id', auctionId)
.order('name');
// Apply filters
if (search) {
query = query.ilike('name', `%${search}%`);
}
if (status === 'active') {
query = query.eq('is_active', true);
} else if (status === 'inactive') {
query = query.eq('is_active', false);
}
// Apply pagination
const from = (page - 1) * limit;
const to = from + limit - 1;
query = query.range(from, to);
const { data, error, count } = await query;
if (error) {
console.error('Error fetching locations:', error);
return { locations: [], total: 0, totalPages: 0 };
}
return {
locations: data || [],
total: count || 0,
totalPages: Math.ceil((count || 0) / limit),
};
}
Using Actions in Client Components
Copy
'use client';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { useRouter } from 'next/navigation';
import { toast } from 'sonner';
import { createLocation, type CreateLocationInput } from '@/lib/actions/locations';
const formSchema = z.object({
name: z.string().min(2),
address: z.string().optional(),
capacity: z.number().int().positive().optional(),
});
export function CreateLocationForm({ auctionId }: { auctionId: string }) {
const router = useRouter();
const [isPending, startTransition] = useTransition();
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: { name: '', address: '', capacity: undefined },
});
const onSubmit = (values: z.infer<typeof formSchema>) => {
startTransition(async () => {
const result = await createLocation({
...values,
auction_id: auctionId,
});
if (result.success) {
toast.success('Location created successfully');
router.push(`/auctions/${auctionId}/locations`);
} else {
toast.error(result.error);
}
});
};
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input {...field} placeholder="North Lot" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button type="submit" disabled={isPending}>
{isPending ? 'Creating...' : 'Create Location'}
</Button>
</form>
</Form>
);
}
Component Patterns
Data Tables with TanStack Table
Copy
'use client';
import {
useReactTable,
getCoreRowModel,
getSortedRowModel,
type ColumnDef,
type SortingState,
flexRender,
} from '@tanstack/react-table';
import { useState } from 'react';
import { ArrowUpDown, MoreHorizontal } from 'lucide-react';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
interface Location {
id: string;
name: string;
address: string | null;
is_active: boolean;
user_count: number;
}
const columns: ColumnDef<Location>[] = [
{
accessorKey: 'name',
header: ({ column }) => (
<Button
variant="ghost"
onClick={() => column.toggleSorting(column.getIsSorted() === 'asc')}
>
Name
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
),
cell: ({ row }) => (
<div className="font-medium">{row.getValue('name')}</div>
),
},
{
accessorKey: 'address',
header: 'Address',
cell: ({ row }) => row.getValue('address') || '—',
},
{
accessorKey: 'is_active',
header: 'Status',
cell: ({ row }) => (
<Badge variant={row.getValue('is_active') ? 'default' : 'secondary'}>
{row.getValue('is_active') ? 'Active' : 'Inactive'}
</Badge>
),
},
{
accessorKey: 'user_count',
header: 'Team Members',
cell: ({ row }) => row.getValue('user_count'),
},
{
id: 'actions',
cell: ({ row }) => {
const location = row.original;
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0">
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem>Edit</DropdownMenuItem>
<DropdownMenuItem className="text-destructive">
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
},
},
];
export function LocationsTable({ locations }: { locations: Location[] }) {
const [sorting, setSorting] = useState<SortingState>([]);
const table = useReactTable({
data: locations,
columns,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
state: { sorting },
onSortingChange: setSorting,
});
return (
<div className="rounded-md border">
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<TableHead key={header.id}>
{flexRender(
header.column.columnDef.header,
header.getContext()
)}
</TableHead>
))}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows.length ? (
table.getRowModel().rows.map((row) => (
<TableRow key={row.id}>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={columns.length} className="h-24 text-center">
No locations found.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
);
}
Charts with Recharts
Copy
'use client';
import {
PieChart,
Pie,
Cell,
ResponsiveContainer,
Tooltip,
Legend,
} from 'recharts';
import {
Card,
CardHeader,
CardTitle,
CardDescription,
CardContent,
} from '@/components/ui/card';
const COLORS = ['#4F8EF7', '#22C55E', '#F59E0B', '#EF4444', '#8B5CF6'];
interface ChartData {
typeId: string;
typeName: string;
carCount: number;
percentage: number;
}
export function SubmissionTypeChart({
data,
dateRangeLabel,
}: {
data: ChartData[];
dateRangeLabel: string;
}) {
return (
<Card>
<CardHeader>
<CardTitle>Car Count by Type</CardTitle>
<CardDescription>{dateRangeLabel}</CardDescription>
</CardHeader>
<CardContent>
<ResponsiveContainer width="100%" height={300}>
<PieChart>
<Pie
data={data}
dataKey="carCount"
nameKey="typeName"
cx="50%"
cy="50%"
outerRadius={100}
label={({ payload }) => `${payload.percentage}%`}
>
{data.map((entry, index) => (
<Cell
key={entry.typeId}
fill={COLORS[index % COLORS.length]}
/>
))}
</Pie>
<Tooltip
formatter={(value: number) => [value.toLocaleString(), 'Cars']}
/>
<Legend />
</PieChart>
</ResponsiveContainer>
</CardContent>
</Card>
);
}
Form Patterns
Copy
'use client';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Switch } from '@/components/ui/switch';
const memberSchema = z.object({
email: z.string().email('Invalid email address'),
full_name: z.string().min(2, 'Name must be at least 2 characters'),
role: z.enum(['admin', 'team_member']),
send_invite: z.boolean().default(true),
});
type MemberFormData = z.infer<typeof memberSchema>;
export function InviteMemberForm({
auctionId,
onSuccess,
}: {
auctionId: string;
onSuccess?: () => void;
}) {
const [isPending, startTransition] = useTransition();
const form = useForm<MemberFormData>({
resolver: zodResolver(memberSchema),
defaultValues: {
email: '',
full_name: '',
role: 'team_member',
send_invite: true,
},
});
const onSubmit = (values: MemberFormData) => {
startTransition(async () => {
const result = await inviteMember({ ...values, auctionId });
if (result.success) {
toast.success('Invitation sent successfully');
form.reset();
onSuccess?.();
} else {
toast.error(result.error);
}
});
};
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input {...field} type="email" placeholder="[email protected]" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="full_name"
render={({ field }) => (
<FormItem>
<FormLabel>Full Name</FormLabel>
<FormControl>
<Input {...field} placeholder="John Smith" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="role"
render={({ field }) => (
<FormItem>
<FormLabel>Role</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select a role" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="team_member">Team Member</SelectItem>
<SelectItem value="admin">Admin</SelectItem>
</SelectContent>
</Select>
<FormDescription>
Admins can manage users and settings.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="send_invite"
render={({ field }) => (
<FormItem className="flex items-center justify-between rounded-lg border p-4">
<div className="space-y-0.5">
<FormLabel className="text-base">Send Invitation</FormLabel>
<FormDescription>
Send an email invitation to the user
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
<Button type="submit" disabled={isPending}>
{isPending ? 'Sending...' : 'Send Invitation'}
</Button>
</form>
</Form>
);
}
Dialog Pattern
Copy
'use client';
import { useState, useTransition } from 'react';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { changeMemberRole } from '@/lib/actions/members';
import { toast } from 'sonner';
export function ChangeRoleDialog({
member,
auctionId,
}: {
member: Member;
auctionId: string;
}) {
const [open, setOpen] = useState(false);
const [selectedRole, setSelectedRole] = useState(member.role);
const [isPending, startTransition] = useTransition();
const handleSubmit = () => {
startTransition(async () => {
const result = await changeMemberRole(auctionId, member.user_id, selectedRole);
if (result.success) {
toast.success('Role updated successfully');
setOpen(false);
} else {
toast.error(result.error);
}
});
};
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button variant="outline" size="sm">
Change Role
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Change Role</DialogTitle>
<DialogDescription>
Update the role for {member.user.full_name}
</DialogDescription>
</DialogHeader>
<div className="py-4">
<Select value={selectedRole} onValueChange={setSelectedRole}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="team_member">Team Member</SelectItem>
<SelectItem value="admin">Admin</SelectItem>
<SelectItem value="owner">Owner</SelectItem>
</SelectContent>
</Select>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setOpen(false)}>
Cancel
</Button>
<Button onClick={handleSubmit} disabled={isPending}>
{isPending ? 'Saving...' : 'Save Changes'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
Supabase Integration
Server Client
The server client uses cookie-based authentication:Copy
// lib/supabase/server.ts
import { createServerClient } from '@supabase/ssr';
import { cookies } from 'next/headers';
import type { Database } from '@/types/database';
// For authenticated requests (respects RLS)
export async function createClient() {
const cookieStore = await cookies();
return createServerClient<Database>(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
getAll() {
return cookieStore.getAll();
},
setAll(cookiesToSet) {
try {
cookiesToSet.forEach(({ name, value, options }) => {
cookieStore.set(name, value, options);
});
} catch {
// Ignored in Server Components
}
},
},
}
);
}
// For admin operations (bypasses RLS)
export function createServiceClient() {
return createServerClient<Database>(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY!,
{
cookies: {
getAll: () => [],
setAll: () => {},
},
auth: {
autoRefreshToken: false,
persistSession: false,
},
}
);
}
Copy
// In Server Components - use createClient() for user-scoped queries
export default async function Page() {
const supabase = await createClient();
const { data: { user } } = await supabase.auth.getUser();
// RLS policies filter data based on user
const { data } = await supabase.from('locations').select();
}
// In Server Actions - use createServiceClient() for admin operations
export async function createLocation(input: CreateLocationInput) {
const supabase = createServiceClient();
// Bypasses RLS - use for admin CRUD operations
const { data, error } = await supabase
.from('locations')
.insert(input)
.select()
.single();
}
Browser Client
For client components that need real-time subscriptions:Copy
// lib/supabase/client.ts
import { createBrowserClient } from '@supabase/ssr';
import type { Database } from '@/types/database';
export function createClient() {
return createBrowserClient<Database>(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
);
}
Copy
'use client';
import { useEffect, useState } from 'react';
import { createClient } from '@/lib/supabase/client';
export function useRealtimeMessages(channelId: string) {
const [messages, setMessages] = useState<Message[]>([]);
const supabase = createClient();
useEffect(() => {
const channel = supabase
.channel(`messages:${channelId}`)
.on(
'postgres_changes',
{
event: 'INSERT',
schema: 'public',
table: 'messages',
filter: `channel_id=eq.${channelId}`,
},
(payload) => {
setMessages((prev) => [...prev, payload.new as Message]);
}
)
.subscribe();
return () => {
supabase.removeChannel(channel);
};
}, [channelId, supabase]);
return messages;
}
Authentication
Login Flow
Copy
// app/(auth)/login/page.tsx
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { createClient } from '@/lib/supabase/client';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { toast } from 'sonner';
export default function LoginPage() {
const router = useRouter();
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [isLoading, setIsLoading] = useState(false);
const handleLogin = async (e: React.FormEvent) => {
e.preventDefault();
setIsLoading(true);
const supabase = createClient();
const { error } = await supabase.auth.signInWithPassword({
email,
password,
});
if (error) {
toast.error(error.message);
setIsLoading(false);
return;
}
// Check if user is admin
const { data: isSuperAdmin } = await supabase.rpc('is_super_admin');
if (isSuperAdmin) {
router.push('/auctions');
} else {
// Check auction memberships
const { data: memberships } = await supabase
.from('auction_members')
.select('auction_id, role')
.in('role', ['owner', 'admin']);
if (memberships?.length) {
router.push(`/auctions/${memberships[0].auction_id}`);
} else {
toast.error('You do not have admin access');
await supabase.auth.signOut();
}
}
setIsLoading(false);
};
return (
<form onSubmit={handleLogin} className="space-y-4">
<Input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="Email"
required
/>
<Input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Password"
required
/>
<Button type="submit" disabled={isLoading} className="w-full">
{isLoading ? 'Signing in...' : 'Sign In'}
</Button>
</form>
);
}
Route Protection
The dashboard layout handles authentication:Copy
// app/(dashboard)/layout.tsx
import { redirect } from 'next/navigation';
import { createClient } from '@/lib/supabase/server';
export default async function DashboardLayout({
children,
}: {
children: React.ReactNode;
}) {
const supabase = await createClient();
const { data: { user } } = await supabase.auth.getUser();
if (!user) {
redirect('/login');
}
// Fetch user profile and memberships in parallel
const [profileResult, membershipsResult] = await Promise.all([
supabase.from('users').select('*').eq('id', user.id).single(),
supabase.from('auction_members').select('*, auction:auctions(*)').eq('user_id', user.id),
]);
return (
<DashboardShell
user={profileResult.data}
auctions={membershipsResult.data?.map((m) => m.auction)}
>
{children}
</DashboardShell>
);
}
Auction Context Provider
Copy
// components/providers/auction-provider.tsx
'use client';
import { createContext, useContext } from 'react';
export interface AuctionContextData {
id: string;
name: string;
slug: string;
role: 'owner' | 'admin' | 'team_member';
logo_url: string | null;
settings: AuctionSettings;
is_active: boolean;
}
const AuctionContext = createContext<AuctionContextData | null>(null);
export function AuctionProvider({
auction,
children,
}: {
auction: AuctionContextData;
children: React.ReactNode;
}) {
return (
<AuctionContext.Provider value={auction}>
{children}
</AuctionContext.Provider>
);
}
// Hooks for accessing auction context
export function useAuction(): AuctionContextData {
const context = useContext(AuctionContext);
if (!context) {
throw new Error('useAuction must be used within AuctionProvider');
}
return context;
}
export function useAuctionRole() {
return useAuction().role;
}
export function useIsAuctionAdmin() {
const role = useAuctionRole();
return role === 'admin' || role === 'owner';
}
export function useIsAuctionOwner() {
return useAuctionRole() === 'owner';
}
Copy
// app/(dashboard)/auctions/[auctionId]/layout.tsx
import { AuctionProvider } from '@/components/providers/auction-provider';
import { getAuctionWithAccess } from '@/lib/actions/auctions';
export default async function AuctionLayout({
children,
params,
}: {
children: React.ReactNode;
params: Promise<{ auctionId: string }>;
}) {
const { auctionId } = await params;
const auction = await getAuctionWithAccess(auctionId);
return (
<AuctionProvider auction={auction}>
{children}
</AuctionProvider>
);
}
Testing
Vitest Configuration
Copy
// vitest.config.ts
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';
import path from 'path';
export default defineConfig({
plugins: [react()],
test: {
environment: 'jsdom',
setupFiles: ['./src/__tests__/setup.ts'],
include: ['**/*.test.{ts,tsx}'],
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html'],
exclude: ['node_modules/', 'e2e/', '**/*.d.ts'],
},
},
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
});
Testing Server Actions
Copy
// lib/actions/__tests__/locations.test.ts
import { describe, expect, it, vi, beforeEach } from 'vitest';
import { createLocation, getLocations } from '../locations';
// Mock Supabase
vi.mock('@/lib/supabase/server', () => ({
createServiceClient: vi.fn(() => ({
from: vi.fn(() => ({
insert: vi.fn(() => ({
select: vi.fn(() => ({
single: vi.fn(() => ({
data: { id: '1', name: 'Test Location' },
error: null,
})),
})),
})),
select: vi.fn(() => ({
eq: vi.fn(() => ({
order: vi.fn(() => ({
range: vi.fn(() => ({
data: [],
error: null,
count: 0,
})),
})),
})),
})),
})),
})),
}));
vi.mock('next/cache', () => ({
revalidatePath: vi.fn(),
}));
describe('createLocation', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('should create a location successfully', async () => {
const result = await createLocation({
auction_id: 'auction-1',
name: 'Test Location',
});
expect(result.success).toBe(true);
expect(result.location).toBeDefined();
});
it('should validate required fields', async () => {
const result = await createLocation({
auction_id: 'auction-1',
name: '', // Invalid - too short
});
expect(result.success).toBe(false);
expect(result.error).toContain('at least 2 characters');
});
});
Playwright E2E Tests
Copy
// e2e/locations.spec.ts
import { test, expect } from '@playwright/test';
test.describe('Locations', () => {
test.beforeEach(async ({ page }) => {
// Login before each test
await page.goto('/login');
await page.fill('[name="email"]', '[email protected]');
await page.fill('[name="password"]', 'password');
await page.click('button[type="submit"]');
await page.waitForURL('/auctions/**');
});
test('should display locations list', async ({ page }) => {
await page.goto('/auctions/test-auction/locations');
await expect(page.getByRole('heading', { name: 'Locations' })).toBeVisible();
await expect(page.getByRole('table')).toBeVisible();
});
test('should create a new location', async ({ page }) => {
await page.goto('/auctions/test-auction/locations');
await page.click('text=Add Location');
await page.fill('[name="name"]', 'New Test Location');
await page.fill('[name="address"]', '123 Test Street');
await page.click('button[type="submit"]');
await expect(page.getByText('Location created successfully')).toBeVisible();
await expect(page.getByText('New Test Location')).toBeVisible();
});
});
Common Tasks
Adding a New Page
1
Create the Page Component
Create a new page in the appropriate route group:
Copy
// app/(dashboard)/auctions/[auctionId]/new-feature/page.tsx
import { Suspense } from 'react';
import { PageHeader } from '@/components/layout/page-header';
interface NewFeaturePageProps {
params: Promise<{ auctionId: string }>;
}
export default async function NewFeaturePage({ params }: NewFeaturePageProps) {
const { auctionId } = await params;
return (
<div className="space-y-6">
<PageHeader title="New Feature" />
<Suspense fallback={<div>Loading...</div>}>
<NewFeatureContent auctionId={auctionId} />
</Suspense>
</div>
);
}
2
Add Navigation Link
Update the sidebar to include the new page:
Copy
// components/layout/sidebar.tsx
const navItems = [
// ... existing items
{
title: 'New Feature',
href: `/auctions/${auctionId}/new-feature`,
icon: Star,
},
];
3
Create Server Actions
Add any needed server actions:
Copy
// lib/actions/new-feature.ts
'use server';
import { createServiceClient } from '@/lib/supabase/server';
export async function getNewFeatureData(auctionId: string) {
const supabase = createServiceClient();
// ... implementation
}
Creating a Server Action
1
Define the Schema
Copy
// lib/actions/example.ts
import { z } from 'zod';
const exampleSchema = z.object({
name: z.string().min(1),
value: z.number().positive(),
});
export type ExampleInput = z.infer<typeof exampleSchema>;
2
Implement the Action
Copy
'use server';
import { revalidatePath } from 'next/cache';
import { createServiceClient } from '@/lib/supabase/server';
export async function createExample(
auctionId: string,
input: ExampleInput
): Promise<ActionResult<Example>> {
const validation = exampleSchema.safeParse(input);
if (!validation.success) {
return { success: false, error: 'Invalid input' };
}
const supabase = createServiceClient();
const { data, error } = await supabase
.from('examples')
.insert({ ...validation.data, auction_id: auctionId })
.select()
.single();
if (error) {
return { success: false, error: error.message };
}
revalidatePath(`/auctions/${auctionId}/examples`);
return { success: true, data };
}
3
Use in Component
Copy
'use client';
import { useTransition } from 'react';
import { createExample } from '@/lib/actions/example';
export function CreateExampleButton({ auctionId }: { auctionId: string }) {
const [isPending, startTransition] = useTransition();
const handleClick = () => {
startTransition(async () => {
const result = await createExample(auctionId, {
name: 'Test',
value: 42,
});
if (result.success) {
toast.success('Created!');
} else {
toast.error(result.error);
}
});
};
return (
<Button onClick={handleClick} disabled={isPending}>
{isPending ? 'Creating...' : 'Create'}
</Button>
);
}
Adding a Data Table
1
Define Column Types
Copy
// components/example/columns.tsx
'use client';
import { type ColumnDef } from '@tanstack/react-table';
export const columns: ColumnDef<Example>[] = [
{
accessorKey: 'name',
header: 'Name',
},
{
accessorKey: 'value',
header: 'Value',
cell: ({ row }) => row.getValue('value').toLocaleString(),
},
// ... more columns
];
2
Create the Table Component
Copy
// components/example/example-table.tsx
'use client';
import { useReactTable, getCoreRowModel, flexRender } from '@tanstack/react-table';
import { columns } from './columns';
export function ExampleTable({ data }: { data: Example[] }) {
const table = useReactTable({
data,
columns,
getCoreRowModel: getCoreRowModel(),
});
return (
<Table>
{/* ... render table */}
</Table>
);
}
3
Use in Page
Copy
// app/(dashboard)/auctions/[auctionId]/examples/page.tsx
import { ExampleTable } from '@/components/example/example-table';
import { getExamples } from '@/lib/actions/example';
export default async function ExamplesPage({ params }) {
const { auctionId } = await params;
const examples = await getExamples(auctionId);
return <ExampleTable data={examples} />;
}
Key Commands
| Command | Description |
|---|---|
pnpm dev | Start dev server with Turbopack |
pnpm build | Production build |
pnpm start | Start production server |
pnpm check | Biome lint + format |
pnpm lint | Biome linting only |
pnpm format | Biome formatting only |
pnpm test | Run Vitest unit tests |
pnpm test:watch | Run tests in watch mode |
pnpm test:e2e | Run Playwright E2E tests |
pnpm test:e2e:ui | Run E2E tests with UI |
Best Practices
Server First
Use Server Components by default. Only add ‘use client’ when you need interactivity.
Type Everything
Use TypeScript strictly. Leverage generated database types and Zod validation.
Multi-Tenant Isolation
Always filter by
auction_id. Never expose data across tenants.Handle All States
Every component should handle loading, error, and empty states gracefully.
Code Style
- Biome for linting and formatting (not ESLint/Prettier)
- Run
pnpm checkbefore committing - Use TypeScript strict mode
Performance
- Use Suspense boundaries for progressive loading
- Implement proper pagination for large datasets
- Use
revalidatePathjudiciously to avoid over-fetching - Consider caching strategies for expensive queries
Security
- Never expose
SUPABASE_SERVICE_ROLE_KEYto the client - Validate all inputs with Zod schemas
- Rely on RLS policies as the primary access control
- Verify
auction_idownership in Server Actions