From cf5f775e0f37d3bac8108e94c4cc0f60a35b09f5 Mon Sep 17 00:00:00 2001 From: Nicholai Date: Mon, 20 Oct 2025 18:45:31 -0600 Subject: [PATCH] feat(flash): add Flash (predrawn) items - schema, API, hooks, UI section on artist page; booking form prefill via flashId --- app/api/flash/[artistId]/route.ts | 22 +++++++++++++ app/api/flash/item/[id]/route.ts | 24 ++++++++++++++ components/artist-portfolio.tsx | 30 ++++++++++++++++++ components/booking-form.tsx | 19 ++++++++++++ hooks/use-flash.ts | 31 +++++++++++++++++++ lib/db.ts | 24 +++++++++++++- .../20251021_0002_add_flash_items.sql | 19 ++++++++++++ types/database.ts | 14 +++++++++ 8 files changed, 182 insertions(+), 1 deletion(-) create mode 100644 app/api/flash/[artistId]/route.ts create mode 100644 app/api/flash/item/[id]/route.ts create mode 100644 hooks/use-flash.ts create mode 100644 sql/migrations/20251021_0002_add_flash_items.sql diff --git a/app/api/flash/[artistId]/route.ts b/app/api/flash/[artistId]/route.ts new file mode 100644 index 000000000..4ae009b62 --- /dev/null +++ b/app/api/flash/[artistId]/route.ts @@ -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 }) + } +} + + diff --git a/app/api/flash/item/[id]/route.ts b/app/api/flash/item/[id]/route.ts new file mode 100644 index 000000000..e0e8f8022 --- /dev/null +++ b/app/api/flash/item/[id]/route.ts @@ -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 }) + } +} + + diff --git a/components/artist-portfolio.tsx b/components/artist-portfolio.tsx index 4f5b41ae2..e6e67ffc2 100644 --- a/components/artist-portfolio.tsx +++ b/components/artist-portfolio.tsx @@ -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(null) @@ -463,6 +466,33 @@ export function ArtistPortfolio({ artistId }: ArtistPortfolioProps) { + {/* Available Flash (both desktop and mobile if items exist) */} + {flashItems && flashItems.length > 0 && ( +
+
+

Available Flash

+
+ {flashItems.map((item) => ( + +
+ {item.title +
+
+
+
{item.title || 'Flash piece'}
+ {item.sizeHint &&
{item.sizeHint}
} +
+ +
+
+ ))} +
+
+
+ )} + {/* Contact Section */}
diff --git a/components/booking-form.tsx b/components/booking-form.tsx index 03a044f00..02fcb7b14 100644 --- a/components/booking-form.tsx +++ b/components/booking-form.tsx @@ -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() @@ -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(() => { diff --git a/hooks/use-flash.ts b/hooks/use-flash.ts new file mode 100644 index 000000000..6d9995749 --- /dev/null +++ b/hooks/use-flash.ts @@ -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 { + 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 +} + + diff --git a/lib/db.ts b/lib/db.ts index 1ff41788d..6b4855879 100644 --- a/lib/db.ts +++ b/lib/db.ts @@ -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 ({ + 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), diff --git a/sql/migrations/20251021_0002_add_flash_items.sql b/sql/migrations/20251021_0002_add_flash_items.sql new file mode 100644 index 000000000..d21126279 --- /dev/null +++ b/sql/migrations/20251021_0002_add_flash_items.sql @@ -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); + + diff --git a/types/database.ts b/types/database.ts index bec8c31af..36f614434 100644 --- a/types/database.ts +++ b/types/database.ts @@ -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