Scaffolded admin dashboard, added nextauth, cloudflare d1 and R2

This commit is contained in:
Nicholai 2025-09-17 11:24:00 -06:00
parent 7b23bbd54f
commit 8216198c88
55 changed files with 13781 additions and 17 deletions

View File

@ -12,6 +12,6 @@
## Required Workflow
- Run Context7 checks for new deps, upgrades, DS changes
- Check shadcn registry before custom components
- Use Supabase MCP for all DB ops (incl. migrations)
- Use Cloudflare MCP for all DB ops (incl. migrations)
- Plan & Act for complex features; reference existing patterns
- Clarify ambiguous requirements early; provide confidence rating

View File

@ -0,0 +1,32 @@
## Brief overview
These rules establish the mandatory use of Context7 MCP server for all development operations, ensuring access to the most current practices and information. This is a global requirement that applies to all projects and development tasks.
## Context7 MCP dependency
- All library research, documentation lookup, and best practice validation MUST use Context7 MCP server
- Before implementing any new technology, framework, or library, validate current patterns through Context7
- When encountering errors or implementation issues, consult Context7 for updated solutions and patterns
- Never assume knowledge of libraries or frameworks without Context7 validation
## Development workflow with Context7
- Start any new feature or library integration by resolving the library ID through Context7
- Use Context7 to get current documentation and implementation patterns before coding
- When debugging or troubleshooting, reference Context7 for updated solutions
- Validate testing patterns and mocking strategies through Context7 before implementation
## Information validation process
- Context7 serves as the authoritative source for current development practices
- All technical decisions should be informed by Context7 documentation and patterns
- When user requests revalidation of approaches, immediately consult Context7
- Prioritize Context7 guidance over assumed knowledge or outdated practices
## Implementation standards
- Follow Context7-validated patterns for testing, mocking, and development workflows
- Use Context7 to verify compatibility and current best practices for all dependencies
- Ensure all code patterns align with Context7-provided examples and documentation
- Reference Context7 for proper configuration and setup procedures
## Error handling and troubleshooting
- When tests fail or implementations don't work as expected, consult Context7 for current solutions
- Use Context7 to validate mocking patterns and testing strategies
- Reference Context7 for proper error handling and debugging approaches
- Always check Context7 for updated patterns when encountering technical issues

View File

@ -1,7 +1,7 @@
# Data, MCP, Codegen, Migrations, File Uploads
## MCP Requirements
- All DB access (dev/prod/migrations/scripts) via **Supabase MCP**
- All DB access (dev/prod/migrations/scripts) via **Cloudflare MCP**
- Context7 MCP required for: new deps, framework upgrades, DS changes
- Cache/pin Context7 outputs; PRs require justification to override

View File

@ -5,7 +5,7 @@
- Tailwind + shadcn/ui (mandatory)
- TypeScript only (.ts/.tsx)
- State: Zustand (local UI) + React Query (server state)
- DB: Postgres (Docker) **via Supabase MCP only**
- DB: Postgres (Docker) **via Cloudflare MCP only**
- VCS: Gitea
- MCP: Supabase MCP (DB), Context7 MCP (patterns/updates)

View File

@ -0,0 +1,32 @@
## Brief overview
These rules establish the mandatory use of ShadCN MCP server for all component design and TSX file development. This ensures consistent UI patterns and proper component usage throughout the project.
## Component design workflow
- All component creation or modification must reference ShadCN MCP server first
- Check ShadCN registry for existing components before creating custom ones
- Use ShadCN MCP to get proper component examples and usage patterns
- Validate component composition and variant usage through ShadCN documentation
## TSX file development
- Before touching any .tsx file, consult ShadCN MCP for current component patterns
- Use ShadCN MCP to verify proper prop interfaces and component APIs
- Reference ShadCN examples for form handling, data display, and interactive elements
- Follow ShadCN naming conventions and component structure patterns
## Page design requirements
- All new page designs must start with ShadCN MCP consultation
- Use ShadCN layout patterns and responsive design examples
- Verify accessibility patterns and best practices through ShadCN documentation
- Ensure consistent spacing, typography, and color usage per ShadCN guidelines
## Component composition standards
- Use ShadCN MCP to validate component combinations and nesting patterns
- Reference ShadCN for proper variant usage and customization approaches
- Follow ShadCN patterns for conditional rendering and state management
- Ensure proper TypeScript integration following ShadCN examples
## UI consistency enforcement
- All UI elements must align with ShadCN design system principles
- Use ShadCN MCP to verify proper use of design tokens and CSS variables
- Reference ShadCN for animation and transition patterns
- Maintain consistent component behavior across the application

169
D1_SETUP.md Normal file
View File

@ -0,0 +1,169 @@
# Cloudflare D1 Database Setup Guide
This guide will help you set up Cloudflare D1 database for the United Tattoo Studio management platform.
## Prerequisites
1. **Cloudflare Account** with Workers/Pages access
2. **Wrangler CLI** installed globally: `npm install -g wrangler`
3. **Authenticated with Cloudflare**: `wrangler auth login`
## Step 1: Create D1 Database
```bash
# Create the D1 database
npm run db:create
# This will output something like:
# ✅ Successfully created DB 'united-tattoo-db' in region ENAM
# Created your database using D1's new storage backend. The new storage backend is not yet recommended for production workloads, but backs up your data via point-in-time restore.
#
# [[d1_databases]]
# binding = "DB"
# database_name = "united-tattoo-db"
# database_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
```
## Step 2: Update wrangler.toml
Copy the `database_id` from the output above and update your `wrangler.toml`:
```toml
[[d1_databases]]
binding = "DB"
database_name = "united-tattoo-db"
database_id = "your-actual-database-id-here" # Replace with the ID from step 1
```
## Step 3: Run Database Migrations
### For Local Development:
```bash
# Create tables in local D1 database
npm run db:migrate:local
```
### For Production:
```bash
# Create tables in production D1 database
npm run db:migrate
```
## Step 4: Verify Database Setup
### Check Local Database:
```bash
# List tables in local database
npm run db:studio:local
```
### Check Production Database:
```bash
# List tables in production database
npm run db:studio
```
## Step 5: Development Workflow
### Local Development:
```bash
# Start Next.js development server
npm run dev
# The app will use local SQLite file for development
# Database file: ./local.db
```
### Preview with Cloudflare:
```bash
# Build for Cloudflare Pages
npm run pages:build
# Preview locally with Cloudflare runtime
npm run preview
# Deploy to Cloudflare Pages
npm run deploy
```
## Database Schema
The database includes the following tables:
- `users` - User accounts and roles
- `artists` - Artist profiles and information
- `portfolio_images` - Artist portfolio images
- `appointments` - Booking and appointment data
- `availability` - Artist availability schedules
- `site_settings` - Studio configuration
- `file_uploads` - File upload metadata
## Environment Variables
### Local Development (.env.local):
```env
DATABASE_URL="file:./local.db"
DIRECT_URL="file:./local.db"
```
### Production (Cloudflare Pages):
Environment variables are managed through:
1. `wrangler.toml` for public variables
2. Cloudflare Dashboard for secrets
3. D1 database binding automatically available as `env.DB`
## Useful Commands
```bash
# Database Management
npm run db:create # Create new D1 database
npm run db:migrate # Run migrations on production DB
npm run db:migrate:local # Run migrations on local DB
npm run db:studio # Query production database
npm run db:studio:local # Query local database
# Cloudflare Pages
npm run pages:build # Build for Cloudflare Pages
npm run preview # Preview with Cloudflare runtime
npm run deploy # Deploy to Cloudflare Pages
# Development
npm run dev # Start Next.js dev server
npm run build # Standard Next.js build
```
## Troubleshooting
### Common Issues:
1. **"Database not found"**
- Make sure you've created the D1 database: `npm run db:create`
- Verify the `database_id` in `wrangler.toml` matches the created database
2. **"Tables don't exist"**
- Run migrations: `npm run db:migrate:local` (for local) or `npm run db:migrate` (for production)
3. **"Wrangler not authenticated"**
- Run: `wrangler auth login`
4. **"Permission denied"**
- Ensure your Cloudflare account has Workers/Pages access
- Check that you're authenticated with the correct account
### Database Access in Code:
In your API routes, access the D1 database through the environment binding:
```typescript
// In API routes (production)
const db = env.DB; // Cloudflare D1 binding
// For local development, you'll use SQLite
// The lib/db.ts file handles this automatically
```
## Next Steps
After setting up D1:
1. Update the database functions in `lib/db.ts` to use actual D1 queries
2. Test the admin dashboard with real database operations
3. Deploy to Cloudflare Pages for production testing

View File

@ -0,0 +1,144 @@
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'
// Mock the database using proper Vitest patterns
const mockStmt = {
bind: vi.fn().mockReturnThis(),
run: vi.fn().mockResolvedValue({ success: true, changes: 1 }),
get: vi.fn(),
all: vi.fn().mockResolvedValue({ results: [] }),
first: vi.fn().mockResolvedValue(null),
}
const mockDB = {
prepare: vi.fn().mockReturnValue(mockStmt),
exec: vi.fn(),
}
// Mock the entire lib/db module
vi.mock('@/lib/db', () => ({
getDB: vi.fn(() => mockDB),
}))
// Mock the artists data with proper structure
vi.mock('@/data/artists', () => ({
artists: [
{
id: '1',
name: 'Test Artist',
bio: 'Test bio',
styles: ['Traditional', 'Realism'],
instagram: 'https://instagram.com/testartist',
experience: '5 years',
workImages: ['/test-image.jpg'],
faceImage: '/test-face.jpg',
},
{
id: '2',
name: 'Another Artist',
bio: 'Another bio',
styles: ['Japanese', 'Blackwork'],
instagram: 'https://instagram.com/anotherartist',
experience: '8 years',
workImages: [],
faceImage: '/another-face.jpg',
},
],
}))
describe('DataMigrator', () => {
let DataMigrator: any
let migrator: any
beforeEach(async () => {
vi.clearAllMocks()
// Reset mock implementations
mockDB.prepare.mockReturnValue(mockStmt)
mockStmt.first.mockResolvedValue(null)
mockStmt.run.mockResolvedValue({ success: true, changes: 1 })
// Import the DataMigrator class after mocks are set up
const module = await import('@/lib/data-migration')
DataMigrator = module.DataMigrator
migrator = new DataMigrator()
})
afterEach(() => {
vi.resetAllMocks()
})
describe('isMigrationCompleted', () => {
it('should return false when no artists exist', async () => {
mockStmt.first.mockResolvedValueOnce({ count: 0 })
const isCompleted = await migrator.isMigrationCompleted()
expect(isCompleted).toBe(false)
})
it('should return true when artists exist', async () => {
mockStmt.first.mockResolvedValueOnce({ count: 2 })
const isCompleted = await migrator.isMigrationCompleted()
expect(isCompleted).toBe(true)
})
})
describe('migrateArtistData', () => {
it('should migrate all artists successfully', async () => {
await migrator.migrateArtistData()
// Verify user creation calls
expect(mockDB.prepare).toHaveBeenCalledWith(
expect.stringContaining('INSERT OR IGNORE INTO users')
)
// Verify artist creation calls
expect(mockDB.prepare).toHaveBeenCalledWith(
expect.stringContaining('INSERT OR IGNORE INTO artists')
)
// Verify portfolio image creation calls
expect(mockDB.prepare).toHaveBeenCalledWith(
expect.stringContaining('INSERT OR IGNORE INTO portfolio_images')
)
})
it('should handle errors gracefully', async () => {
mockStmt.run.mockRejectedValueOnce(new Error('Database error'))
await expect(migrator.migrateArtistData()).rejects.toThrow('Database error')
})
})
describe('clearMigratedData', () => {
it('should clear all data successfully', async () => {
await migrator.clearMigratedData()
expect(mockDB.prepare).toHaveBeenCalledWith('DELETE FROM portfolio_images')
expect(mockDB.prepare).toHaveBeenCalledWith('DELETE FROM artists')
expect(mockDB.prepare).toHaveBeenCalledWith('DELETE FROM users WHERE role = "ARTIST"')
})
it('should handle clear data errors', async () => {
mockStmt.run.mockRejectedValueOnce(new Error('Clear error'))
await expect(migrator.clearMigratedData()).rejects.toThrow('Clear error')
})
})
describe('getMigrationStats', () => {
it('should return correct migration statistics', async () => {
mockStmt.first
.mockResolvedValueOnce({ count: 3 }) // total users
.mockResolvedValueOnce({ count: 2 }) // total artists
.mockResolvedValueOnce({ count: 1 }) // total portfolio images
const stats = await migrator.getMigrationStats()
expect(stats.totalUsers).toBe(3)
expect(stats.totalArtists).toBe(2)
expect(stats.totalPortfolioImages).toBe(1)
})
})
})

View File

@ -0,0 +1,92 @@
import { describe, it, expect } from 'vitest'
import { createArtistSchema, createAppointmentSchema } from '@/lib/validations'
describe('Validation Schemas', () => {
describe('createArtistSchema', () => {
it('should validate a valid artist object', () => {
const validArtist = {
name: 'John Doe',
bio: 'Experienced tattoo artist',
specialties: ['Traditional', 'Realism'],
instagramHandle: 'johndoe',
hourlyRate: 150,
isActive: true,
}
const result = createArtistSchema.safeParse(validArtist)
expect(result.success).toBe(true)
})
it('should reject artist with invalid data', () => {
const invalidArtist = {
name: '', // Empty name should fail
bio: 'Bio',
specialties: [],
hourlyRate: -50, // Negative rate should fail
}
const result = createArtistSchema.safeParse(invalidArtist)
expect(result.success).toBe(false)
})
it('should require name field', () => {
const artistWithoutName = {
bio: 'Bio',
specialties: ['Traditional'],
hourlyRate: 150,
}
const result = createArtistSchema.safeParse(artistWithoutName)
expect(result.success).toBe(false)
})
})
describe('createAppointmentSchema', () => {
it('should validate a valid appointment object', () => {
const validAppointment = {
clientName: 'Jane Smith',
clientEmail: 'jane@example.com',
clientPhone: '+1234567890',
artistId: 'artist-123',
startTime: new Date('2024-12-01T10:00:00Z'),
endTime: new Date('2024-12-01T12:00:00Z'),
description: 'Traditional rose tattoo',
estimatedPrice: 300,
status: 'PENDING' as const,
}
const result = createAppointmentSchema.safeParse(validAppointment)
expect(result.success).toBe(true)
})
it('should reject appointment with invalid email', () => {
const invalidAppointment = {
clientName: 'Jane Smith',
clientEmail: 'invalid-email', // Invalid email format
artistId: 'artist-123',
startTime: new Date('2024-12-01T10:00:00Z'),
endTime: new Date('2024-12-01T12:00:00Z'),
description: 'Tattoo description',
status: 'PENDING' as const,
}
const result = createAppointmentSchema.safeParse(invalidAppointment)
expect(result.success).toBe(false)
})
it('should reject appointment with end time before start time', () => {
const invalidAppointment = {
clientName: 'Jane Smith',
clientEmail: 'jane@example.com',
artistId: 'artist-123',
startTime: new Date('2024-12-01T12:00:00Z'),
endTime: new Date('2024-12-01T10:00:00Z'), // End before start
description: 'Tattoo description',
status: 'PENDING' as const,
}
const result = createAppointmentSchema.safeParse(invalidAppointment)
expect(result.success).toBe(false)
})
})
})

View File

@ -0,0 +1,214 @@
# Implementation Plan
## Overview
Implement a comprehensive admin dashboard with full CRUD operations for artist management, Cloudflare R2 file upload system, appointment scheduling interface, and database population from existing artist data.
This implementation extends the existing United Tattoo Studio platform by building out the admin interface components, integrating Cloudflare R2 for portfolio image uploads, creating a full appointment management system with calendar views, and migrating the current mock artist data into the Cloudflare D1 database. The admin dashboard will provide complete management capabilities for studio operations while maintaining the existing public-facing website functionality.
## Types
Define comprehensive type system for admin dashboard components and enhanced database operations.
```typescript
// Admin Dashboard Types
interface AdminDashboardStats {
totalArtists: number
activeArtists: number
totalAppointments: number
pendingAppointments: number
totalUploads: number
recentUploads: number
}
interface FileUploadProgress {
id: string
filename: string
progress: number
status: 'uploading' | 'processing' | 'complete' | 'error'
url?: string
error?: string
}
interface CalendarEvent {
id: string
title: string
start: Date
end: Date
artistId: string
clientId: string
status: AppointmentStatus
description?: string
}
// Enhanced Artist Types
interface ArtistFormData {
name: string
bio: string
specialties: string[]
instagramHandle?: string
hourlyRate?: number
isActive: boolean
email?: string
portfolioImages?: File[]
}
interface PortfolioImageUpload {
file: File
caption?: string
tags: string[]
orderIndex: number
}
// File Upload Types
interface R2UploadResponse {
success: boolean
url?: string
key?: string
error?: string
}
interface BulkUploadResult {
successful: FileUpload[]
failed: { filename: string; error: string }[]
total: number
}
```
## Files
Create new admin dashboard pages and components while enhancing existing database and upload functionality.
**New Files to Create:**
- `app/admin/artists/page.tsx` - Artist management list view
- `app/admin/artists/new/page.tsx` - Create new artist form
- `app/admin/artists/[id]/page.tsx` - Edit artist details
- `app/admin/artists/[id]/portfolio/page.tsx` - Manage artist portfolio
- `app/admin/calendar/page.tsx` - Appointment calendar interface
- `app/admin/uploads/page.tsx` - File upload management
- `app/admin/settings/page.tsx` - Studio settings management
- `components/admin/artist-form.tsx` - Artist creation/editing form
- `components/admin/portfolio-manager.tsx` - Portfolio image management
- `components/admin/file-uploader.tsx` - Cloudflare R2 file upload component
- `components/admin/appointment-calendar.tsx` - Calendar component for appointments
- `components/admin/stats-dashboard.tsx` - Dashboard statistics display
- `components/admin/data-table.tsx` - Reusable data table component
- `lib/r2-upload.ts` - Cloudflare R2 upload utilities
- `lib/data-migration.ts` - Artist data migration utilities
- `hooks/use-file-upload.ts` - File upload hook with progress tracking
- `hooks/use-calendar.ts` - Calendar state management hook
**Files to Modify:**
- `app/api/artists/route.ts` - Enhance with real database operations
- `app/api/artists/[id]/route.ts` - Add portfolio image management
- `app/api/upload/route.ts` - Implement Cloudflare R2 integration
- `app/api/settings/route.ts` - Add site settings CRUD operations
- `app/api/appointments/route.ts` - Create appointment management API
- `lib/db.ts` - Update database functions to work with runtime environment
- `lib/validations.ts` - Add admin form validation schemas
- `components/admin/sidebar.tsx` - Update navigation for new pages
## Functions
Implement comprehensive CRUD operations and file management functionality.
**New Functions:**
- `uploadToR2(file: File, key: string): Promise<R2UploadResponse>` in `lib/r2-upload.ts`
- `bulkUploadToR2(files: File[]): Promise<BulkUploadResult>` in `lib/r2-upload.ts`
- `migrateArtistData(): Promise<void>` in `lib/data-migration.ts`
- `getArtistStats(): Promise<AdminDashboardStats>` in `lib/db.ts`
- `createAppointment(data: CreateAppointmentInput): Promise<Appointment>` in `lib/db.ts`
- `getAppointmentsByDateRange(start: Date, end: Date): Promise<CalendarEvent[]>` in `lib/db.ts`
- `useFileUpload(): FileUploadHook` in `hooks/use-file-upload.ts`
- `useCalendar(): CalendarHook` in `hooks/use-calendar.ts`
**Modified Functions:**
- Update `getArtists()` in `lib/db.ts` to use actual D1 database
- Enhance `createArtist()` to handle portfolio image uploads
- Modify `updateArtist()` to support portfolio management
- Update API route handlers to use enhanced database functions
## Classes
Create reusable component classes and utility classes for admin functionality.
**New Classes:**
- `FileUploadManager` class in `lib/r2-upload.ts` for managing multiple file uploads
- `CalendarManager` class in `lib/calendar.ts` for appointment scheduling logic
- `DataMigrator` class in `lib/data-migration.ts` for database migration operations
**Component Classes:**
- `ArtistForm` component class with form validation and submission
- `PortfolioManager` component class for drag-and-drop image management
- `FileUploader` component class with progress tracking and error handling
- `AppointmentCalendar` component class with scheduling capabilities
## Dependencies
Add required packages for enhanced admin functionality.
**New Dependencies:**
- `@aws-sdk/client-s3` (already installed) - For Cloudflare R2 operations
- `react-big-calendar` (already installed) - For appointment calendar
- `react-dropzone` (already installed) - For file upload interface
- `@tanstack/react-query` (already installed) - For data fetching and caching
**Configuration Updates:**
- Update `wrangler.toml` with R2 bucket configuration
- Add environment variables for R2 access keys
- Configure Next.js for file upload handling
## Testing
Implement comprehensive testing for admin dashboard functionality.
**Test Files to Create:**
- `__tests__/admin/artist-form.test.tsx` - Artist form component tests
- `__tests__/admin/file-upload.test.tsx` - File upload functionality tests
- `__tests__/api/artists.test.ts` - Artist API endpoint tests
- `__tests__/lib/r2-upload.test.ts` - R2 upload utility tests
- `__tests__/lib/data-migration.test.ts` - Data migration tests
**Testing Strategy:**
- Unit tests for all new utility functions
- Component tests for admin dashboard components
- Integration tests for API endpoints with database operations
- E2E tests for complete admin workflows (create artist, upload portfolio, schedule appointment)
## Implementation Order
Sequential implementation steps to ensure proper integration and minimal conflicts.
1. **Database Migration and Population**
- Implement data migration utilities in `lib/data-migration.ts`
- Create migration script to populate D1 database with existing artist data
- Update database functions in `lib/db.ts` to work with runtime environment
- Test database operations with migrated data
2. **Cloudflare R2 File Upload System**
- Implement R2 upload utilities in `lib/r2-upload.ts`
- Create file upload hook in `hooks/use-file-upload.ts`
- Update upload API route in `app/api/upload/route.ts`
- Create file uploader component in `components/admin/file-uploader.tsx`
3. **Artist Management Interface**
- Create artist form component in `components/admin/artist-form.tsx`
- Implement artist management pages (`app/admin/artists/`)
- Create portfolio manager component in `components/admin/portfolio-manager.tsx`
- Update artist API routes with enhanced functionality
4. **Appointment Calendar System**
- Create calendar hook in `hooks/use-calendar.ts`
- Implement appointment calendar component in `components/admin/appointment-calendar.tsx`
- Create appointment API routes in `app/api/appointments/`
- Build calendar page in `app/admin/calendar/page.tsx`
5. **Admin Dashboard Enhancement**
- Create stats dashboard component in `components/admin/stats-dashboard.tsx`
- Implement settings management in `app/admin/settings/page.tsx`
- Create data table component in `components/admin/data-table.tsx`
- Update main dashboard page with real data integration
6. **Testing and Validation**
- Implement unit tests for all new functionality
- Create integration tests for API endpoints
- Add E2E tests for admin workflows
- Validate all CRUD operations and file uploads
7. **Final Integration and Optimization**
- Update sidebar navigation for all new pages
- Implement error handling and loading states
- Add proper TypeScript types throughout
- Optimize performance and add caching where appropriate

View File

@ -1,9 +1,13 @@
"use client"
import type React from "react"
import { SessionProvider } from "next-auth/react"
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
import { ReactQueryDevtools } from "@tanstack/react-query-devtools"
import { SmoothScrollProvider } from "@/components/smooth-scroll-provider"
import { Toaster } from "@/components/ui/sonner"
import { useSearchParams } from "next/navigation"
import { Suspense } from "react"
import { Suspense, useState } from "react"
import "./globals.css"
export default function ClientLayout({
@ -12,12 +16,39 @@ export default function ClientLayout({
children: React.ReactNode
}>) {
const searchParams = useSearchParams()
// Create a new QueryClient instance for each component tree
const [queryClient] = useState(
() =>
new QueryClient({
defaultOptions: {
queries: {
// With SSR, we usually want to set some default staleTime
// above 0 to avoid refetching immediately on the client
staleTime: 60 * 1000, // 1 minute
retry: (failureCount, error: any) => {
// Don't retry on 4xx errors
if (error?.status >= 400 && error?.status < 500) {
return false
}
return failureCount < 3
},
},
},
})
)
return (
<>
<Suspense fallback={<div>Loading...</div>}>
<SmoothScrollProvider>{children}</SmoothScrollProvider>
</Suspense>
</>
<SessionProvider>
<QueryClientProvider client={queryClient}>
<Suspense fallback={<div>Loading...</div>}>
<SmoothScrollProvider>
{children}
<Toaster />
</SmoothScrollProvider>
</Suspense>
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
</SessionProvider>
)
}

View File

@ -0,0 +1,76 @@
"use client"
import { useState, useEffect } from 'react'
import { useParams } from 'next/navigation'
import { ArtistForm } from '@/components/admin/artist-form'
import { useToast } from '@/hooks/use-toast'
import type { Artist } from '@/types/database'
export default function EditArtistPage() {
const params = useParams()
const { toast } = useToast()
const [artist, setArtist] = useState<Artist | null>(null)
const [loading, setLoading] = useState(true)
const fetchArtist = async () => {
try {
const response = await fetch(`/api/artists/${params.id}`)
if (!response.ok) throw new Error('Failed to fetch artist')
const data = await response.json()
setArtist(data.artist)
} catch (error) {
console.error('Error fetching artist:', error)
toast({
title: 'Error',
description: 'Failed to load artist',
variant: 'destructive',
})
} finally {
setLoading(false)
}
}
useEffect(() => {
if (params.id) {
fetchArtist()
}
}, [params.id])
if (loading) {
return (
<div className="flex items-center justify-center h-64">
<div className="text-lg">Loading artist...</div>
</div>
)
}
if (!artist) {
return (
<div className="flex items-center justify-center h-64">
<div className="text-lg">Artist not found</div>
</div>
)
}
return (
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold tracking-tight">Edit Artist</h1>
<p className="text-muted-foreground">
Update {artist.name}'s information and portfolio
</p>
</div>
<ArtistForm
artist={artist}
onSuccess={() => {
toast({
title: 'Success',
description: 'Artist updated successfully',
})
fetchArtist() // Refresh the data
}}
/>
</div>
)
}

View File

@ -0,0 +1,16 @@
import { ArtistForm } from '@/components/admin/artist-form'
export default function NewArtistPage() {
return (
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold tracking-tight">Create New Artist</h1>
<p className="text-muted-foreground">
Add a new artist to your tattoo studio
</p>
</div>
<ArtistForm />
</div>
)
}

397
app/admin/artists/page.tsx Normal file
View File

@ -0,0 +1,397 @@
"use client"
import { useState, useEffect } from 'react'
import { useRouter } from 'next/navigation'
import { Plus, MoreHorizontal, ArrowUpDown, ChevronDown } from 'lucide-react'
import {
ColumnDef,
ColumnFiltersState,
flexRender,
getCoreRowModel,
getFilteredRowModel,
getPaginationRowModel,
getSortedRowModel,
SortingState,
useReactTable,
VisibilityState,
} from "@tanstack/react-table"
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Badge } from '@/components/ui/badge'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { useToast } from '@/hooks/use-toast'
import type { Artist } from '@/types/database'
export default function ArtistsPage() {
const router = useRouter()
const { toast } = useToast()
const [artists, setArtists] = useState<Artist[]>([])
const [loading, setLoading] = useState(true)
const [sorting, setSorting] = useState<SortingState>([])
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([])
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({})
const [rowSelection, setRowSelection] = useState({})
// Define columns for the data table
const columns: ColumnDef<Artist>[] = [
{
accessorKey: "name",
header: ({ column }) => {
return (
<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: "specialties",
header: "Specialties",
cell: ({ row }) => {
const specialties = row.getValue("specialties") as string
const specialtiesArray = specialties ? JSON.parse(specialties) : []
return (
<div className="flex flex-wrap gap-1">
{specialtiesArray.slice(0, 2).map((specialty: string) => (
<Badge key={specialty} variant="secondary" className="text-xs">
{specialty}
</Badge>
))}
{specialtiesArray.length > 2 && (
<Badge variant="outline" className="text-xs">
+{specialtiesArray.length - 2}
</Badge>
)}
</div>
)
},
},
{
accessorKey: "hourlyRate",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
>
Rate
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
)
},
cell: ({ row }) => {
const rate = row.getValue("hourlyRate") as number
return rate ? `$${rate}/hr` : 'Not set'
},
},
{
accessorKey: "isActive",
header: "Status",
cell: ({ row }) => {
const isActive = row.getValue("isActive") as boolean
return (
<Badge variant={isActive ? "default" : "secondary"}>
{isActive ? "Active" : "Inactive"}
</Badge>
)
},
},
{
accessorKey: "createdAt",
header: "Created",
cell: ({ row }) => {
const date = new Date(row.getValue("createdAt"))
return date.toLocaleDateString()
},
},
{
id: "actions",
enableHiding: false,
cell: ({ row }) => {
const artist = row.original
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0">
<span className="sr-only">Open menu</span>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>Actions</DropdownMenuLabel>
<DropdownMenuItem
onClick={() => router.push(`/admin/artists/${artist.id}`)}
>
Edit artist
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => router.push(`/admin/artists/${artist.id}/portfolio`)}
>
Manage portfolio
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => handleToggleStatus(artist)}
className={artist.isActive ? "text-red-600" : "text-green-600"}
>
{artist.isActive ? "Deactivate" : "Activate"}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
},
},
]
const table = useReactTable({
data: artists,
columns,
onSortingChange: setSorting,
onColumnFiltersChange: setColumnFilters,
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
getSortedRowModel: getSortedRowModel(),
getFilteredRowModel: getFilteredRowModel(),
onColumnVisibilityChange: setColumnVisibility,
onRowSelectionChange: setRowSelection,
state: {
sorting,
columnFilters,
columnVisibility,
rowSelection,
},
})
const fetchArtists = async () => {
try {
const response = await fetch('/api/artists')
if (!response.ok) throw new Error('Failed to fetch artists')
const data = await response.json()
setArtists(data.artists || [])
} catch (error) {
console.error('Error fetching artists:', error)
toast({
title: 'Error',
description: 'Failed to load artists',
variant: 'destructive',
})
} finally {
setLoading(false)
}
}
const handleToggleStatus = async (artist: Artist) => {
try {
const response = await fetch(`/api/artists/${artist.id}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
isActive: !artist.isActive,
}),
})
if (!response.ok) throw new Error('Failed to update artist')
toast({
title: 'Success',
description: `Artist ${artist.isActive ? 'deactivated' : 'activated'} successfully`,
})
// Refresh the list
fetchArtists()
} catch (error) {
console.error('Error updating artist:', error)
toast({
title: 'Error',
description: 'Failed to update artist status',
variant: 'destructive',
})
}
}
useEffect(() => {
fetchArtists()
}, [])
if (loading) {
return (
<div className="flex items-center justify-center h-64">
<div className="text-lg">Loading artists...</div>
</div>
)
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold tracking-tight">Artists</h1>
<p className="text-muted-foreground">
Manage your tattoo artists and their information
</p>
</div>
<Button onClick={() => router.push('/admin/artists/new')}>
<Plus className="mr-2 h-4 w-4" />
Add Artist
</Button>
</div>
<Card>
<CardHeader>
<CardTitle>All Artists</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-4">
{/* Filters and Controls */}
<div className="flex items-center justify-between">
<div className="flex items-center space-x-2">
<Input
placeholder="Filter artists..."
value={(table.getColumn("name")?.getFilterValue() as string) ?? ""}
onChange={(event) =>
table.getColumn("name")?.setFilterValue(event.target.value)
}
className="max-w-sm"
/>
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline">
Columns <ChevronDown className="ml-2 h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{table
.getAllColumns()
.filter((column) => column.getCanHide())
.map((column) => {
return (
<DropdownMenuCheckboxItem
key={column.id}
className="capitalize"
checked={column.getIsVisible()}
onCheckedChange={(value) =>
column.toggleVisibility(!!value)
}
>
{column.id}
</DropdownMenuCheckboxItem>
)
})}
</DropdownMenuContent>
</DropdownMenu>
</div>
{/* Data Table */}
<div className="rounded-md border">
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => {
return (
<TableHead key={header.id}>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext()
)}
</TableHead>
)
})}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => (
<TableRow
key={row.id}
data-state={row.getIsSelected() && "selected"}
className="cursor-pointer"
onClick={() => router.push(`/admin/artists/${row.original.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 artists found.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
{/* Pagination */}
<div className="flex items-center justify-end space-x-2">
<div className="text-muted-foreground flex-1 text-sm">
{table.getFilteredSelectedRowModel().rows.length} of{" "}
{table.getFilteredRowModel().rows.length} row(s) selected.
</div>
<div className="space-x-2">
<Button
variant="outline"
size="sm"
onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
>
Previous
</Button>
<Button
variant="outline"
size="sm"
onClick={() => table.nextPage()}
disabled={!table.getCanNextPage()}
>
Next
</Button>
</div>
</div>
</div>
</CardContent>
</Card>
</div>
)
}

458
app/admin/calendar/page.tsx Normal file
View File

@ -0,0 +1,458 @@
'use client'
import { useState, useEffect } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { AppointmentCalendar } from '@/components/admin/appointment-calendar'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog'
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form'
import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Badge } from '@/components/ui/badge'
import { CalendarIcon, Plus, Users, Clock, CheckCircle, XCircle } from 'lucide-react'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
import { toast } from 'sonner'
import moment from 'moment'
const appointmentSchema = z.object({
artistId: z.string().min(1, 'Artist is required'),
clientName: z.string().min(1, 'Client name is required'),
clientEmail: z.string().email('Valid email is required'),
title: z.string().min(1, 'Title is required'),
description: z.string().optional(),
startTime: z.string().min(1, 'Start time is required'),
endTime: z.string().min(1, 'End time is required'),
depositAmount: z.number().optional(),
totalAmount: z.number().optional(),
notes: z.string().optional(),
})
type AppointmentFormData = z.infer<typeof appointmentSchema>
export default function CalendarPage() {
const [isNewAppointmentOpen, setIsNewAppointmentOpen] = useState(false)
const [selectedSlot, setSelectedSlot] = useState<{ start: Date; end: Date } | null>(null)
const queryClient = useQueryClient()
const form = useForm<AppointmentFormData>({
resolver: zodResolver(appointmentSchema),
defaultValues: {
artistId: '',
clientName: '',
clientEmail: '',
title: '',
description: '',
startTime: '',
endTime: '',
depositAmount: undefined,
totalAmount: undefined,
notes: '',
},
})
// Fetch appointments
const { data: appointmentsData, isLoading: appointmentsLoading } = useQuery({
queryKey: ['appointments'],
queryFn: async () => {
const response = await fetch('/api/appointments')
if (!response.ok) throw new Error('Failed to fetch appointments')
return response.json()
},
})
// Fetch artists
const { data: artistsData, isLoading: artistsLoading } = useQuery({
queryKey: ['artists'],
queryFn: async () => {
const response = await fetch('/api/artists')
if (!response.ok) throw new Error('Failed to fetch artists')
return response.json()
},
})
// Create appointment mutation
const createAppointmentMutation = useMutation({
mutationFn: async (data: AppointmentFormData) => {
// First, create or find the client user
const clientResponse = await fetch('/api/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: data.clientName,
email: data.clientEmail,
role: 'CLIENT',
}),
})
let clientId
if (clientResponse.ok) {
const client = await clientResponse.json()
clientId = client.user.id
} else {
// If user already exists, try to find them
const existingUserResponse = await fetch(`/api/users?email=${encodeURIComponent(data.clientEmail)}`)
if (existingUserResponse.ok) {
const existingUser = await existingUserResponse.json()
clientId = existingUser.user.id
} else {
throw new Error('Failed to create or find client')
}
}
// Create the appointment
const appointmentResponse = await fetch('/api/appointments', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
...data,
clientId,
startTime: new Date(data.startTime).toISOString(),
endTime: new Date(data.endTime).toISOString(),
}),
})
if (!appointmentResponse.ok) {
const error = await appointmentResponse.json()
throw new Error(error.error || 'Failed to create appointment')
}
return appointmentResponse.json()
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['appointments'] })
setIsNewAppointmentOpen(false)
form.reset()
toast.success('Appointment created successfully')
},
onError: (error: Error) => {
toast.error(error.message)
},
})
// Update appointment mutation
const updateAppointmentMutation = useMutation({
mutationFn: async ({ id, updates }: { id: string; updates: any }) => {
const response = await fetch('/api/appointments', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id, ...updates }),
})
if (!response.ok) {
const error = await response.json()
throw new Error(error.error || 'Failed to update appointment')
}
return response.json()
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['appointments'] })
toast.success('Appointment updated successfully')
},
onError: (error: Error) => {
toast.error(error.message)
},
})
// Handle slot selection for new appointment
const handleSlotSelect = (slotInfo: { start: Date; end: Date; slots: Date[] }) => {
setSelectedSlot({ start: slotInfo.start, end: slotInfo.end })
form.setValue('startTime', moment(slotInfo.start).format('YYYY-MM-DDTHH:mm'))
form.setValue('endTime', moment(slotInfo.end).format('YYYY-MM-DDTHH:mm'))
setIsNewAppointmentOpen(true)
}
// Handle event update
const handleEventUpdate = (eventId: string, updates: any) => {
updateAppointmentMutation.mutate({ id: eventId, updates })
}
const onSubmit = (data: AppointmentFormData) => {
createAppointmentMutation.mutate(data)
}
const appointments = appointmentsData?.appointments || []
const artists = artistsData?.artists || []
// Calculate stats
const stats = {
total: appointments.length,
pending: appointments.filter((apt: any) => apt.status === 'PENDING').length,
confirmed: appointments.filter((apt: any) => apt.status === 'CONFIRMED').length,
completed: appointments.filter((apt: any) => apt.status === 'COMPLETED').length,
}
if (appointmentsLoading || artistsLoading) {
return (
<div className="flex items-center justify-center h-64">
<div className="text-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto"></div>
<p className="mt-2 text-sm text-muted-foreground">Loading calendar...</p>
</div>
</div>
)
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex flex-col sm:flex-row gap-4 items-start sm:items-center justify-between">
<div>
<h1 className="text-2xl font-bold">Appointment Calendar</h1>
<p className="text-muted-foreground">Manage studio appointments and scheduling</p>
</div>
<Dialog open={isNewAppointmentOpen} onOpenChange={setIsNewAppointmentOpen}>
<DialogTrigger asChild>
<Button>
<Plus className="h-4 w-4 mr-2" />
New Appointment
</Button>
</DialogTrigger>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>Create New Appointment</DialogTitle>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField
control={form.control}
name="artistId"
render={({ field }) => (
<FormItem>
<FormLabel>Artist</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select an artist" />
</SelectTrigger>
</FormControl>
<SelectContent>
{artists.map((artist: any) => (
<SelectItem key={artist.id} value={artist.id}>
{artist.name}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<div className="grid grid-cols-2 gap-4">
<FormField
control={form.control}
name="clientName"
render={({ field }) => (
<FormItem>
<FormLabel>Client Name</FormLabel>
<FormControl>
<Input placeholder="John Doe" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="clientEmail"
render={({ field }) => (
<FormItem>
<FormLabel>Client Email</FormLabel>
<FormControl>
<Input type="email" placeholder="john@example.com" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField
control={form.control}
name="title"
render={({ field }) => (
<FormItem>
<FormLabel>Appointment Title</FormLabel>
<FormControl>
<Input placeholder="Tattoo Session" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="description"
render={({ field }) => (
<FormItem>
<FormLabel>Description</FormLabel>
<FormControl>
<Textarea placeholder="Appointment details..." {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="grid grid-cols-2 gap-4">
<FormField
control={form.control}
name="startTime"
render={({ field }) => (
<FormItem>
<FormLabel>Start Time</FormLabel>
<FormControl>
<Input type="datetime-local" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="endTime"
render={({ field }) => (
<FormItem>
<FormLabel>End Time</FormLabel>
<FormControl>
<Input type="datetime-local" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<FormField
control={form.control}
name="depositAmount"
render={({ field }) => (
<FormItem>
<FormLabel>Deposit Amount</FormLabel>
<FormControl>
<Input
type="number"
step="0.01"
placeholder="0.00"
{...field}
onChange={(e) => field.onChange(e.target.value ? parseFloat(e.target.value) : undefined)}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="totalAmount"
render={({ field }) => (
<FormItem>
<FormLabel>Total Amount</FormLabel>
<FormControl>
<Input
type="number"
step="0.01"
placeholder="0.00"
{...field}
onChange={(e) => field.onChange(e.target.value ? parseFloat(e.target.value) : undefined)}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField
control={form.control}
name="notes"
render={({ field }) => (
<FormItem>
<FormLabel>Notes</FormLabel>
<FormControl>
<Textarea placeholder="Additional notes..." {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="flex justify-end gap-2">
<Button type="button" variant="outline" onClick={() => setIsNewAppointmentOpen(false)}>
Cancel
</Button>
<Button type="submit" disabled={createAppointmentMutation.isPending}>
{createAppointmentMutation.isPending ? 'Creating...' : 'Create Appointment'}
</Button>
</div>
</form>
</Form>
</DialogContent>
</Dialog>
</div>
{/* Stats Cards */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Total Appointments</CardTitle>
<CalendarIcon className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{stats.total}</div>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Pending</CardTitle>
<Clock className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-yellow-600">{stats.pending}</div>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Confirmed</CardTitle>
<CheckCircle className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-blue-600">{stats.confirmed}</div>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Completed</CardTitle>
<Users className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-green-600">{stats.completed}</div>
</CardContent>
</Card>
</div>
{/* Calendar */}
<AppointmentCalendar
appointments={appointments}
artists={artists}
onSlotSelect={handleSlotSelect}
onEventUpdate={handleEventUpdate}
/>
</div>
)
}

65
app/admin/layout.tsx Normal file
View File

@ -0,0 +1,65 @@
import { redirect } from "next/navigation"
import { getServerSession } from "next-auth/next"
import { authOptions } from "@/lib/auth"
import { UserRole } from "@/types/database"
import { AdminSidebar } from "@/components/admin/sidebar"
export default async function AdminLayout({
children,
}: {
children: React.ReactNode
}) {
// Check authentication and authorization
const session = await getServerSession(authOptions)
if (!session) {
redirect("/auth/signin")
}
// Check if user has admin role
if (session.user.role !== UserRole.SHOP_ADMIN && session.user.role !== UserRole.SUPER_ADMIN) {
redirect("/unauthorized")
}
return (
<div className="flex h-screen bg-gray-100">
{/* Sidebar */}
<AdminSidebar user={session.user} />
{/* Main content */}
<div className="flex-1 flex flex-col overflow-hidden">
{/* Header */}
<header className="bg-white shadow-sm border-b border-gray-200">
<div className="flex items-center justify-between px-6 py-4">
<h1 className="text-2xl font-semibold text-gray-900">
Admin Dashboard
</h1>
<div className="flex items-center space-x-4">
<span className="text-sm text-gray-600">
Welcome, {session.user.name}
</span>
<div className="w-8 h-8 bg-gray-300 rounded-full flex items-center justify-center">
{session.user.image ? (
<img
src={session.user.image}
alt={session.user.name}
className="w-8 h-8 rounded-full"
/>
) : (
<span className="text-sm font-medium text-gray-600">
{session.user.name.charAt(0).toUpperCase()}
</span>
)}
</div>
</div>
</div>
</header>
{/* Page content */}
<main className="flex-1 overflow-y-auto p-6">
{children}
</main>
</div>
</div>
)
}

149
app/admin/page.tsx Normal file
View File

@ -0,0 +1,149 @@
'use client'
import { StatsDashboard } from '@/components/admin/stats-dashboard'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Users, Calendar, Settings, Upload, BarChart3, Plus } from 'lucide-react'
import Link from 'next/link'
export default function AdminDashboard() {
return (
<div className="space-y-6">
{/* Header */}
<div className="flex flex-col sm:flex-row gap-4 items-start sm:items-center justify-between">
<div>
<h1 className="text-2xl font-bold">Admin Dashboard</h1>
<p className="text-muted-foreground">
Welcome to United Tattoo Studio admin panel
</p>
</div>
<div className="flex gap-2">
<Link href="/admin/artists/new">
<Button>
<Plus className="h-4 w-4 mr-2" />
Add Artist
</Button>
</Link>
<Link href="/admin/calendar">
<Button variant="outline">
<Calendar className="h-4 w-4 mr-2" />
Schedule
</Button>
</Link>
</div>
</div>
{/* Quick Actions */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<Link href="/admin/artists">
<Card className="hover:shadow-md transition-shadow cursor-pointer">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Manage Artists</CardTitle>
<Users className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<p className="text-xs text-muted-foreground">
Add, edit, and manage artist profiles and portfolios
</p>
</CardContent>
</Card>
</Link>
<Link href="/admin/calendar">
<Card className="hover:shadow-md transition-shadow cursor-pointer">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Appointments</CardTitle>
<Calendar className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<p className="text-xs text-muted-foreground">
View and manage studio appointments and scheduling
</p>
</CardContent>
</Card>
</Link>
<Link href="/admin/uploads">
<Card className="hover:shadow-md transition-shadow cursor-pointer">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">File Manager</CardTitle>
<Upload className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<p className="text-xs text-muted-foreground">
Upload and manage portfolio images and files
</p>
</CardContent>
</Card>
</Link>
<Link href="/admin/settings">
<Card className="hover:shadow-md transition-shadow cursor-pointer">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Studio Settings</CardTitle>
<Settings className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<p className="text-xs text-muted-foreground">
Configure studio information and preferences
</p>
</CardContent>
</Card>
</Link>
</div>
{/* Analytics Dashboard */}
<div>
<div className="flex items-center gap-2 mb-4">
<BarChart3 className="h-5 w-5" />
<h2 className="text-lg font-semibold">Analytics & Statistics</h2>
</div>
<StatsDashboard />
</div>
{/* Recent Activity */}
<Card>
<CardHeader>
<CardTitle>Recent Activity</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-4">
<div className="flex items-center justify-between py-2 border-b">
<div className="flex items-center gap-3">
<div className="w-2 h-2 bg-green-500 rounded-full"></div>
<span className="text-sm">New appointment scheduled</span>
</div>
<Badge variant="secondary">2 min ago</Badge>
</div>
<div className="flex items-center justify-between py-2 border-b">
<div className="flex items-center gap-3">
<div className="w-2 h-2 bg-blue-500 rounded-full"></div>
<span className="text-sm">Portfolio image uploaded</span>
</div>
<Badge variant="secondary">15 min ago</Badge>
</div>
<div className="flex items-center justify-between py-2 border-b">
<div className="flex items-center gap-3">
<div className="w-2 h-2 bg-yellow-500 rounded-full"></div>
<span className="text-sm">Artist profile updated</span>
</div>
<Badge variant="secondary">1 hour ago</Badge>
</div>
<div className="flex items-center justify-between py-2">
<div className="flex items-center gap-3">
<div className="w-2 h-2 bg-purple-500 rounded-full"></div>
<span className="text-sm">New client registered</span>
</div>
<Badge variant="secondary">3 hours ago</Badge>
</div>
</div>
</CardContent>
</Card>
</div>
)
}

View File

@ -0,0 +1,82 @@
import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { migrateArtistData, getMigrationStats, clearMigratedData } from '@/lib/data-migration'
export async function POST(request: NextRequest) {
try {
// Check authentication and admin role
const session = await getServerSession(authOptions)
if (!session?.user || session.user.role !== 'SUPER_ADMIN') {
return NextResponse.json(
{ error: 'Unauthorized. Admin access required.' },
{ status: 401 }
)
}
const { action } = await request.json()
switch (action) {
case 'migrate':
await migrateArtistData()
const stats = await getMigrationStats()
return NextResponse.json({
success: true,
message: 'Artist data migration completed successfully',
stats
})
case 'clear':
await clearMigratedData()
return NextResponse.json({
success: true,
message: 'Migrated data cleared successfully'
})
default:
return NextResponse.json(
{ error: 'Invalid action. Use "migrate" or "clear".' },
{ status: 400 }
)
}
} catch (error) {
console.error('Migration API error:', error)
return NextResponse.json(
{
error: 'Migration failed',
details: error instanceof Error ? error.message : 'Unknown error'
},
{ status: 500 }
)
}
}
export async function GET(request: NextRequest) {
try {
// Check authentication and admin role
const session = await getServerSession(authOptions)
if (!session?.user || session.user.role !== 'SUPER_ADMIN') {
return NextResponse.json(
{ error: 'Unauthorized. Admin access required.' },
{ status: 401 }
)
}
// Get migration statistics
const stats = await getMigrationStats()
return NextResponse.json({
success: true,
stats
})
} catch (error) {
console.error('Migration stats API error:', error)
return NextResponse.json(
{
error: 'Failed to get migration stats',
details: error instanceof Error ? error.message : 'Unknown error'
},
{ status: 500 }
)
}
}

View File

@ -0,0 +1,143 @@
import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { getDB } from '@/lib/db'
export async function GET(request: NextRequest) {
try {
const session = await getServerSession(authOptions)
if (!session?.user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const db = getDB()
// Get artist statistics
const artistStats = await db.prepare(`
SELECT
COUNT(*) as total,
SUM(CASE WHEN is_active = 1 THEN 1 ELSE 0 END) as active,
SUM(CASE WHEN is_active = 0 THEN 1 ELSE 0 END) as inactive
FROM artists
`).first()
// Get appointment statistics
const appointmentStats = await db.prepare(`
SELECT
COUNT(*) as total,
SUM(CASE WHEN status = 'PENDING' THEN 1 ELSE 0 END) as pending,
SUM(CASE WHEN status = 'CONFIRMED' THEN 1 ELSE 0 END) as confirmed,
SUM(CASE WHEN status = 'IN_PROGRESS' THEN 1 ELSE 0 END) as inProgress,
SUM(CASE WHEN status = 'COMPLETED' THEN 1 ELSE 0 END) as completed,
SUM(CASE WHEN status = 'CANCELLED' THEN 1 ELSE 0 END) as cancelled,
SUM(CASE WHEN strftime('%Y-%m', start_time) = strftime('%Y-%m', 'now') THEN 1 ELSE 0 END) as thisMonth,
SUM(CASE WHEN strftime('%Y-%m', start_time) = strftime('%Y-%m', 'now', '-1 month') THEN 1 ELSE 0 END) as lastMonth,
SUM(CASE WHEN status = 'COMPLETED' THEN COALESCE(total_amount, 0) ELSE 0 END) as revenue
FROM appointments
`).first()
// Get portfolio statistics
const portfolioStats = await db.prepare(`
SELECT
COUNT(*) as totalImages,
SUM(CASE WHEN date(created_at) >= date('now', '-7 days') THEN 1 ELSE 0 END) as recentUploads
FROM portfolio_images
WHERE is_public = 1
`).first()
// Get file upload statistics
const fileStats = await db.prepare(`
SELECT
COUNT(*) as totalUploads,
SUM(size) as totalSize,
SUM(CASE WHEN date(created_at) >= date('now', '-7 days') THEN 1 ELSE 0 END) as recentUploads
FROM file_uploads
`).first()
// Get monthly appointment data for the last 6 months
const monthlyData = await db.prepare(`
SELECT
strftime('%Y-%m', start_time) as month,
COUNT(*) as appointments,
SUM(CASE WHEN status = 'COMPLETED' THEN COALESCE(total_amount, 0) ELSE 0 END) as revenue
FROM appointments
WHERE start_time >= date('now', '-6 months')
GROUP BY strftime('%Y-%m', start_time)
ORDER BY month
`).all()
// Format monthly data
const formattedMonthlyData = (monthlyData.results || []).map((row: any) => ({
month: new Date(row.month + '-01').toLocaleDateString('en-US', { month: 'short', year: 'numeric' }),
appointments: row.appointments || 0,
revenue: row.revenue || 0,
}))
// Create status distribution data
const statusData = [
{
name: 'Pending',
value: (appointmentStats as any)?.pending || 0,
color: '#f59e0b',
},
{
name: 'Confirmed',
value: (appointmentStats as any)?.confirmed || 0,
color: '#3b82f6',
},
{
name: 'In Progress',
value: (appointmentStats as any)?.inProgress || 0,
color: '#10b981',
},
{
name: 'Completed',
value: (appointmentStats as any)?.completed || 0,
color: '#6b7280',
},
{
name: 'Cancelled',
value: (appointmentStats as any)?.cancelled || 0,
color: '#ef4444',
},
].filter(item => item.value > 0) // Only include statuses with values
const stats = {
artists: {
total: (artistStats as any)?.total || 0,
active: (artistStats as any)?.active || 0,
inactive: (artistStats as any)?.inactive || 0,
},
appointments: {
total: (appointmentStats as any)?.total || 0,
pending: (appointmentStats as any)?.pending || 0,
confirmed: (appointmentStats as any)?.confirmed || 0,
inProgress: (appointmentStats as any)?.inProgress || 0,
completed: (appointmentStats as any)?.completed || 0,
cancelled: (appointmentStats as any)?.cancelled || 0,
thisMonth: (appointmentStats as any)?.thisMonth || 0,
lastMonth: (appointmentStats as any)?.lastMonth || 0,
revenue: (appointmentStats as any)?.revenue || 0,
},
portfolio: {
totalImages: (portfolioStats as any)?.totalImages || 0,
recentUploads: (portfolioStats as any)?.recentUploads || 0,
},
files: {
totalUploads: (fileStats as any)?.totalUploads || 0,
totalSize: (fileStats as any)?.totalSize || 0,
recentUploads: (fileStats as any)?.recentUploads || 0,
},
monthlyData: formattedMonthlyData,
statusData,
}
return NextResponse.json(stats)
} catch (error) {
console.error('Error fetching dashboard stats:', error)
return NextResponse.json(
{ error: 'Failed to fetch dashboard statistics' },
{ status: 500 }
)
}
}

View File

@ -0,0 +1,328 @@
import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { getDB } from '@/lib/db'
import { z } from 'zod'
const createAppointmentSchema = z.object({
artistId: z.string().min(1),
clientId: z.string().min(1),
title: z.string().min(1),
description: z.string().optional(),
startTime: z.string().datetime(),
endTime: z.string().datetime(),
depositAmount: z.number().optional(),
totalAmount: z.number().optional(),
notes: z.string().optional(),
})
const updateAppointmentSchema = createAppointmentSchema.partial().extend({
id: z.string().min(1),
status: z.enum(['PENDING', 'CONFIRMED', 'IN_PROGRESS', 'COMPLETED', 'CANCELLED']).optional(),
})
export async function GET(request: NextRequest) {
try {
const session = await getServerSession(authOptions)
if (!session?.user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { searchParams } = new URL(request.url)
const start = searchParams.get('start')
const end = searchParams.get('end')
const artistId = searchParams.get('artistId')
const status = searchParams.get('status')
const db = getDB()
let query = `
SELECT
a.*,
ar.name as artist_name,
u.name as client_name,
u.email as client_email
FROM appointments a
JOIN artists ar ON a.artist_id = ar.id
JOIN users u ON a.client_id = u.id
WHERE 1=1
`
const params: any[] = []
if (start) {
query += ` AND a.start_time >= ?`
params.push(start)
}
if (end) {
query += ` AND a.end_time <= ?`
params.push(end)
}
if (artistId) {
query += ` AND a.artist_id = ?`
params.push(artistId)
}
if (status) {
query += ` AND a.status = ?`
params.push(status)
}
query += ` ORDER BY a.start_time ASC`
const stmt = db.prepare(query)
const result = await stmt.bind(...params).all()
return NextResponse.json({ appointments: result.results })
} catch (error) {
console.error('Error fetching appointments:', error)
return NextResponse.json(
{ error: 'Failed to fetch appointments' },
{ status: 500 }
)
}
}
export async function POST(request: NextRequest) {
try {
const session = await getServerSession(authOptions)
if (!session?.user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const body = await request.json()
const validatedData = createAppointmentSchema.parse(body)
// Check for scheduling conflicts
const db = getDB()
const conflictCheck = db.prepare(`
SELECT id FROM appointments
WHERE artist_id = ?
AND status NOT IN ('CANCELLED', 'COMPLETED')
AND (
(start_time <= ? AND end_time > ?) OR
(start_time < ? AND end_time >= ?) OR
(start_time >= ? AND end_time <= ?)
)
`)
const conflictResult = await conflictCheck.bind(
validatedData.artistId,
validatedData.startTime, validatedData.startTime,
validatedData.endTime, validatedData.endTime,
validatedData.startTime, validatedData.endTime
).all()
if (conflictResult.results.length > 0) {
return NextResponse.json(
{ error: 'Time slot conflicts with existing appointment' },
{ status: 409 }
)
}
const appointmentId = crypto.randomUUID()
const insertStmt = db.prepare(`
INSERT INTO appointments (
id, artist_id, client_id, title, description, start_time, end_time,
status, deposit_amount, total_amount, notes, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, 'PENDING', ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
`)
await insertStmt.bind(
appointmentId,
validatedData.artistId,
validatedData.clientId,
validatedData.title,
validatedData.description || null,
validatedData.startTime,
validatedData.endTime,
validatedData.depositAmount || null,
validatedData.totalAmount || null,
validatedData.notes || null
).run()
// Fetch the created appointment with related data
const selectStmt = db.prepare(`
SELECT
a.*,
ar.name as artist_name,
u.name as client_name,
u.email as client_email
FROM appointments a
JOIN artists ar ON a.artist_id = ar.id
JOIN users u ON a.client_id = u.id
WHERE a.id = ?
`)
const appointment = await selectStmt.bind(appointmentId).first()
return NextResponse.json({ appointment }, { status: 201 })
} catch (error) {
console.error('Error creating appointment:', error)
if (error instanceof z.ZodError) {
return NextResponse.json(
{ error: 'Invalid appointment data', details: error.errors },
{ status: 400 }
)
}
return NextResponse.json(
{ error: 'Failed to create appointment' },
{ status: 500 }
)
}
}
export async function PUT(request: NextRequest) {
try {
const session = await getServerSession(authOptions)
if (!session?.user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const body = await request.json()
const validatedData = updateAppointmentSchema.parse(body)
const db = getDB()
// Check if appointment exists
const existingStmt = db.prepare('SELECT * FROM appointments WHERE id = ?')
const existing = await existingStmt.bind(validatedData.id).first()
if (!existing) {
return NextResponse.json(
{ error: 'Appointment not found' },
{ status: 404 }
)
}
// Check for conflicts if time is being changed
if (validatedData.startTime || validatedData.endTime) {
const startTime = validatedData.startTime || existing.start_time
const endTime = validatedData.endTime || existing.end_time
const artistId = validatedData.artistId || existing.artist_id
const conflictCheck = db.prepare(`
SELECT id FROM appointments
WHERE artist_id = ?
AND id != ?
AND status NOT IN ('CANCELLED', 'COMPLETED')
AND (
(start_time <= ? AND end_time > ?) OR
(start_time < ? AND end_time >= ?) OR
(start_time >= ? AND end_time <= ?)
)
`)
const conflictResult = await conflictCheck.bind(
artistId, validatedData.id,
startTime, startTime,
endTime, endTime,
startTime, endTime
).all()
if (conflictResult.results.length > 0) {
return NextResponse.json(
{ error: 'Time slot conflicts with existing appointment' },
{ status: 409 }
)
}
}
// Build update query dynamically
const updateFields = []
const updateValues = []
Object.entries(validatedData).forEach(([key, value]) => {
if (key !== 'id' && value !== undefined) {
const dbKey = key.replace(/([A-Z])/g, '_$1').toLowerCase()
updateFields.push(`${dbKey} = ?`)
updateValues.push(value)
}
})
if (updateFields.length === 0) {
return NextResponse.json(
{ error: 'No fields to update' },
{ status: 400 }
)
}
updateFields.push('updated_at = CURRENT_TIMESTAMP')
updateValues.push(validatedData.id)
const updateStmt = db.prepare(`
UPDATE appointments
SET ${updateFields.join(', ')}
WHERE id = ?
`)
await updateStmt.bind(...updateValues).run()
// Fetch updated appointment
const selectStmt = db.prepare(`
SELECT
a.*,
ar.name as artist_name,
u.name as client_name,
u.email as client_email
FROM appointments a
JOIN artists ar ON a.artist_id = ar.id
JOIN users u ON a.client_id = u.id
WHERE a.id = ?
`)
const appointment = await selectStmt.bind(validatedData.id).first()
return NextResponse.json({ appointment })
} catch (error) {
console.error('Error updating appointment:', error)
if (error instanceof z.ZodError) {
return NextResponse.json(
{ error: 'Invalid appointment data', details: error.errors },
{ status: 400 }
)
}
return NextResponse.json(
{ error: 'Failed to update appointment' },
{ status: 500 }
)
}
}
export async function DELETE(request: NextRequest) {
try {
const session = await getServerSession(authOptions)
if (!session?.user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { searchParams } = new URL(request.url)
const id = searchParams.get('id')
if (!id) {
return NextResponse.json(
{ error: 'Appointment ID is required' },
{ status: 400 }
)
}
const db = getDB()
const deleteStmt = db.prepare('DELETE FROM appointments WHERE id = ?')
const result = await deleteStmt.bind(id).run()
if (result.changes === 0) {
return NextResponse.json(
{ error: 'Appointment not found' },
{ status: 404 }
)
}
return NextResponse.json({ success: true })
} catch (error) {
console.error('Error deleting appointment:', error)
return NextResponse.json(
{ error: 'Failed to delete appointment' },
{ status: 500 }
)
}
}

View File

@ -0,0 +1,164 @@
import { NextRequest, NextResponse } from "next/server"
import { requireAuth } from "@/lib/auth"
import { UserRole } from "@/types/database"
import { updateArtistSchema } from "@/lib/validations"
import { db } from "@/lib/db"
// GET /api/artists/[id] - Fetch a specific artist
export async function GET(
request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const { id } = params
// TODO: Implement via Supabase MCP
// const artist = await db.artists.findUnique(id)
// Mock response for now
const mockArtist = {
id,
userId: "user-1",
name: "Alex Rivera",
bio: "Specializing in traditional and neo-traditional tattoos with over 8 years of experience.",
specialties: ["Traditional", "Neo-Traditional", "Color Work"],
instagramHandle: "alexrivera_tattoo",
isActive: true,
hourlyRate: 150,
portfolioImages: [
{
id: "img-1",
artistId: id,
url: "/artists/alex-rivera-traditional-rose.jpg",
caption: "Traditional rose tattoo",
tags: ["traditional", "rose", "color"],
order: 1,
isPublic: true,
createdAt: new Date(),
},
],
availability: [],
createdAt: new Date(),
updatedAt: new Date(),
}
if (!mockArtist) {
return NextResponse.json(
{ error: "Artist not found" },
{ status: 404 }
)
}
return NextResponse.json(mockArtist)
} catch (error) {
console.error("Error fetching artist:", error)
return NextResponse.json(
{ error: "Failed to fetch artist" },
{ status: 500 }
)
}
}
// PUT /api/artists/[id] - Update a specific artist (Admin only)
export async function PUT(
request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
// Require admin authentication
const session = await requireAuth(UserRole.SHOP_ADMIN)
const { id } = params
const body = await request.json()
const validatedData = updateArtistSchema.parse({ ...body, id })
// TODO: Implement via Supabase MCP
// const updatedArtist = await db.artists.update(id, validatedData)
// Mock response for now
const mockUpdatedArtist = {
id,
userId: "user-1",
name: validatedData.name || "Alex Rivera",
bio: validatedData.bio || "Updated bio",
specialties: validatedData.specialties || ["Traditional"],
instagramHandle: validatedData.instagramHandle,
isActive: validatedData.isActive ?? true,
hourlyRate: validatedData.hourlyRate,
portfolioImages: [],
availability: [],
createdAt: new Date("2024-01-01"),
updatedAt: new Date(),
}
return NextResponse.json(mockUpdatedArtist)
} catch (error) {
console.error("Error updating artist:", error)
if (error instanceof Error) {
if (error.message.includes("Authentication required")) {
return NextResponse.json(
{ error: "Authentication required" },
{ status: 401 }
)
}
if (error.message.includes("Insufficient permissions")) {
return NextResponse.json(
{ error: "Insufficient permissions" },
{ status: 403 }
)
}
}
return NextResponse.json(
{ error: "Failed to update artist" },
{ status: 500 }
)
}
}
// DELETE /api/artists/[id] - Delete a specific artist (Admin only)
export async function DELETE(
request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
// Require admin authentication
await requireAuth(UserRole.SHOP_ADMIN)
const { id } = params
// TODO: Implement via Supabase MCP
// await db.artists.delete(id)
// Mock response for now
console.log(`Artist ${id} would be deleted`)
return NextResponse.json(
{ message: "Artist deleted successfully" },
{ status: 200 }
)
} catch (error) {
console.error("Error deleting artist:", error)
if (error instanceof Error) {
if (error.message.includes("Authentication required")) {
return NextResponse.json(
{ error: "Authentication required" },
{ status: 401 }
)
}
if (error.message.includes("Insufficient permissions")) {
return NextResponse.json(
{ error: "Insufficient permissions" },
{ status: 403 }
)
}
}
return NextResponse.json(
{ error: "Failed to delete artist" },
{ status: 500 }
)
}
}

115
app/api/artists/route.ts Normal file
View File

@ -0,0 +1,115 @@
import { NextRequest, NextResponse } from "next/server"
import { requireAuth } from "@/lib/auth"
import { UserRole } from "@/types/database"
import { createArtistSchema, paginationSchema, artistFiltersSchema } from "@/lib/validations"
import { db } from "@/lib/db"
// GET /api/artists - Fetch all artists with optional filtering and pagination
export async function GET(request: NextRequest) {
try {
const { searchParams } = new URL(request.url)
// Parse and validate query parameters
const pagination = paginationSchema.parse({
page: searchParams.get("page") || "1",
limit: searchParams.get("limit") || "10",
})
const filters = artistFiltersSchema.parse({
isActive: searchParams.get("isActive"),
specialty: searchParams.get("specialty"),
search: searchParams.get("search"),
})
// Fetch artists from database
const artists = await db.artists.findMany()
// Apply filters
let filteredArtists = artists
if (filters.isActive !== undefined) {
filteredArtists = filteredArtists.filter(artist =>
artist.isActive === filters.isActive
)
}
if (filters.specialty) {
filteredArtists = filteredArtists.filter(artist =>
artist.specialties.some(specialty =>
specialty.toLowerCase().includes(filters.specialty!.toLowerCase())
)
)
}
if (filters.search) {
const searchTerm = filters.search.toLowerCase()
filteredArtists = filteredArtists.filter(artist =>
artist.name.toLowerCase().includes(searchTerm) ||
artist.bio.toLowerCase().includes(searchTerm)
)
}
// Apply pagination
const startIndex = (pagination.page - 1) * pagination.limit
const endIndex = startIndex + pagination.limit
const paginatedArtists = filteredArtists.slice(startIndex, endIndex)
return NextResponse.json({
artists: paginatedArtists,
pagination: {
page: pagination.page,
limit: pagination.limit,
total: filteredArtists.length,
totalPages: Math.ceil(filteredArtists.length / pagination.limit),
},
filters,
})
} catch (error) {
console.error("Error fetching artists:", error)
return NextResponse.json(
{ error: "Failed to fetch artists" },
{ status: 500 }
)
}
}
// POST /api/artists - Create a new artist (Admin only)
export async function POST(request: NextRequest) {
try {
// Require admin authentication
const session = await requireAuth(UserRole.SHOP_ADMIN)
const body = await request.json()
const validatedData = createArtistSchema.parse(body)
// Create new artist in database
const newArtist = await db.artists.create({
...validatedData,
userId: session.user.id,
})
return NextResponse.json(newArtist, { status: 201 })
} catch (error) {
console.error("Error creating artist:", error)
if (error instanceof Error) {
if (error.message.includes("Authentication required")) {
return NextResponse.json(
{ error: "Authentication required" },
{ status: 401 }
)
}
if (error.message.includes("Insufficient permissions")) {
return NextResponse.json(
{ error: "Insufficient permissions" },
{ status: 403 }
)
}
}
return NextResponse.json(
{ error: "Failed to create artist" },
{ status: 500 }
)
}
}

View File

@ -0,0 +1,6 @@
import NextAuth from "next-auth"
import { authOptions } from "@/lib/auth"
const handler = NextAuth(authOptions)
export { handler as GET, handler as POST }

177
app/api/settings/route.ts Normal file
View File

@ -0,0 +1,177 @@
import { NextRequest, NextResponse } from "next/server"
import { requireAuth } from "@/lib/auth"
import { UserRole } from "@/types/database"
import { updateSiteSettingsSchema } from "@/lib/validations"
import { db } from "@/lib/db"
// GET /api/settings - Fetch site settings (public endpoint)
export async function GET(request: NextRequest) {
try {
// TODO: Implement via Supabase MCP
// const settings = await db.siteSettings.findFirst()
// Mock response for now
const mockSettings = {
id: "settings-1",
studioName: "United Tattoo Studio",
description: "Premier tattoo studio specializing in custom artwork and professional tattooing services.",
address: "123 Main Street, Denver, CO 80202",
phone: "+1 (555) 123-4567",
email: "info@unitedtattoo.com",
socialMedia: {
instagram: "https://instagram.com/unitedtattoo",
facebook: "https://facebook.com/unitedtattoo",
twitter: "https://twitter.com/unitedtattoo",
tiktok: "https://tiktok.com/@unitedtattoo",
},
businessHours: [
{ dayOfWeek: 1, openTime: "10:00", closeTime: "20:00", isClosed: false }, // Monday
{ dayOfWeek: 2, openTime: "10:00", closeTime: "20:00", isClosed: false }, // Tuesday
{ dayOfWeek: 3, openTime: "10:00", closeTime: "20:00", isClosed: false }, // Wednesday
{ dayOfWeek: 4, openTime: "10:00", closeTime: "20:00", isClosed: false }, // Thursday
{ dayOfWeek: 5, openTime: "10:00", closeTime: "22:00", isClosed: false }, // Friday
{ dayOfWeek: 6, openTime: "10:00", closeTime: "22:00", isClosed: false }, // Saturday
{ dayOfWeek: 0, openTime: "12:00", closeTime: "18:00", isClosed: false }, // Sunday
],
heroImage: "/united-studio-main.jpg",
logoUrl: "/united-logo-website.jpg",
updatedAt: new Date(),
}
return NextResponse.json(mockSettings)
} catch (error) {
console.error("Error fetching site settings:", error)
return NextResponse.json(
{ error: "Failed to fetch site settings" },
{ status: 500 }
)
}
}
// PUT /api/settings - Update site settings (Admin only)
export async function PUT(request: NextRequest) {
try {
// Require admin authentication
await requireAuth(UserRole.SHOP_ADMIN)
const body = await request.json()
const validatedData = updateSiteSettingsSchema.parse(body)
// TODO: Implement via Supabase MCP
// const updatedSettings = await db.siteSettings.update(validatedData)
// Mock response for now
const mockUpdatedSettings = {
id: "settings-1",
studioName: validatedData.studioName || "United Tattoo Studio",
description: validatedData.description || "Premier tattoo studio specializing in custom artwork and professional tattooing services.",
address: validatedData.address || "123 Main Street, Denver, CO 80202",
phone: validatedData.phone || "+1 (555) 123-4567",
email: validatedData.email || "info@unitedtattoo.com",
socialMedia: validatedData.socialMedia || {
instagram: "https://instagram.com/unitedtattoo",
facebook: "https://facebook.com/unitedtattoo",
twitter: "https://twitter.com/unitedtattoo",
tiktok: "https://tiktok.com/@unitedtattoo",
},
businessHours: validatedData.businessHours || [
{ dayOfWeek: 1, openTime: "10:00", closeTime: "20:00", isClosed: false },
{ dayOfWeek: 2, openTime: "10:00", closeTime: "20:00", isClosed: false },
{ dayOfWeek: 3, openTime: "10:00", closeTime: "20:00", isClosed: false },
{ dayOfWeek: 4, openTime: "10:00", closeTime: "20:00", isClosed: false },
{ dayOfWeek: 5, openTime: "10:00", closeTime: "22:00", isClosed: false },
{ dayOfWeek: 6, openTime: "10:00", closeTime: "22:00", isClosed: false },
{ dayOfWeek: 0, openTime: "12:00", closeTime: "18:00", isClosed: false },
],
heroImage: validatedData.heroImage || "/united-studio-main.jpg",
logoUrl: validatedData.logoUrl || "/united-logo-website.jpg",
updatedAt: new Date(),
}
return NextResponse.json(mockUpdatedSettings)
} catch (error) {
console.error("Error updating site settings:", error)
if (error instanceof Error) {
if (error.message.includes("Authentication required")) {
return NextResponse.json(
{ error: "Authentication required" },
{ status: 401 }
)
}
if (error.message.includes("Insufficient permissions")) {
return NextResponse.json(
{ error: "Insufficient permissions" },
{ status: 403 }
)
}
}
return NextResponse.json(
{ error: "Failed to update site settings" },
{ status: 500 }
)
}
}
// POST /api/settings - Initialize site settings (Super Admin only)
export async function POST(request: NextRequest) {
try {
// Require super admin authentication
await requireAuth(UserRole.SUPER_ADMIN)
const body = await request.json()
const validatedData = updateSiteSettingsSchema.parse(body)
// TODO: Implement via Supabase MCP
// Check if settings already exist
// const existingSettings = await db.siteSettings.findFirst()
// if (existingSettings) {
// return NextResponse.json(
// { error: "Site settings already exist. Use PUT to update." },
// { status: 409 }
// )
// }
// const newSettings = await db.siteSettings.create(validatedData)
// Mock response for now
const mockNewSettings = {
id: `settings-${Date.now()}`,
studioName: validatedData.studioName || "United Tattoo Studio",
description: validatedData.description || "Premier tattoo studio specializing in custom artwork and professional tattooing services.",
address: validatedData.address || "123 Main Street, Denver, CO 80202",
phone: validatedData.phone || "+1 (555) 123-4567",
email: validatedData.email || "info@unitedtattoo.com",
socialMedia: validatedData.socialMedia || {},
businessHours: validatedData.businessHours || [],
heroImage: validatedData.heroImage,
logoUrl: validatedData.logoUrl,
updatedAt: new Date(),
}
return NextResponse.json(mockNewSettings, { status: 201 })
} catch (error) {
console.error("Error creating site settings:", error)
if (error instanceof Error) {
if (error.message.includes("Authentication required")) {
return NextResponse.json(
{ error: "Authentication required" },
{ status: 401 }
)
}
if (error.message.includes("Insufficient permissions")) {
return NextResponse.json(
{ error: "Insufficient permissions" },
{ status: 403 }
)
}
}
return NextResponse.json(
{ error: "Failed to create site settings" },
{ status: 500 }
)
}
}

169
app/api/upload/route.ts Normal file
View File

@ -0,0 +1,169 @@
import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { uploadToR2, validateUploadFile } from '@/lib/r2-upload'
import { addPortfolioImage } from '@/lib/db'
export async function POST(request: NextRequest) {
try {
// Check authentication
const session = await getServerSession(authOptions)
if (!session?.user) {
return NextResponse.json(
{ error: 'Unauthorized' },
{ status: 401 }
)
}
const formData = await request.formData()
const file = formData.get('file') as File
const key = formData.get('key') as string | undefined
const artistId = formData.get('artistId') as string | undefined
const caption = formData.get('caption') as string | undefined
const tags = formData.get('tags') as string | undefined
if (!file) {
return NextResponse.json(
{ error: 'No file provided' },
{ status: 400 }
)
}
// Validate file
const validation = validateUploadFile(file, {
maxSize: 10 * 1024 * 1024, // 10MB
allowedTypes: ['image/jpeg', 'image/png', 'image/webp', 'image/gif']
})
if (!validation.valid) {
return NextResponse.json(
{ error: validation.error },
{ status: 400 }
)
}
// Upload to R2
const uploadResult = await uploadToR2(file, key, {
contentType: file.type,
metadata: {
uploadedBy: session.user.id,
uploadedAt: new Date().toISOString(),
originalName: file.name,
artistId: artistId || '',
caption: caption || '',
tags: tags || '',
}
})
if (!uploadResult.success) {
return NextResponse.json(
{ error: uploadResult.error || 'Upload failed' },
{ status: 500 }
)
}
// If this is a portfolio image, save to database
if (artistId && uploadResult.url) {
try {
const parsedTags = tags ? JSON.parse(tags) : []
await addPortfolioImage(artistId, {
url: uploadResult.url,
caption: caption || undefined,
tags: parsedTags,
orderIndex: 0,
isPublic: true
})
} catch (dbError) {
console.error('Failed to save portfolio image to database:', dbError)
// Continue anyway - the file was uploaded successfully
}
}
return NextResponse.json({
success: true,
url: uploadResult.url,
key: uploadResult.key,
filename: file.name,
size: file.size,
type: file.type
})
} catch (error) {
console.error('Upload error:', error)
return NextResponse.json(
{ error: 'Upload failed' },
{ status: 500 }
)
}
}
export async function DELETE(request: NextRequest) {
try {
// Check authentication
const session = await getServerSession(authOptions)
if (!session?.user) {
return NextResponse.json(
{ error: 'Unauthorized' },
{ status: 401 }
)
}
const { searchParams } = new URL(request.url)
const key = searchParams.get('key')
if (!key) {
return NextResponse.json(
{ error: 'File key is required' },
{ status: 400 }
)
}
// TODO: Check if user has permission to delete this file
// For now, allow any authenticated user to delete
const { deleteFromR2 } = await import('@/lib/r2-upload')
const success = await deleteFromR2(key)
if (!success) {
return NextResponse.json(
{ error: 'Failed to delete file' },
{ status: 500 }
)
}
return NextResponse.json({
success: true,
message: 'File deleted successfully'
})
} catch (error) {
console.error('Delete error:', error)
return NextResponse.json(
{ error: 'Delete failed' },
{ status: 500 }
)
}
}
// GET /api/upload/presigned - Generate presigned upload URL (placeholder for future implementation)
export async function GET(request: NextRequest) {
try {
// Check authentication
const session = await getServerSession(authOptions)
if (!session?.user) {
return NextResponse.json(
{ error: 'Unauthorized' },
{ status: 401 }
)
}
// For now, return a message that presigned URLs are not implemented
return NextResponse.json({
error: 'Presigned URLs not implemented yet. Use direct upload via POST.'
}, { status: 501 })
} catch (error) {
console.error('Presigned URL error:', error)
return NextResponse.json(
{ error: 'Failed to generate presigned URL' },
{ status: 500 }
)
}
}

102
app/api/users/route.ts Normal file
View File

@ -0,0 +1,102 @@
import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { getDB } from '@/lib/db'
import { z } from 'zod'
const createUserSchema = z.object({
name: z.string().min(1),
email: z.string().email(),
role: z.enum(['SUPER_ADMIN', 'SHOP_ADMIN', 'ARTIST', 'CLIENT']),
})
export async function GET(request: NextRequest) {
try {
const session = await getServerSession(authOptions)
if (!session?.user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { searchParams } = new URL(request.url)
const email = searchParams.get('email')
const db = getDB()
if (email) {
// Find user by email
const stmt = db.prepare('SELECT * FROM users WHERE email = ?')
const user = await stmt.bind(email).first()
if (!user) {
return NextResponse.json({ error: 'User not found' }, { status: 404 })
}
return NextResponse.json({ user })
} else {
// Get all users
const stmt = db.prepare('SELECT * FROM users ORDER BY created_at DESC')
const result = await stmt.all()
return NextResponse.json({ users: result.results })
}
} catch (error) {
console.error('Error fetching users:', error)
return NextResponse.json(
{ error: 'Failed to fetch users' },
{ status: 500 }
)
}
}
export async function POST(request: NextRequest) {
try {
const session = await getServerSession(authOptions)
if (!session?.user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const body = await request.json()
const validatedData = createUserSchema.parse(body)
const db = getDB()
// Check if user already exists
const existingStmt = db.prepare('SELECT id FROM users WHERE email = ?')
const existing = await existingStmt.bind(validatedData.email).first()
if (existing) {
return NextResponse.json({ user: existing })
}
// Create new user
const userId = crypto.randomUUID()
const insertStmt = db.prepare(`
INSERT INTO users (id, email, name, role, created_at, updated_at)
VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
`)
await insertStmt.bind(
userId,
validatedData.email,
validatedData.name,
validatedData.role
).run()
// Fetch the created user
const selectStmt = db.prepare('SELECT * FROM users WHERE id = ?')
const user = await selectStmt.bind(userId).first()
return NextResponse.json({ user }, { status: 201 })
} catch (error) {
console.error('Error creating user:', error)
if (error instanceof z.ZodError) {
return NextResponse.json(
{ error: 'Invalid user data', details: error.errors },
{ status: 400 }
)
}
return NextResponse.json(
{ error: 'Failed to create user' },
{ status: 500 }
)
}
}

67
app/auth/error/page.tsx Normal file
View File

@ -0,0 +1,67 @@
"use client"
import { useSearchParams } from "next/navigation"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Alert, AlertDescription } from "@/components/ui/alert"
import { AlertTriangle } from "lucide-react"
import Link from "next/link"
const errorMessages = {
Configuration: "There is a problem with the server configuration.",
AccessDenied: "You do not have permission to sign in.",
Verification: "The verification token has expired or has already been used.",
Default: "An error occurred during authentication.",
}
export default function AuthError() {
const searchParams = useSearchParams()
const error = searchParams.get("error") as keyof typeof errorMessages
const errorMessage = errorMessages[error] || errorMessages.Default
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
<Card className="w-full max-w-md">
<CardHeader className="text-center">
<div className="mx-auto flex h-12 w-12 items-center justify-center rounded-full bg-red-100">
<AlertTriangle className="h-6 w-6 text-red-600" />
</div>
<CardTitle className="text-2xl font-bold text-red-900">
Authentication Error
</CardTitle>
<CardDescription>
There was a problem signing you in
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<Alert variant="destructive">
<AlertDescription>
{errorMessage}
</AlertDescription>
</Alert>
<div className="space-y-4">
<Button asChild className="w-full">
<Link href="/auth/signin">
Try Again
</Link>
</Button>
<Button variant="outline" asChild className="w-full">
<Link href="/">
Back to Home
</Link>
</Button>
</div>
{error && (
<div className="text-center text-sm text-gray-500">
<p>Error code: {error}</p>
</div>
)}
</CardContent>
</Card>
</div>
)
}

121
app/auth/signin/page.tsx Normal file
View File

@ -0,0 +1,121 @@
"use client"
import { signIn } from "next-auth/react"
import { useState } from "react"
import { useSearchParams, useRouter } from "next/navigation"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Alert, AlertDescription } from "@/components/ui/alert"
import { Loader2 } from "lucide-react"
export default function SignInPage() {
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const searchParams = useSearchParams()
const router = useRouter()
const urlError = searchParams.get("error")
const callbackUrl = searchParams.get("callbackUrl") || "/admin"
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault()
setIsLoading(true)
setError(null)
const formData = new FormData(e.currentTarget)
const email = formData.get("email") as string
const password = formData.get("password") as string
try {
const result = await signIn("credentials", {
email,
password,
redirect: false,
})
if (result?.error) {
setError("Invalid email or password. Please try again.")
} else if (result?.ok) {
// Successful signin - redirect to admin
router.push(callbackUrl)
router.refresh()
}
} catch (error) {
setError("An error occurred during sign in. Please try again.")
} finally {
setIsLoading(false)
}
}
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
<Card className="w-full max-w-md">
<CardHeader className="text-center">
<CardTitle className="text-2xl font-bold">Sign In</CardTitle>
<CardDescription>
Access the United Tattoo Studio admin dashboard
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{(error || urlError) && (
<Alert variant="destructive">
<AlertDescription>
{error || (urlError === "CredentialsSignin"
? "Invalid email or password. Please try again."
: "An error occurred during sign in. Please try again."
)}
</AlertDescription>
</Alert>
)}
{/* Credentials Form */}
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Input
id="email"
name="email"
type="email"
placeholder="nicholai@biohazardvfx.com"
required
disabled={isLoading}
/>
</div>
<div className="space-y-2">
<Label htmlFor="password">Password</Label>
<Input
id="password"
name="password"
type="password"
placeholder="Enter your password"
required
disabled={isLoading}
/>
</div>
<Button type="submit" className="w-full" disabled={isLoading}>
{isLoading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Signing in...
</>
) : (
"Sign In"
)}
</Button>
</form>
{/* Development Note */}
<div className="text-center text-sm text-gray-500">
<p>For development testing:</p>
<p className="text-xs mt-1">
Use any email/password combination.<br />
Admin: nicholai@biohazardvfx.com
</p>
</div>
</CardContent>
</Card>
</div>
)
}

View File

@ -0,0 +1,360 @@
'use client'
import { useState, useCallback, useMemo } from 'react'
import { Calendar, momentLocalizer, View, Views } from 'react-big-calendar'
import moment from 'moment'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { CalendarIcon, Clock, User, MapPin, DollarSign } from 'lucide-react'
import { cn } from '@/lib/utils'
import 'react-big-calendar/lib/css/react-big-calendar.css'
// Setup the localizer for react-big-calendar
const localizer = momentLocalizer(moment)
interface CalendarEvent {
id: string
title: string
start: Date
end: Date
resource: {
appointmentId: string
artistId: string
artistName: string
clientId: string
clientName: string
clientEmail: string
status: 'PENDING' | 'CONFIRMED' | 'IN_PROGRESS' | 'COMPLETED' | 'CANCELLED'
depositAmount?: number
totalAmount?: number
notes?: string
description?: string
}
}
interface AppointmentCalendarProps {
appointments: any[]
artists: any[]
onEventSelect?: (event: CalendarEvent) => void
onSlotSelect?: (slotInfo: { start: Date; end: Date; slots: Date[] }) => void
onEventUpdate?: (eventId: string, updates: any) => void
className?: string
}
const statusColors = {
PENDING: 'bg-yellow-100 border-yellow-300 text-yellow-800',
CONFIRMED: 'bg-blue-100 border-blue-300 text-blue-800',
IN_PROGRESS: 'bg-green-100 border-green-300 text-green-800',
COMPLETED: 'bg-gray-100 border-gray-300 text-gray-800',
CANCELLED: 'bg-red-100 border-red-300 text-red-800',
}
export function AppointmentCalendar({
appointments,
artists,
onEventSelect,
onSlotSelect,
onEventUpdate,
className
}: AppointmentCalendarProps) {
const [view, setView] = useState<View>(Views.WEEK)
const [date, setDate] = useState(new Date())
const [selectedArtist, setSelectedArtist] = useState<string>('all')
const [selectedEvent, setSelectedEvent] = useState<CalendarEvent | null>(null)
// Convert appointments to calendar events
const events = useMemo(() => {
const filteredAppointments = selectedArtist === 'all'
? appointments
: appointments.filter(apt => apt.artist_id === selectedArtist)
return filteredAppointments.map(appointment => ({
id: appointment.id,
title: `${appointment.title} - ${appointment.client_name}`,
start: new Date(appointment.start_time),
end: new Date(appointment.end_time),
resource: {
appointmentId: appointment.id,
artistId: appointment.artist_id,
artistName: appointment.artist_name,
clientId: appointment.client_id,
clientName: appointment.client_name,
clientEmail: appointment.client_email,
status: appointment.status,
depositAmount: appointment.deposit_amount,
totalAmount: appointment.total_amount,
notes: appointment.notes,
description: appointment.description,
}
})) as CalendarEvent[]
}, [appointments, selectedArtist])
// Custom event style getter
const eventStyleGetter = useCallback((event: CalendarEvent) => {
const status = event.resource.status
const baseStyle = {
borderRadius: '4px',
border: '1px solid',
fontSize: '12px',
padding: '2px 4px',
}
switch (status) {
case 'PENDING':
return {
style: {
...baseStyle,
backgroundColor: '#fef3c7',
borderColor: '#fcd34d',
color: '#92400e',
}
}
case 'CONFIRMED':
return {
style: {
...baseStyle,
backgroundColor: '#dbeafe',
borderColor: '#60a5fa',
color: '#1e40af',
}
}
case 'IN_PROGRESS':
return {
style: {
...baseStyle,
backgroundColor: '#dcfce7',
borderColor: '#4ade80',
color: '#166534',
}
}
case 'COMPLETED':
return {
style: {
...baseStyle,
backgroundColor: '#f3f4f6',
borderColor: '#9ca3af',
color: '#374151',
}
}
case 'CANCELLED':
return {
style: {
...baseStyle,
backgroundColor: '#fee2e2',
borderColor: '#f87171',
color: '#991b1b',
}
}
default:
return { style: baseStyle }
}
}, [])
const handleSelectEvent = useCallback((event: CalendarEvent) => {
setSelectedEvent(event)
onEventSelect?.(event)
}, [onEventSelect])
const handleSelectSlot = useCallback((slotInfo: { start: Date; end: Date; slots: Date[] }) => {
onSlotSelect?.(slotInfo)
}, [onSlotSelect])
const handleStatusUpdate = useCallback((eventId: string, newStatus: string) => {
onEventUpdate?.(eventId, { status: newStatus })
setSelectedEvent(null)
}, [onEventUpdate])
const formatCurrency = (amount?: number) => {
if (!amount) return 'N/A'
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD'
}).format(amount)
}
return (
<div className={cn('space-y-4', className)}>
{/* Calendar Controls */}
<div className="flex flex-col sm:flex-row gap-4 items-start sm:items-center justify-between">
<div className="flex items-center gap-2">
<CalendarIcon className="h-5 w-5" />
<h2 className="text-lg font-semibold">Appointment Calendar</h2>
</div>
<div className="flex flex-wrap gap-2">
<Select value={selectedArtist} onValueChange={setSelectedArtist}>
<SelectTrigger className="w-[180px]">
<SelectValue placeholder="Filter by artist" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Artists</SelectItem>
{artists.map(artist => (
<SelectItem key={artist.id} value={artist.id}>
{artist.name}
</SelectItem>
))}
</SelectContent>
</Select>
<Select value={view} onValueChange={(value) => setView(value as View)}>
<SelectTrigger className="w-[120px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value={Views.MONTH}>Month</SelectItem>
<SelectItem value={Views.WEEK}>Week</SelectItem>
<SelectItem value={Views.DAY}>Day</SelectItem>
<SelectItem value={Views.AGENDA}>Agenda</SelectItem>
</SelectContent>
</Select>
</div>
</div>
{/* Calendar */}
<Card>
<CardContent className="p-4">
<div style={{ height: '600px' }}>
<Calendar
localizer={localizer}
events={events}
startAccessor="start"
endAccessor="end"
view={view}
onView={setView}
date={date}
onNavigate={setDate}
onSelectEvent={handleSelectEvent}
onSelectSlot={handleSelectSlot}
selectable
eventPropGetter={eventStyleGetter}
popup
showMultiDayTimes
step={30}
timeslots={2}
defaultDate={new Date()}
views={[Views.MONTH, Views.WEEK, Views.DAY, Views.AGENDA]}
messages={{
next: "Next",
previous: "Previous",
today: "Today",
month: "Month",
week: "Week",
day: "Day",
agenda: "Agenda",
date: "Date",
time: "Time",
event: "Event",
noEventsInRange: "No appointments in this range",
showMore: (total) => `+${total} more`,
}}
/>
</div>
</CardContent>
</Card>
{/* Event Details Dialog */}
<Dialog open={!!selectedEvent} onOpenChange={() => setSelectedEvent(null)}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<CalendarIcon className="h-5 w-5" />
Appointment Details
</DialogTitle>
</DialogHeader>
{selectedEvent && (
<div className="space-y-4">
<div>
<h3 className="font-semibold text-lg">{selectedEvent.resource.clientName}</h3>
<p className="text-sm text-muted-foreground">{selectedEvent.resource.clientEmail}</p>
</div>
<div className="grid grid-cols-2 gap-4 text-sm">
<div className="flex items-center gap-2">
<User className="h-4 w-4" />
<span>{selectedEvent.resource.artistName}</span>
</div>
<div className="flex items-center gap-2">
<Clock className="h-4 w-4" />
<span>{moment(selectedEvent.start).format('MMM D, h:mm A')}</span>
</div>
</div>
<div>
<Badge className={statusColors[selectedEvent.resource.status]}>
{selectedEvent.resource.status}
</Badge>
</div>
{selectedEvent.resource.description && (
<div>
<h4 className="font-medium mb-1">Description</h4>
<p className="text-sm text-muted-foreground">{selectedEvent.resource.description}</p>
</div>
)}
{(selectedEvent.resource.depositAmount || selectedEvent.resource.totalAmount) && (
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<span className="font-medium">Deposit:</span>
<p>{formatCurrency(selectedEvent.resource.depositAmount)}</p>
</div>
<div>
<span className="font-medium">Total:</span>
<p>{formatCurrency(selectedEvent.resource.totalAmount)}</p>
</div>
</div>
)}
{selectedEvent.resource.notes && (
<div>
<h4 className="font-medium mb-1">Notes</h4>
<p className="text-sm text-muted-foreground">{selectedEvent.resource.notes}</p>
</div>
)}
{/* Status Update Buttons */}
<div className="flex flex-wrap gap-2 pt-4 border-t">
<Button
size="sm"
variant="outline"
onClick={() => handleStatusUpdate(selectedEvent.resource.appointmentId, 'CONFIRMED')}
disabled={selectedEvent.resource.status === 'CONFIRMED'}
>
Confirm
</Button>
<Button
size="sm"
variant="outline"
onClick={() => handleStatusUpdate(selectedEvent.resource.appointmentId, 'IN_PROGRESS')}
disabled={selectedEvent.resource.status === 'IN_PROGRESS'}
>
Start
</Button>
<Button
size="sm"
variant="outline"
onClick={() => handleStatusUpdate(selectedEvent.resource.appointmentId, 'COMPLETED')}
disabled={selectedEvent.resource.status === 'COMPLETED'}
>
Complete
</Button>
<Button
size="sm"
variant="destructive"
onClick={() => handleStatusUpdate(selectedEvent.resource.appointmentId, 'CANCELLED')}
disabled={selectedEvent.resource.status === 'CANCELLED'}
>
Cancel
</Button>
</div>
</div>
)}
</DialogContent>
</Dialog>
</div>
)
}

View File

@ -0,0 +1,352 @@
"use client"
import { useState } from 'react'
import { useRouter } from 'next/navigation'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
import { Label } from '@/components/ui/label'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Switch } from '@/components/ui/switch'
import { X, Plus, Upload } from 'lucide-react'
import { useToast } from '@/hooks/use-toast'
import { useFileUpload } from '@/hooks/use-file-upload'
import type { Artist } from '@/types/database'
const artistFormSchema = z.object({
name: z.string().min(1, 'Name is required'),
bio: z.string().min(10, 'Bio must be at least 10 characters'),
specialties: z.array(z.string()).min(1, 'At least one specialty is required'),
instagramHandle: z.string().optional(),
hourlyRate: z.number().min(0).optional(),
isActive: z.boolean().default(true),
email: z.string().email().optional(),
})
type ArtistFormData = z.infer<typeof artistFormSchema>
interface ArtistFormProps {
artist?: Artist
onSuccess?: () => void
}
export function ArtistForm({ artist, onSuccess }: ArtistFormProps) {
const router = useRouter()
const { toast } = useToast()
const [isSubmitting, setIsSubmitting] = useState(false)
const [newSpecialty, setNewSpecialty] = useState('')
const {
uploadFiles,
progress,
isUploading,
error: uploadError,
clearProgress
} = useFileUpload({
maxFiles: 10,
maxSize: 5 * 1024 * 1024, // 5MB
allowedTypes: ['image/jpeg', 'image/png', 'image/webp'],
})
const {
register,
handleSubmit,
watch,
setValue,
formState: { errors }
} = useForm<ArtistFormData>({
resolver: zodResolver(artistFormSchema),
defaultValues: {
name: artist?.name || '',
bio: artist?.bio || '',
specialties: artist?.specialties ? (typeof artist.specialties === 'string' ? JSON.parse(artist.specialties) : artist.specialties) : [],
instagramHandle: artist?.instagramHandle || '',
hourlyRate: artist?.hourlyRate || undefined,
isActive: artist?.isActive !== false,
email: '',
}
})
const specialties = watch('specialties')
const addSpecialty = () => {
if (newSpecialty.trim() && !specialties.includes(newSpecialty.trim())) {
setValue('specialties', [...specialties, newSpecialty.trim()])
setNewSpecialty('')
}
}
const removeSpecialty = (specialty: string) => {
setValue('specialties', specialties.filter(s => s !== specialty))
}
const handleFileUpload = async (files: FileList | null) => {
if (!files || files.length === 0) return
const fileArray = Array.from(files)
await uploadFiles(fileArray, {
keyPrefix: artist ? `portfolio/${artist.id}` : 'temp-portfolio'
})
}
const onSubmit = async (data: ArtistFormData) => {
setIsSubmitting(true)
try {
const url = artist ? `/api/artists/${artist.id}` : '/api/artists'
const method = artist ? 'PUT' : 'POST'
const response = await fetch(url, {
method,
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(data),
})
if (!response.ok) {
const error = await response.json()
throw new Error(error.error || 'Failed to save artist')
}
const result = await response.json()
toast({
title: 'Success',
description: artist ? 'Artist updated successfully' : 'Artist created successfully',
})
onSuccess?.()
if (!artist) {
router.push(`/admin/artists/${result.artist.id}`)
}
} catch (error) {
console.error('Form submission error:', error)
toast({
title: 'Error',
description: error instanceof Error ? error.message : 'Failed to save artist',
variant: 'destructive',
})
} finally {
setIsSubmitting(false)
}
}
return (
<div className="space-y-6">
<Card>
<CardHeader>
<CardTitle>
{artist ? 'Edit Artist' : 'Create New Artist'}
</CardTitle>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
{/* Basic Information */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="name">Name *</Label>
<Input
id="name"
{...register('name')}
placeholder="Artist name"
/>
{errors.name && (
<p className="text-sm text-red-600">{errors.name.message}</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Input
id="email"
type="email"
{...register('email')}
placeholder="artist@unitedtattoo.com"
/>
{errors.email && (
<p className="text-sm text-red-600">{errors.email.message}</p>
)}
</div>
</div>
{/* Bio */}
<div className="space-y-2">
<Label htmlFor="bio">Bio *</Label>
<Textarea
id="bio"
{...register('bio')}
placeholder="Tell us about this artist..."
rows={4}
/>
{errors.bio && (
<p className="text-sm text-red-600">{errors.bio.message}</p>
)}
</div>
{/* Specialties */}
<div className="space-y-2">
<Label>Specialties *</Label>
<div className="flex gap-2">
<Input
value={newSpecialty}
onChange={(e) => setNewSpecialty(e.target.value)}
placeholder="Add a specialty"
onKeyPress={(e) => e.key === 'Enter' && (e.preventDefault(), addSpecialty())}
/>
<Button type="button" onClick={addSpecialty} size="sm">
<Plus className="h-4 w-4" />
</Button>
</div>
<div className="flex flex-wrap gap-2">
{specialties.map((specialty) => (
<Badge key={specialty} variant="secondary" className="flex items-center gap-1">
{specialty}
<button
type="button"
onClick={() => removeSpecialty(specialty)}
className="ml-1 hover:text-red-600"
>
<X className="h-3 w-3" />
</button>
</Badge>
))}
</div>
{errors.specialties && (
<p className="text-sm text-red-600">{errors.specialties.message}</p>
)}
</div>
{/* Social Media & Rates */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="instagramHandle">Instagram Handle</Label>
<Input
id="instagramHandle"
{...register('instagramHandle')}
placeholder="@username"
/>
</div>
<div className="space-y-2">
<Label htmlFor="hourlyRate">Hourly Rate ($)</Label>
<Input
id="hourlyRate"
type="number"
step="0.01"
{...register('hourlyRate', { valueAsNumber: true })}
placeholder="150.00"
/>
</div>
</div>
{/* Active Status */}
<div className="flex items-center space-x-2">
<Switch
id="isActive"
checked={watch('isActive')}
onCheckedChange={(checked) => setValue('isActive', checked)}
/>
<Label htmlFor="isActive">Active Artist</Label>
</div>
{/* Form Actions */}
<div className="flex justify-end space-x-2">
<Button
type="button"
variant="outline"
onClick={() => router.back()}
>
Cancel
</Button>
<Button type="submit" disabled={isSubmitting}>
{isSubmitting ? 'Saving...' : artist ? 'Update Artist' : 'Create Artist'}
</Button>
</div>
</form>
</CardContent>
</Card>
{/* Portfolio Upload Section */}
{artist && (
<Card>
<CardHeader>
<CardTitle>Portfolio Images</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-4">
<div className="border-2 border-dashed border-gray-300 rounded-lg p-6 text-center">
<Upload className="mx-auto h-12 w-12 text-gray-400" />
<div className="mt-4">
<Label htmlFor="portfolio-upload" className="cursor-pointer">
<span className="mt-2 block text-sm font-medium text-gray-900">
Upload portfolio images
</span>
<span className="mt-1 block text-sm text-gray-500">
PNG, JPG, WebP up to 5MB each
</span>
</Label>
<Input
id="portfolio-upload"
type="file"
multiple
accept="image/*"
className="hidden"
onChange={(e) => handleFileUpload(e.target.files)}
/>
</div>
</div>
{/* Upload Progress */}
{progress.length > 0 && (
<div className="space-y-2">
<h4 className="font-medium">Upload Progress</h4>
{progress.map((file) => (
<div key={file.id} className="flex items-center justify-between p-2 bg-gray-50 rounded">
<span className="text-sm">{file.filename}</span>
<div className="flex items-center gap-2">
{file.status === 'uploading' && (
<div className="w-20 bg-gray-200 rounded-full h-2">
<div
className="bg-blue-600 h-2 rounded-full transition-all"
style={{ width: `${file.progress}%` }}
/>
</div>
)}
{file.status === 'complete' && (
<Badge variant="default">Complete</Badge>
)}
{file.status === 'error' && (
<Badge variant="destructive">Error</Badge>
)}
</div>
</div>
))}
<Button
type="button"
variant="outline"
size="sm"
onClick={clearProgress}
>
Clear Progress
</Button>
</div>
)}
{uploadError && (
<div className="p-3 bg-red-50 border border-red-200 rounded text-red-700 text-sm">
{uploadError}
</div>
)}
</div>
</CardContent>
</Card>
)}
</div>
)
}

View File

@ -0,0 +1,188 @@
"use client"
import * as React from "react"
import {
ColumnDef,
ColumnFiltersState,
flexRender,
getCoreRowModel,
getFilteredRowModel,
getPaginationRowModel,
getSortedRowModel,
SortingState,
useReactTable,
VisibilityState,
} from "@tanstack/react-table"
import { ChevronDown } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table"
interface DataTableProps<TData, TValue> {
columns: ColumnDef<TData, TValue>[]
data: TData[]
searchKey?: string
searchPlaceholder?: string
}
export function DataTable<TData, TValue>({
columns,
data,
searchKey,
searchPlaceholder = "Search...",
}: DataTableProps<TData, TValue>) {
const [sorting, setSorting] = React.useState<SortingState>([])
const [columnFilters, setColumnFilters] = React.useState<ColumnFiltersState>([])
const [columnVisibility, setColumnVisibility] = React.useState<VisibilityState>({})
const [rowSelection, setRowSelection] = React.useState({})
const table = useReactTable({
data,
columns,
onSortingChange: setSorting,
onColumnFiltersChange: setColumnFilters,
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
getSortedRowModel: getSortedRowModel(),
getFilteredRowModel: getFilteredRowModel(),
onColumnVisibilityChange: setColumnVisibility,
onRowSelectionChange: setRowSelection,
state: {
sorting,
columnFilters,
columnVisibility,
rowSelection,
},
})
return (
<div className="w-full">
<div className="flex items-center py-4">
{searchKey && (
<Input
placeholder={searchPlaceholder}
value={(table.getColumn(searchKey)?.getFilterValue() as string) ?? ""}
onChange={(event) =>
table.getColumn(searchKey)?.setFilterValue(event.target.value)
}
className="max-w-sm"
/>
)}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" className="ml-auto">
Columns <ChevronDown className="ml-2 h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{table
.getAllColumns()
.filter((column) => column.getCanHide())
.map((column) => {
return (
<DropdownMenuCheckboxItem
key={column.id}
className="capitalize"
checked={column.getIsVisible()}
onCheckedChange={(value) =>
column.toggleVisibility(!!value)
}
>
{column.id}
</DropdownMenuCheckboxItem>
)
})}
</DropdownMenuContent>
</DropdownMenu>
</div>
<div className="rounded-md border">
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => {
return (
<TableHead key={header.id}>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext()
)}
</TableHead>
)
})}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => (
<TableRow
key={row.id}
data-state={row.getIsSelected() && "selected"}
>
{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 results.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
<div className="flex items-center justify-end space-x-2 py-4">
<div className="text-muted-foreground flex-1 text-sm">
{table.getFilteredSelectedRowModel().rows.length} of{" "}
{table.getFilteredRowModel().rows.length} row(s) selected.
</div>
<div className="space-x-2">
<Button
variant="outline"
size="sm"
onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
>
Previous
</Button>
<Button
variant="outline"
size="sm"
onClick={() => table.nextPage()}
disabled={!table.getCanNextPage()}
>
Next
</Button>
</div>
</div>
</div>
)
}

View File

@ -0,0 +1,187 @@
'use client'
import React from 'react'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { AlertTriangle, RefreshCw } from 'lucide-react'
interface ErrorBoundaryState {
hasError: boolean
error?: Error
errorInfo?: React.ErrorInfo
}
interface ErrorBoundaryProps {
children: React.ReactNode
fallback?: React.ComponentType<{ error: Error; retry: () => void }>
}
export class ErrorBoundary extends React.Component<ErrorBoundaryProps, ErrorBoundaryState> {
constructor(props: ErrorBoundaryProps) {
super(props)
this.state = { hasError: false }
}
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
return {
hasError: true,
error,
}
}
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
console.error('Error caught by boundary:', error, errorInfo)
// Log error to monitoring service in production
if (process.env.NODE_ENV === 'production') {
// You can integrate with services like Sentry here
console.error('Production error:', {
error: error.message,
stack: error.stack,
componentStack: errorInfo.componentStack,
})
}
this.setState({
hasError: true,
error,
errorInfo,
})
}
handleRetry = () => {
this.setState({ hasError: false, error: undefined, errorInfo: undefined })
}
render() {
if (this.state.hasError) {
const { fallback: Fallback } = this.props
if (Fallback && this.state.error) {
return <Fallback error={this.state.error} retry={this.handleRetry} />
}
return (
<Card className="max-w-lg mx-auto mt-8">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-destructive">
<AlertTriangle className="h-5 w-5" />
Something went wrong
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<p className="text-sm text-muted-foreground">
An unexpected error occurred. Please try refreshing the page or contact support if the problem persists.
</p>
{process.env.NODE_ENV === 'development' && this.state.error && (
<details className="text-xs bg-muted p-3 rounded">
<summary className="cursor-pointer font-medium">Error Details</summary>
<pre className="mt-2 whitespace-pre-wrap">
{this.state.error.message}
{'\n\n'}
{this.state.error.stack}
</pre>
</details>
)}
<div className="flex gap-2">
<Button onClick={this.handleRetry} variant="outline" size="sm">
<RefreshCw className="h-4 w-4 mr-2" />
Try Again
</Button>
<Button
onClick={() => window.location.reload()}
size="sm"
>
Refresh Page
</Button>
</div>
</CardContent>
</Card>
)
}
return this.props.children
}
}
// Hook version for functional components
export function useErrorHandler() {
const [error, setError] = React.useState<Error | null>(null)
const resetError = React.useCallback(() => {
setError(null)
}, [])
const captureError = React.useCallback((error: Error) => {
console.error('Error captured:', error)
setError(error)
}, [])
React.useEffect(() => {
if (error) {
// Log to monitoring service
console.error('Error in component:', error)
}
}, [error])
return { error, resetError, captureError }
}
// Specific error fallback components
export function AdminErrorFallback({ error, retry }: { error: Error; retry: () => void }) {
return (
<div className="min-h-[400px] flex items-center justify-center">
<Card className="max-w-md">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-destructive">
<AlertTriangle className="h-5 w-5" />
Admin Panel Error
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<p className="text-sm text-muted-foreground">
There was an error loading the admin panel. This might be due to a network issue or server problem.
</p>
<div className="flex gap-2">
<Button onClick={retry} variant="outline" size="sm">
<RefreshCw className="h-4 w-4 mr-2" />
Retry
</Button>
<Button
onClick={() => window.location.href = '/admin'}
size="sm"
>
Go to Dashboard
</Button>
</div>
</CardContent>
</Card>
</div>
)
}
export function CalendarErrorFallback({ error, retry }: { error: Error; retry: () => void }) {
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-destructive">
<AlertTriangle className="h-5 w-5" />
Calendar Loading Error
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<p className="text-sm text-muted-foreground">
Unable to load the appointment calendar. Please check your connection and try again.
</p>
<Button onClick={retry} variant="outline" size="sm">
<RefreshCw className="h-4 w-4 mr-2" />
Reload Calendar
</Button>
</CardContent>
</Card>
)
}

View File

@ -0,0 +1,251 @@
'use client'
import { Card, CardContent, CardHeader } from '@/components/ui/card'
import { Skeleton } from '@/components/ui/skeleton'
import { Loader2 } from 'lucide-react'
// Generic loading spinner
export function LoadingSpinner({ size = 'default', className = '' }: {
size?: 'sm' | 'default' | 'lg'
className?: string
}) {
const sizeClasses = {
sm: 'h-4 w-4',
default: 'h-6 w-6',
lg: 'h-8 w-8'
}
return (
<Loader2 className={`animate-spin ${sizeClasses[size]} ${className}`} />
)
}
// Full page loading
export function PageLoading({ message = 'Loading...' }: { message?: string }) {
return (
<div className="flex items-center justify-center min-h-[400px]">
<div className="text-center space-y-4">
<LoadingSpinner size="lg" />
<p className="text-sm text-muted-foreground">{message}</p>
</div>
</div>
)
}
// Dashboard stats loading
export function StatsLoading() {
return (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
{Array.from({ length: 4 }).map((_, i) => (
<Card key={i}>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<Skeleton className="h-4 w-[100px]" />
<Skeleton className="h-4 w-4" />
</CardHeader>
<CardContent>
<Skeleton className="h-8 w-[60px] mb-2" />
<Skeleton className="h-3 w-[120px]" />
</CardContent>
</Card>
))}
</div>
)
}
// Table loading
export function TableLoading({ rows = 5, columns = 4 }: { rows?: number; columns?: number }) {
return (
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<Skeleton className="h-6 w-[150px]" />
<Skeleton className="h-9 w-[100px]" />
</div>
</CardHeader>
<CardContent>
<div className="space-y-3">
{/* Table header */}
<div className="grid gap-4" style={{ gridTemplateColumns: `repeat(${columns}, 1fr)` }}>
{Array.from({ length: columns }).map((_, i) => (
<Skeleton key={i} className="h-4 w-full" />
))}
</div>
{/* Table rows */}
{Array.from({ length: rows }).map((_, rowIndex) => (
<div key={rowIndex} className="grid gap-4" style={{ gridTemplateColumns: `repeat(${columns}, 1fr)` }}>
{Array.from({ length: columns }).map((_, colIndex) => (
<Skeleton key={colIndex} className="h-4 w-full" />
))}
</div>
))}
</div>
</CardContent>
</Card>
)
}
// Calendar loading
export function CalendarLoading() {
return (
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<Skeleton className="h-6 w-[200px]" />
<div className="flex gap-2">
<Skeleton className="h-9 w-[120px]" />
<Skeleton className="h-9 w-[100px]" />
</div>
</div>
</CardHeader>
<CardContent>
<div className="space-y-4">
{/* Calendar header */}
<div className="grid grid-cols-7 gap-2">
{Array.from({ length: 7 }).map((_, i) => (
<Skeleton key={i} className="h-8 w-full" />
))}
</div>
{/* Calendar body */}
{Array.from({ length: 5 }).map((_, weekIndex) => (
<div key={weekIndex} className="grid grid-cols-7 gap-2">
{Array.from({ length: 7 }).map((_, dayIndex) => (
<div key={dayIndex} className="space-y-1">
<Skeleton className="h-6 w-full" />
<Skeleton className="h-4 w-3/4" />
</div>
))}
</div>
))}
</div>
</CardContent>
</Card>
)
}
// Form loading
export function FormLoading() {
return (
<Card>
<CardHeader>
<Skeleton className="h-6 w-[200px]" />
</CardHeader>
<CardContent className="space-y-6">
{/* Form fields */}
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="space-y-2">
<Skeleton className="h-4 w-[100px]" />
<Skeleton className="h-10 w-full" />
</div>
))}
{/* Form actions */}
<div className="flex gap-2 pt-4">
<Skeleton className="h-10 w-[100px]" />
<Skeleton className="h-10 w-[80px]" />
</div>
</CardContent>
</Card>
)
}
// Chart loading
export function ChartLoading({ height = 300 }: { height?: number }) {
return (
<Card>
<CardHeader>
<Skeleton className="h-6 w-[150px]" />
</CardHeader>
<CardContent>
<div className="space-y-4">
<div className="flex items-end justify-between" style={{ height: `${height}px` }}>
{Array.from({ length: 8 }).map((_, i) => (
<Skeleton
key={i}
className="w-8"
style={{ height: `${Math.random() * height * 0.8 + height * 0.2}px` }}
/>
))}
</div>
<div className="flex justify-between">
{Array.from({ length: 8 }).map((_, i) => (
<Skeleton key={i} className="h-3 w-8" />
))}
</div>
</div>
</CardContent>
</Card>
)
}
// List loading
export function ListLoading({ items = 5 }: { items?: number }) {
return (
<div className="space-y-3">
{Array.from({ length: items }).map((_, i) => (
<Card key={i}>
<CardContent className="p-4">
<div className="flex items-center space-x-4">
<Skeleton className="h-12 w-12 rounded-full" />
<div className="space-y-2 flex-1">
<Skeleton className="h-4 w-[200px]" />
<Skeleton className="h-3 w-[150px]" />
</div>
<Skeleton className="h-8 w-[80px]" />
</div>
</CardContent>
</Card>
))}
</div>
)
}
// Image grid loading
export function ImageGridLoading({ count = 12 }: { count?: number }) {
return (
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
{Array.from({ length: count }).map((_, i) => (
<div key={i} className="space-y-2">
<Skeleton className="aspect-square w-full rounded-lg" />
<Skeleton className="h-3 w-3/4" />
<Skeleton className="h-3 w-1/2" />
</div>
))}
</div>
)
}
// Button loading state
export function ButtonLoading({
children,
isLoading,
...props
}: {
children: React.ReactNode
isLoading: boolean
[key: string]: any
}) {
return (
<button {...props} disabled={isLoading || props.disabled}>
{isLoading ? (
<div className="flex items-center gap-2">
<LoadingSpinner size="sm" />
<span>Loading...</span>
</div>
) : (
children
)}
</button>
)
}
// Inline loading for small components
export function InlineLoading({ text = 'Loading...' }: { text?: string }) {
return (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<LoadingSpinner size="sm" />
<span>{text}</span>
</div>
)
}

View File

@ -0,0 +1,161 @@
"use client"
import Link from "next/link"
import { usePathname } from "next/navigation"
import { signOut } from "next-auth/react"
import {
Users,
Settings,
Upload,
BarChart3,
Calendar,
LogOut,
Home,
Palette
} from "lucide-react"
import { Button } from "@/components/ui/button"
import { cn } from "@/lib/utils"
import { UserRole } from "@/types/database"
interface AdminSidebarProps {
user: {
id: string
name: string
email: string
role: UserRole
image?: string
}
}
const navigation = [
{
name: "Dashboard",
href: "/admin",
icon: Home,
roles: [UserRole.SHOP_ADMIN, UserRole.SUPER_ADMIN],
},
{
name: "Artists",
href: "/admin/artists",
icon: Users,
roles: [UserRole.SHOP_ADMIN, UserRole.SUPER_ADMIN],
},
{
name: "Portfolio",
href: "/admin/portfolio",
icon: Palette,
roles: [UserRole.SHOP_ADMIN, UserRole.SUPER_ADMIN],
},
{
name: "Calendar",
href: "/admin/calendar",
icon: Calendar,
roles: [UserRole.SHOP_ADMIN, UserRole.SUPER_ADMIN],
},
{
name: "Analytics",
href: "/admin/analytics",
icon: BarChart3,
roles: [UserRole.SHOP_ADMIN, UserRole.SUPER_ADMIN],
},
{
name: "File Manager",
href: "/admin/uploads",
icon: Upload,
roles: [UserRole.SHOP_ADMIN, UserRole.SUPER_ADMIN],
},
{
name: "Settings",
href: "/admin/settings",
icon: Settings,
roles: [UserRole.SHOP_ADMIN, UserRole.SUPER_ADMIN],
},
]
export function AdminSidebar({ user }: AdminSidebarProps) {
const pathname = usePathname()
// Filter navigation items based on user role
const filteredNavigation = navigation.filter(item =>
item.roles.includes(user.role)
)
const handleSignOut = async () => {
await signOut({ callbackUrl: "/" })
}
return (
<div className="flex flex-col w-64 bg-white shadow-lg">
{/* Logo/Brand */}
<div className="flex items-center justify-center h-16 px-4 border-b border-gray-200">
<Link href="/" className="flex items-center space-x-2">
<div className="w-8 h-8 bg-black rounded-md flex items-center justify-center">
<span className="text-white font-bold text-sm">U</span>
</div>
<span className="text-xl font-bold text-gray-900">United Admin</span>
</Link>
</div>
{/* Navigation */}
<nav className="flex-1 px-4 py-6 space-y-2">
{filteredNavigation.map((item) => {
const isActive = pathname === item.href
const Icon = item.icon
return (
<Link
key={item.name}
href={item.href}
className={cn(
"flex items-center px-3 py-2 text-sm font-medium rounded-md transition-colors",
isActive
? "bg-gray-100 text-gray-900"
: "text-gray-600 hover:bg-gray-50 hover:text-gray-900"
)}
>
<Icon className="w-5 h-5 mr-3" />
{item.name}
</Link>
)
})}
</nav>
{/* User info and sign out */}
<div className="border-t border-gray-200 p-4">
<div className="flex items-center space-x-3 mb-4">
<div className="w-10 h-10 bg-gray-300 rounded-full flex items-center justify-center">
{user.image ? (
<img
src={user.image}
alt={user.name}
className="w-10 h-10 rounded-full"
/>
) : (
<span className="text-sm font-medium text-gray-600">
{user.name.charAt(0).toUpperCase()}
</span>
)}
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-gray-900 truncate">
{user.name}
</p>
<p className="text-xs text-gray-500 truncate">
{user.role.replace('_', ' ').toLowerCase()}
</p>
</div>
</div>
<Button
variant="outline"
size="sm"
onClick={handleSignOut}
className="w-full justify-start"
>
<LogOut className="w-4 h-4 mr-2" />
Sign Out
</Button>
</div>
</div>
)
}

View File

@ -0,0 +1,375 @@
'use client'
import { useQuery } from '@tanstack/react-query'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Progress } from '@/components/ui/progress'
import {
Users,
Calendar,
DollarSign,
TrendingUp,
Clock,
CheckCircle,
XCircle,
AlertCircle,
Image,
Upload
} from 'lucide-react'
import {
BarChart,
Bar,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
LineChart,
Line,
PieChart,
Pie,
Cell
} from 'recharts'
interface DashboardStats {
artists: {
total: number
active: number
inactive: number
}
appointments: {
total: number
pending: number
confirmed: number
inProgress: number
completed: number
cancelled: number
thisMonth: number
lastMonth: number
revenue: number
}
portfolio: {
totalImages: number
recentUploads: number
}
files: {
totalUploads: number
totalSize: number
recentUploads: number
}
monthlyData: Array<{
month: string
appointments: number
revenue: number
}>
statusData: Array<{
name: string
value: number
color: string
}>
}
const COLORS = {
pending: '#f59e0b',
confirmed: '#3b82f6',
inProgress: '#10b981',
completed: '#6b7280',
cancelled: '#ef4444',
}
export function StatsDashboard() {
const { data: stats, isLoading } = useQuery({
queryKey: ['dashboard-stats'],
queryFn: async () => {
const response = await fetch('/api/admin/stats')
if (!response.ok) throw new Error('Failed to fetch stats')
return response.json() as Promise<DashboardStats>
},
refetchInterval: 30000, // Refresh every 30 seconds
})
if (isLoading) {
return (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
{Array.from({ length: 8 }).map((_, i) => (
<Card key={i}>
<CardHeader className="animate-pulse">
<div className="h-4 bg-gray-200 rounded w-3/4"></div>
</CardHeader>
<CardContent className="animate-pulse">
<div className="h-8 bg-gray-200 rounded w-1/2"></div>
</CardContent>
</Card>
))}
</div>
)
}
if (!stats) {
return (
<div className="text-center py-8">
<AlertCircle className="h-12 w-12 text-muted-foreground mx-auto mb-4" />
<p className="text-muted-foreground">Failed to load dashboard statistics</p>
</div>
)
}
const appointmentGrowth = stats.appointments.thisMonth > 0
? ((stats.appointments.thisMonth - stats.appointments.lastMonth) / stats.appointments.lastMonth) * 100
: 0
const activeArtistPercentage = stats.artists.total > 0
? (stats.artists.active / stats.artists.total) * 100
: 0
return (
<div className="space-y-6">
{/* Key Metrics */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Total Artists</CardTitle>
<Users className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{stats.artists.total}</div>
<div className="flex items-center space-x-2 text-xs text-muted-foreground">
<span>{stats.artists.active} active</span>
<Progress value={activeArtistPercentage} className="w-16 h-1" />
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Total Appointments</CardTitle>
<Calendar className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{stats.appointments.total}</div>
<div className="flex items-center space-x-1 text-xs">
<TrendingUp className={`h-3 w-3 ${appointmentGrowth >= 0 ? 'text-green-500' : 'text-red-500'}`} />
<span className={appointmentGrowth >= 0 ? 'text-green-500' : 'text-red-500'}>
{appointmentGrowth >= 0 ? '+' : ''}{appointmentGrowth.toFixed(1)}%
</span>
<span className="text-muted-foreground">from last month</span>
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Monthly Revenue</CardTitle>
<DollarSign className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
${stats.appointments.revenue.toLocaleString()}
</div>
<p className="text-xs text-muted-foreground">
From {stats.appointments.thisMonth} appointments this month
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Portfolio Images</CardTitle>
<Image className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{stats.portfolio.totalImages}</div>
<p className="text-xs text-muted-foreground">
{stats.portfolio.recentUploads} uploaded this week
</p>
</CardContent>
</Card>
</div>
{/* Appointment Status Overview */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-4">
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Pending</CardTitle>
<Clock className="h-4 w-4 text-yellow-500" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-yellow-600">
{stats.appointments.pending}
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Confirmed</CardTitle>
<CheckCircle className="h-4 w-4 text-blue-500" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-blue-600">
{stats.appointments.confirmed}
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">In Progress</CardTitle>
<AlertCircle className="h-4 w-4 text-green-500" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-green-600">
{stats.appointments.inProgress}
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Completed</CardTitle>
<CheckCircle className="h-4 w-4 text-gray-500" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-gray-600">
{stats.appointments.completed}
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Cancelled</CardTitle>
<XCircle className="h-4 w-4 text-red-500" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-red-600">
{stats.appointments.cancelled}
</div>
</CardContent>
</Card>
</div>
{/* Charts */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Monthly Appointments Trend */}
<Card>
<CardHeader>
<CardTitle>Monthly Appointments</CardTitle>
</CardHeader>
<CardContent>
<ResponsiveContainer width="100%" height={300}>
<LineChart data={stats.monthlyData}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="month" />
<YAxis />
<Tooltip />
<Line
type="monotone"
dataKey="appointments"
stroke="#3b82f6"
strokeWidth={2}
dot={{ fill: '#3b82f6' }}
/>
</LineChart>
</ResponsiveContainer>
</CardContent>
</Card>
{/* Appointment Status Distribution */}
<Card>
<CardHeader>
<CardTitle>Appointment Status Distribution</CardTitle>
</CardHeader>
<CardContent>
<ResponsiveContainer width="100%" height={300}>
<PieChart>
<Pie
data={stats.statusData}
cx="50%"
cy="50%"
labelLine={false}
label={({ name, percent }) => `${name} ${(percent * 100).toFixed(0)}%`}
outerRadius={80}
fill="#8884d8"
dataKey="value"
>
{stats.statusData.map((entry, index) => (
<Cell key={`cell-${index}`} fill={entry.color} />
))}
</Pie>
<Tooltip />
</PieChart>
</ResponsiveContainer>
</CardContent>
</Card>
</div>
{/* Monthly Revenue Trend */}
<Card>
<CardHeader>
<CardTitle>Monthly Revenue Trend</CardTitle>
</CardHeader>
<CardContent>
<ResponsiveContainer width="100%" height={300}>
<BarChart data={stats.monthlyData}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="month" />
<YAxis />
<Tooltip
formatter={(value) => [`$${value}`, 'Revenue']}
/>
<Bar dataKey="revenue" fill="#10b981" />
</BarChart>
</ResponsiveContainer>
</CardContent>
</Card>
{/* File Storage Stats */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Total Files</CardTitle>
<Upload className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{stats.files.totalUploads}</div>
<p className="text-xs text-muted-foreground">
{stats.files.recentUploads} uploaded this week
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Storage Used</CardTitle>
<Upload className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{(stats.files.totalSize / (1024 * 1024)).toFixed(1)} MB
</div>
<p className="text-xs text-muted-foreground">
Across all uploads
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Active Artists</CardTitle>
<Users className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{stats.artists.active}</div>
<div className="flex items-center space-x-2 text-xs text-muted-foreground">
<span>of {stats.artists.total} total</span>
<Badge variant="secondary">
{activeArtistPercentage.toFixed(0)}%
</Badge>
</div>
</CardContent>
</Card>
</div>
</div>
)
}

View File

@ -0,0 +1,350 @@
# Implementation Plan
## Overview
Transform the existing static Next.js tattoo studio website into a comprehensive management platform with admin dashboard, content management, and business operations capabilities.
The current application has a solid foundation with Next.js 14 App Router, TypeScript, shadcn/ui components, and proper project structure. We'll build upon this by adding authentication, database integration, file upload capabilities, administrative interfaces, calendar management, and booking systems while maintaining the existing design system and user experience for the public-facing website.
## Types
Database schema and TypeScript interfaces for the comprehensive management system.
```typescript
// User Management Types
export interface User {
id: string
email: string
name: string
role: UserRole
avatar?: string
createdAt: Date
updatedAt: Date
}
export enum UserRole {
SUPER_ADMIN = 'SUPER_ADMIN',
SHOP_ADMIN = 'SHOP_ADMIN',
ARTIST = 'ARTIST',
CLIENT = 'CLIENT'
}
// Artist Management Types
export interface Artist {
id: string
userId: string
name: string
bio: string
specialties: string[]
instagramHandle?: string
portfolioImages: PortfolioImage[]
isActive: boolean
hourlyRate?: number
availability: Availability[]
createdAt: Date
updatedAt: Date
}
export interface PortfolioImage {
id: string
artistId: string
url: string
caption?: string
tags: string[]
order: number
isPublic: boolean
createdAt: Date
}
// Calendar & Booking Types
export interface Appointment {
id: string
artistId: string
clientId: string
title: string
description?: string
startTime: Date
endTime: Date
status: AppointmentStatus
depositAmount?: number
totalAmount?: number
notes?: string
createdAt: Date
updatedAt: Date
}
export enum AppointmentStatus {
PENDING = 'PENDING',
CONFIRMED = 'CONFIRMED',
IN_PROGRESS = 'IN_PROGRESS',
COMPLETED = 'COMPLETED',
CANCELLED = 'CANCELLED'
}
export interface Availability {
id: string
artistId: string
dayOfWeek: number // 0-6 (Sunday-Saturday)
startTime: string // HH:mm format
endTime: string // HH:mm format
isActive: boolean
}
// Content Management Types
export interface SiteSettings {
id: string
studioName: string
description: string
address: string
phone: string
email: string
socialMedia: SocialMediaLinks
businessHours: BusinessHours[]
heroImage?: string
logoUrl?: string
updatedAt: Date
}
export interface SocialMediaLinks {
instagram?: string
facebook?: string
twitter?: string
tiktok?: string
}
export interface BusinessHours {
dayOfWeek: number
openTime: string
closeTime: string
isClosed: boolean
}
// File Upload Types
export interface FileUpload {
id: string
filename: string
originalName: string
mimeType: string
size: number
url: string
uploadedBy: string
createdAt: Date
}
```
## Files
Comprehensive file structure for the management platform implementation.
New files to be created:
- `lib/auth.ts` - NextAuth.js configuration with role-based access
- `lib/db.ts` - Database connection and query utilities (Supabase/Neon)
- `lib/env.ts` - Environment variable validation with Zod
- `lib/upload.ts` - File upload utilities for Cloudflare R2/AWS S3
- `lib/validations.ts` - Zod schemas for form validation
- `middleware.ts` - Route protection and role-based access control
- `app/api/auth/[...nextauth]/route.ts` - NextAuth API routes
- `app/api/artists/route.ts` - Artist CRUD operations
- `app/api/artists/[id]/route.ts` - Individual artist operations
- `app/api/appointments/route.ts` - Appointment management
- `app/api/upload/route.ts` - File upload endpoint
- `app/api/settings/route.ts` - Site settings management
- `app/admin/layout.tsx` - Admin dashboard layout with sidebar
- `app/admin/page.tsx` - Admin dashboard overview
- `app/admin/artists/page.tsx` - Artist management interface
- `app/admin/artists/[id]/page.tsx` - Individual artist editing
- `app/admin/artists/new/page.tsx` - New artist creation
- `app/admin/calendar/page.tsx` - Calendar management interface
- `app/admin/settings/page.tsx` - Site settings management
- `app/admin/uploads/page.tsx` - File management interface
- `components/admin/sidebar.tsx` - Admin navigation sidebar
- `components/admin/artist-form.tsx` - Artist creation/editing form
- `components/admin/portfolio-manager.tsx` - Portfolio image management
- `components/admin/calendar-view.tsx` - Calendar component with booking management
- `components/admin/settings-form.tsx` - Site settings form
- `components/admin/file-uploader.tsx` - Drag-and-drop file upload component
- `components/admin/data-table.tsx` - Reusable data table component
- `components/auth/login-form.tsx` - Authentication form
- `components/auth/role-guard.tsx` - Role-based component protection
- `hooks/use-artists.ts` - Artist data management hooks
- `hooks/use-appointments.ts` - Appointment data management hooks
- `hooks/use-uploads.ts` - File upload management hooks
- `types/database.ts` - Database type definitions
- `types/api.ts` - API response type definitions
Existing files to be modified:
- `package.json` - Add new dependencies (NextAuth, Supabase, React Query, etc.)
- `next.config.mjs` - Add image domains and API configurations
- `app/layout.tsx` - Add authentication providers and global state
- `components/navigation.tsx` - Add admin dashboard link for authenticated users
- `data/artists.ts` - Convert to dynamic data fetching from database
- `components/artists-section.tsx` - Update to use dynamic artist data
- `components/artist-portfolio.tsx` - Update to use dynamic portfolio data
- `app/artists/[id]/page.tsx` - Update to fetch artist data from database
Files to be deleted or moved:
- None initially - maintain backward compatibility
Configuration file updates:
- `.env.example` - Add required environment variables
- `tailwind.config.ts` - Add admin dashboard color scheme
- `components.json` - Ensure all required shadcn/ui components are available
## Functions
Core functionality for the management platform.
New functions/components:
- `lib/auth.ts`
- `export const authOptions: NextAuthOptions` - NextAuth configuration
- `export function getServerSession()` - Server-side session retrieval
- `export function requireAuth(role?: UserRole)` - Route protection utility
- `lib/db.ts`
- `export async function getArtists()` - Fetch all artists
- `export async function getArtist(id: string)` - Fetch single artist
- `export async function createArtist(data: CreateArtistInput)` - Create new artist
- `export async function updateArtist(id: string, data: UpdateArtistInput)` - Update artist
- `export async function deleteArtist(id: string)` - Delete artist
- `export async function getAppointments(filters?: AppointmentFilters)` - Fetch appointments
- `export async function createAppointment(data: CreateAppointmentInput)` - Create appointment
- `export async function getSiteSettings()` - Fetch site settings
- `export async function updateSiteSettings(data: UpdateSiteSettingsInput)` - Update settings
- `lib/upload.ts`
- `export async function uploadFile(file: File, path: string)` - Upload file to storage
- `export async function deleteFile(url: string)` - Delete file from storage
- `export function getSignedUrl(key: string)` - Generate signed URLs
- `components/admin/artist-form.tsx`
- `export function ArtistForm({ artist, onSubmit }: ArtistFormProps)` - Artist creation/editing form
- `components/admin/portfolio-manager.tsx`
- `export function PortfolioManager({ artistId, images }: PortfolioManagerProps)` - Portfolio management
- `components/admin/calendar-view.tsx`
- `export function CalendarView({ appointments, onAppointmentClick }: CalendarViewProps)` - Calendar interface
- `components/admin/file-uploader.tsx`
- `export function FileUploader({ onUpload, accept }: FileUploaderProps)` - File upload component
Modified functions/components:
- `data/artists.ts`
- Convert static data to `export async function getArtistsData()` that fetches from database
- `components/artists-section.tsx`
- Update to use React Query for data fetching
- Add loading and error states
- `components/artist-portfolio.tsx`
- Update to fetch dynamic portfolio data
- Add image optimization and lazy loading
- `app/artists/[id]/page.tsx`
- Add database integration for artist data
- Implement proper error handling and 404 states
Removed functions/components:
- None initially - maintain backward compatibility
## Classes
No class-based components - using React function components throughout.
All components will be implemented as TypeScript function components with proper prop typing and error boundaries where appropriate.
## Dependencies
Required new packages for the comprehensive platform.
```json
{
"dependencies": {
"next-auth": "^4.24.5",
"@auth/supabase-adapter": "^1.0.0",
"@supabase/supabase-js": "^2.39.0",
"@tanstack/react-query": "^5.17.0",
"@tanstack/react-query-devtools": "^5.17.0",
"zod": "^3.22.4",
"react-hook-form": "^7.48.2",
"@hookform/resolvers": "^3.3.2",
"react-dropzone": "^14.2.3",
"@aws-sdk/client-s3": "^3.490.0",
"@aws-sdk/s3-request-presigner": "^3.490.0",
"date-fns": "^3.0.6",
"react-big-calendar": "^1.8.5",
"recharts": "^2.8.0",
"sonner": "^1.3.1"
},
"devDependencies": {
"@types/react-big-calendar": "^1.8.0"
}
}
```
Integration requirements:
- Supabase for database and real-time subscriptions
- NextAuth.js for authentication with multiple providers
- React Query for server state management
- Cloudflare R2 or AWS S3 for file storage
- Zod for runtime validation
## Testing
Comprehensive testing strategy for the management platform.
Test file requirements:
- `__tests__/lib/auth.test.ts` - Authentication utility tests
- `__tests__/lib/db.test.ts` - Database operation tests
- `__tests__/components/admin/artist-form.test.tsx` - Artist form component tests
- `__tests__/api/artists.test.ts` - Artist API endpoint tests
- `__tests__/pages/admin/artists.test.tsx` - Artist management page tests
Existing test modifications:
- Update existing component tests to handle dynamic data
- Add integration tests for database operations
- Add E2E tests for admin workflows
Validation strategies:
- Unit tests for all utility functions
- Component tests for admin interface components
- Integration tests for API endpoints
- E2E tests for critical user journeys (artist creation, appointment booking)
- Performance testing for file uploads and image optimization
## Implementation Order
Logical sequence to minimize conflicts and ensure successful integration.
1. **Environment & Database Setup**
- Set up environment variables and validation (`lib/env.ts`)
- Configure database connection (`lib/db.ts`)
- Set up authentication (`lib/auth.ts`, `middleware.ts`)
2. **Core API Infrastructure**
- Implement artist API endpoints (`app/api/artists/`)
- Implement file upload API (`app/api/upload/route.ts`)
- Implement settings API (`app/api/settings/route.ts`)
3. **Authentication System**
- Create login/logout functionality (`components/auth/`)
- Implement role-based access control
- Add authentication to navigation
4. **Admin Dashboard Foundation**
- Create admin layout and sidebar (`app/admin/layout.tsx`, `components/admin/sidebar.tsx`)
- Implement admin dashboard overview (`app/admin/page.tsx`)
- Add role-based route protection
5. **Artist Management System**
- Create artist management interface (`app/admin/artists/`)
- Implement artist form component (`components/admin/artist-form.tsx`)
- Add portfolio management (`components/admin/portfolio-manager.tsx`)
6. **File Upload System**
- Implement file upload utilities (`lib/upload.ts`)
- Create file uploader component (`components/admin/file-uploader.tsx`)
- Add file management interface (`app/admin/uploads/page.tsx`)
7. **Site Settings Management**
- Create settings form (`components/admin/settings-form.tsx`)
- Implement settings management page (`app/admin/settings/page.tsx`)
- Update public site to use dynamic settings
8. **Dynamic Content Integration**
- Update existing components to use database data
- Implement React Query for data fetching
- Add loading states and error handling
9. **Calendar & Appointment System** (Future Phase)
- Implement calendar view (`components/admin/calendar-view.tsx`)
- Add appointment management (`app/api/appointments/`)
- Create booking interface for clients
10. **Testing & Optimization**
- Add comprehensive test coverage
- Implement performance optimizations
- Add monitoring and analytics

224
hooks/use-artists.ts Normal file
View File

@ -0,0 +1,224 @@
"use client"
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
import { toast } from "sonner"
import type { Artist, CreateArtistInput, UpdateArtistInput } from "@/types/database"
// API functions
async function fetchArtists(params?: {
page?: number
limit?: number
isActive?: boolean
specialty?: string
search?: string
}): Promise<{
artists: Artist[]
pagination: {
page: number
limit: number
total: number
totalPages: number
}
}> {
const searchParams = new URLSearchParams()
if (params?.page) searchParams.set("page", params.page.toString())
if (params?.limit) searchParams.set("limit", params.limit.toString())
if (params?.isActive !== undefined) searchParams.set("isActive", params.isActive.toString())
if (params?.specialty) searchParams.set("specialty", params.specialty)
if (params?.search) searchParams.set("search", params.search)
const response = await fetch(`/api/artists?${searchParams}`)
if (!response.ok) {
throw new Error("Failed to fetch artists")
}
return response.json()
}
async function fetchArtist(id: string): Promise<Artist> {
const response = await fetch(`/api/artists/${id}`)
if (!response.ok) {
if (response.status === 404) {
throw new Error("Artist not found")
}
throw new Error("Failed to fetch artist")
}
return response.json()
}
async function createArtist(data: CreateArtistInput): Promise<Artist> {
const response = await fetch("/api/artists", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(data),
})
if (!response.ok) {
const error = await response.json()
throw new Error(error.error || "Failed to create artist")
}
return response.json()
}
async function updateArtist(id: string, data: Partial<UpdateArtistInput>): Promise<Artist> {
const response = await fetch(`/api/artists/${id}`, {
method: "PUT",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(data),
})
if (!response.ok) {
const error = await response.json()
throw new Error(error.error || "Failed to update artist")
}
return response.json()
}
async function deleteArtist(id: string): Promise<void> {
const response = await fetch(`/api/artists/${id}`, {
method: "DELETE",
})
if (!response.ok) {
const error = await response.json()
throw new Error(error.error || "Failed to delete artist")
}
}
// React Query hooks
export function useArtists(params?: {
page?: number
limit?: number
isActive?: boolean
specialty?: string
search?: string
}) {
return useQuery({
queryKey: ["artists", params],
queryFn: () => fetchArtists(params),
staleTime: 5 * 60 * 1000, // 5 minutes
})
}
export function useArtist(id: string) {
return useQuery({
queryKey: ["artists", id],
queryFn: () => fetchArtist(id),
enabled: !!id,
staleTime: 5 * 60 * 1000, // 5 minutes
})
}
export function useCreateArtist() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: createArtist,
onSuccess: (newArtist) => {
// Invalidate and refetch artists list
queryClient.invalidateQueries({ queryKey: ["artists"] })
// Add the new artist to the cache
queryClient.setQueryData(["artists", newArtist.id], newArtist)
toast.success("Artist created successfully")
},
onError: (error: Error) => {
toast.error(error.message || "Failed to create artist")
},
})
}
export function useUpdateArtist() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: ({ id, data }: { id: string; data: Partial<UpdateArtistInput> }) =>
updateArtist(id, data),
onSuccess: (updatedArtist) => {
// Update the specific artist in cache
queryClient.setQueryData(["artists", updatedArtist.id], updatedArtist)
// Invalidate artists list to ensure consistency
queryClient.invalidateQueries({ queryKey: ["artists"] })
toast.success("Artist updated successfully")
},
onError: (error: Error) => {
toast.error(error.message || "Failed to update artist")
},
})
}
export function useDeleteArtist() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: deleteArtist,
onSuccess: (_, deletedId) => {
// Remove the artist from cache
queryClient.removeQueries({ queryKey: ["artists", deletedId] })
// Invalidate artists list
queryClient.invalidateQueries({ queryKey: ["artists"] })
toast.success("Artist deleted successfully")
},
onError: (error: Error) => {
toast.error(error.message || "Failed to delete artist")
},
})
}
// Utility hooks for common operations
export function useActiveArtists() {
return useArtists({ isActive: true })
}
export function useArtistsBySpecialty(specialty: string) {
return useArtists({ specialty, isActive: true })
}
export function useSearchArtists(search: string) {
return useArtists({ search, isActive: true })
}
// Prefetch functions for better UX
export function usePrefetchArtist() {
const queryClient = useQueryClient()
return (id: string) => {
queryClient.prefetchQuery({
queryKey: ["artists", id],
queryFn: () => fetchArtist(id),
staleTime: 5 * 60 * 1000,
})
}
}
// Cache management utilities
export function useInvalidateArtists() {
const queryClient = useQueryClient()
return () => {
queryClient.invalidateQueries({ queryKey: ["artists"] })
}
}
export function useRefreshArtist() {
const queryClient = useQueryClient()
return (id: string) => {
queryClient.invalidateQueries({ queryKey: ["artists", id] })
}
}

281
hooks/use-file-upload.ts Normal file
View File

@ -0,0 +1,281 @@
import { useState, useCallback } from 'react'
import type { FileUploadProgress, R2UploadResponse, BulkUploadResult } from '@/lib/r2-upload'
export interface UseFileUploadOptions {
maxFiles?: number
maxSize?: number
allowedTypes?: string[]
onProgress?: (progress: FileUploadProgress[]) => void
onComplete?: (results: BulkUploadResult) => void
onError?: (error: string) => void
}
export interface FileUploadHook {
uploadFiles: (files: File[], options?: { keyPrefix?: string }) => Promise<void>
uploadSingleFile: (file: File, key?: string) => Promise<R2UploadResponse>
progress: FileUploadProgress[]
isUploading: boolean
error: string | null
clearProgress: () => void
removeFile: (id: string) => void
}
export function useFileUpload(options: UseFileUploadOptions = {}): FileUploadHook {
const [progress, setProgress] = useState<FileUploadProgress[]>([])
const [isUploading, setIsUploading] = useState(false)
const [error, setError] = useState<string | null>(null)
const {
maxFiles = 10,
maxSize = 10 * 1024 * 1024, // 10MB
allowedTypes = ['image/jpeg', 'image/png', 'image/webp', 'image/gif'],
onProgress,
onComplete,
onError,
} = options
const validateFiles = useCallback((files: File[]): { valid: File[]; errors: string[] } => {
const valid: File[] = []
const errors: string[] = []
if (files.length > maxFiles) {
errors.push(`Maximum ${maxFiles} files allowed`)
return { valid, errors }
}
for (const file of files) {
if (file.size > maxSize) {
errors.push(`${file.name}: File size exceeds ${Math.round(maxSize / 1024 / 1024)}MB limit`)
continue
}
if (!allowedTypes.includes(file.type)) {
errors.push(`${file.name}: File type ${file.type} not allowed`)
continue
}
valid.push(file)
}
return { valid, errors }
}, [maxFiles, maxSize, allowedTypes])
const uploadSingleFile = useCallback(async (
file: File,
key?: string
): Promise<R2UploadResponse> => {
const fileId = `${Date.now()}-${Math.random().toString(36).substring(2)}`
// Add to progress tracking
const initialProgress: FileUploadProgress = {
id: fileId,
filename: file.name,
progress: 0,
status: 'uploading',
}
setProgress(prev => [...prev, initialProgress])
setError(null)
try {
// Simulate progress updates (since we can't track actual upload progress with R2)
const progressInterval = setInterval(() => {
setProgress(prev => prev.map(p =>
p.id === fileId && p.progress < 90
? { ...p, progress: Math.min(90, p.progress + Math.random() * 20) }
: p
))
}, 200)
// Upload to API endpoint
const formData = new FormData()
formData.append('file', file)
if (key) formData.append('key', key)
const response = await fetch('/api/upload', {
method: 'POST',
body: formData,
})
clearInterval(progressInterval)
const result = await response.json()
if (result.success) {
setProgress(prev => prev.map(p =>
p.id === fileId
? { ...p, progress: 100, status: 'complete', url: result.url }
: p
))
return result
} else {
setProgress(prev => prev.map(p =>
p.id === fileId
? { ...p, status: 'error', error: result.error }
: p
))
return {
success: false,
error: result.error || 'Upload failed',
}
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Upload failed'
setProgress(prev => prev.map(p =>
p.id === fileId
? { ...p, status: 'error', error: errorMessage }
: p
))
return {
success: false,
error: errorMessage,
}
}
}, [])
const uploadFiles = useCallback(async (
files: File[],
uploadOptions?: { keyPrefix?: string }
): Promise<void> => {
setIsUploading(true)
setError(null)
try {
// Validate files
const { valid, errors } = validateFiles(files)
if (errors.length > 0) {
const errorMessage = errors.join(', ')
setError(errorMessage)
onError?.(errorMessage)
return
}
if (valid.length === 0) {
setError('No valid files to upload')
onError?.('No valid files to upload')
return
}
// Upload files sequentially to avoid overwhelming the server
const results: R2UploadResponse[] = []
for (const file of valid) {
const key = uploadOptions?.keyPrefix
? `${uploadOptions.keyPrefix}/${Date.now()}-${file.name}`
: undefined
const result = await uploadSingleFile(file, key)
results.push(result)
}
// Process results
const successful = results.filter(r => r.success).map(r => ({
filename: valid.find(f => results.indexOf(r) === valid.indexOf(f))?.name || '',
url: r.url || '',
key: r.key || '',
size: valid.find(f => results.indexOf(r) === valid.indexOf(f))?.size || 0,
mimeType: valid.find(f => results.indexOf(r) === valid.indexOf(f))?.type || '',
}))
const failed = results
.map((r, index) => ({ result: r, file: valid[index] }))
.filter(({ result }) => !result.success)
.map(({ result, file }) => ({
filename: file.name,
error: result.error || 'Upload failed',
}))
const bulkResult: BulkUploadResult = {
successful,
failed,
total: valid.length,
}
onComplete?.(bulkResult)
// Update progress with final results
const currentProgress = [...progress]
onProgress?.(currentProgress)
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Upload failed'
setError(errorMessage)
onError?.(errorMessage)
} finally {
setIsUploading(false)
}
}, [progress, validateFiles, uploadSingleFile, onProgress, onComplete, onError])
const clearProgress = useCallback(() => {
setProgress([])
setError(null)
}, [])
const removeFile = useCallback((id: string) => {
setProgress(prev => prev.filter(p => p.id !== id))
}, [])
return {
uploadFiles,
uploadSingleFile,
progress,
isUploading,
error,
clearProgress,
removeFile,
}
}
/**
* Hook specifically for portfolio image uploads
*/
export function usePortfolioUpload(artistId: string) {
const baseHook = useFileUpload({
maxFiles: 20,
maxSize: 5 * 1024 * 1024, // 5MB for portfolio images
allowedTypes: ['image/jpeg', 'image/png', 'image/webp'],
})
const uploadPortfolioImages = useCallback(async (
files: File[],
options?: {
caption?: string
tags?: string[]
}
) => {
return baseHook.uploadFiles(files, {
keyPrefix: `portfolio/${artistId}`,
})
}, [artistId, baseHook])
return {
...baseHook,
uploadPortfolioImages,
}
}
/**
* Hook for artist profile image upload
*/
export function useProfileImageUpload(artistId: string) {
const baseHook = useFileUpload({
maxFiles: 1,
maxSize: 2 * 1024 * 1024, // 2MB for profile images
allowedTypes: ['image/jpeg', 'image/png', 'image/webp'],
})
const uploadProfileImage = useCallback(async (file: File) => {
const key = `profiles/${artistId}/profile-${Date.now()}.${file.name.split('.').pop()}`
return baseHook.uploadSingleFile(file, key)
}, [artistId, baseHook])
return {
...baseHook,
uploadProfileImage,
}
}

197
lib/auth.ts Normal file
View File

@ -0,0 +1,197 @@
import { NextAuthOptions } from "next-auth"
import GoogleProvider from "next-auth/providers/google"
import GitHubProvider from "next-auth/providers/github"
import CredentialsProvider from "next-auth/providers/credentials"
import { env } from "./env"
import { UserRole } from "@/types/database"
export const authOptions: NextAuthOptions = {
// Note: Database adapter will be configured via Supabase MCP
// For now, using JWT strategy without database adapter
providers: [
// Credentials provider for email/password login
CredentialsProvider({
name: "credentials",
credentials: {
email: { label: "Email", type: "email" },
password: { label: "Password", type: "password" }
},
async authorize(credentials) {
console.log("Authorize called with:", credentials)
if (!credentials?.email || !credentials?.password) {
console.log("Missing email or password")
return null
}
console.log("Email received:", credentials.email)
console.log("Password received:", credentials.password ? "***" : "empty")
// Seed admin user for nicholai@biohazardvfx.com
if (credentials.email === "nicholai@biohazardvfx.com") {
console.log("Admin user recognized!")
return {
id: "admin-nicholai",
email: "nicholai@biohazardvfx.com",
name: "Nicholai",
role: UserRole.SUPER_ADMIN,
}
}
// For development: Accept any other email/password combination
console.log("Using fallback user creation")
const user = {
id: "dev-user-" + Date.now(),
email: credentials.email,
name: credentials.email.split("@")[0],
role: UserRole.SUPER_ADMIN, // Give admin access for testing
}
console.log("Created user:", user)
return user
}
}),
// Google OAuth provider (optional)
...(env.GOOGLE_CLIENT_ID && env.GOOGLE_CLIENT_SECRET ? [
GoogleProvider({
clientId: env.GOOGLE_CLIENT_ID,
clientSecret: env.GOOGLE_CLIENT_SECRET,
})
] : []),
// GitHub OAuth provider (optional)
...(env.GITHUB_CLIENT_ID && env.GITHUB_CLIENT_SECRET ? [
GitHubProvider({
clientId: env.GITHUB_CLIENT_ID,
clientSecret: env.GITHUB_CLIENT_SECRET,
})
] : []),
],
session: {
strategy: "jwt",
maxAge: 30 * 24 * 60 * 60, // 30 days
},
callbacks: {
async jwt({ token, user, account }) {
// Add user role to JWT token
if (user) {
// Use the role from the user object (set in authorize function)
token.role = (user as any).role || UserRole.CLIENT
token.userId = user.id
}
return token
},
async session({ session, token }) {
// Add user role and ID to session
if (token) {
session.user.id = token.userId as string
session.user.role = token.role as UserRole
}
return session
},
async signIn({ user, account, profile }) {
// Custom sign-in logic
return true
},
async redirect({ url, baseUrl }) {
// Follows NextAuth.js best practices for redirect
if (url.startsWith("/")) return `${baseUrl}${url}`
else if (new URL(url).origin === baseUrl) return url
return `${baseUrl}/admin`
},
},
pages: {
signIn: "/auth/signin",
error: "/auth/error",
},
events: {
async signIn({ user, account, profile, isNewUser }) {
// Log sign-in events
console.log(`User ${user.email} signed in`)
},
async signOut({ session, token }) {
// Log sign-out events
console.log(`User signed out`)
},
},
debug: env.NODE_ENV === "development",
}
/**
* Utility function to get server-side session
*/
export async function getServerSession() {
const { getServerSession: getNextAuthServerSession } = await import("next-auth/next")
return getNextAuthServerSession(authOptions)
}
/**
* Route protection utility
* @param requiredRole - Minimum role required to access the route
*/
export async function requireAuth(requiredRole?: UserRole) {
const session = await getServerSession()
if (!session) {
throw new Error("Authentication required")
}
if (requiredRole && !hasRole(session.user.role, requiredRole)) {
throw new Error("Insufficient permissions")
}
return session
}
/**
* Check if user has required role or higher
*/
export function hasRole(userRole: UserRole, requiredRole: UserRole): boolean {
const roleHierarchy = {
[UserRole.CLIENT]: 0,
[UserRole.ARTIST]: 1,
[UserRole.SHOP_ADMIN]: 2,
[UserRole.SUPER_ADMIN]: 3,
}
return roleHierarchy[userRole] >= roleHierarchy[requiredRole]
}
/**
* Check if user is admin (SHOP_ADMIN or SUPER_ADMIN)
*/
export function isAdmin(role: UserRole): boolean {
return role === UserRole.SHOP_ADMIN || role === UserRole.SUPER_ADMIN
}
/**
* Check if user is super admin
*/
export function isSuperAdmin(role: UserRole): boolean {
return role === UserRole.SUPER_ADMIN
}
// Extend NextAuth types
declare module "next-auth" {
interface Session {
user: {
id: string
email: string
name: string
image?: string
role: UserRole
}
}
interface User {
role: UserRole
}
}
declare module "next-auth/jwt" {
interface JWT {
userId: string
role: UserRole
}
}

308
lib/data-migration.ts Normal file
View File

@ -0,0 +1,308 @@
import { artists } from '@/data/artists'
import type { CreateArtistInput } from '@/types/database'
// Type for Cloudflare D1 database binding
interface Env {
DB: D1Database;
}
// Get the database instance from the environment
function getDB(): D1Database {
// @ts-ignore - This will be available in the Cloudflare Workers runtime
return globalThis.DB || (globalThis as any).env?.DB;
}
/**
* Migration utility to populate D1 database with existing artist data
*/
export class DataMigrator {
private db: D1Database;
constructor() {
this.db = getDB();
}
/**
* Migrate all artist data from data/artists.ts to D1 database
*/
async migrateArtistData(): Promise<void> {
console.log('Starting artist data migration...');
try {
// First, create users for each artist
const userInserts = artists.map(artist => this.createUserForArtist(artist));
await Promise.all(userInserts);
// Then create artist records
const artistInserts = artists.map(artist => this.createArtistRecord(artist));
await Promise.all(artistInserts);
// Finally, create portfolio images
const portfolioInserts = artists.map(artist => this.createPortfolioImages(artist));
await Promise.all(portfolioInserts);
console.log(`Successfully migrated ${artists.length} artists to database`);
} catch (error) {
console.error('Error during artist data migration:', error);
throw error;
}
}
/**
* Create a user record for an artist
*/
private async createUserForArtist(artist: any): Promise<void> {
const userId = `user-${artist.id}`;
const email = artist.email || `${artist.name.toLowerCase().replace(/\s+/g, '.')}@unitedtattoo.com`;
try {
await this.db.prepare(`
INSERT OR IGNORE INTO users (id, email, name, role, created_at, updated_at)
VALUES (?, ?, ?, 'ARTIST', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
`).bind(userId, email, artist.name).run();
console.log(`Created user for artist: ${artist.name}`);
} catch (error) {
console.error(`Error creating user for artist ${artist.name}:`, error);
throw error;
}
}
/**
* Create an artist record
*/
private async createArtistRecord(artist: any): Promise<void> {
const artistId = `artist-${artist.id}`;
const userId = `user-${artist.id}`;
// Convert styles array to specialties
const specialties = artist.styles || [];
// Extract hourly rate from experience or set default
const hourlyRate = this.extractHourlyRate(artist.experience);
try {
await this.db.prepare(`
INSERT OR IGNORE INTO artists (
id, user_id, name, bio, specialties, instagram_handle,
hourly_rate, is_active, created_at, updated_at
)
VALUES (?, ?, ?, ?, ?, ?, ?, 1, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
`).bind(
artistId,
userId,
artist.name,
artist.bio,
JSON.stringify(specialties),
artist.instagram ? this.extractInstagramHandle(artist.instagram) : null,
hourlyRate,
).run();
console.log(`Created artist record: ${artist.name}`);
} catch (error) {
console.error(`Error creating artist record for ${artist.name}:`, error);
throw error;
}
}
/**
* Create portfolio images for an artist
*/
private async createPortfolioImages(artist: any): Promise<void> {
const artistId = `artist-${artist.id}`;
// Create portfolio images from workImages array
if (artist.workImages && Array.isArray(artist.workImages)) {
for (let i = 0; i < artist.workImages.length; i++) {
const imageUrl = artist.workImages[i];
const imageId = `portfolio-${artist.id}-${i + 1}`;
try {
await this.db.prepare(`
INSERT OR IGNORE INTO portfolio_images (
id, artist_id, url, caption, tags, order_index,
is_public, created_at
)
VALUES (?, ?, ?, ?, ?, ?, 1, CURRENT_TIMESTAMP)
`).bind(
imageId,
artistId,
imageUrl,
`${artist.name} - Portfolio Image ${i + 1}`,
JSON.stringify(artist.styles || []),
i
).run();
} catch (error) {
console.error(`Error creating portfolio image for ${artist.name}:`, error);
}
}
}
// Also add the face image as a portfolio image
if (artist.faceImage) {
const faceImageId = `portfolio-${artist.id}-face`;
try {
await this.db.prepare(`
INSERT OR IGNORE INTO portfolio_images (
id, artist_id, url, caption, tags, order_index,
is_public, created_at
)
VALUES (?, ?, ?, ?, ?, ?, 1, CURRENT_TIMESTAMP)
`).bind(
faceImageId,
artistId,
artist.faceImage,
`${artist.name} - Profile Photo`,
JSON.stringify(['profile']),
-1 // Face image gets negative order to appear first
).run();
} catch (error) {
console.error(`Error creating face image for ${artist.name}:`, error);
}
}
console.log(`Created portfolio images for: ${artist.name}`);
}
/**
* Extract Instagram handle from full URL
*/
private extractInstagramHandle(instagramUrl: string): string | null {
if (!instagramUrl) return null;
// Extract handle from Instagram URL
const match = instagramUrl.match(/instagram\.com\/([^\/\?]+)/);
return match ? match[1] : null;
}
/**
* Extract or estimate hourly rate based on experience
*/
private extractHourlyRate(experience: string): number {
// Default rates based on experience level
const experienceRates: { [key: string]: number } = {
'Apprentice': 80,
'5 years': 120,
'6 years': 130,
'7 years': 140,
'8 years': 150,
'10 years': 170,
'12+ years': 200,
'22+ years': 250,
'30+ years': 300,
};
// Try to find exact match first
if (experienceRates[experience]) {
return experienceRates[experience];
}
// Extract years from experience string and estimate rate
const yearMatch = experience.match(/(\d+)/);
if (yearMatch) {
const years = parseInt(yearMatch[1]);
if (years <= 2) return 80;
if (years <= 5) return 120;
if (years <= 10) return 150;
if (years <= 15) return 180;
if (years <= 20) return 220;
return 250;
}
// Default rate for unknown experience
return 120;
}
/**
* Check if migration has already been completed
*/
async isMigrationCompleted(): Promise<boolean> {
try {
const result = await this.db.prepare('SELECT COUNT(*) as count FROM artists').first();
return (result as any)?.count > 0;
} catch (error) {
console.error('Error checking migration status:', error);
return false;
}
}
/**
* Clear all migrated data (for testing purposes)
*/
async clearMigratedData(): Promise<void> {
console.log('Clearing migrated data...');
try {
// Delete in reverse order due to foreign key constraints
await this.db.prepare('DELETE FROM portfolio_images').run();
await this.db.prepare('DELETE FROM artists').run();
await this.db.prepare('DELETE FROM users WHERE role = "ARTIST"').run();
console.log('Successfully cleared migrated data');
} catch (error) {
console.error('Error clearing migrated data:', error);
throw error;
}
}
/**
* Get migration statistics
*/
async getMigrationStats(): Promise<{
totalUsers: number;
totalArtists: number;
totalPortfolioImages: number;
}> {
try {
const [usersResult, artistsResult, imagesResult] = await Promise.all([
this.db.prepare('SELECT COUNT(*) as count FROM users WHERE role = "ARTIST"').first(),
this.db.prepare('SELECT COUNT(*) as count FROM artists').first(),
this.db.prepare('SELECT COUNT(*) as count FROM portfolio_images').first(),
]);
return {
totalUsers: (usersResult as any)?.count || 0,
totalArtists: (artistsResult as any)?.count || 0,
totalPortfolioImages: (imagesResult as any)?.count || 0,
};
} catch (error) {
console.error('Error getting migration stats:', error);
return { totalUsers: 0, totalArtists: 0, totalPortfolioImages: 0 };
}
}
}
/**
* Convenience function to run migration
*/
export async function migrateArtistData(): Promise<void> {
const migrator = new DataMigrator();
// Check if migration has already been completed
const isCompleted = await migrator.isMigrationCompleted();
if (isCompleted) {
console.log('Migration already completed. Skipping...');
return;
}
await migrator.migrateArtistData();
}
/**
* Convenience function to get migration stats
*/
export async function getMigrationStats() {
const migrator = new DataMigrator();
return await migrator.getMigrationStats();
}
/**
* Convenience function to clear migrated data (for development/testing)
*/
export async function clearMigratedData(): Promise<void> {
const migrator = new DataMigrator();
await migrator.clearMigratedData();
}

457
lib/db.ts Normal file
View File

@ -0,0 +1,457 @@
import type {
Artist,
PortfolioImage,
Appointment,
SiteSettings,
CreateArtistInput,
UpdateArtistInput,
CreateAppointmentInput,
UpdateSiteSettingsInput,
AppointmentFilters
} from '@/types/database'
// Type for Cloudflare D1 database binding
interface Env {
DB: D1Database;
R2_BUCKET: R2Bucket;
}
// Get the database instance from the environment
// In development, this will be available through the runtime
// In production, this will be bound via wrangler.toml
export function getDB(): D1Database {
// @ts-ignore - This will be available in the Cloudflare Workers runtime
return globalThis.DB || (globalThis as any).env?.DB;
}
/**
* Artist Management Functions
*/
export async function getArtists(): Promise<Artist[]> {
const db = getDB();
const result = await db.prepare(`
SELECT
a.*,
u.name as user_name,
u.email as user_email
FROM artists a
LEFT JOIN users u ON a.user_id = u.id
WHERE a.is_active = 1
ORDER BY a.created_at DESC
`).all();
return result.results as Artist[];
}
export async function getArtist(id: string): Promise<Artist | null> {
const db = getDB();
const result = await db.prepare(`
SELECT
a.*,
u.name as user_name,
u.email as user_email
FROM artists a
LEFT JOIN users u ON a.user_id = u.id
WHERE a.id = ?
`).bind(id).first();
return result as Artist | null;
}
export async function createArtist(data: CreateArtistInput): Promise<Artist> {
const db = getDB();
const id = crypto.randomUUID();
// First create or get the user
let userId = data.userId;
if (!userId) {
const userResult = await db.prepare(`
INSERT INTO users (id, email, name, role)
VALUES (?, ?, ?, 'ARTIST')
RETURNING id
`).bind(crypto.randomUUID(), data.email || `${data.name.toLowerCase().replace(/\s+/g, '.')}@unitedtattoo.com`, data.name).first();
userId = (userResult as any)?.id;
}
const result = await db.prepare(`
INSERT INTO artists (id, user_id, name, bio, specialties, instagram_handle, hourly_rate, is_active)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
RETURNING *
`).bind(
id,
userId,
data.name,
data.bio,
JSON.stringify(data.specialties),
data.instagramHandle || null,
data.hourlyRate || null,
data.isActive !== false
).first();
return result as Artist;
}
export async function updateArtist(id: string, data: UpdateArtistInput): Promise<Artist> {
const db = getDB();
const setParts: string[] = [];
const values: any[] = [];
if (data.name !== undefined) {
setParts.push('name = ?');
values.push(data.name);
}
if (data.bio !== undefined) {
setParts.push('bio = ?');
values.push(data.bio);
}
if (data.specialties !== undefined) {
setParts.push('specialties = ?');
values.push(JSON.stringify(data.specialties));
}
if (data.instagramHandle !== undefined) {
setParts.push('instagram_handle = ?');
values.push(data.instagramHandle);
}
if (data.hourlyRate !== undefined) {
setParts.push('hourly_rate = ?');
values.push(data.hourlyRate);
}
if (data.isActive !== undefined) {
setParts.push('is_active = ?');
values.push(data.isActive);
}
setParts.push('updated_at = CURRENT_TIMESTAMP');
values.push(id);
const result = await db.prepare(`
UPDATE artists
SET ${setParts.join(', ')}
WHERE id = ?
RETURNING *
`).bind(...values).first();
return result as Artist;
}
export async function deleteArtist(id: string): Promise<void> {
const db = getDB();
await db.prepare('UPDATE artists SET is_active = 0 WHERE id = ?').bind(id).run();
}
/**
* Portfolio Image Management Functions
*/
export async function getPortfolioImages(artistId: string): Promise<PortfolioImage[]> {
const db = getDB();
const result = await db.prepare(`
SELECT * FROM portfolio_images
WHERE artist_id = ? AND is_public = 1
ORDER BY order_index ASC, created_at DESC
`).bind(artistId).all();
return result.results as PortfolioImage[];
}
export async function addPortfolioImage(artistId: string, imageData: Omit<PortfolioImage, 'id' | 'artistId' | 'createdAt'>): Promise<PortfolioImage> {
const db = getDB();
const id = crypto.randomUUID();
const result = await db.prepare(`
INSERT INTO portfolio_images (id, artist_id, url, caption, tags, order_index, is_public)
VALUES (?, ?, ?, ?, ?, ?, ?)
RETURNING *
`).bind(
id,
artistId,
imageData.url,
imageData.caption || null,
imageData.tags ? JSON.stringify(imageData.tags) : null,
imageData.orderIndex || 0,
imageData.isPublic !== false
).first();
return result as PortfolioImage;
}
export async function updatePortfolioImage(id: string, data: Partial<PortfolioImage>): Promise<PortfolioImage> {
const db = getDB();
const setParts: string[] = [];
const values: any[] = [];
if (data.url !== undefined) {
setParts.push('url = ?');
values.push(data.url);
}
if (data.caption !== undefined) {
setParts.push('caption = ?');
values.push(data.caption);
}
if (data.tags !== undefined) {
setParts.push('tags = ?');
values.push(data.tags ? JSON.stringify(data.tags) : null);
}
if (data.orderIndex !== undefined) {
setParts.push('order_index = ?');
values.push(data.orderIndex);
}
if (data.isPublic !== undefined) {
setParts.push('is_public = ?');
values.push(data.isPublic);
}
values.push(id);
const result = await db.prepare(`
UPDATE portfolio_images
SET ${setParts.join(', ')}
WHERE id = ?
RETURNING *
`).bind(...values).first();
return result as PortfolioImage;
}
export async function deletePortfolioImage(id: string): Promise<void> {
const db = getDB();
await db.prepare('DELETE FROM portfolio_images WHERE id = ?').bind(id).run();
}
/**
* Appointment Management Functions
*/
export async function getAppointments(filters?: AppointmentFilters): Promise<Appointment[]> {
const db = getDB();
let query = `
SELECT
a.*,
ar.name as artist_name,
u.name as client_name,
u.email as client_email
FROM appointments a
LEFT JOIN artists ar ON a.artist_id = ar.id
LEFT JOIN users u ON a.client_id = u.id
WHERE 1=1
`;
const values: any[] = [];
if (filters?.artistId) {
query += ' AND a.artist_id = ?';
values.push(filters.artistId);
}
if (filters?.status) {
query += ' AND a.status = ?';
values.push(filters.status);
}
if (filters?.startDate) {
query += ' AND a.start_time >= ?';
values.push(filters.startDate);
}
if (filters?.endDate) {
query += ' AND a.start_time <= ?';
values.push(filters.endDate);
}
query += ' ORDER BY a.start_time ASC';
const result = await db.prepare(query).bind(...values).all();
return result.results as Appointment[];
}
export async function createAppointment(data: CreateAppointmentInput): Promise<Appointment> {
const db = getDB();
const id = crypto.randomUUID();
const result = await db.prepare(`
INSERT INTO appointments (
id, artist_id, client_id, title, description,
start_time, end_time, status, deposit_amount, total_amount, notes
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
RETURNING *
`).bind(
id,
data.artistId,
data.clientId,
data.title,
data.description || null,
data.startTime,
data.endTime,
data.status || 'PENDING',
data.depositAmount || null,
data.totalAmount || null,
data.notes || null
).first();
return result as Appointment;
}
export async function updateAppointment(id: string, data: Partial<Appointment>): Promise<Appointment> {
const db = getDB();
const setParts: string[] = [];
const values: any[] = [];
if (data.title !== undefined) {
setParts.push('title = ?');
values.push(data.title);
}
if (data.description !== undefined) {
setParts.push('description = ?');
values.push(data.description);
}
if (data.startTime !== undefined) {
setParts.push('start_time = ?');
values.push(data.startTime);
}
if (data.endTime !== undefined) {
setParts.push('end_time = ?');
values.push(data.endTime);
}
if (data.status !== undefined) {
setParts.push('status = ?');
values.push(data.status);
}
if (data.depositAmount !== undefined) {
setParts.push('deposit_amount = ?');
values.push(data.depositAmount);
}
if (data.totalAmount !== undefined) {
setParts.push('total_amount = ?');
values.push(data.totalAmount);
}
if (data.notes !== undefined) {
setParts.push('notes = ?');
values.push(data.notes);
}
setParts.push('updated_at = CURRENT_TIMESTAMP');
values.push(id);
const result = await db.prepare(`
UPDATE appointments
SET ${setParts.join(', ')}
WHERE id = ?
RETURNING *
`).bind(...values).first();
return result as Appointment;
}
export async function deleteAppointment(id: string): Promise<void> {
const db = getDB();
await db.prepare('DELETE FROM appointments WHERE id = ?').bind(id).run();
}
/**
* Site Settings Management Functions
*/
export async function getSiteSettings(): Promise<SiteSettings | null> {
const db = getDB();
const result = await db.prepare('SELECT * FROM site_settings WHERE id = ?').bind('default').first();
return result as SiteSettings | null;
}
export async function updateSiteSettings(data: UpdateSiteSettingsInput): Promise<SiteSettings> {
const db = getDB();
const setParts: string[] = [];
const values: any[] = [];
if (data.studioName !== undefined) {
setParts.push('studio_name = ?');
values.push(data.studioName);
}
if (data.description !== undefined) {
setParts.push('description = ?');
values.push(data.description);
}
if (data.address !== undefined) {
setParts.push('address = ?');
values.push(data.address);
}
if (data.phone !== undefined) {
setParts.push('phone = ?');
values.push(data.phone);
}
if (data.email !== undefined) {
setParts.push('email = ?');
values.push(data.email);
}
if (data.socialMedia !== undefined) {
setParts.push('social_media = ?');
values.push(JSON.stringify(data.socialMedia));
}
if (data.businessHours !== undefined) {
setParts.push('business_hours = ?');
values.push(JSON.stringify(data.businessHours));
}
if (data.heroImage !== undefined) {
setParts.push('hero_image = ?');
values.push(data.heroImage);
}
if (data.logoUrl !== undefined) {
setParts.push('logo_url = ?');
values.push(data.logoUrl);
}
setParts.push('updated_at = CURRENT_TIMESTAMP');
values.push('default');
const result = await db.prepare(`
UPDATE site_settings
SET ${setParts.join(', ')}
WHERE id = ?
RETURNING *
`).bind(...values).first();
return result as SiteSettings;
}
/**
* Utility Functions
*/
// Type-safe query builder helpers
export const db = {
artists: {
findMany: getArtists,
findUnique: getArtist,
create: createArtist,
update: updateArtist,
delete: deleteArtist,
},
portfolioImages: {
findMany: getPortfolioImages,
create: addPortfolioImage,
update: updatePortfolioImage,
delete: deletePortfolioImage,
},
appointments: {
findMany: getAppointments,
create: createAppointment,
update: updateAppointment,
delete: deleteAppointment,
},
siteSettings: {
findFirst: getSiteSettings,
update: updateSiteSettings,
},
}
// Helper function to get R2 bucket for file uploads
export function getR2Bucket(): R2Bucket {
// @ts-ignore - This will be available in the Cloudflare Workers runtime
return globalThis.R2_BUCKET || (globalThis as any).env?.R2_BUCKET;
}

53
lib/env.ts Normal file
View File

@ -0,0 +1,53 @@
import { z } from "zod"
const envSchema = z.object({
// Database
DATABASE_URL: z.string().url(),
DIRECT_URL: z.string().url().optional(),
// Authentication
NEXTAUTH_URL: z.string().url(),
NEXTAUTH_SECRET: z.string().min(1),
// OAuth Providers (optional)
GOOGLE_CLIENT_ID: z.string().optional(),
GOOGLE_CLIENT_SECRET: z.string().optional(),
GITHUB_CLIENT_ID: z.string().optional(),
GITHUB_CLIENT_SECRET: z.string().optional(),
// File Storage (AWS S3 or Cloudflare R2)
AWS_ACCESS_KEY_ID: z.string().min(1),
AWS_SECRET_ACCESS_KEY: z.string().min(1),
AWS_REGION: z.string().min(1),
AWS_BUCKET_NAME: z.string().min(1),
AWS_ENDPOINT_URL: z.string().url().optional(), // For Cloudflare R2
// Application
NODE_ENV: z.enum(["development", "production", "test"]).default("development"),
// Optional: Email service
SMTP_HOST: z.string().optional(),
SMTP_PORT: z.string().optional(),
SMTP_USER: z.string().optional(),
SMTP_PASSWORD: z.string().optional(),
// Optional: Analytics
VERCEL_ANALYTICS_ID: z.string().optional(),
})
export type Env = z.infer<typeof envSchema>
// Validate environment variables at boot
function validateEnv(): Env {
try {
return envSchema.parse(process.env)
} catch (error) {
if (error instanceof z.ZodError) {
const missingVars = error.errors.map(err => err.path.join('.')).join(', ')
throw new Error(`Missing or invalid environment variables: ${missingVars}`)
}
throw error
}
}
export const env = validateEnv()

355
lib/r2-upload.ts Normal file
View File

@ -0,0 +1,355 @@
import { getR2Bucket } from '@/lib/db'
export interface R2UploadResponse {
success: boolean
url?: string
key?: string
error?: string
}
export interface BulkUploadResult {
successful: FileUploadResult[]
failed: { filename: string; error: string }[]
total: number
}
export interface FileUploadResult {
filename: string
url: string
key: string
size: number
mimeType: string
}
export interface FileUploadProgress {
id: string
filename: string
progress: number
status: 'uploading' | 'processing' | 'complete' | 'error'
url?: string
error?: string
}
/**
* File Upload Manager for Cloudflare R2
*/
export class FileUploadManager {
private bucket: R2Bucket
private baseUrl: string
constructor() {
this.bucket = getR2Bucket()
// R2 public URL format: https://<account-id>.r2.cloudflarestorage.com/<bucket-name>
this.baseUrl = process.env.R2_PUBLIC_URL || ''
}
/**
* Upload a single file to R2
*/
async uploadFile(
file: File | Buffer,
key: string,
options?: {
contentType?: string
metadata?: Record<string, string>
}
): Promise<R2UploadResponse> {
try {
const fileBuffer = file instanceof File ? await file.arrayBuffer() : file.buffer as ArrayBuffer
const contentType = options?.contentType || (file instanceof File ? file.type : 'application/octet-stream')
// Upload to R2
await this.bucket.put(key, fileBuffer, {
httpMetadata: {
contentType,
},
customMetadata: options?.metadata || {},
})
const url = `${this.baseUrl}/${key}`
return {
success: true,
url,
key,
}
} catch (error) {
console.error('R2 upload error:', error)
return {
success: false,
error: error instanceof Error ? error.message : 'Upload failed',
}
}
}
/**
* Upload multiple files to R2
*/
async bulkUpload(
files: File[],
keyPrefix: string = 'uploads'
): Promise<BulkUploadResult> {
const successful: FileUploadResult[] = []
const failed: { filename: string; error: string }[] = []
for (const file of files) {
try {
const key = `${keyPrefix}/${Date.now()}-${file.name}`
const result = await this.uploadFile(file, key, {
contentType: file.type,
metadata: {
originalName: file.name,
uploadedAt: new Date().toISOString(),
},
})
if (result.success && result.url && result.key) {
successful.push({
filename: file.name,
url: result.url,
key: result.key,
size: file.size,
mimeType: file.type,
})
} else {
failed.push({
filename: file.name,
error: result.error || 'Upload failed',
})
}
} catch (error) {
failed.push({
filename: file.name,
error: error instanceof Error ? error.message : 'Upload failed',
})
}
}
return {
successful,
failed,
total: files.length,
}
}
/**
* Delete a file from R2
*/
async deleteFile(key: string): Promise<boolean> {
try {
await this.bucket.delete(key)
return true
} catch (error) {
console.error('R2 delete error:', error)
return false
}
}
/**
* Get file metadata from R2
*/
async getFileMetadata(key: string): Promise<R2Object | null> {
try {
return await this.bucket.get(key)
} catch (error) {
console.error('R2 metadata error:', error)
return null
}
}
/**
* Generate a presigned URL for direct upload
*/
async generatePresignedUrl(
key: string,
expiresIn: number = 3600
): Promise<string | null> {
try {
// Note: R2 presigned URLs require additional setup
// For now, we'll use direct upload through our API
return null
} catch (error) {
console.error('Presigned URL error:', error)
return null
}
}
/**
* Validate file before upload
*/
validateFile(file: File, options?: {
maxSize?: number
allowedTypes?: string[]
}): { valid: boolean; error?: string } {
const maxSize = options?.maxSize || 10 * 1024 * 1024 // 10MB default
const allowedTypes = options?.allowedTypes || [
'image/jpeg',
'image/png',
'image/webp',
'image/gif',
]
if (file.size > maxSize) {
return {
valid: false,
error: `File size exceeds ${Math.round(maxSize / 1024 / 1024)}MB limit`,
}
}
if (!allowedTypes.includes(file.type)) {
return {
valid: false,
error: `File type ${file.type} not allowed`,
}
}
return { valid: true }
}
/**
* Generate a unique key for file upload
*/
generateFileKey(filename: string, prefix: string = 'uploads'): string {
const timestamp = Date.now()
const randomString = Math.random().toString(36).substring(2, 15)
const extension = filename.split('.').pop()
const baseName = filename.replace(/\.[^/.]+$/, '').replace(/[^a-zA-Z0-9]/g, '-')
return `${prefix}/${timestamp}-${randomString}-${baseName}.${extension}`
}
}
/**
* Convenience functions for common upload operations
*/
export async function uploadToR2(
file: File,
key?: string,
options?: {
contentType?: string
metadata?: Record<string, string>
}
): Promise<R2UploadResponse> {
const manager = new FileUploadManager()
const uploadKey = key || manager.generateFileKey(file.name)
return await manager.uploadFile(file, uploadKey, options)
}
export async function bulkUploadToR2(
files: File[],
keyPrefix?: string
): Promise<BulkUploadResult> {
const manager = new FileUploadManager()
return await manager.bulkUpload(files, keyPrefix)
}
export async function deleteFromR2(key: string): Promise<boolean> {
const manager = new FileUploadManager()
return await manager.deleteFile(key)
}
export function validateUploadFile(
file: File,
options?: {
maxSize?: number
allowedTypes?: string[]
}
): { valid: boolean; error?: string } {
const manager = new FileUploadManager()
return manager.validateFile(file, options)
}
/**
* Portfolio image specific upload functions
*/
export async function uploadPortfolioImage(
file: File,
artistId: string,
options?: {
caption?: string
tags?: string[]
}
): Promise<R2UploadResponse & { portfolioData?: any }> {
const manager = new FileUploadManager()
// Validate image file
const validation = manager.validateFile(file, {
maxSize: 5 * 1024 * 1024, // 5MB for portfolio images
allowedTypes: ['image/jpeg', 'image/png', 'image/webp'],
})
if (!validation.valid) {
return {
success: false,
error: validation.error,
}
}
// Generate key for portfolio image
const key = manager.generateFileKey(file.name, `portfolio/${artistId}`)
// Upload to R2
const uploadResult = await manager.uploadFile(file, key, {
contentType: file.type,
metadata: {
artistId,
originalName: file.name,
caption: options?.caption || '',
tags: JSON.stringify(options?.tags || []),
uploadedAt: new Date().toISOString(),
},
})
if (uploadResult.success) {
return {
...uploadResult,
portfolioData: {
artistId,
url: uploadResult.url,
caption: options?.caption,
tags: options?.tags || [],
},
}
}
return uploadResult
}
/**
* Artist profile image upload
*/
export async function uploadArtistProfileImage(
file: File,
artistId: string
): Promise<R2UploadResponse> {
const manager = new FileUploadManager()
// Validate image file
const validation = manager.validateFile(file, {
maxSize: 2 * 1024 * 1024, // 2MB for profile images
allowedTypes: ['image/jpeg', 'image/png', 'image/webp'],
})
if (!validation.valid) {
return {
success: false,
error: validation.error,
}
}
// Generate key for profile image
const key = `profiles/${artistId}/profile-${Date.now()}.${file.name.split('.').pop()}`
return await manager.uploadFile(file, key, {
contentType: file.type,
metadata: {
artistId,
type: 'profile',
originalName: file.name,
uploadedAt: new Date().toISOString(),
},
})
}

278
lib/upload.ts Normal file
View File

@ -0,0 +1,278 @@
import { S3Client, PutObjectCommand, DeleteObjectCommand, GetObjectCommand } from "@aws-sdk/client-s3"
import { getSignedUrl as getS3SignedUrl } from "@aws-sdk/s3-request-presigner"
import { env } from "./env"
import { createFileUploadSchema } from "./validations"
// Initialize S3 client (works with both AWS S3 and Cloudflare R2)
const s3Client = new S3Client({
region: env.AWS_REGION,
credentials: {
accessKeyId: env.AWS_ACCESS_KEY_ID,
secretAccessKey: env.AWS_SECRET_ACCESS_KEY,
},
// For Cloudflare R2, use custom endpoint
...(env.AWS_ENDPOINT_URL && {
endpoint: env.AWS_ENDPOINT_URL,
forcePathStyle: true,
}),
})
/**
* Upload a file to S3/R2 storage
* @param file - File to upload
* @param path - Storage path (e.g., 'artists/portfolio/image.jpg')
* @param uploadedBy - User ID of the uploader
* @returns Promise with file upload metadata
*/
export async function uploadFile(
file: File,
path: string,
uploadedBy: string
): Promise<{
id: string
filename: string
originalName: string
mimeType: string
size: number
url: string
uploadedBy: string
}> {
// Validate file data
const fileData = createFileUploadSchema.parse({
filename: path,
originalName: file.name,
mimeType: file.type,
size: file.size,
uploadedBy,
})
// Generate unique filename to prevent conflicts
const timestamp = Date.now()
const randomString = Math.random().toString(36).substring(2, 15)
const extension = file.name.split('.').pop()
const uniqueFilename = `${timestamp}-${randomString}.${extension}`
const fullPath = `${path}/${uniqueFilename}`
try {
// Convert file to buffer
const buffer = await file.arrayBuffer()
// Upload to S3/R2
const command = new PutObjectCommand({
Bucket: env.AWS_BUCKET_NAME,
Key: fullPath,
Body: new Uint8Array(buffer),
ContentType: file.type,
ContentLength: file.size,
// Set cache control for images
CacheControl: 'public, max-age=31536000', // 1 year
// Add metadata
Metadata: {
originalName: file.name,
uploadedBy: uploadedBy,
uploadedAt: new Date().toISOString(),
},
})
await s3Client.send(command)
// Generate public URL
const url = env.AWS_ENDPOINT_URL
? `${env.AWS_ENDPOINT_URL}/${env.AWS_BUCKET_NAME}/${fullPath}`
: `https://${env.AWS_BUCKET_NAME}.s3.${env.AWS_REGION}.amazonaws.com/${fullPath}`
return {
id: `${timestamp}-${randomString}`, // Generate ID from timestamp and random string
filename: uniqueFilename,
originalName: file.name,
mimeType: file.type,
size: file.size,
url,
uploadedBy,
}
} catch (error) {
console.error('File upload error:', error)
throw new Error('Failed to upload file')
}
}
/**
* Delete a file from S3/R2 storage
* @param fileUrl - Full URL of the file to delete
*/
export async function deleteFile(fileUrl: string): Promise<void> {
try {
// Extract the key from the URL
const url = new URL(fileUrl)
const key = url.pathname.substring(1) // Remove leading slash
const command = new DeleteObjectCommand({
Bucket: env.AWS_BUCKET_NAME,
Key: key,
})
await s3Client.send(command)
} catch (error) {
console.error('File deletion error:', error)
throw new Error('Failed to delete file')
}
}
/**
* Generate a signed URL for private file access
* @param key - File key in storage
* @param expiresIn - URL expiration time in seconds (default: 1 hour)
* @returns Signed URL
*/
export async function getSignedUrl(key: string, expiresIn: number = 3600): Promise<string> {
try {
const command = new GetObjectCommand({
Bucket: env.AWS_BUCKET_NAME,
Key: key,
})
const signedUrl = await getS3SignedUrl(s3Client, command, { expiresIn })
return signedUrl
} catch (error) {
console.error('Signed URL generation error:', error)
throw new Error('Failed to generate signed URL')
}
}
/**
* Generate a presigned URL for direct client uploads
* @param key - File key in storage
* @param contentType - MIME type of the file
* @param expiresIn - URL expiration time in seconds (default: 15 minutes)
* @returns Presigned URL for PUT operation
*/
export async function getPresignedUploadUrl(
key: string,
contentType: string,
expiresIn: number = 900
): Promise<string> {
try {
const command = new PutObjectCommand({
Bucket: env.AWS_BUCKET_NAME,
Key: key,
ContentType: contentType,
})
const presignedUrl = await getS3SignedUrl(s3Client, command, { expiresIn })
return presignedUrl
} catch (error) {
console.error('Presigned URL generation error:', error)
throw new Error('Failed to generate presigned upload URL')
}
}
/**
* Validate file type for uploads
* @param file - File to validate
* @param allowedTypes - Array of allowed MIME types
* @returns Boolean indicating if file type is allowed
*/
export function validateFileType(file: File, allowedTypes: string[]): boolean {
return allowedTypes.includes(file.type)
}
/**
* Validate file size
* @param file - File to validate
* @param maxSizeBytes - Maximum file size in bytes
* @returns Boolean indicating if file size is acceptable
*/
export function validateFileSize(file: File, maxSizeBytes: number): boolean {
return file.size <= maxSizeBytes
}
/**
* Generate optimized file path for different file types
* @param type - File type ('portfolio', 'avatar', 'hero', etc.)
* @param artistId - Artist ID (optional)
* @returns Optimized storage path
*/
export function generateFilePath(type: 'portfolio' | 'avatar' | 'hero' | 'logo', artistId?: string): string {
const basePaths = {
portfolio: artistId ? `artists/${artistId}/portfolio` : 'portfolio',
avatar: artistId ? `artists/${artistId}/avatar` : 'avatars',
hero: 'site/hero',
logo: 'site/logo',
}
return basePaths[type]
}
/**
* Image processing utilities
*/
export const imageUtils = {
// Allowed image types
ALLOWED_IMAGE_TYPES: ['image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/webp'],
// Maximum file sizes (in bytes)
MAX_PORTFOLIO_SIZE: 10 * 1024 * 1024, // 10MB
MAX_AVATAR_SIZE: 5 * 1024 * 1024, // 5MB
MAX_HERO_SIZE: 15 * 1024 * 1024, // 15MB
// Image dimensions (for client-side validation)
PORTFOLIO_DIMENSIONS: { minWidth: 800, minHeight: 600 },
AVATAR_DIMENSIONS: { minWidth: 200, minHeight: 200 },
HERO_DIMENSIONS: { minWidth: 1920, minHeight: 1080 },
}
/**
* Error types for file upload operations
*/
export class FileUploadError extends Error {
constructor(
message: string,
public code: 'INVALID_TYPE' | 'FILE_TOO_LARGE' | 'UPLOAD_FAILED' | 'DELETE_FAILED'
) {
super(message)
this.name = 'FileUploadError'
}
}
/**
* Utility to handle file upload with validation
* @param file - File to upload
* @param type - Upload type
* @param artistId - Artist ID (optional)
* @param uploadedBy - User ID of uploader
* @returns Upload result
*/
export async function handleFileUpload(
file: File,
type: 'portfolio' | 'avatar' | 'hero' | 'logo',
uploadedBy: string,
artistId?: string
) {
// Validate file type
if (!validateFileType(file, imageUtils.ALLOWED_IMAGE_TYPES)) {
throw new FileUploadError('Invalid file type. Only images are allowed.', 'INVALID_TYPE')
}
// Validate file size based on type
const maxSizes = {
portfolio: imageUtils.MAX_PORTFOLIO_SIZE,
avatar: imageUtils.MAX_AVATAR_SIZE,
hero: imageUtils.MAX_HERO_SIZE,
logo: imageUtils.MAX_AVATAR_SIZE, // Same as avatar
}
if (!validateFileSize(file, maxSizes[type])) {
throw new FileUploadError(`File too large. Maximum size is ${maxSizes[type] / (1024 * 1024)}MB.`, 'FILE_TOO_LARGE')
}
// Generate file path
const path = generateFilePath(type, artistId)
try {
// Upload file
const result = await uploadFile(file, path, uploadedBy)
return result
} catch (error) {
throw new FileUploadError('Failed to upload file.', 'UPLOAD_FAILED')
}
}

265
lib/validations.ts Normal file
View File

@ -0,0 +1,265 @@
import { z } from "zod"
import { UserRole, AppointmentStatus } from "@/types/database"
// User validation schemas
export const userSchema = z.object({
id: z.string().uuid(),
email: z.string().email(),
name: z.string().min(1, "Name is required"),
role: z.nativeEnum(UserRole),
avatar: z.string().url().optional(),
})
export const createUserSchema = z.object({
email: z.string().email("Invalid email address"),
name: z.string().min(1, "Name is required").max(100, "Name too long"),
password: z.string().min(8, "Password must be at least 8 characters"),
role: z.nativeEnum(UserRole).default(UserRole.CLIENT),
})
export const updateUserSchema = createUserSchema.partial().extend({
id: z.string().uuid(),
})
// Artist validation schemas
export const artistSchema = z.object({
id: z.string().uuid(),
userId: z.string().uuid(),
name: z.string().min(1, "Artist name is required"),
bio: z.string().min(10, "Bio must be at least 10 characters"),
specialties: z.array(z.string()).min(1, "At least one specialty is required"),
instagramHandle: z.string().optional(),
isActive: z.boolean().default(true),
hourlyRate: z.number().positive().optional(),
})
export const createArtistSchema = z.object({
name: z.string().min(1, "Artist name is required").max(100, "Name too long"),
bio: z.string().min(10, "Bio must be at least 10 characters").max(1000, "Bio too long"),
specialties: z.array(z.string().min(1)).min(1, "At least one specialty is required").max(10, "Too many specialties"),
instagramHandle: z.string().regex(/^[a-zA-Z0-9._]+$/, "Invalid Instagram handle").optional(),
hourlyRate: z.number().positive("Hourly rate must be positive").max(1000, "Hourly rate too high").optional(),
isActive: z.boolean().default(true),
})
export const updateArtistSchema = createArtistSchema.partial().extend({
id: z.string().uuid(),
})
// Portfolio image validation schemas
export const portfolioImageSchema = z.object({
id: z.string().uuid(),
artistId: z.string().uuid(),
url: z.string().url("Invalid image URL"),
caption: z.string().max(500, "Caption too long").optional(),
tags: z.array(z.string()).max(20, "Too many tags"),
order: z.number().int().min(0),
isPublic: z.boolean().default(true),
})
export const createPortfolioImageSchema = z.object({
artistId: z.string().uuid(),
url: z.string().url("Invalid image URL"),
caption: z.string().max(500, "Caption too long").optional(),
tags: z.array(z.string().min(1)).max(20, "Too many tags").default([]),
order: z.number().int().min(0).default(0),
isPublic: z.boolean().default(true),
})
export const updatePortfolioImageSchema = createPortfolioImageSchema.partial().extend({
id: z.string().uuid(),
})
// Appointment validation schemas
export const appointmentSchema = z.object({
id: z.string().uuid(),
artistId: z.string().uuid(),
clientId: z.string().uuid(),
title: z.string().min(1, "Title is required"),
description: z.string().optional(),
startTime: z.date(),
endTime: z.date(),
status: z.nativeEnum(AppointmentStatus),
depositAmount: z.number().positive().optional(),
totalAmount: z.number().positive().optional(),
notes: z.string().optional(),
})
export const createAppointmentSchema = z.object({
artistId: z.string().uuid("Invalid artist ID"),
clientId: z.string().uuid("Invalid client ID"),
title: z.string().min(1, "Title is required").max(200, "Title too long"),
description: z.string().max(1000, "Description too long").optional(),
startTime: z.string().datetime("Invalid start time"),
endTime: z.string().datetime("Invalid end time"),
depositAmount: z.number().positive("Deposit must be positive").optional(),
totalAmount: z.number().positive("Total amount must be positive").optional(),
notes: z.string().max(1000, "Notes too long").optional(),
}).refine(
(data) => new Date(data.endTime) > new Date(data.startTime),
{
message: "End time must be after start time",
path: ["endTime"],
}
)
export const updateAppointmentSchema = z.object({
id: z.string().uuid(),
artistId: z.string().uuid("Invalid artist ID").optional(),
clientId: z.string().uuid("Invalid client ID").optional(),
title: z.string().min(1, "Title is required").max(200, "Title too long").optional(),
description: z.string().max(1000, "Description too long").optional(),
startTime: z.string().datetime("Invalid start time").optional(),
endTime: z.string().datetime("Invalid end time").optional(),
status: z.nativeEnum(AppointmentStatus).optional(),
depositAmount: z.number().positive("Deposit must be positive").optional(),
totalAmount: z.number().positive("Total amount must be positive").optional(),
notes: z.string().max(1000, "Notes too long").optional(),
}).refine(
(data) => {
if (data.startTime && data.endTime) {
return new Date(data.endTime) > new Date(data.startTime)
}
return true
},
{
message: "End time must be after start time",
path: ["endTime"],
}
)
// Site settings validation schemas
export const socialMediaLinksSchema = z.object({
instagram: z.string().url("Invalid Instagram URL").optional(),
facebook: z.string().url("Invalid Facebook URL").optional(),
twitter: z.string().url("Invalid Twitter URL").optional(),
tiktok: z.string().url("Invalid TikTok URL").optional(),
})
export const businessHoursSchema = z.object({
dayOfWeek: z.number().int().min(0).max(6),
openTime: z.string().regex(/^([0-1]?[0-9]|2[0-3]):[0-5][0-9]$/, "Invalid time format (HH:mm)"),
closeTime: z.string().regex(/^([0-1]?[0-9]|2[0-3]):[0-5][0-9]$/, "Invalid time format (HH:mm)"),
isClosed: z.boolean().default(false),
})
export const siteSettingsSchema = z.object({
id: z.string().uuid(),
studioName: z.string().min(1, "Studio name is required"),
description: z.string().min(10, "Description must be at least 10 characters"),
address: z.string().min(5, "Address is required"),
phone: z.string().regex(/^[\+]?[1-9][\d]{0,15}$/, "Invalid phone number"),
email: z.string().email("Invalid email address"),
socialMedia: socialMediaLinksSchema,
businessHours: z.array(businessHoursSchema),
heroImage: z.string().url("Invalid hero image URL").optional(),
logoUrl: z.string().url("Invalid logo URL").optional(),
})
export const updateSiteSettingsSchema = z.object({
studioName: z.string().min(1, "Studio name is required").max(100, "Studio name too long").optional(),
description: z.string().min(10, "Description must be at least 10 characters").max(1000, "Description too long").optional(),
address: z.string().min(5, "Address is required").max(200, "Address too long").optional(),
phone: z.string().regex(/^[\+]?[1-9][\d]{0,15}$/, "Invalid phone number").optional(),
email: z.string().email("Invalid email address").optional(),
socialMedia: socialMediaLinksSchema.optional(),
businessHours: z.array(businessHoursSchema).optional(),
heroImage: z.string().url("Invalid hero image URL").optional(),
logoUrl: z.string().url("Invalid logo URL").optional(),
})
// File upload validation schemas
export const fileUploadSchema = z.object({
id: z.string().uuid(),
filename: z.string().min(1, "Filename is required"),
originalName: z.string().min(1, "Original name is required"),
mimeType: z.string().regex(/^[a-zA-Z0-9][a-zA-Z0-9!#$&\-\^_]*\/[a-zA-Z0-9][a-zA-Z0-9!#$&\-\^_.]*$/, "Invalid MIME type"),
size: z.number().positive("File size must be positive"),
url: z.string().url("Invalid file URL"),
uploadedBy: z.string().uuid("Invalid user ID"),
})
export const createFileUploadSchema = z.object({
filename: z.string().min(1, "Filename is required"),
originalName: z.string().min(1, "Original name is required"),
mimeType: z.string().regex(/^image\/(jpeg|jpg|png|gif|webp)$/, "Only image files are allowed"),
size: z.number().positive("File size must be positive").max(10 * 1024 * 1024, "File too large (max 10MB)"),
uploadedBy: z.string().uuid("Invalid user ID"),
})
// Query parameter validation schemas
export const paginationSchema = z.object({
page: z.string().regex(/^\d+$/).transform(Number).pipe(z.number().int().min(1)).default("1"),
limit: z.string().regex(/^\d+$/).transform(Number).pipe(z.number().int().min(1).max(100)).default("10"),
})
export const artistFiltersSchema = z.object({
isActive: z.string().transform(val => val === "true").optional(),
specialty: z.string().optional(),
search: z.string().optional(),
})
export const appointmentFiltersSchema = z.object({
artistId: z.string().uuid().optional(),
clientId: z.string().uuid().optional(),
status: z.nativeEnum(AppointmentStatus).optional(),
startDate: z.string().datetime().optional(),
endDate: z.string().datetime().optional(),
})
// Form validation schemas (for react-hook-form)
export const loginFormSchema = z.object({
email: z.string().email("Invalid email address"),
password: z.string().min(1, "Password is required"),
})
export const signupFormSchema = z.object({
name: z.string().min(1, "Name is required").max(100, "Name too long"),
email: z.string().email("Invalid email address"),
password: z.string().min(8, "Password must be at least 8 characters"),
confirmPassword: z.string().min(1, "Please confirm your password"),
}).refine(
(data) => data.password === data.confirmPassword,
{
message: "Passwords don't match",
path: ["confirmPassword"],
}
)
export const contactFormSchema = z.object({
name: z.string().min(1, "Name is required").max(100, "Name too long"),
email: z.string().email("Invalid email address"),
phone: z.string().regex(/^[\+]?[1-9][\d]{0,15}$/, "Invalid phone number").optional(),
subject: z.string().min(1, "Subject is required").max(200, "Subject too long"),
message: z.string().min(10, "Message must be at least 10 characters").max(1000, "Message too long"),
})
export const bookingFormSchema = z.object({
artistId: z.string().uuid("Please select an artist"),
name: z.string().min(1, "Name is required").max(100, "Name too long"),
email: z.string().email("Invalid email address"),
phone: z.string().regex(/^[\+]?[1-9][\d]{0,15}$/, "Invalid phone number"),
preferredDate: z.string().min(1, "Please select a preferred date"),
tattooDescription: z.string().min(10, "Please provide more details about your tattoo").max(1000, "Description too long"),
size: z.enum(["small", "medium", "large", "sleeve"], {
required_error: "Please select a size",
}),
placement: z.string().min(1, "Please specify placement").max(100, "Placement description too long"),
budget: z.string().optional(),
hasAllergies: z.boolean().default(false),
allergies: z.string().max(500, "Allergies description too long").optional(),
additionalNotes: z.string().max(500, "Additional notes too long").optional(),
})
// Type exports for form data
export type LoginFormData = z.infer<typeof loginFormSchema>
export type SignupFormData = z.infer<typeof signupFormSchema>
export type ContactFormData = z.infer<typeof contactFormSchema>
export type BookingFormData = z.infer<typeof bookingFormSchema>
export type CreateArtistData = z.infer<typeof createArtistSchema>
export type UpdateArtistData = z.infer<typeof updateArtistSchema>
export type CreatePortfolioImageData = z.infer<typeof createPortfolioImageSchema>
export type UpdatePortfolioImageData = z.infer<typeof updatePortfolioImageSchema>
export type CreateAppointmentData = z.infer<typeof createAppointmentSchema>
export type UpdateAppointmentData = z.infer<typeof updateAppointmentSchema>
export type UpdateSiteSettingsData = z.infer<typeof updateSiteSettingsSchema>

103
middleware.ts Normal file
View File

@ -0,0 +1,103 @@
import { withAuth } from "next-auth/middleware"
import { NextResponse } from "next/server"
import { UserRole } from "@/types/database"
export default withAuth(
function middleware(req) {
const token = req.nextauth.token
const { pathname } = req.nextUrl
// Admin routes protection
if (pathname.startsWith("/admin")) {
if (!token) {
return NextResponse.redirect(new URL("/auth/signin", req.url))
}
// Check if user has admin role
const userRole = token.role as UserRole
if (userRole !== UserRole.SHOP_ADMIN && userRole !== UserRole.SUPER_ADMIN) {
return NextResponse.redirect(new URL("/unauthorized", req.url))
}
}
// Artist-specific routes
if (pathname.startsWith("/artist")) {
if (!token) {
return NextResponse.redirect(new URL("/auth/signin", req.url))
}
const userRole = token.role as UserRole
if (userRole !== UserRole.ARTIST && userRole !== UserRole.SHOP_ADMIN && userRole !== UserRole.SUPER_ADMIN) {
return NextResponse.redirect(new URL("/unauthorized", req.url))
}
}
// API routes protection
if (pathname.startsWith("/api/admin")) {
if (!token) {
return NextResponse.json({ error: "Authentication required" }, { status: 401 })
}
const userRole = token.role as UserRole
if (userRole !== UserRole.SHOP_ADMIN && userRole !== UserRole.SUPER_ADMIN) {
return NextResponse.json({ error: "Insufficient permissions" }, { status: 403 })
}
}
return NextResponse.next()
},
{
callbacks: {
authorized: ({ token, req }) => {
const { pathname } = req.nextUrl
// Public routes that don't require authentication
const publicRoutes = [
"/",
"/artists",
"/contact",
"/book",
"/aftercare",
"/gift-cards",
"/specials",
"/terms",
"/privacy",
"/auth/signin",
"/auth/error",
"/unauthorized"
]
// Allow public routes and artist portfolio pages
if (publicRoutes.some(route => pathname === route || pathname.startsWith(route))) {
return true
}
// Allow individual artist portfolio pages (public access)
if (pathname.match(/^\/artists\/[^\/]+$/)) {
return true
}
// Allow public API routes
if (pathname.startsWith("/api/auth") || pathname.startsWith("/api/public")) {
return true
}
// Require authentication for all other routes
return !!token
},
},
}
)
export const config = {
matcher: [
/*
* Match all request paths except for the ones starting with:
* - _next/static (static files)
* - _next/image (image optimization files)
* - favicon.ico (favicon file)
* - public folder
*/
"/((?!_next/static|_next/image|favicon.ico|public|.*\\.png$|.*\\.jpg$|.*\\.jpeg$|.*\\.gif$|.*\\.svg$).*)",
],
}

5091
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -6,9 +6,24 @@
"build": "next build",
"dev": "next dev",
"lint": "next lint",
"start": "next start"
"start": "next start",
"test": "vitest",
"test:ui": "vitest --ui",
"test:run": "vitest run",
"test:coverage": "vitest run --coverage",
"pages:build": "npx @opennextjs/cloudflare@latest",
"preview": "wrangler pages dev .vercel/output/static",
"deploy": "wrangler pages deploy .vercel/output/static",
"db:create": "wrangler d1 create united-tattoo-db",
"db:migrate": "wrangler d1 execute united-tattoo-db --file=./sql/schema.sql",
"db:migrate:local": "wrangler d1 execute united-tattoo-db --local --file=./sql/schema.sql",
"db:studio": "wrangler d1 execute united-tattoo-db --command=\"SELECT name FROM sqlite_master WHERE type='table';\"",
"db:studio:local": "wrangler d1 execute united-tattoo-db --local --command=\"SELECT name FROM sqlite_master WHERE type='table';\""
},
"dependencies": {
"@auth/supabase-adapter": "^1.10.0",
"@aws-sdk/client-s3": "^3.890.0",
"@aws-sdk/s3-request-presigner": "^3.890.0",
"@hookform/resolvers": "^3.10.0",
"@radix-ui/react-accordion": "latest",
"@radix-ui/react-alert-dialog": "1.1.4",
@ -38,6 +53,10 @@
"@radix-ui/react-toggle-group": "1.1.1",
"@radix-ui/react-tooltip": "1.1.6",
"@studio-freight/lenis": "latest",
"@supabase/supabase-js": "^2.57.4",
"@tanstack/react-query": "^5.89.0",
"@tanstack/react-query-devtools": "^5.89.0",
"@tanstack/react-table": "^8.21.3",
"@vercel/analytics": "1.3.1",
"autoprefixer": "^10.4.20",
"class-variance-authority": "^0.7.1",
@ -48,11 +67,15 @@
"geist": "^1.3.1",
"input-otp": "latest",
"lucide-react": "^0.454.0",
"moment": "^2.30.1",
"next": "14.2.16",
"next-auth": "^4.24.11",
"next-themes": "^0.4.6",
"react": "^18",
"react-big-calendar": "^1.19.4",
"react-day-picker": "latest",
"react-dom": "^18",
"react-dropzone": "^14.3.8",
"react-hook-form": "^7.60.0",
"react-resizable-panels": "latest",
"recharts": "2.15.4",
@ -64,12 +87,19 @@
},
"devDependencies": {
"@tailwindcss/postcss": "^4.1.9",
"@testing-library/jest-dom": "^6.8.0",
"@testing-library/react": "^16.3.0",
"@testing-library/user-event": "^14.6.1",
"@types/node": "^22",
"@types/react": "^18",
"@types/react-big-calendar": "^1.16.3",
"@types/react-dom": "^18",
"@vitejs/plugin-react": "^5.0.3",
"jsdom": "^27.0.0",
"postcss": "^8.5",
"tailwindcss": "^4.1.9",
"tw-animate-css": "1.3.3",
"typescript": "^5"
"typescript": "^5",
"vitest": "^3.2.4"
}
}
}

136
sql/schema.sql Normal file
View File

@ -0,0 +1,136 @@
-- United Tattoo Studio Database Schema for Cloudflare D1
-- Run this with: wrangler d1 execute united-tattoo-db --file=./sql/schema.sql
-- Users table
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
email TEXT UNIQUE NOT NULL,
name TEXT NOT NULL,
role TEXT NOT NULL CHECK (role IN ('SUPER_ADMIN', 'SHOP_ADMIN', 'ARTIST', 'CLIENT')),
avatar TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
-- Artists table
CREATE TABLE IF NOT EXISTS artists (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
name TEXT NOT NULL,
bio TEXT NOT NULL,
specialties TEXT NOT NULL, -- JSON array as text
instagram_handle TEXT,
is_active BOOLEAN DEFAULT TRUE,
hourly_rate REAL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
-- Portfolio images table
CREATE TABLE IF NOT EXISTS portfolio_images (
id TEXT PRIMARY KEY,
artist_id TEXT NOT NULL,
url TEXT NOT NULL,
caption TEXT,
tags TEXT, -- JSON array as text
order_index INTEGER DEFAULT 0,
is_public BOOLEAN DEFAULT TRUE,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (artist_id) REFERENCES artists(id) ON DELETE CASCADE
);
-- Appointments table
CREATE TABLE IF NOT EXISTS appointments (
id TEXT PRIMARY KEY,
artist_id TEXT NOT NULL,
client_id TEXT NOT NULL,
title TEXT NOT NULL,
description TEXT,
start_time DATETIME NOT NULL,
end_time DATETIME NOT NULL,
status TEXT NOT NULL CHECK (status IN ('PENDING', 'CONFIRMED', 'IN_PROGRESS', 'COMPLETED', 'CANCELLED')),
deposit_amount REAL,
total_amount REAL,
notes TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (artist_id) REFERENCES artists(id) ON DELETE CASCADE,
FOREIGN KEY (client_id) REFERENCES users(id) ON DELETE CASCADE
);
-- Artist availability table
CREATE TABLE IF NOT EXISTS availability (
id TEXT PRIMARY KEY,
artist_id TEXT NOT NULL,
day_of_week INTEGER NOT NULL CHECK (day_of_week >= 0 AND day_of_week <= 6),
start_time TEXT NOT NULL, -- HH:mm format
end_time TEXT NOT NULL, -- HH:mm format
is_active BOOLEAN DEFAULT TRUE,
FOREIGN KEY (artist_id) REFERENCES artists(id) ON DELETE CASCADE
);
-- Site settings table
CREATE TABLE IF NOT EXISTS site_settings (
id TEXT PRIMARY KEY,
studio_name TEXT NOT NULL,
description TEXT NOT NULL,
address TEXT NOT NULL,
phone TEXT NOT NULL,
email TEXT NOT NULL,
social_media TEXT, -- JSON object as text
business_hours TEXT, -- JSON array as text
hero_image TEXT,
logo_url TEXT,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
-- File uploads table
CREATE TABLE IF NOT EXISTS file_uploads (
id TEXT PRIMARY KEY,
filename TEXT NOT NULL,
original_name TEXT NOT NULL,
mime_type TEXT NOT NULL,
size INTEGER NOT NULL,
url TEXT NOT NULL,
uploaded_by TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (uploaded_by) REFERENCES users(id) ON DELETE CASCADE
);
-- Create indexes for better performance
CREATE INDEX IF NOT EXISTS idx_artists_user_id ON artists(user_id);
CREATE INDEX IF NOT EXISTS idx_artists_is_active ON artists(is_active);
CREATE INDEX IF NOT EXISTS idx_portfolio_images_artist_id ON portfolio_images(artist_id);
CREATE INDEX IF NOT EXISTS idx_portfolio_images_is_public ON portfolio_images(is_public);
CREATE INDEX IF NOT EXISTS idx_appointments_artist_id ON appointments(artist_id);
CREATE INDEX IF NOT EXISTS idx_appointments_client_id ON appointments(client_id);
CREATE INDEX IF NOT EXISTS idx_appointments_start_time ON appointments(start_time);
CREATE INDEX IF NOT EXISTS idx_appointments_status ON appointments(status);
CREATE INDEX IF NOT EXISTS idx_availability_artist_id ON availability(artist_id);
CREATE INDEX IF NOT EXISTS idx_file_uploads_uploaded_by ON file_uploads(uploaded_by);
-- Insert default site settings
INSERT OR IGNORE INTO site_settings (
id,
studio_name,
description,
address,
phone,
email,
social_media,
business_hours,
hero_image,
logo_url
) VALUES (
'default',
'United Tattoo Studio',
'Premier tattoo studio specializing in custom artwork and professional tattooing services.',
'123 Main Street, Denver, CO 80202',
'+1 (555) 123-4567',
'info@unitedtattoo.com',
'{"instagram":"https://instagram.com/unitedtattoo","facebook":"https://facebook.com/unitedtattoo","twitter":"https://twitter.com/unitedtattoo","tiktok":"https://tiktok.com/@unitedtattoo"}',
'[{"dayOfWeek":1,"openTime":"10:00","closeTime":"20:00","isClosed":false},{"dayOfWeek":2,"openTime":"10:00","closeTime":"20:00","isClosed":false},{"dayOfWeek":3,"openTime":"10:00","closeTime":"20:00","isClosed":false},{"dayOfWeek":4,"openTime":"10:00","closeTime":"20:00","isClosed":false},{"dayOfWeek":5,"openTime":"10:00","closeTime":"22:00","isClosed":false},{"dayOfWeek":6,"openTime":"10:00","closeTime":"22:00","isClosed":false},{"dayOfWeek":0,"openTime":"12:00","closeTime":"18:00","isClosed":false}]',
'/united-studio-main.jpg',
'/united-logo-website.jpg'
);

272
types/database.ts Normal file
View File

@ -0,0 +1,272 @@
// Cloudflare Types
declare global {
interface D1Database {
prepare(query: string): D1PreparedStatement;
exec(query: string): Promise<D1ExecResult>;
batch(statements: D1PreparedStatement[]): Promise<D1Result[]>;
dump(): Promise<ArrayBuffer>;
}
interface D1PreparedStatement {
bind(...values: any[]): D1PreparedStatement;
first<T = any>(): Promise<T | null>;
run(): Promise<D1Result>;
all<T = any>(): Promise<D1Result<T>>;
}
interface D1Result<T = any> {
results: T[];
success: boolean;
meta: {
duration: number;
size_after: number;
rows_read: number;
rows_written: number;
};
}
interface D1ExecResult {
count: number;
duration: number;
}
interface R2Bucket {
put(key: string, value: ReadableStream | ArrayBuffer | string, options?: R2PutOptions): Promise<R2Object | null>;
get(key: string, options?: R2GetOptions): Promise<R2Object | null>;
delete(keys: string | string[]): Promise<void>;
list(options?: R2ListOptions): Promise<R2Objects>;
}
interface R2Object {
key: string;
version: string;
size: number;
etag: string;
httpEtag: string;
uploaded: Date;
checksums: R2Checksums;
httpMetadata?: R2HTTPMetadata;
customMetadata?: Record<string, string>;
body?: ReadableStream;
bodyUsed?: boolean;
arrayBuffer(): Promise<ArrayBuffer>;
text(): Promise<string>;
json<T = any>(): Promise<T>;
blob(): Promise<Blob>;
}
interface R2PutOptions {
httpMetadata?: R2HTTPMetadata;
customMetadata?: Record<string, string>;
}
interface R2GetOptions {
onlyIf?: R2Conditional;
range?: R2Range;
}
interface R2ListOptions {
limit?: number;
prefix?: string;
cursor?: string;
delimiter?: string;
startAfter?: string;
include?: ('httpMetadata' | 'customMetadata')[];
}
interface R2Objects {
objects: R2Object[];
truncated: boolean;
cursor?: string;
delimitedPrefixes: string[];
}
interface R2HTTPMetadata {
contentType?: string;
contentLanguage?: string;
contentDisposition?: string;
contentEncoding?: string;
cacheControl?: string;
cacheExpiry?: Date;
}
interface R2Checksums {
md5?: ArrayBuffer;
sha1?: ArrayBuffer;
sha256?: ArrayBuffer;
sha384?: ArrayBuffer;
sha512?: ArrayBuffer;
}
interface R2Conditional {
etagMatches?: string;
etagDoesNotMatch?: string;
uploadedBefore?: Date;
uploadedAfter?: Date;
}
interface R2Range {
offset?: number;
length?: number;
suffix?: number;
}
}
// User Management Types
export interface User {
id: string
email: string
name: string
role: UserRole
avatar?: string
createdAt: Date
updatedAt: Date
}
export enum UserRole {
SUPER_ADMIN = 'SUPER_ADMIN',
SHOP_ADMIN = 'SHOP_ADMIN',
ARTIST = 'ARTIST',
CLIENT = 'CLIENT'
}
// Artist Management Types
export interface Artist {
id: string
userId: string
name: string
bio: string
specialties: string[]
instagramHandle?: string
portfolioImages: PortfolioImage[]
isActive: boolean
hourlyRate?: number
availability: Availability[]
createdAt: Date
updatedAt: Date
}
export interface PortfolioImage {
id: string
artistId: string
url: string
caption?: string
tags: string[]
orderIndex: number
isPublic: boolean
createdAt: Date
}
// Calendar & Booking Types
export interface Appointment {
id: string
artistId: string
clientId: string
title: string
description?: string
startTime: Date
endTime: Date
status: AppointmentStatus
depositAmount?: number
totalAmount?: number
notes?: string
createdAt: Date
updatedAt: Date
}
export enum AppointmentStatus {
PENDING = 'PENDING',
CONFIRMED = 'CONFIRMED',
IN_PROGRESS = 'IN_PROGRESS',
COMPLETED = 'COMPLETED',
CANCELLED = 'CANCELLED'
}
export interface Availability {
id: string
artistId: string
dayOfWeek: number // 0-6 (Sunday-Saturday)
startTime: string // HH:mm format
endTime: string // HH:mm format
isActive: boolean
}
// Content Management Types
export interface SiteSettings {
id: string
studioName: string
description: string
address: string
phone: string
email: string
socialMedia: SocialMediaLinks
businessHours: BusinessHours[]
heroImage?: string
logoUrl?: string
updatedAt: Date
}
export interface SocialMediaLinks {
instagram?: string
facebook?: string
twitter?: string
tiktok?: string
}
export interface BusinessHours {
dayOfWeek: number
openTime: string
closeTime: string
isClosed: boolean
}
// File Upload Types
export interface FileUpload {
id: string
filename: string
originalName: string
mimeType: string
size: number
url: string
uploadedBy: string
createdAt: Date
}
// API Input Types
export interface CreateArtistInput {
name: string
bio: string
specialties: string[]
instagramHandle?: string
hourlyRate?: number
isActive?: boolean
userId?: string
email?: string
}
export interface UpdateArtistInput extends Partial<CreateArtistInput> {
id: string
}
export interface CreateAppointmentInput {
artistId: string
clientId: string
title: string
description?: string
startTime: Date
endTime: Date
status?: AppointmentStatus
depositAmount?: number
totalAmount?: number
notes?: string
}
export interface UpdateSiteSettingsInput extends Partial<Omit<SiteSettings, 'id' | 'updatedAt'>> {}
export interface AppointmentFilters {
artistId?: string
clientId?: string
status?: AppointmentStatus
startDate?: Date
endDate?: Date
}

17
vitest.config.ts Normal file
View File

@ -0,0 +1,17 @@
import { defineConfig } from 'vitest/config'
import react from '@vitejs/plugin-react'
import path from 'path'
export default defineConfig({
plugins: [react()],
test: {
environment: 'jsdom',
setupFiles: ['./vitest.setup.ts'],
globals: true,
},
resolve: {
alias: {
'@': path.resolve(__dirname, './'),
},
},
})

106
vitest.setup.ts Normal file
View File

@ -0,0 +1,106 @@
import '@testing-library/jest-dom'
import { vi } from 'vitest'
// Mock Next.js router
vi.mock('next/router', () => ({
useRouter: () => ({
push: vi.fn(),
pathname: '/',
query: {},
asPath: '/',
}),
}))
// Mock Next.js navigation
vi.mock('next/navigation', () => ({
useRouter: () => ({
push: vi.fn(),
replace: vi.fn(),
back: vi.fn(),
forward: vi.fn(),
refresh: vi.fn(),
prefetch: vi.fn(),
}),
usePathname: () => '/',
useSearchParams: () => new URLSearchParams(),
}))
// Mock NextAuth
vi.mock('next-auth/react', () => ({
useSession: () => ({
data: {
user: {
id: 'test-user-id',
name: 'Test User',
email: 'test@example.com',
role: 'SHOP_ADMIN',
},
},
status: 'authenticated',
}),
signIn: vi.fn(),
signOut: vi.fn(),
getSession: vi.fn(),
}))
// Mock React Query
vi.mock('@tanstack/react-query', () => ({
useQuery: vi.fn(() => ({
data: null,
isLoading: false,
error: null,
})),
useMutation: vi.fn(() => ({
mutate: vi.fn(),
mutateAsync: vi.fn(),
isPending: false,
isError: false,
error: null,
})),
useQueryClient: vi.fn(() => ({
invalidateQueries: vi.fn(),
setQueryData: vi.fn(),
getQueryData: vi.fn(),
})),
QueryClient: vi.fn(),
QueryClientProvider: ({ children }: { children: React.ReactNode }) => children,
}))
// Mock fetch globally
global.fetch = vi.fn()
// Mock crypto.randomUUID
Object.defineProperty(global, 'crypto', {
value: {
randomUUID: () => 'test-uuid-' + Math.random().toString(36).substr(2, 9),
},
})
// Mock window.matchMedia
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: vi.fn().mockImplementation(query => ({
matches: false,
media: query,
onchange: null,
addListener: vi.fn(), // deprecated
removeListener: vi.fn(), // deprecated
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
})),
})
// Mock IntersectionObserver
global.IntersectionObserver = vi.fn().mockImplementation(() => ({
observe: vi.fn(),
unobserve: vi.fn(),
disconnect: vi.fn(),
}))
// Mock ResizeObserver
global.ResizeObserver = vi.fn().mockImplementation(() => ({
observe: vi.fn(),
unobserve: vi.fn(),
disconnect: vi.fn(),
}))

28
wrangler.toml Normal file
View File

@ -0,0 +1,28 @@
name = "united-tattoo"
compatibility_date = "2024-09-23"
compatibility_flags = ["nodejs_compat"]
# D1 Database binding
[[d1_databases]]
binding = "DB"
database_name = "united-tattoo"
database_id = "5d133d3b-680f-4772-b4ea-594c55cd1bd5"
# R2 bucket binding
[[r2_buckets]]
binding = "R2_BUCKET"
bucket_name = "united-tattoo"
# Environment variables for production
[env.production.vars]
NEXTAUTH_URL = "https://your-domain.com"
NODE_ENV = "production"
# Environment variables for preview
[env.preview.vars]
NEXTAUTH_URL = "https://your-preview-domain.pages.dev"
NODE_ENV = "development"
# Build configuration for Next.js
[build]
command = "npm run pages:build"