feat(flash): add Flash (predrawn) items - schema, API, hooks, UI section on artist page; booking form prefill via flashId
This commit is contained in:
parent
5cafc8a80b
commit
cf5f775e0f
22
app/api/flash/[artistId]/route.ts
Normal file
22
app/api/flash/[artistId]/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
24
app/api/flash/item/[id]/route.ts
Normal file
24
app/api/flash/item/[id]/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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
31
hooks/use-flash.ts
Normal 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
|
||||
}
|
||||
|
||||
|
||||
24
lib/db.ts
24
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<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),
|
||||
|
||||
19
sql/migrations/20251021_0002_add_flash_items.sql
Normal file
19
sql/migrations/20251021_0002_add_flash_items.sql
Normal 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);
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user