feat(flash): add Flash (predrawn) items - schema, API, hooks, UI section on artist page; booking form prefill via flashId

This commit is contained in:
Nicholai 2025-10-20 18:45:31 -06:00
parent 5cafc8a80b
commit cf5f775e0f
8 changed files with 182 additions and 1 deletions

View File

@ -0,0 +1,22 @@
import { NextRequest } from 'next/server'
import { getDB } from '@/lib/db'
export async function GET(_req: NextRequest, { params }: { params: { artistId: string } }) {
try {
const db = getDB()
const result = await db.prepare(`
SELECT * FROM flash_items
WHERE artist_id = ? AND is_available = 1
ORDER BY order_index ASC, created_at DESC
`).bind(params.artistId).all()
return new Response(JSON.stringify({ items: result.results }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
})
} catch (err: any) {
return new Response(JSON.stringify({ error: err?.message || 'Failed to fetch flash items' }), { status: 500 })
}
}

View File

@ -0,0 +1,24 @@
import { NextRequest } from 'next/server'
import { getDB } from '@/lib/db'
export async function GET(_req: NextRequest, { params }: { params: { id: string } }) {
try {
const db = getDB()
const result = await db.prepare(`
SELECT * FROM flash_items WHERE id = ?
`).bind(params.id).first()
if (!result) {
return new Response(JSON.stringify({ error: 'Not found' }), { status: 404 })
}
return new Response(JSON.stringify({ item: result }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
})
} catch (err: any) {
return new Response(JSON.stringify({ error: err?.message || 'Failed to fetch flash item' }), { status: 500 })
}
}

View File

@ -8,6 +8,8 @@ import { Instagram, ExternalLink, Loader2 } from "lucide-react"
import { useArtist } from "@/hooks/use-artist-data"
import { useIsMobile } from "@/hooks/use-mobile"
import { type CarouselApi, Carousel, CarouselContent, CarouselItem } from "@/components/ui/carousel"
import { Card } from "@/components/ui/card"
import { useFlash } from "@/hooks/use-flash"
// Removed mobile filter scroll area
interface ArtistPortfolioProps {
@ -28,6 +30,7 @@ export function ArtistPortfolio({ artistId }: ArtistPortfolioProps) {
// Fetch artist data from API
const { data: artist, isLoading, error } = useArtist(artistId)
const { data: flashItems = [] } = useFlash(artistId)
// keep a reference to the last focused thumbnail so we can return focus on modal close
const lastFocusedRef = useRef<HTMLElement | null>(null)
@ -463,6 +466,33 @@ export function ArtistPortfolio({ artistId }: ArtistPortfolioProps) {
</div>
</section>
{/* Available Flash (both desktop and mobile if items exist) */}
{flashItems && flashItems.length > 0 && (
<section className="bg-black border-t border-white/10 py-10">
<div className="px-4 md:px-12 max-w-6xl mx-auto">
<h3 className="font-playfair text-3xl md:text-4xl font-bold mb-6">Available Flash</h3>
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-6">
{flashItems.map((item) => (
<Card key={item.id} className="bg-white/5 border-white/10 overflow-hidden">
<div className="relative w-full aspect-[4/5] bg-black">
<Image src={item.url} alt={item.title || `${artist?.name} flash`} fill sizes="(max-width:768px) 100vw, 33vw" className="object-cover" />
</div>
<div className="p-4 flex items-center justify-between">
<div>
<div className="font-medium">{item.title || 'Flash piece'}</div>
{item.sizeHint && <div className="text-sm text-white/60">{item.sizeHint}</div>}
</div>
<Button asChild size="sm" className="bg-white text-black hover:bg-gray-100 !text-black">
<Link href={`/book?artist=${artist?.slug}&flashId=${item.id}`}>Book this</Link>
</Button>
</div>
</Card>
))}
</div>
</div>
</section>
)}
{/* Contact Section */}
<section className="relative py-32 bg-black border-t border-white/10">
<div className="container mx-auto px-8 text-center">

View File

@ -1,6 +1,9 @@
"use client"
import type React from "react"
import { useSearchParams } from "next/navigation"
import { useEffect } from "react"
import { fetchFlashItem } from "@/hooks/use-flash"
import { useState, useMemo } from "react"
import { Button } from "@/components/ui/button"
@ -33,6 +36,8 @@ interface BookingFormProps {
}
export function BookingForm({ artistId }: BookingFormProps) {
const search = useSearchParams()
const flashIdParam = search?.get('flashId') || undefined
const [step, setStep] = useState(1)
const [selectedDate, setSelectedDate] = useState<Date>()
@ -68,11 +73,25 @@ export function BookingForm({ artistId }: BookingFormProps) {
depositAmount: 100,
agreeToTerms: false,
agreeToDeposit: false,
flashId: flashIdParam || "",
})
const selectedArtist = artists?.find((a) => a.slug === formData.artistId)
const selectedSize = tattooSizes.find((size) => size.size === formData.tattooSize)
const bookingEnabled = useFeatureFlag("BOOKING_ENABLED")
// Prefill from flash piece if provided
useEffect(() => {
const load = async () => {
if (!flashIdParam) return
const item = await fetchFlashItem(flashIdParam)
if (!item) return
setFormData((prev) => ({
...prev,
tattooDescription: [item.title, item.description].filter(Boolean).join(' - '),
}))
}
load()
}, [flashIdParam])
// Calculate appointment start and end times for availability checking
const { appointmentStart, appointmentEnd } = useMemo(() => {

31
hooks/use-flash.ts Normal file
View File

@ -0,0 +1,31 @@
import { useQuery } from '@tanstack/react-query'
import type { FlashItem } from '@/types/database'
export const flashKeys = {
listByArtist: (artistId: string) => ['flash', 'list', artistId] as const,
item: (id: string) => ['flash', 'item', id] as const,
}
export function useFlash(artistId: string | undefined) {
return useQuery({
queryKey: flashKeys.listByArtist(artistId || ''),
queryFn: async () => {
if (!artistId) return [] as FlashItem[]
const res = await fetch(`/api/flash/${artistId}`)
if (!res.ok) throw new Error('Failed to fetch flash')
const data = await res.json()
return (data.items || []) as FlashItem[]
},
enabled: !!artistId,
staleTime: 1000 * 60 * 5,
})
}
export async function fetchFlashItem(id: string): Promise<FlashItem | null> {
const res = await fetch(`/api/flash/item/${id}`)
if (!res.ok) return null
const data = await res.json()
return (data.item || null) as FlashItem | null
}

View File

@ -7,7 +7,8 @@ import type {
UpdateArtistInput,
CreateAppointmentInput,
UpdateSiteSettingsInput,
AppointmentFilters
AppointmentFilters,
FlashItem
} from '@/types/database'
// Type for Cloudflare D1 database binding
@ -163,6 +164,13 @@ export async function getArtistWithPortfolio(id: string, env?: any): Promise<imp
ORDER BY order_index ASC, created_at DESC
`).bind(id).all();
// Fetch flash items (public only)
const flashResult = await db.prepare(`
SELECT * FROM flash_items
WHERE artist_id = ? AND is_available = 1
ORDER BY order_index ASC, created_at DESC
`).bind(id).all();
const artist = artistResult as any;
return {
@ -185,6 +193,20 @@ export async function getArtistWithPortfolio(id: string, env?: any): Promise<imp
isPublic: Boolean(img.is_public),
createdAt: new Date(img.created_at)
})),
// Attach as non-breaking field (not in Artist type but useful to callers)
flashItems: (flashResult.results as any[]).map(row => ({
id: row.id,
artistId: row.artist_id,
url: row.url,
title: row.title || undefined,
description: row.description || undefined,
price: row.price ?? undefined,
sizeHint: row.size_hint || undefined,
tags: row.tags ? JSON.parse(row.tags) : undefined,
orderIndex: row.order_index || 0,
isAvailable: Boolean(row.is_available),
createdAt: new Date(row.created_at)
})) as FlashItem[],
availability: [],
createdAt: new Date(artist.created_at),
updatedAt: new Date(artist.updated_at),

View File

@ -0,0 +1,19 @@
-- Add flash_items table for predrawn/flash pieces
CREATE TABLE IF NOT EXISTS flash_items (
id TEXT PRIMARY KEY,
artist_id TEXT NOT NULL,
url TEXT NOT NULL,
title TEXT,
description TEXT,
price INTEGER,
size_hint TEXT,
tags TEXT,
order_index INTEGER DEFAULT 0,
is_available INTEGER DEFAULT 1,
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (artist_id) REFERENCES artists(id)
);
CREATE INDEX IF NOT EXISTS idx_flash_artist ON flash_items(artist_id, is_available, order_index);

View File

@ -194,6 +194,20 @@ export interface PortfolioImage {
createdAt: Date
}
export interface FlashItem {
id: string
artistId: string
url: string
title?: string
description?: string
price?: number // cents
sizeHint?: string
tags?: string[]
orderIndex: number
isAvailable: boolean
createdAt: Date
}
// Calendar & Booking Types
export interface Appointment {
id: string