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
fe187dd744
commit
bb64e4c4b9
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 { useArtist } from "@/hooks/use-artist-data"
|
||||||
import { useIsMobile } from "@/hooks/use-mobile"
|
import { useIsMobile } from "@/hooks/use-mobile"
|
||||||
import { type CarouselApi, Carousel, CarouselContent, CarouselItem } from "@/components/ui/carousel"
|
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
|
// Removed mobile filter scroll area
|
||||||
|
|
||||||
interface ArtistPortfolioProps {
|
interface ArtistPortfolioProps {
|
||||||
@ -28,6 +30,7 @@ export function ArtistPortfolio({ artistId }: ArtistPortfolioProps) {
|
|||||||
|
|
||||||
// Fetch artist data from API
|
// Fetch artist data from API
|
||||||
const { data: artist, isLoading, error } = useArtist(artistId)
|
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
|
// keep a reference to the last focused thumbnail so we can return focus on modal close
|
||||||
const lastFocusedRef = useRef<HTMLElement | null>(null)
|
const lastFocusedRef = useRef<HTMLElement | null>(null)
|
||||||
@ -463,6 +466,33 @@ export function ArtistPortfolio({ artistId }: ArtistPortfolioProps) {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</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 */}
|
{/* Contact Section */}
|
||||||
<section className="relative py-32 bg-black border-t border-white/10">
|
<section className="relative py-32 bg-black border-t border-white/10">
|
||||||
<div className="container mx-auto px-8 text-center">
|
<div className="container mx-auto px-8 text-center">
|
||||||
|
|||||||
@ -1,6 +1,9 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import type React from "react"
|
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 { useState, useMemo } from "react"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
@ -33,6 +36,8 @@ interface BookingFormProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function BookingForm({ artistId }: BookingFormProps) {
|
export function BookingForm({ artistId }: BookingFormProps) {
|
||||||
|
const search = useSearchParams()
|
||||||
|
const flashIdParam = search?.get('flashId') || undefined
|
||||||
const [step, setStep] = useState(1)
|
const [step, setStep] = useState(1)
|
||||||
const [selectedDate, setSelectedDate] = useState<Date>()
|
const [selectedDate, setSelectedDate] = useState<Date>()
|
||||||
|
|
||||||
@ -68,11 +73,25 @@ export function BookingForm({ artistId }: BookingFormProps) {
|
|||||||
depositAmount: 100,
|
depositAmount: 100,
|
||||||
agreeToTerms: false,
|
agreeToTerms: false,
|
||||||
agreeToDeposit: false,
|
agreeToDeposit: false,
|
||||||
|
flashId: flashIdParam || "",
|
||||||
})
|
})
|
||||||
|
|
||||||
const selectedArtist = artists?.find((a) => a.slug === formData.artistId)
|
const selectedArtist = artists?.find((a) => a.slug === formData.artistId)
|
||||||
const selectedSize = tattooSizes.find((size) => size.size === formData.tattooSize)
|
const selectedSize = tattooSizes.find((size) => size.size === formData.tattooSize)
|
||||||
const bookingEnabled = useFeatureFlag("BOOKING_ENABLED")
|
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
|
// Calculate appointment start and end times for availability checking
|
||||||
const { appointmentStart, appointmentEnd } = useMemo(() => {
|
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,
|
UpdateArtistInput,
|
||||||
CreateAppointmentInput,
|
CreateAppointmentInput,
|
||||||
UpdateSiteSettingsInput,
|
UpdateSiteSettingsInput,
|
||||||
AppointmentFilters
|
AppointmentFilters,
|
||||||
|
FlashItem
|
||||||
} from '@/types/database'
|
} from '@/types/database'
|
||||||
|
|
||||||
// Type for Cloudflare D1 database binding
|
// 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
|
ORDER BY order_index ASC, created_at DESC
|
||||||
`).bind(id).all();
|
`).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;
|
const artist = artistResult as any;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@ -185,6 +193,20 @@ export async function getArtistWithPortfolio(id: string, env?: any): Promise<imp
|
|||||||
isPublic: Boolean(img.is_public),
|
isPublic: Boolean(img.is_public),
|
||||||
createdAt: new Date(img.created_at)
|
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: [],
|
availability: [],
|
||||||
createdAt: new Date(artist.created_at),
|
createdAt: new Date(artist.created_at),
|
||||||
updatedAt: new Date(artist.updated_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
|
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
|
// Calendar & Booking Types
|
||||||
export interface Appointment {
|
export interface Appointment {
|
||||||
id: string
|
id: string
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user