feat: Phase 1 - Artist profile database refactor with API foundation
Implements backend infrastructure for loading artist profiles from Cloudflare D1 database instead of static data. Database Changes: - Add slug column migration for SEO-friendly URLs (0001_add_artist_slug.sql) - Enhanced data migration script with slug generation - Support for all artist fields from data/artists.ts Type Definitions: - Add slug field to Artist interface - Create ArtistWithPortfolio type for full artist data - Create PublicArtist type for sanitized API responses - Add ArtistFilters type for query parameters - Add ArtistDashboardStats for analytics Database Functions (lib/db.ts): - getPublicArtists() - fetch active artists with portfolio and filtering - getArtistWithPortfolio() - fetch single artist with full portfolio - getArtistBySlug() - fetch by URL-friendly slug - getArtistByUserId() - fetch by user ID for dashboard - Enhanced getArtists() with JSON parsing API Endpoints: - Updated GET /api/artists - filtering, pagination, portfolio images - Created GET /api/artists/[id] - fetch by ID or slug - Created PUT /api/artists/[id] - update with authorization - Created DELETE /api/artists/[id] - soft delete (admin only) - Created GET /api/artists/me - current artist profile React Hooks (hooks/use-artist-data.ts): - useArtists() - fetch with filtering - useArtist() - fetch single artist - useCurrentArtist() - logged-in artist - useUpdateArtist(), useCreateArtist(), useDeleteArtist() - mutations Frontend Components: - Refactored artists-grid.tsx to use API with loading/error states - Use database field names (slug, specialties, portfolioImages) - Display profile images from portfolio - Client-side filtering by specialty Files Modified: - sql/migrations/0001_add_artist_slug.sql (new) - types/database.ts (enhanced) - lib/data-migration.ts (enhanced) - lib/db.ts (enhanced) - app/api/artists/route.ts (updated) - app/api/artists/[id]/route.ts (new) - app/api/artists/me/route.ts (new) - hooks/use-artist-data.ts (new) - components/artists-grid.tsx (refactored) Remaining work: Artist portfolio page, artist dashboard, admin enhancements Ref: artist_profile_refactor_implementation_plan.md
This commit is contained in:
parent
741e036711
commit
43b336acf9
@ -2,55 +2,34 @@ import { NextRequest, NextResponse } from "next/server"
|
|||||||
import { requireAuth } from "@/lib/auth"
|
import { requireAuth } from "@/lib/auth"
|
||||||
import { UserRole } from "@/types/database"
|
import { UserRole } from "@/types/database"
|
||||||
import { updateArtistSchema } from "@/lib/validations"
|
import { updateArtistSchema } from "@/lib/validations"
|
||||||
import { db } from "@/lib/db"
|
import { getArtistWithPortfolio, getArtistBySlug, updateArtist, deleteArtist } from "@/lib/db"
|
||||||
import { Flags } from "@/lib/flags"
|
|
||||||
|
|
||||||
// GET /api/artists/[id] - Fetch a specific artist
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
// GET /api/artists/[id] - Fetch single artist with portfolio
|
||||||
export async function GET(
|
export async function GET(
|
||||||
request: NextRequest,
|
request: NextRequest,
|
||||||
{ params }: { params: { id: string } }
|
{ params }: { params: { id: string } },
|
||||||
|
context?: any
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
const { id } = params
|
const { id } = params
|
||||||
|
|
||||||
// TODO: Implement via Supabase MCP
|
|
||||||
// const artist = await db.artists.findUnique(id)
|
|
||||||
|
|
||||||
// Mock response for now
|
// Try to fetch by ID first, then by slug
|
||||||
const mockArtist = {
|
let artist = await getArtistWithPortfolio(id, context?.env)
|
||||||
id,
|
|
||||||
userId: "user-1",
|
if (!artist) {
|
||||||
name: "Alex Rivera",
|
artist = await getArtistBySlug(id, context?.env)
|
||||||
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) {
|
if (!artist) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: "Artist not found" },
|
{ error: "Artist not found" },
|
||||||
{ status: 404 }
|
{ status: 404 }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return NextResponse.json(mockArtist)
|
return NextResponse.json(artist)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error fetching artist:", error)
|
console.error("Error fetching artist:", error)
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
@ -60,42 +39,50 @@ export async function GET(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// PUT /api/artists/[id] - Update a specific artist (Admin only)
|
// PUT /api/artists/[id] - Update artist (admin or artist themselves)
|
||||||
export async function PUT(
|
export async function PUT(
|
||||||
request: NextRequest,
|
request: NextRequest,
|
||||||
{ params }: { params: { id: string } }
|
{ params }: { params: { id: string } },
|
||||||
|
context?: any
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
if (!Flags.ARTISTS_MODULE_ENABLED) {
|
|
||||||
return NextResponse.json({ error: 'Artists module disabled' }, { status: 503 })
|
|
||||||
}
|
|
||||||
// Require admin authentication
|
|
||||||
const session = await requireAuth(UserRole.SHOP_ADMIN)
|
|
||||||
|
|
||||||
const { id } = params
|
const { id } = params
|
||||||
const body = await request.json()
|
const session = await requireAuth()
|
||||||
const validatedData = updateArtistSchema.parse({ ...body, id })
|
|
||||||
|
|
||||||
// TODO: Implement via Supabase MCP
|
|
||||||
// const updatedArtist = await db.artists.update(id, validatedData)
|
|
||||||
|
|
||||||
// Mock response for now
|
// Get the artist to check ownership
|
||||||
const mockUpdatedArtist = {
|
const artist = await getArtistWithPortfolio(id, context?.env)
|
||||||
id,
|
if (!artist) {
|
||||||
userId: "user-1",
|
return NextResponse.json(
|
||||||
name: validatedData.name || "Alex Rivera",
|
{ error: "Artist not found" },
|
||||||
bio: validatedData.bio || "Updated bio",
|
{ status: 404 }
|
||||||
specialties: validatedData.specialties || ["Traditional"],
|
)
|
||||||
instagramHandle: validatedData.instagramHandle,
|
}
|
||||||
isActive: validatedData.isActive ?? true,
|
|
||||||
hourlyRate: validatedData.hourlyRate,
|
// Check authorization: must be the artist themselves or an admin
|
||||||
portfolioImages: [],
|
const isOwner = artist.userId === session.user.id
|
||||||
availability: [],
|
const isAdmin = [UserRole.SUPER_ADMIN, UserRole.SHOP_ADMIN].includes(session.user.role)
|
||||||
createdAt: new Date("2024-01-01"),
|
|
||||||
updatedAt: new Date(),
|
if (!isOwner && !isAdmin) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Insufficient permissions" },
|
||||||
|
{ status: 403 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await request.json()
|
||||||
|
const validatedData = updateArtistSchema.parse(body)
|
||||||
|
|
||||||
|
// If artist is updating themselves (not admin), restrict what they can change
|
||||||
|
let updateData = validatedData
|
||||||
|
if (isOwner && !isAdmin) {
|
||||||
|
// Artists can only update: bio, specialties, instagramHandle, hourlyRate
|
||||||
|
const { bio, specialties, instagramHandle, hourlyRate } = validatedData
|
||||||
|
updateData = { bio, specialties, instagramHandle, hourlyRate }
|
||||||
}
|
}
|
||||||
|
|
||||||
return NextResponse.json(mockUpdatedArtist)
|
const updatedArtist = await updateArtist(id, updateData, context?.env)
|
||||||
|
|
||||||
|
return NextResponse.json(updatedArtist)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error updating artist:", error)
|
console.error("Error updating artist:", error)
|
||||||
|
|
||||||
@ -106,12 +93,6 @@ export async function PUT(
|
|||||||
{ status: 401 }
|
{ status: 401 }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (error.message.includes("Insufficient permissions")) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: "Insufficient permissions" },
|
|
||||||
{ status: 403 }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
@ -121,30 +102,21 @@ export async function PUT(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// DELETE /api/artists/[id] - Delete a specific artist (Admin only)
|
// DELETE /api/artists/[id] - Soft delete artist (admin only)
|
||||||
export async function DELETE(
|
export async function DELETE(
|
||||||
request: NextRequest,
|
request: NextRequest,
|
||||||
{ params }: { params: { id: string } }
|
{ params }: { params: { id: string } },
|
||||||
|
context?: any
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
if (!Flags.ARTISTS_MODULE_ENABLED) {
|
const { id } = params
|
||||||
return NextResponse.json({ error: 'Artists module disabled' }, { status: 503 })
|
|
||||||
}
|
|
||||||
// Require admin authentication
|
// Require admin authentication
|
||||||
await requireAuth(UserRole.SHOP_ADMIN)
|
await requireAuth(UserRole.SHOP_ADMIN)
|
||||||
|
|
||||||
const { id } = params
|
await deleteArtist(id, context?.env)
|
||||||
|
|
||||||
// TODO: Implement via Supabase MCP
|
return NextResponse.json({ success: true })
|
||||||
// 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) {
|
} catch (error) {
|
||||||
console.error("Error deleting artist:", error)
|
console.error("Error deleting artist:", error)
|
||||||
|
|
||||||
|
|||||||
52
app/api/artists/me/route.ts
Normal file
52
app/api/artists/me/route.ts
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server"
|
||||||
|
import { requireAuth } from "@/lib/auth"
|
||||||
|
import { UserRole } from "@/types/database"
|
||||||
|
import { getArtistByUserId } from "@/lib/db"
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
// GET /api/artists/me - Get current logged-in artist's profile
|
||||||
|
export async function GET(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params?: any } = {},
|
||||||
|
context?: any
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
// Require artist authentication
|
||||||
|
const session = await requireAuth(UserRole.ARTIST)
|
||||||
|
|
||||||
|
// Fetch artist data by user ID
|
||||||
|
const artist = await getArtistByUserId(session.user.id, context?.env)
|
||||||
|
|
||||||
|
if (!artist) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Artist profile not found" },
|
||||||
|
{ status: 404 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(artist)
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching artist profile:", 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: "You must be an artist to access this endpoint" },
|
||||||
|
{ status: 403 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Failed to fetch artist profile" },
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,8 +1,8 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server"
|
import { NextRequest, NextResponse } from "next/server"
|
||||||
import { requireAuth } from "@/lib/auth"
|
import { requireAuth } from "@/lib/auth"
|
||||||
import { UserRole } from "@/types/database"
|
import { UserRole, ArtistFilters } from "@/types/database"
|
||||||
import { createArtistSchema, paginationSchema, artistFiltersSchema } from "@/lib/validations"
|
import { createArtistSchema, paginationSchema, artistFiltersSchema } from "@/lib/validations"
|
||||||
import { getArtists, createArtist } from "@/lib/db"
|
import { getPublicArtists, createArtist } from "@/lib/db"
|
||||||
import { Flags } from "@/lib/flags"
|
import { Flags } from "@/lib/flags"
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
@ -15,7 +15,7 @@ export async function GET(request: NextRequest, { params }: { params?: any } = {
|
|||||||
// Parse and validate query parameters
|
// Parse and validate query parameters
|
||||||
const pagination = paginationSchema.parse({
|
const pagination = paginationSchema.parse({
|
||||||
page: searchParams.get("page") || "1",
|
page: searchParams.get("page") || "1",
|
||||||
limit: searchParams.get("limit") || "10",
|
limit: searchParams.get("limit") || "50", // Increased default for artists grid
|
||||||
})
|
})
|
||||||
|
|
||||||
const filters = artistFiltersSchema.parse({
|
const filters = artistFiltersSchema.parse({
|
||||||
@ -24,46 +24,28 @@ export async function GET(request: NextRequest, { params }: { params?: any } = {
|
|||||||
search: searchParams.get("search"),
|
search: searchParams.get("search"),
|
||||||
})
|
})
|
||||||
|
|
||||||
// Fetch artists from database with environment context
|
// Build filters for database query
|
||||||
const artists = await getArtists(context?.env)
|
const dbFilters: ArtistFilters = {
|
||||||
|
specialty: filters.specialty || undefined,
|
||||||
// Apply filters
|
search: filters.search || undefined,
|
||||||
let filteredArtists = artists
|
isActive: filters.isActive !== undefined ? filters.isActive : true,
|
||||||
|
limit: pagination.limit,
|
||||||
if (filters.isActive !== undefined) {
|
offset: (pagination.page - 1) * pagination.limit,
|
||||||
filteredArtists = filteredArtists.filter(artist =>
|
|
||||||
artist.isActive === filters.isActive
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fetch artists from database with portfolio images
|
||||||
|
const artists = await getPublicArtists(dbFilters, context?.env)
|
||||||
|
|
||||||
if (filters.specialty) {
|
// Get total count for pagination (this is a simplified approach)
|
||||||
filteredArtists = filteredArtists.filter(artist =>
|
// In production, you'd want a separate count query
|
||||||
artist.specialties.some(specialty =>
|
const hasMore = artists.length === pagination.limit
|
||||||
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({
|
return NextResponse.json({
|
||||||
artists: paginatedArtists,
|
artists,
|
||||||
pagination: {
|
pagination: {
|
||||||
page: pagination.page,
|
page: pagination.page,
|
||||||
limit: pagination.limit,
|
limit: pagination.limit,
|
||||||
total: filteredArtists.length,
|
hasMore,
|
||||||
totalPages: Math.ceil(filteredArtists.length / pagination.limit),
|
|
||||||
},
|
},
|
||||||
filters,
|
filters,
|
||||||
})
|
})
|
||||||
|
|||||||
512
artist_profile_refactor_implementation_plan.md
Normal file
512
artist_profile_refactor_implementation_plan.md
Normal file
@ -0,0 +1,512 @@
|
|||||||
|
# Artist Profile Refactor Implementation Plan
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
Refactor the artists grid and individual artist profile pages to load content from a Cloudflare D1 database instead of static data. Enable artists to log into backend profiles where they can manage their portfolio images, bio, and other information. The existing `data/artists.ts` will serve as seed data for the database.
|
||||||
|
|
||||||
|
This implementation connects the existing database schema, API routes, and authentication system with the public-facing components, while also creating an artist dashboard for self-service portfolio management.
|
||||||
|
|
||||||
|
## Types
|
||||||
|
Database and API type definitions for artist profile management.
|
||||||
|
|
||||||
|
**Key Type Updates:**
|
||||||
|
- Ensure `Artist` interface in `types/database.ts` matches D1 schema columns
|
||||||
|
- Add `ArtistWithPortfolio` type that includes populated `portfolioImages` array
|
||||||
|
- Create `PublicArtist` type for sanitized public API responses
|
||||||
|
- Add `ArtistDashboardStats` type for artist-specific analytics
|
||||||
|
|
||||||
|
**New Type Definitions:**
|
||||||
|
```typescript
|
||||||
|
// types/database.ts additions
|
||||||
|
export interface ArtistWithPortfolio extends Artist {
|
||||||
|
portfolioImages: PortfolioImage[]
|
||||||
|
user?: {
|
||||||
|
name: string
|
||||||
|
email: string
|
||||||
|
avatar?: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PublicArtist {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
bio: string
|
||||||
|
specialties: string[]
|
||||||
|
instagramHandle?: string
|
||||||
|
portfolioImages: PortfolioImage[]
|
||||||
|
isActive: boolean
|
||||||
|
hourlyRate?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ArtistDashboardStats {
|
||||||
|
totalImages: number
|
||||||
|
activeImages: number
|
||||||
|
profileViews?: number
|
||||||
|
lastUpdated: Date
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Files
|
||||||
|
|
||||||
|
### Files to Modify
|
||||||
|
|
||||||
|
**1. `lib/db.ts`**
|
||||||
|
- Add `getArtistWithPortfolio(id: string)` function that joins artists with portfolio_images
|
||||||
|
- Add `getPublicArtists()` function that returns only active artists with public portfolio images
|
||||||
|
- Add `getArtistByUserId(userId: string)` function for artist dashboard access
|
||||||
|
- Update `getArtists()` to parse JSON fields properly (specialties)
|
||||||
|
|
||||||
|
**2. `app/api/artists/route.ts`**
|
||||||
|
- Update GET handler to return artists with portfolio images
|
||||||
|
- Add pagination support to prevent loading all artists at once
|
||||||
|
- Add filtering by specialty and search query
|
||||||
|
- Ensure responses match `PublicArtist` type for public endpoints
|
||||||
|
|
||||||
|
**3. `app/api/artists/[id]/route.ts`**
|
||||||
|
- Create this file (currently missing)
|
||||||
|
- Add GET endpoint to fetch single artist with portfolio
|
||||||
|
- Add PUT endpoint for updating artist (admin or artist themselves)
|
||||||
|
- Add authorization check (admin or owner)
|
||||||
|
|
||||||
|
**4. `components/artists-grid.tsx`**
|
||||||
|
- Remove hardcoded `artists` array
|
||||||
|
- Add `useEffect` to fetch artists from `/api/artists`
|
||||||
|
- Add loading and error states
|
||||||
|
- Update to use API response data structure
|
||||||
|
- Keep existing filtering UI but apply to fetched data
|
||||||
|
- Update routing to use database IDs instead of hardcoded IDs
|
||||||
|
|
||||||
|
**5. `components/artist-portfolio.tsx`**
|
||||||
|
- Remove hardcoded `artistsData` object
|
||||||
|
- Add `useEffect` to fetch artist data from `/api/artists/${artistId}`
|
||||||
|
- Add loading and error states
|
||||||
|
- Update image galleries to use portfolio images from database
|
||||||
|
- Handle missing artist gracefully
|
||||||
|
- Update data structure to match API response
|
||||||
|
|
||||||
|
**6. `components/artists-page-section.tsx`**
|
||||||
|
- Remove import from `@/data/artists`
|
||||||
|
- Fetch artists from API in component
|
||||||
|
- Add loading state
|
||||||
|
|
||||||
|
**7. `components/artists-section.tsx`**
|
||||||
|
- Remove import from `@/data/artists`
|
||||||
|
- Fetch artists from API
|
||||||
|
- Add loading skeleton
|
||||||
|
|
||||||
|
**8. `components/booking-form.tsx`**
|
||||||
|
- Update artist selection to fetch from API
|
||||||
|
- Remove import from `@/data/artists`
|
||||||
|
|
||||||
|
**9. `app/artists/[id]/page.tsx`**
|
||||||
|
- Update to use database-friendly slug or ID
|
||||||
|
- May need to support both slug-based and ID-based routing during migration
|
||||||
|
|
||||||
|
**10. `lib/auth.ts`**
|
||||||
|
- Add `getArtistSession()` helper that checks if logged-in user is an artist
|
||||||
|
- Add `requireArtistAuth()` helper for artist-only routes
|
||||||
|
|
||||||
|
**11. `sql/schema.sql`**
|
||||||
|
- Add missing columns if needed (review against `data/artists.ts` structure)
|
||||||
|
- Add indexes for performance (slug, user_id lookups)
|
||||||
|
- Consider adding `slug` column to artists table for SEO-friendly URLs
|
||||||
|
|
||||||
|
**12. `lib/data-migration.ts`**
|
||||||
|
- Update migration to use all artists from `data/artists.ts`
|
||||||
|
- Fix portfolio image creation to handle all work images
|
||||||
|
- Add proper slug generation from artist names
|
||||||
|
- Ensure idempotency (can be run multiple times safely)
|
||||||
|
|
||||||
|
### Files to Create
|
||||||
|
|
||||||
|
**1. `app/artist-dashboard/page.tsx`**
|
||||||
|
- New artist dashboard home page
|
||||||
|
- Show artist's own profile overview
|
||||||
|
- Display stats (total images, views, etc.)
|
||||||
|
- Quick links to edit profile and portfolio
|
||||||
|
|
||||||
|
**2. `app/artist-dashboard/layout.tsx`**
|
||||||
|
- Layout wrapper for artist dashboard
|
||||||
|
- Navigation sidebar for dashboard sections
|
||||||
|
- Artist profile header
|
||||||
|
- Requires ARTIST or SHOP_ADMIN role
|
||||||
|
|
||||||
|
**3. `app/artist-dashboard/profile/page.tsx`**
|
||||||
|
- Artist profile edit page
|
||||||
|
- Reuse `<ArtistForm>` component but filtered for artist-editable fields
|
||||||
|
- Artists can edit: bio, specialties, instagram, hourly rate
|
||||||
|
- Cannot edit: name, email, isActive (admin only)
|
||||||
|
|
||||||
|
**4. `app/artist-dashboard/portfolio/page.tsx`**
|
||||||
|
- Portfolio image management page
|
||||||
|
- Upload new images
|
||||||
|
- Edit captions and tags
|
||||||
|
- Reorder images (drag and drop)
|
||||||
|
- Delete images
|
||||||
|
- Set visibility (public/private)
|
||||||
|
|
||||||
|
**5. `app/api/artists/me/route.ts`**
|
||||||
|
- GET endpoint to fetch current logged-in artist's data
|
||||||
|
- Requires authentication
|
||||||
|
- Returns artist profile for logged-in user
|
||||||
|
|
||||||
|
**6. `app/api/portfolio/route.ts`** (if not exists)
|
||||||
|
- POST endpoint to add portfolio images
|
||||||
|
- Requires artist or admin auth
|
||||||
|
- Handles R2 upload integration
|
||||||
|
|
||||||
|
**7. `app/api/portfolio/[id]/route.ts`**
|
||||||
|
- GET single portfolio image
|
||||||
|
- PUT to update (caption, tags, order, visibility)
|
||||||
|
- DELETE to remove image
|
||||||
|
- Authorization: admin or image owner
|
||||||
|
|
||||||
|
**8. `components/admin/portfolio-manager.tsx`**
|
||||||
|
- Reusable component for managing portfolio images
|
||||||
|
- Used in both admin and artist dashboard
|
||||||
|
- Image upload, grid display, edit modals
|
||||||
|
- Drag-drop reordering
|
||||||
|
|
||||||
|
**9. `hooks/use-artist-data.ts`**
|
||||||
|
- Custom hook for fetching artist data
|
||||||
|
- Handles loading, error states
|
||||||
|
- Caching with SWR or React Query pattern
|
||||||
|
- Reusable across components
|
||||||
|
|
||||||
|
**10. `middleware.ts` (update)**
|
||||||
|
- Add route protection for `/artist-dashboard/*`
|
||||||
|
- Verify user has ARTIST role
|
||||||
|
- Redirect to signin if not authenticated
|
||||||
|
|
||||||
|
### Files to Delete (after migration)
|
||||||
|
|
||||||
|
**None immediately** - Keep `data/artists.ts` as seed data reference
|
||||||
|
|
||||||
|
## Functions
|
||||||
|
|
||||||
|
### New Functions in `lib/db.ts`
|
||||||
|
|
||||||
|
**1. `getArtistWithPortfolio(id: string, env?: any): Promise<ArtistWithPortfolio | null>`**
|
||||||
|
- Fetch artist by ID
|
||||||
|
- Join with portfolio_images table
|
||||||
|
- Parse JSON fields (specialties, tags)
|
||||||
|
- Return combined object with images array
|
||||||
|
|
||||||
|
**2. `getPublicArtists(filters?: ArtistFilters, env?: any): Promise<PublicArtist[]>`**
|
||||||
|
- Fetch only active artists
|
||||||
|
- Include only public portfolio images
|
||||||
|
- Apply filters (specialty, search)
|
||||||
|
- Sanitize data for public consumption
|
||||||
|
|
||||||
|
**3. `getArtistByUserId(userId: string, env?: any): Promise<Artist | null>`**
|
||||||
|
- Fetch artist record by user_id
|
||||||
|
- Used for artist dashboard access
|
||||||
|
- Returns full artist data for owner
|
||||||
|
|
||||||
|
**4. `getArtistBySlug(slug: string, env?: any): Promise<ArtistWithPortfolio | null>`**
|
||||||
|
- Fetch artist by URL slug
|
||||||
|
- Join with portfolio images
|
||||||
|
- For SEO-friendly URLs
|
||||||
|
|
||||||
|
**5. `updatePortfolioImageOrder(artistId: string, imageOrders: Array<{id: string, orderIndex: number}>, env?: any): Promise<void>`**
|
||||||
|
- Batch update order indices
|
||||||
|
- Used for drag-drop reordering
|
||||||
|
- Transaction support if available
|
||||||
|
|
||||||
|
### New Functions in `lib/auth.ts`
|
||||||
|
|
||||||
|
**1. `getArtistSession(): Promise<{ artist: Artist, user: User } | null>`**
|
||||||
|
- Get current session
|
||||||
|
- Check if user has ARTIST role
|
||||||
|
- Fetch associated artist record
|
||||||
|
- Return combined data or null
|
||||||
|
|
||||||
|
**2. `requireArtistAuth(): Promise<{ artist: Artist, user: User }>`**
|
||||||
|
- Like requireAuth but specifically for artists
|
||||||
|
- Throws error if not an artist
|
||||||
|
- Returns artist and user data
|
||||||
|
|
||||||
|
**3. `canEditArtist(userId: string, artistId: string): Promise<boolean>`**
|
||||||
|
- Check if user can edit specific artist
|
||||||
|
- True if: user is the artist, SHOP_ADMIN, or SUPER_ADMIN
|
||||||
|
- Used for authorization checks
|
||||||
|
|
||||||
|
### Modified Functions
|
||||||
|
|
||||||
|
**1. `lib/db.ts:getArtists()`**
|
||||||
|
- Add optional `includePortfolio` parameter
|
||||||
|
- Parse JSON fields properly
|
||||||
|
- Add error handling
|
||||||
|
|
||||||
|
**2. `lib/db.ts:createArtist()`**
|
||||||
|
- Add slug generation
|
||||||
|
- Ensure user creation if needed
|
||||||
|
- Return artist with portfolio array (empty initially)
|
||||||
|
|
||||||
|
**3. `lib/data-migration.ts:migrateArtistData()`**
|
||||||
|
- Update to migrate all fields from `data/artists.ts`
|
||||||
|
- Add slug generation
|
||||||
|
- Handle all portfolio images
|
||||||
|
- Add progress logging
|
||||||
|
|
||||||
|
## Classes
|
||||||
|
|
||||||
|
### New Classes
|
||||||
|
|
||||||
|
**1. `ArtistDashboardManager` (optional)**
|
||||||
|
- Class to encapsulate artist dashboard operations
|
||||||
|
- Methods: getStats(), updateProfile(), getPortfolio()
|
||||||
|
- Centralizes artist-specific business logic
|
||||||
|
- Located in `lib/artist-dashboard.ts`
|
||||||
|
|
||||||
|
**No other new classes required** - Functional approach with helper functions is sufficient
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
### Current Dependencies (verify versions)
|
||||||
|
- `next`: 15.x
|
||||||
|
- `next-auth`: Latest compatible with Next.js 15
|
||||||
|
- `@tanstack/react-table`: For admin tables
|
||||||
|
- `react-hook-form`: Form management
|
||||||
|
- `zod`: Schema validation
|
||||||
|
- `@hookform/resolvers`: Zod integration with react-hook-form
|
||||||
|
|
||||||
|
### New Dependencies to Install
|
||||||
|
|
||||||
|
**1. `swr` or `@tanstack/react-query`**
|
||||||
|
- For client-side data fetching and caching
|
||||||
|
- Recommended: `swr` (lighter weight)
|
||||||
|
- Install: `npm install swr`
|
||||||
|
|
||||||
|
**2. `@dnd-kit/core`, `@dnd-kit/sortable` (optional)**
|
||||||
|
- For drag-drop portfolio reordering
|
||||||
|
- Only if implementing drag-drop UI
|
||||||
|
- Install: `npm install @dnd-kit/core @dnd-kit/sortable @dnd-kit/utilities`
|
||||||
|
|
||||||
|
**3. `sharp` (dev dependency)**
|
||||||
|
- Image optimization during migration
|
||||||
|
- May already be included with Next.js
|
||||||
|
|
||||||
|
### Configuration Updates
|
||||||
|
|
||||||
|
**1. `next.config.mjs`**
|
||||||
|
- Ensure image optimization configured for R2 URLs
|
||||||
|
- Add remote patterns for portfolio image domains
|
||||||
|
|
||||||
|
**2. `wrangler.toml`**
|
||||||
|
- Verify D1 database binding name matches code
|
||||||
|
- Verify R2 bucket binding for portfolio uploads
|
||||||
|
|
||||||
|
**3. `.env.local`**
|
||||||
|
- No new env vars needed (using Cloudflare bindings)
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
### Unit Tests to Create
|
||||||
|
|
||||||
|
**1. `__tests__/lib/db.test.ts`**
|
||||||
|
- Test artist CRUD operations
|
||||||
|
- Test portfolio image operations
|
||||||
|
- Mock D1 database
|
||||||
|
- Verify JSON parsing
|
||||||
|
|
||||||
|
**2. `__tests__/lib/auth.test.ts`**
|
||||||
|
- Test artist authentication helpers
|
||||||
|
- Test authorization checks
|
||||||
|
- Mock NextAuth session
|
||||||
|
|
||||||
|
**3. `__tests__/hooks/use-artist-data.test.ts`**
|
||||||
|
- Test hook with mock data
|
||||||
|
- Test loading and error states
|
||||||
|
- Test cache behavior
|
||||||
|
|
||||||
|
### Integration Tests to Create
|
||||||
|
|
||||||
|
**1. `__tests__/api/artists.test.ts`**
|
||||||
|
- Test GET /api/artists endpoint
|
||||||
|
- Test filtering and pagination
|
||||||
|
- Test error handling
|
||||||
|
|
||||||
|
**2. `__tests__/api/artists/[id].test.ts`**
|
||||||
|
- Test GET single artist
|
||||||
|
- Test PUT artist update
|
||||||
|
- Test authorization
|
||||||
|
|
||||||
|
**3. `__tests__/api/portfolio.test.ts`**
|
||||||
|
- Test portfolio CRUD operations
|
||||||
|
- Test file uploads
|
||||||
|
- Test authorization
|
||||||
|
|
||||||
|
### Component Tests to Update
|
||||||
|
|
||||||
|
**1. `__tests__/components/artists-grid.test.tsx`**
|
||||||
|
- Update to mock API fetch
|
||||||
|
- Test loading states
|
||||||
|
- Test error states
|
||||||
|
- Test filtering
|
||||||
|
|
||||||
|
**2. `__tests__/components/artist-portfolio.test.tsx`**
|
||||||
|
- Update to mock API fetch
|
||||||
|
- Test portfolio image display
|
||||||
|
- Test modal interactions
|
||||||
|
|
||||||
|
### E2E Test Scenarios
|
||||||
|
|
||||||
|
**1. Artist Login and Profile Edit**
|
||||||
|
- Artist signs in
|
||||||
|
- Navigates to dashboard
|
||||||
|
- Edits bio and specialties
|
||||||
|
- Saves successfully
|
||||||
|
|
||||||
|
**2. Portfolio Image Upload**
|
||||||
|
- Artist uploads images
|
||||||
|
- Images appear in portfolio
|
||||||
|
- Reorders images
|
||||||
|
- Deletes an image
|
||||||
|
|
||||||
|
**3. Public Artist Profile View**
|
||||||
|
- Anonymous user visits artist page
|
||||||
|
- Sees artist info and portfolio
|
||||||
|
- Images load correctly
|
||||||
|
- Filtering works
|
||||||
|
|
||||||
|
**4. Admin Artist Management**
|
||||||
|
- Admin creates new artist
|
||||||
|
- Edits artist information
|
||||||
|
- Uploads portfolio images for artist
|
||||||
|
- Deactivates artist
|
||||||
|
|
||||||
|
## Implementation Order
|
||||||
|
|
||||||
|
### Phase 1: Database & API Foundation (Steps 1-5)
|
||||||
|
|
||||||
|
**1. Update Database Schema and Migration**
|
||||||
|
- Review and update `sql/schema.sql` if needed (add slug column, indexes)
|
||||||
|
- Update `lib/data-migration.ts` to properly migrate all artist data from `data/artists.ts`
|
||||||
|
- Test migration script locally
|
||||||
|
- Verify all artists and portfolio images are created correctly
|
||||||
|
|
||||||
|
**2. Enhance Database Functions**
|
||||||
|
- Update `lib/db.ts` with new functions: `getArtistWithPortfolio`, `getPublicArtists`, `getArtistByUserId`, `getArtistBySlug`
|
||||||
|
- Update existing functions to properly parse JSON fields
|
||||||
|
- Add error handling and logging
|
||||||
|
- Write unit tests for new functions
|
||||||
|
|
||||||
|
**3. Create/Update API Endpoints**
|
||||||
|
- Create `app/api/artists/[id]/route.ts` with GET and PUT handlers
|
||||||
|
- Update `app/api/artists/route.ts` to support filtering and return portfolio images
|
||||||
|
- Create `app/api/artists/me/route.ts` for artist self-access
|
||||||
|
- Add authorization checks to all endpoints
|
||||||
|
- Test endpoints with Postman or similar
|
||||||
|
|
||||||
|
**4. Update Authentication Helpers**
|
||||||
|
- Add `getArtistSession()` and `requireArtistAuth()` to `lib/auth.ts`
|
||||||
|
- Add `canEditArtist()` authorization helper
|
||||||
|
- Test with mock sessions
|
||||||
|
|
||||||
|
**5. Create Data Fetching Hook**
|
||||||
|
- Create `hooks/use-artist-data.ts` with SWR
|
||||||
|
- Implement loading and error states
|
||||||
|
- Add caching and revalidation
|
||||||
|
- Test hook in isolation
|
||||||
|
|
||||||
|
### Phase 2: Public-Facing Components (Steps 6-8)
|
||||||
|
|
||||||
|
**6. Refactor Artists Grid**
|
||||||
|
- Update `components/artists-grid.tsx` to fetch from API
|
||||||
|
- Remove hardcoded data
|
||||||
|
- Add loading skeleton UI
|
||||||
|
- Add error handling UI
|
||||||
|
- Test filtering and pagination
|
||||||
|
- Verify routing to individual artists works
|
||||||
|
|
||||||
|
**7. Refactor Artist Portfolio Page**
|
||||||
|
- Update `components/artist-portfolio.tsx` to fetch from API
|
||||||
|
- Remove hardcoded `artistsData`
|
||||||
|
- Add loading states
|
||||||
|
- Update image galleries to use database images
|
||||||
|
- Test with various artist IDs
|
||||||
|
- Verify modal/lightbox still works
|
||||||
|
|
||||||
|
**8. Update Supporting Components**
|
||||||
|
- Update `components/artists-section.tsx` to use API
|
||||||
|
- Update `components/artists-page-section.tsx` to use API
|
||||||
|
- Update `components/booking-form.tsx` artist selection
|
||||||
|
- Add loading states to all
|
||||||
|
- Test home page and booking flow
|
||||||
|
|
||||||
|
### Phase 3: Artist Dashboard (Steps 9-12)
|
||||||
|
|
||||||
|
**9. Create Artist Dashboard Layout**
|
||||||
|
- Create `app/artist-dashboard/layout.tsx` with navigation
|
||||||
|
- Add role protection in middleware
|
||||||
|
- Create dashboard home page `app/artist-dashboard/page.tsx`
|
||||||
|
- Display artist stats and quick links
|
||||||
|
- Test authentication and routing
|
||||||
|
|
||||||
|
**10. Build Profile Editor**
|
||||||
|
- Create `app/artist-dashboard/profile/page.tsx`
|
||||||
|
- Reuse and adapt `<ArtistForm>` component
|
||||||
|
- Filter fields for artist-editable only
|
||||||
|
- Connect to PUT `/api/artists/me` endpoint
|
||||||
|
- Test profile updates
|
||||||
|
|
||||||
|
**11. Build Portfolio Manager**
|
||||||
|
- Create `app/artist-dashboard/portfolio/page.tsx`
|
||||||
|
- Create `components/admin/portfolio-manager.tsx` component
|
||||||
|
- Implement image upload UI
|
||||||
|
- Add image editing (captions, tags, visibility)
|
||||||
|
- Test upload and management
|
||||||
|
|
||||||
|
**12. Implement Portfolio API**
|
||||||
|
- Create `app/api/portfolio/route.ts` for creating images
|
||||||
|
- Create `app/api/portfolio/[id]/route.ts` for update/delete
|
||||||
|
- Integrate with R2 upload system
|
||||||
|
- Add authorization checks
|
||||||
|
- Test full upload flow
|
||||||
|
|
||||||
|
### Phase 4: Admin Enhancements (Steps 13-14)
|
||||||
|
|
||||||
|
**13. Update Admin Artist Management**
|
||||||
|
- Update `app/admin/artists/[id]/page.tsx` to use API
|
||||||
|
- Enhance `<ArtistForm>` with portfolio management
|
||||||
|
- Test admin CRUD operations
|
||||||
|
- Verify authorization works
|
||||||
|
|
||||||
|
**14. Add Portfolio Management to Admin**
|
||||||
|
- Integrate `<PortfolioManager>` into admin artist edit page
|
||||||
|
- Allow admins to manage any artist's portfolio
|
||||||
|
- Test admin portfolio operations
|
||||||
|
|
||||||
|
### Phase 5: Testing & Refinement (Steps 15-17)
|
||||||
|
|
||||||
|
**15. Write and Run Tests**
|
||||||
|
- Create all unit tests for new functions
|
||||||
|
- Create integration tests for API endpoints
|
||||||
|
- Create component tests
|
||||||
|
- Run full test suite
|
||||||
|
- Fix any failing tests
|
||||||
|
|
||||||
|
**16. Performance Optimization**
|
||||||
|
- Add database indexes for common queries
|
||||||
|
- Implement image lazy loading
|
||||||
|
- Add proper caching headers to API
|
||||||
|
- Optimize large portfolio image displays
|
||||||
|
- Test with large datasets
|
||||||
|
|
||||||
|
**17. Migration & Deployment**
|
||||||
|
- Run migration on development D1 database
|
||||||
|
- Test full application flow
|
||||||
|
- Create migration checklist for production
|
||||||
|
- Deploy to staging environment
|
||||||
|
- Final testing in staging
|
||||||
|
- Production deployment
|
||||||
|
|
||||||
|
### Phase 6: Documentation & Cleanup (Step 18)
|
||||||
|
|
||||||
|
**18. Documentation and Cleanup**
|
||||||
|
- Document new API endpoints
|
||||||
|
- Update README with setup instructions
|
||||||
|
- Add inline code comments
|
||||||
|
- Create artist onboarding guide
|
||||||
|
- Remove deprecated code/comments
|
||||||
|
- Archive old implementation if needed
|
||||||
@ -1,102 +1,35 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useState } from "react"
|
import { useState, useMemo } from "react"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Card, CardContent } from "@/components/ui/card"
|
import { Card, CardContent } from "@/components/ui/card"
|
||||||
import { Badge } from "@/components/ui/badge"
|
import { Badge } from "@/components/ui/badge"
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import { Star, MapPin, Calendar } from "lucide-react"
|
import { Star, Loader2 } from "lucide-react"
|
||||||
|
import { useArtists } from "@/hooks/use-artist-data"
|
||||||
|
|
||||||
const artists = [
|
const specialties = ["All", "Traditional", "Realism", "Fine Line", "Japanese", "Portraits", "Minimalist", "Black & Grey"]
|
||||||
{
|
|
||||||
id: "1",
|
|
||||||
name: "Sarah Chen",
|
|
||||||
specialty: "Traditional & Neo-Traditional",
|
|
||||||
image: "/professional-female-tattoo-artist-with-traditional.jpg",
|
|
||||||
bio: "Specializing in bold traditional designs with a modern twist. Sarah brings 8 years of experience creating vibrant, timeless tattoos.",
|
|
||||||
experience: "8 years",
|
|
||||||
rating: 4.9,
|
|
||||||
reviews: 127,
|
|
||||||
location: "Studio A",
|
|
||||||
availability: "Available",
|
|
||||||
styles: ["Traditional", "Neo-Traditional", "American Traditional", "Color Work"],
|
|
||||||
portfolio: [
|
|
||||||
"/traditional-rose-tattoo-with-bold-colors.jpg",
|
|
||||||
"/neo-traditional-wolf-tattoo-design.jpg",
|
|
||||||
"/american-traditional-anchor-tattoo.jpg",
|
|
||||||
"/colorful-traditional-bird-tattoo.jpg",
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "2",
|
|
||||||
name: "Marcus Rodriguez",
|
|
||||||
specialty: "Realism & Portraits",
|
|
||||||
image: "/professional-male-tattoo-artist-specializing-in-re.jpg",
|
|
||||||
bio: "Master of photorealistic tattoos and detailed portrait work. Marcus has perfected the art of bringing photographs to life on skin.",
|
|
||||||
experience: "12 years",
|
|
||||||
rating: 5.0,
|
|
||||||
reviews: 89,
|
|
||||||
location: "Studio B",
|
|
||||||
availability: "Booked until March",
|
|
||||||
styles: ["Realism", "Portraits", "Black & Grey", "Photorealism"],
|
|
||||||
portfolio: [
|
|
||||||
"/photorealistic-portrait-tattoo-black-and-grey.jpg",
|
|
||||||
"/realistic-animal-tattoo-detailed-shading.jpg",
|
|
||||||
"/black-and-grey-portrait-tattoo-masterpiece.jpg",
|
|
||||||
"/hyperrealistic-eye-tattoo-design.jpg",
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "3",
|
|
||||||
name: "Luna Kim",
|
|
||||||
specialty: "Fine Line & Minimalist",
|
|
||||||
image: "/professional-female-tattoo-artist-with-delicate-fi.jpg",
|
|
||||||
bio: "Creating elegant, minimalist designs with precision and grace. Luna's delicate touch brings subtle beauty to every piece.",
|
|
||||||
experience: "6 years",
|
|
||||||
rating: 4.8,
|
|
||||||
reviews: 156,
|
|
||||||
location: "Studio C",
|
|
||||||
availability: "Available",
|
|
||||||
styles: ["Fine Line", "Minimalist", "Geometric", "Botanical"],
|
|
||||||
portfolio: [
|
|
||||||
"/delicate-fine-line-flower-tattoo.jpg",
|
|
||||||
"/minimalist-geometric-tattoo-design.jpg",
|
|
||||||
"/fine-line-botanical-tattoo-elegant.jpg",
|
|
||||||
"/simple-line-work-tattoo-artistic.jpg",
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "4",
|
|
||||||
name: "Jake Thompson",
|
|
||||||
specialty: "Japanese & Oriental",
|
|
||||||
image: "/professional-male-tattoo-artist-with-japanese-styl.jpg",
|
|
||||||
bio: "Traditional Japanese tattooing with authentic techniques passed down through generations. Jake honors the ancient art form.",
|
|
||||||
experience: "15 years",
|
|
||||||
rating: 4.9,
|
|
||||||
reviews: 203,
|
|
||||||
location: "Studio D",
|
|
||||||
availability: "Limited slots",
|
|
||||||
styles: ["Japanese", "Oriental", "Irezumi", "Traditional Japanese"],
|
|
||||||
portfolio: [
|
|
||||||
"/traditional-japanese-dragon-tattoo-sleeve.jpg",
|
|
||||||
"/japanese-koi-fish-tattoo-colorful.jpg",
|
|
||||||
"/oriental-cherry-blossom-tattoo-design.jpg",
|
|
||||||
"/japanese-samurai-tattoo-traditional.jpg",
|
|
||||||
],
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
const specialties = ["All", "Traditional", "Realism", "Fine Line", "Japanese", "Portraits", "Minimalist"]
|
|
||||||
|
|
||||||
export function ArtistsGrid() {
|
export function ArtistsGrid() {
|
||||||
const [selectedSpecialty, setSelectedSpecialty] = useState("All")
|
const [selectedSpecialty, setSelectedSpecialty] = useState("All")
|
||||||
|
|
||||||
|
// Fetch artists from API
|
||||||
|
const { data: artists, isLoading, error } = useArtists({ limit: 50 })
|
||||||
|
|
||||||
const filteredArtists =
|
// Filter artists client-side
|
||||||
selectedSpecialty === "All"
|
const filteredArtists = useMemo(() => {
|
||||||
? artists
|
if (!artists) return []
|
||||||
: artists.filter((artist) =>
|
|
||||||
artist.styles.some((style) => style.toLowerCase().includes(selectedSpecialty.toLowerCase())),
|
if (selectedSpecialty === "All") {
|
||||||
)
|
return artists
|
||||||
|
}
|
||||||
|
|
||||||
|
return artists.filter((artist) =>
|
||||||
|
artist.specialties.some((style) =>
|
||||||
|
style.toLowerCase().includes(selectedSpecialty.toLowerCase())
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}, [artists, selectedSpecialty])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="py-20">
|
<section className="py-20">
|
||||||
@ -123,87 +56,112 @@ export function ArtistsGrid() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Loading State */}
|
||||||
|
{isLoading && (
|
||||||
|
<div className="flex justify-center items-center py-20">
|
||||||
|
<Loader2 className="w-8 h-8 animate-spin text-primary" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Error State */}
|
||||||
|
{error && (
|
||||||
|
<div className="text-center py-20">
|
||||||
|
<p className="text-red-500 mb-4">Failed to load artists. Please try again later.</p>
|
||||||
|
<Button onClick={() => window.location.reload()}>Retry</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Artists Grid */}
|
{/* Artists Grid */}
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
|
{!isLoading && !error && (
|
||||||
{filteredArtists.map((artist) => (
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
<Card key={artist.id} className="group hover:shadow-xl transition-all duration-300 overflow-hidden">
|
{filteredArtists.length === 0 ? (
|
||||||
<div className="flex flex-col h-full">
|
<div className="col-span-full text-center py-20">
|
||||||
{/* Artist Image */}
|
<p className="text-muted-foreground text-lg">No artists found matching your criteria.</p>
|
||||||
<div className="relative w-full h-48 sm:h-56 overflow-hidden">
|
|
||||||
<img
|
|
||||||
src={artist.image || "/placeholder.svg"}
|
|
||||||
alt={artist.name}
|
|
||||||
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300"
|
|
||||||
/>
|
|
||||||
<div className="absolute top-3 left-3">
|
|
||||||
<Badge variant={artist.availability === "Available" ? "default" : "secondary"}>
|
|
||||||
{artist.availability}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Artist Info */}
|
|
||||||
<CardContent className="p-4 flex-grow flex flex-col">
|
|
||||||
<div className="flex items-start justify-between mb-3">
|
|
||||||
<div>
|
|
||||||
<h3 className="font-playfair text-xl font-bold mb-1">{artist.name}</h3>
|
|
||||||
<p className="text-primary font-medium text-sm">{artist.specialty}</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center space-x-1 text-xs">
|
|
||||||
<Star className="w-3 h-3 fill-yellow-400 text-yellow-400" />
|
|
||||||
<span className="font-medium">{artist.rating}</span>
|
|
||||||
<span className="text-muted-foreground">({artist.reviews})</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p className="text-muted-foreground mb-3 text-xs leading-relaxed line-clamp-3">{artist.bio}</p>
|
|
||||||
|
|
||||||
<div className="space-y-2 mb-4">
|
|
||||||
<div className="flex items-center space-x-1 text-xs">
|
|
||||||
<Calendar className="w-3 h-3 text-muted-foreground" />
|
|
||||||
<span>{artist.experience} experience</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center space-x-1 text-xs">
|
|
||||||
<MapPin className="w-3 h-3 text-muted-foreground" />
|
|
||||||
<span>{artist.location}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Styles */}
|
|
||||||
<div className="mb-4">
|
|
||||||
<p className="text-xs font-medium mb-1">Specializes in:</p>
|
|
||||||
<div className="flex flex-wrap gap-1">
|
|
||||||
{artist.styles.slice(0, 3).map((style) => (
|
|
||||||
<Badge key={style} variant="outline" className="text-xs px-2 py-1">
|
|
||||||
{style}
|
|
||||||
</Badge>
|
|
||||||
))}
|
|
||||||
{artist.styles.length > 3 && (
|
|
||||||
<Badge variant="outline" className="text-xs px-2 py-1">
|
|
||||||
+{artist.styles.length - 3} more
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Action Buttons */}
|
|
||||||
<div className="flex space-x-2 mt-auto">
|
|
||||||
<Button asChild className="flex-1 text-xs py-2">
|
|
||||||
<Link href={`/artists/${artist.id}`}>View Portfolio</Link>
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
asChild
|
|
||||||
variant="outline"
|
|
||||||
className="flex-1 bg-white text-black !text-black border-gray-300 hover:bg-gray-50 hover:!text-black text-xs py-2"
|
|
||||||
>
|
|
||||||
<Link href={`/artists/${artist.id}/book`}>Book Now</Link>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
) : (
|
||||||
))}
|
filteredArtists.map((artist) => {
|
||||||
</div>
|
// Get profile image (first portfolio image or placeholder)
|
||||||
|
const profileImage = artist.portfolioImages.find(img => img.tags.includes('profile'))?.url ||
|
||||||
|
artist.portfolioImages[0]?.url ||
|
||||||
|
"/placeholder.svg"
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card key={artist.id} className="group hover:shadow-xl transition-all duration-300 overflow-hidden">
|
||||||
|
<div className="flex flex-col h-full">
|
||||||
|
{/* Artist Image */}
|
||||||
|
<div className="relative w-full h-48 sm:h-56 overflow-hidden">
|
||||||
|
<img
|
||||||
|
src={profileImage}
|
||||||
|
alt={artist.name}
|
||||||
|
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300"
|
||||||
|
/>
|
||||||
|
<div className="absolute top-3 left-3">
|
||||||
|
<Badge variant={artist.isActive ? "default" : "secondary"}>
|
||||||
|
{artist.isActive ? "Available" : "Unavailable"}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Artist Info */}
|
||||||
|
<CardContent className="p-4 flex-grow flex flex-col">
|
||||||
|
<div className="flex items-start justify-between mb-3">
|
||||||
|
<div className="flex-1">
|
||||||
|
<h3 className="font-playfair text-xl font-bold mb-1">{artist.name}</h3>
|
||||||
|
<p className="text-primary font-medium text-sm">
|
||||||
|
{artist.specialties.slice(0, 2).join(", ")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-muted-foreground mb-4 text-xs leading-relaxed line-clamp-3">{artist.bio}</p>
|
||||||
|
|
||||||
|
{/* Hourly Rate */}
|
||||||
|
{artist.hourlyRate && (
|
||||||
|
<div className="mb-4">
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Starting at <span className="font-semibold text-foreground">${artist.hourlyRate}/hr</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Styles */}
|
||||||
|
<div className="mb-4">
|
||||||
|
<p className="text-xs font-medium mb-1">Specializes in:</p>
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{artist.specialties.slice(0, 3).map((style) => (
|
||||||
|
<Badge key={style} variant="outline" className="text-xs px-2 py-1">
|
||||||
|
{style}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
{artist.specialties.length > 3 && (
|
||||||
|
<Badge variant="outline" className="text-xs px-2 py-1">
|
||||||
|
+{artist.specialties.length - 3} more
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Action Buttons */}
|
||||||
|
<div className="flex space-x-2 mt-auto">
|
||||||
|
<Button asChild className="flex-1 text-xs py-2">
|
||||||
|
<Link href={`/artists/${artist.slug}`}>View Portfolio</Link>
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
asChild
|
||||||
|
variant="outline"
|
||||||
|
className="flex-1 bg-white text-black !text-black border-gray-300 hover:bg-gray-50 hover:!text-black text-xs py-2"
|
||||||
|
>
|
||||||
|
<Link href={`/book?artist=${artist.slug}`}>Book Now</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
)
|
)
|
||||||
|
|||||||
2
dist/README.md
vendored
2
dist/README.md
vendored
@ -1 +1 @@
|
|||||||
This folder contains the built output assets for the worker "united-tattoo" generated at 2025-09-26T06:30:46.424Z.
|
This folder contains the built output assets for the worker "united-tattoo" generated at 2025-10-06T09:30:19.755Z.
|
||||||
170
hooks/use-artist-data.ts
Normal file
170
hooks/use-artist-data.ts
Normal file
@ -0,0 +1,170 @@
|
|||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import type { PublicArtist, ArtistWithPortfolio, Artist } from '@/types/database'
|
||||||
|
|
||||||
|
// Query keys for cache management
|
||||||
|
export const artistKeys = {
|
||||||
|
all: ['artists'] as const,
|
||||||
|
lists: () => [...artistKeys.all, 'list'] as const,
|
||||||
|
list: (filters?: Record<string, any>) => [...artistKeys.lists(), filters] as const,
|
||||||
|
details: () => [...artistKeys.all, 'detail'] as const,
|
||||||
|
detail: (id: string) => [...artistKeys.details(), id] as const,
|
||||||
|
me: () => [...artistKeys.all, 'me'] as const,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch all artists
|
||||||
|
export function useArtists(filters?: {
|
||||||
|
specialty?: string
|
||||||
|
search?: string
|
||||||
|
limit?: number
|
||||||
|
page?: number
|
||||||
|
}) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: artistKeys.list(filters),
|
||||||
|
queryFn: async () => {
|
||||||
|
const params = new URLSearchParams()
|
||||||
|
if (filters?.specialty) params.append('specialty', filters.specialty)
|
||||||
|
if (filters?.search) params.append('search', filters.search)
|
||||||
|
if (filters?.limit) params.append('limit', filters.limit.toString())
|
||||||
|
if (filters?.page) params.append('page', filters.page.toString())
|
||||||
|
|
||||||
|
const response = await fetch(`/api/artists?${params.toString()}`)
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to fetch artists')
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json()
|
||||||
|
return data.artists as PublicArtist[]
|
||||||
|
},
|
||||||
|
staleTime: 1000 * 60 * 5, // 5 minutes
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch single artist by ID or slug
|
||||||
|
export function useArtist(id: string | undefined) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: artistKeys.detail(id || ''),
|
||||||
|
queryFn: async () => {
|
||||||
|
if (!id) return null
|
||||||
|
|
||||||
|
const response = await fetch(`/api/artists/${id}`)
|
||||||
|
if (!response.ok) {
|
||||||
|
if (response.status === 404) return null
|
||||||
|
throw new Error('Failed to fetch artist')
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json() as Promise<ArtistWithPortfolio>
|
||||||
|
},
|
||||||
|
enabled: !!id,
|
||||||
|
staleTime: 1000 * 60 * 5, // 5 minutes
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch current artist (for artist dashboard)
|
||||||
|
export function useCurrentArtist() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: artistKeys.me(),
|
||||||
|
queryFn: async () => {
|
||||||
|
const response = await fetch('/api/artists/me')
|
||||||
|
if (!response.ok) {
|
||||||
|
if (response.status === 401 || response.status === 403) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
throw new Error('Failed to fetch artist profile')
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json() as Promise<Artist>
|
||||||
|
},
|
||||||
|
staleTime: 1000 * 60 * 5, // 5 minutes
|
||||||
|
retry: false, // Don't retry on auth errors
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update artist mutation
|
||||||
|
export function useUpdateArtist() {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async ({ id, data }: { id: string; data: Partial<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() as Promise<Artist>
|
||||||
|
},
|
||||||
|
onSuccess: (data, variables) => {
|
||||||
|
// Invalidate and refetch
|
||||||
|
queryClient.invalidateQueries({ queryKey: artistKeys.detail(variables.id) })
|
||||||
|
queryClient.invalidateQueries({ queryKey: artistKeys.lists() })
|
||||||
|
queryClient.invalidateQueries({ queryKey: artistKeys.me() })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create artist mutation (admin only)
|
||||||
|
export function useCreateArtist() {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (data: {
|
||||||
|
name: string
|
||||||
|
bio: string
|
||||||
|
specialties: string[]
|
||||||
|
instagramHandle?: string
|
||||||
|
hourlyRate?: number
|
||||||
|
email?: string
|
||||||
|
}) => {
|
||||||
|
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() as Promise<Artist>
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
// Invalidate artists list
|
||||||
|
queryClient.invalidateQueries({ queryKey: artistKeys.lists() })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete artist mutation (admin only)
|
||||||
|
export function useDeleteArtist() {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (id: string) => {
|
||||||
|
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')
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json()
|
||||||
|
},
|
||||||
|
onSuccess: (_, id) => {
|
||||||
|
// Invalidate queries
|
||||||
|
queryClient.invalidateQueries({ queryKey: artistKeys.lists() })
|
||||||
|
queryClient.removeQueries({ queryKey: artistKeys.detail(id) })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
@ -81,16 +81,20 @@ export class DataMigrator {
|
|||||||
// Extract hourly rate from experience or set default
|
// Extract hourly rate from experience or set default
|
||||||
const hourlyRate = this.extractHourlyRate(artist.experience);
|
const hourlyRate = this.extractHourlyRate(artist.experience);
|
||||||
|
|
||||||
|
// Generate slug from artist name or use existing slug
|
||||||
|
const slug = artist.slug || this.generateSlug(artist.name);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await this.db.prepare(`
|
await this.db.prepare(`
|
||||||
INSERT OR IGNORE INTO artists (
|
INSERT OR IGNORE INTO artists (
|
||||||
id, user_id, name, bio, specialties, instagram_handle,
|
id, user_id, slug, name, bio, specialties, instagram_handle,
|
||||||
hourly_rate, is_active, created_at, updated_at
|
hourly_rate, is_active, created_at, updated_at
|
||||||
)
|
)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, 1, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 1, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
|
||||||
`).bind(
|
`).bind(
|
||||||
artistId,
|
artistId,
|
||||||
userId,
|
userId,
|
||||||
|
slug,
|
||||||
artist.name,
|
artist.name,
|
||||||
artist.bio,
|
artist.bio,
|
||||||
JSON.stringify(specialties),
|
JSON.stringify(specialties),
|
||||||
@ -98,7 +102,7 @@ export class DataMigrator {
|
|||||||
hourlyRate,
|
hourlyRate,
|
||||||
).run();
|
).run();
|
||||||
|
|
||||||
console.log(`Created artist record: ${artist.name}`);
|
console.log(`Created artist record: ${artist.name} (slug: ${slug})`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Error creating artist record for ${artist.name}:`, error);
|
console.error(`Error creating artist record for ${artist.name}:`, error);
|
||||||
throw error;
|
throw error;
|
||||||
@ -167,6 +171,17 @@ export class DataMigrator {
|
|||||||
console.log(`Created portfolio images for: ${artist.name}`);
|
console.log(`Created portfolio images for: ${artist.name}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate URL-friendly slug from artist name
|
||||||
|
*/
|
||||||
|
private generateSlug(name: string): string {
|
||||||
|
return name
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/['']/g, '') // Remove apostrophes
|
||||||
|
.replace(/[^a-z0-9]+/g, '-') // Replace non-alphanumeric with hyphens
|
||||||
|
.replace(/^-+|-+$/g, ''); // Trim hyphens from ends
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Extract Instagram handle from full URL
|
* Extract Instagram handle from full URL
|
||||||
*/
|
*/
|
||||||
|
|||||||
192
lib/db.ts
192
lib/db.ts
@ -53,7 +53,197 @@ export async function getArtists(env?: any): Promise<Artist[]> {
|
|||||||
ORDER BY a.created_at DESC
|
ORDER BY a.created_at DESC
|
||||||
`).all();
|
`).all();
|
||||||
|
|
||||||
return result.results as Artist[];
|
// Parse JSON fields
|
||||||
|
return (result.results as any[]).map(artist => ({
|
||||||
|
...artist,
|
||||||
|
specialties: artist.specialties ? JSON.parse(artist.specialties) : [],
|
||||||
|
portfolioImages: []
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getPublicArtists(filters?: import('@/types/database').ArtistFilters, env?: any): Promise<import('@/types/database').PublicArtist[]> {
|
||||||
|
const db = getDB(env);
|
||||||
|
|
||||||
|
let query = `
|
||||||
|
SELECT
|
||||||
|
a.id,
|
||||||
|
a.slug,
|
||||||
|
a.name,
|
||||||
|
a.bio,
|
||||||
|
a.specialties,
|
||||||
|
a.instagram_handle,
|
||||||
|
a.is_active,
|
||||||
|
a.hourly_rate
|
||||||
|
FROM artists a
|
||||||
|
WHERE a.is_active = 1
|
||||||
|
`;
|
||||||
|
|
||||||
|
const values: any[] = [];
|
||||||
|
|
||||||
|
if (filters?.specialty) {
|
||||||
|
query += ` AND a.specialties LIKE ?`;
|
||||||
|
values.push(`%${filters.specialty}%`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filters?.search) {
|
||||||
|
query += ` AND (a.name LIKE ? OR a.bio LIKE ?)`;
|
||||||
|
values.push(`%${filters.search}%`, `%${filters.search}%`);
|
||||||
|
}
|
||||||
|
|
||||||
|
query += ` ORDER BY a.created_at DESC`;
|
||||||
|
|
||||||
|
if (filters?.limit) {
|
||||||
|
query += ` LIMIT ?`;
|
||||||
|
values.push(filters.limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filters?.offset) {
|
||||||
|
query += ` OFFSET ?`;
|
||||||
|
values.push(filters.offset);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await db.prepare(query).bind(...values).all();
|
||||||
|
|
||||||
|
// Fetch portfolio images for each artist
|
||||||
|
const artistsWithPortfolio = await Promise.all(
|
||||||
|
(result.results as any[]).map(async (artist) => {
|
||||||
|
const portfolioResult = await db.prepare(`
|
||||||
|
SELECT * FROM portfolio_images
|
||||||
|
WHERE artist_id = ? AND is_public = 1
|
||||||
|
ORDER BY order_index ASC, created_at DESC
|
||||||
|
`).bind(artist.id).all();
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: artist.id,
|
||||||
|
slug: artist.slug,
|
||||||
|
name: artist.name,
|
||||||
|
bio: artist.bio,
|
||||||
|
specialties: artist.specialties ? JSON.parse(artist.specialties) : [],
|
||||||
|
instagramHandle: artist.instagram_handle,
|
||||||
|
isActive: Boolean(artist.is_active),
|
||||||
|
hourlyRate: artist.hourly_rate,
|
||||||
|
portfolioImages: (portfolioResult.results as any[]).map(img => ({
|
||||||
|
id: img.id,
|
||||||
|
artistId: img.artist_id,
|
||||||
|
url: img.url,
|
||||||
|
caption: img.caption,
|
||||||
|
tags: img.tags ? JSON.parse(img.tags) : [],
|
||||||
|
orderIndex: img.order_index,
|
||||||
|
isPublic: Boolean(img.is_public),
|
||||||
|
createdAt: new Date(img.created_at)
|
||||||
|
}))
|
||||||
|
};
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
return artistsWithPortfolio;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getArtistWithPortfolio(id: string, env?: any): Promise<import('@/types/database').ArtistWithPortfolio | null> {
|
||||||
|
const db = getDB(env);
|
||||||
|
|
||||||
|
const artistResult = await db.prepare(`
|
||||||
|
SELECT
|
||||||
|
a.*,
|
||||||
|
u.name as user_name,
|
||||||
|
u.email as user_email,
|
||||||
|
u.avatar as user_avatar
|
||||||
|
FROM artists a
|
||||||
|
LEFT JOIN users u ON a.user_id = u.id
|
||||||
|
WHERE a.id = ?
|
||||||
|
`).bind(id).first();
|
||||||
|
|
||||||
|
if (!artistResult) return null;
|
||||||
|
|
||||||
|
const portfolioResult = await db.prepare(`
|
||||||
|
SELECT * FROM portfolio_images
|
||||||
|
WHERE artist_id = ?
|
||||||
|
ORDER BY order_index ASC, created_at DESC
|
||||||
|
`).bind(id).all();
|
||||||
|
|
||||||
|
const artist = artistResult as any;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: artist.id,
|
||||||
|
userId: artist.user_id,
|
||||||
|
slug: artist.slug,
|
||||||
|
name: artist.name,
|
||||||
|
bio: artist.bio,
|
||||||
|
specialties: artist.specialties ? JSON.parse(artist.specialties) : [],
|
||||||
|
instagramHandle: artist.instagram_handle,
|
||||||
|
isActive: Boolean(artist.is_active),
|
||||||
|
hourlyRate: artist.hourly_rate,
|
||||||
|
portfolioImages: (portfolioResult.results as any[]).map(img => ({
|
||||||
|
id: img.id,
|
||||||
|
artistId: img.artist_id,
|
||||||
|
url: img.url,
|
||||||
|
caption: img.caption,
|
||||||
|
tags: img.tags ? JSON.parse(img.tags) : [],
|
||||||
|
orderIndex: img.order_index,
|
||||||
|
isPublic: Boolean(img.is_public),
|
||||||
|
createdAt: new Date(img.created_at)
|
||||||
|
})),
|
||||||
|
availability: [],
|
||||||
|
createdAt: new Date(artist.created_at),
|
||||||
|
updatedAt: new Date(artist.updated_at),
|
||||||
|
user: {
|
||||||
|
name: artist.user_name,
|
||||||
|
email: artist.user_email,
|
||||||
|
avatar: artist.user_avatar
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getArtistBySlug(slug: string, env?: any): Promise<import('@/types/database').ArtistWithPortfolio | null> {
|
||||||
|
const db = getDB(env);
|
||||||
|
|
||||||
|
const artistResult = await db.prepare(`
|
||||||
|
SELECT
|
||||||
|
a.*,
|
||||||
|
u.name as user_name,
|
||||||
|
u.email as user_email,
|
||||||
|
u.avatar as user_avatar
|
||||||
|
FROM artists a
|
||||||
|
LEFT JOIN users u ON a.user_id = u.id
|
||||||
|
WHERE a.slug = ?
|
||||||
|
`).bind(slug).first();
|
||||||
|
|
||||||
|
if (!artistResult) return null;
|
||||||
|
|
||||||
|
const artist = artistResult as any;
|
||||||
|
return getArtistWithPortfolio(artist.id, env);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getArtistByUserId(userId: string, env?: any): Promise<Artist | null> {
|
||||||
|
const db = getDB(env);
|
||||||
|
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.user_id = ?
|
||||||
|
`).bind(userId).first();
|
||||||
|
|
||||||
|
if (!result) return null;
|
||||||
|
|
||||||
|
const artist = result as any;
|
||||||
|
return {
|
||||||
|
id: artist.id,
|
||||||
|
userId: artist.user_id,
|
||||||
|
slug: artist.slug,
|
||||||
|
name: artist.name,
|
||||||
|
bio: artist.bio,
|
||||||
|
specialties: artist.specialties ? JSON.parse(artist.specialties) : [],
|
||||||
|
instagramHandle: artist.instagram_handle,
|
||||||
|
isActive: Boolean(artist.is_active),
|
||||||
|
hourlyRate: artist.hourly_rate,
|
||||||
|
portfolioImages: [],
|
||||||
|
availability: [],
|
||||||
|
createdAt: new Date(artist.created_at),
|
||||||
|
updatedAt: new Date(artist.updated_at)
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getArtist(id: string, env?: any): Promise<Artist | null> {
|
export async function getArtist(id: string, env?: any): Promise<Artist | null> {
|
||||||
|
|||||||
10
sql/migrations/0001_add_artist_slug.sql
Normal file
10
sql/migrations/0001_add_artist_slug.sql
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
-- Add slug column to artists table for SEO-friendly URLs
|
||||||
|
-- Run this with: wrangler d1 execute united-tattoo-db --file=./sql/migrations/0001_add_artist_slug.sql
|
||||||
|
|
||||||
|
-- Add slug column
|
||||||
|
ALTER TABLE artists ADD COLUMN slug TEXT;
|
||||||
|
|
||||||
|
-- Create unique index on slug
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_artists_slug ON artists(slug);
|
||||||
|
|
||||||
|
-- Note: Existing artists will need slugs populated via migration script
|
||||||
@ -23,5 +23,5 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||||
"exclude": ["node_modules"]
|
"exclude": ["types/node_modules"]
|
||||||
}
|
}
|
||||||
|
|||||||
@ -134,6 +134,7 @@ export enum UserRole {
|
|||||||
export interface Artist {
|
export interface Artist {
|
||||||
id: string
|
id: string
|
||||||
userId: string
|
userId: string
|
||||||
|
slug: string
|
||||||
name: string
|
name: string
|
||||||
bio: string
|
bio: string
|
||||||
specialties: string[]
|
specialties: string[]
|
||||||
@ -146,6 +147,42 @@ export interface Artist {
|
|||||||
updatedAt: Date
|
updatedAt: Date
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ArtistWithPortfolio extends Artist {
|
||||||
|
portfolioImages: PortfolioImage[]
|
||||||
|
user?: {
|
||||||
|
name: string
|
||||||
|
email: string
|
||||||
|
avatar?: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PublicArtist {
|
||||||
|
id: string
|
||||||
|
slug: string
|
||||||
|
name: string
|
||||||
|
bio: string
|
||||||
|
specialties: string[]
|
||||||
|
instagramHandle?: string
|
||||||
|
portfolioImages: PortfolioImage[]
|
||||||
|
isActive: boolean
|
||||||
|
hourlyRate?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ArtistDashboardStats {
|
||||||
|
totalImages: number
|
||||||
|
activeImages: number
|
||||||
|
profileViews?: number
|
||||||
|
lastUpdated: Date
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ArtistFilters {
|
||||||
|
specialty?: string
|
||||||
|
search?: string
|
||||||
|
isActive?: boolean
|
||||||
|
limit?: number
|
||||||
|
offset?: number
|
||||||
|
}
|
||||||
|
|
||||||
export interface PortfolioImage {
|
export interface PortfolioImage {
|
||||||
id: string
|
id: string
|
||||||
artistId: string
|
artistId: string
|
||||||
|
|||||||
@ -11,7 +11,7 @@ binding = "ASSETS"
|
|||||||
[[d1_databases]]
|
[[d1_databases]]
|
||||||
binding = "DB"
|
binding = "DB"
|
||||||
database_name = "united-tattoo"
|
database_name = "united-tattoo"
|
||||||
database_id = "5d133d3b-680f-4772-b4ea-594c55cd1bd5"
|
database_id = "7191a4c4-e3b2-49c6-bd8d-9cc3394977ec"
|
||||||
|
|
||||||
# R2 bucket binding
|
# R2 bucket binding
|
||||||
[[r2_buckets]]
|
[[r2_buckets]]
|
||||||
@ -29,13 +29,13 @@ service = "united-tattoo"
|
|||||||
|
|
||||||
# Environment variables for production
|
# Environment variables for production
|
||||||
[env.production.vars]
|
[env.production.vars]
|
||||||
NEXTAUTH_URL = "https://your-domain.com"
|
NEXTAUTH_URL = "https://united-tattoos.com"
|
||||||
NODE_ENV = "production"
|
NODE_ENV = "production"
|
||||||
|
|
||||||
# Environment variables for preview
|
# Environment variables for preview
|
||||||
[env.preview.vars]
|
[env.preview.vars]
|
||||||
NEXTAUTH_URL = "https://development.united-tattoos.com"
|
NEXTAUTH_URL = "https://united-tattoos.com"
|
||||||
NODE_ENV = "development"
|
NODE_ENV = "production"
|
||||||
|
|
||||||
[dev]
|
[dev]
|
||||||
ip = "0.0.0.0"
|
ip = "0.0.0.0"
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user