Skip to main content

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:
ToolVersionInstallation
Node.js20.x LTSnodejs.org or nvm install 20
pnpm9.xnpm install -g pnpm
GitLatestgit-scm.com
# 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

Getting Started

Repository Setup

1

Clone the Repository

git clone https://github.com/your-org/auction-excellence.git
cd auction-excellence/admin
2

Install Dependencies

pnpm install
This installs all dependencies including Next.js, React, shadcn/ui, and development tools.
3

Configure Environment Variables

Create a .env.local file in the admin directory:
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

pnpm dev
This starts the Next.js development server with Turbopack at 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:
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

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
React components organized by domain:
  • ui/ - shadcn/ui primitives: Button, Card, Dialog, Table, Form
  • layout/ - Header, Sidebar, PageHeader
  • providers/ - AuctionProvider for context
  • [feature]/ - Feature components: locations/, members/, analytics/
Server Actions for all mutations and queries:
  • auctions.ts - Auction CRUD operations
  • locations.ts - Location management
  • members.ts - Team member management
  • analytics.ts - Dashboard analytics queries
  • reports/ - Report generation and sharing
Supabase client configuration:
  • server.ts - Server client with cookie-based auth
  • client.ts - Browser client for client components
  • index.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:
// 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;
Key Features:
  • 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:
{
  "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:
/* 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:
// 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:
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
Auth Layout (minimal):
// 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>
  );
}
Dashboard Layout (full):
// 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:
// 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:
'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:
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

// 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

// 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

// 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

// 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

'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

'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

'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

'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

'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:
// 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,
      },
    }
  );
}
Usage Patterns:
// 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:
// 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!
  );
}
Real-time Subscriptions:
'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

// 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:
// 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

// 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';
}
Usage in auction-scoped layout:
// 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

// 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

// 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

// 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:
// 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:
// 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:
// 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

// 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

'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

'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

// 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

// 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

// 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

CommandDescription
pnpm devStart dev server with Turbopack
pnpm buildProduction build
pnpm startStart production server
pnpm checkBiome lint + format
pnpm lintBiome linting only
pnpm formatBiome formatting only
pnpm testRun Vitest unit tests
pnpm test:watchRun tests in watch mode
pnpm test:e2eRun Playwright E2E tests
pnpm test:e2e:uiRun 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 check before committing
  • Use TypeScript strict mode

Performance

  • Use Suspense boundaries for progressive loading
  • Implement proper pagination for large datasets
  • Use revalidatePath judiciously to avoid over-fetching
  • Consider caching strategies for expensive queries

Security

  • Never expose SUPABASE_SERVICE_ROLE_KEY to the client
  • Validate all inputs with Zod schemas
  • Rely on RLS policies as the primary access control
  • Verify auction_id ownership in Server Actions

Next Steps