Compare commits

...

20 Commits

Author SHA1 Message Date
2fed5d4216 seeded Amari's portfolio to remote db
Some checks failed
Enhanced CI/CD Pipeline / Code Quality (push) Has been cancelled
Enhanced CI/CD Pipeline / Security Scan (push) Has been cancelled
Enhanced CI/CD Pipeline / Tests (push) Has been cancelled
Enhanced CI/CD Pipeline / Build Application (push) Has been cancelled
Enhanced CI/CD Pipeline / Deploy to Preview (push) Has been cancelled
Enhanced CI/CD Pipeline / Deploy to Production (push) Has been cancelled
Enhanced CI/CD Pipeline / Post-Deployment Checks (push) Has been cancelled
Enhanced CI/CD Pipeline / Cleanup (push) Has been cancelled
2025-10-20 21:13:06 -06:00
f4767ec368 feat(routing): permanent redirect /artists/amari-rodriguez -> /artists/amari-kyss via middleware (308) 2025-10-20 20:56:10 -06:00
9fa2e9b72f chore(home): update display name to 'Amari Kyss' in homepage artists section data 2025-10-20 20:46:44 -06:00
da8fd68982 fix(artists): exclude profile and non-public images from Featured Work/gallery filters 2025-10-20 20:39:25 -06:00
a6fb84cdad feat(flash/desktop): edge-to-edge carousel with gradient fades and center-scale effect; preserve mobile layout 2025-10-20 19:28:01 -06:00
d8bfc41fd7 feat(flash): add prev/next buttons and subtle swipe hint to carousel 2025-10-20 19:07:43 -06:00
ff2ffc248e feat(flash): make carousel drag-free/scrollable, remove titles; keep simple book CTA 2025-10-20 19:05:02 -06:00
73f7c3cedb feat(flash): change Available Flash from grid to responsive carousel with book buttons 2025-10-20 19:02:30 -06:00
0790f7f01a fix(flash): fetch flash items with artist.id after artist loads to avoid slug/id mismatch 2025-10-20 18:57:34 -06:00
d9005100aa fix(artists): tolerate missing flash_items table to avoid 500s on artist fetch 2025-10-20 18:48:40 -06:00
bb64e4c4b9 feat(flash): add Flash (predrawn) items - schema, API, hooks, UI section on artist page; booking form prefill via flashId 2025-10-20 18:45:31 -06:00
fe187dd744 chore(artists/desktop): remove desktop stats block (specialties/pieces/rate) for minimalist layout 2025-10-20 18:33:15 -06:00
515ccff03e chore(artists): remove availability badge on both desktop and mobile; strip scroll-area import 2025-10-20 18:30:47 -06:00
8f92b2880b feat(artists/desktop): remove specialties and pricing from desktop hero per simplification request 2025-10-20 18:28:58 -06:00
9cd2cb04cc feat(artists/mobile): hide stats block on mobile (specialties/pieces/rate) for cleaner UI 2025-10-20 18:13:04 -06:00
ca02397f0b feat(artists/mobile): simplify further — remove mobile filters and pricing; keep essentials only 2025-10-20 18:12:12 -06:00
239621af1c feat(artists/mobile): simplify mobile UI (carousel-only), larger typography, collapsible bio, fade-out swipe hint 2025-10-20 18:09:25 -06:00
00d182d7a9 feat(artists/mobile): default mobile view to carousel and add swipe hint overlay 2025-10-20 18:05:13 -06:00
20be36ee6e feat(artists/mobile): add lightbox swipe, preserve scroll on modal, animated filter pills, carousel dots indicators, and a11y roles; desktop untouched 2025-10-20 18:02:25 -06:00
7c50c5324c feat(artists): mobile-only portfolio UI using shadcn Tabs/Carousel + horizontal filters; preserve desktop layout; remove Back to Artists button 2025-10-20 17:54:00 -06:00
112 changed files with 1350 additions and 175 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

@ -3,10 +3,13 @@
import { useState, useEffect, useRef, useCallback } from "react"
import Image from "next/image"
import { Button } from "@/components/ui/button"
import { Badge } from "@/components/ui/badge"
import Link from "next/link"
import { ArrowLeft, Instagram, ExternalLink, Loader2, DollarSign } from "lucide-react"
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, CarouselPrevious, CarouselNext } from "@/components/ui/carousel"
import { useFlash } from "@/hooks/use-flash"
// Removed mobile filter scroll area
interface ArtistPortfolioProps {
artistId: string
@ -16,30 +19,110 @@ export function ArtistPortfolio({ artistId }: ArtistPortfolioProps) {
const [selectedCategory, setSelectedCategory] = useState("All")
const [selectedImage, setSelectedImage] = useState<string | null>(null)
const [scrollY, setScrollY] = useState(0)
const isMobile = useIsMobile()
// carousel indicator state (mobile)
const [carouselApi, setCarouselApi] = useState<CarouselApi | null>(null)
const [carouselCount, setCarouselCount] = useState(0)
const [carouselCurrent, setCarouselCurrent] = useState(0)
const [showSwipeHint, setShowSwipeHint] = useState(true)
const [showFullBio, setShowFullBio] = useState(false)
const [flashApi, setFlashApi] = useState<CarouselApi | null>(null)
// Fetch artist data from API
const { data: artist, isLoading, error } = useArtist(artistId)
const { data: flashItems = [] } = useFlash(artist?.id)
// keep a reference to the last focused thumbnail so we can return focus on modal close
const lastFocusedRef = useRef<HTMLElement | null>(null)
const closeButtonRef = useRef<HTMLButtonElement | null>(null)
const touchStartX = useRef<number | null>(null)
useEffect(() => {
// Enable parallax only on desktop to avoid jank on mobile
if (isMobile) return
const handleScroll = () => setScrollY(window.scrollY)
window.addEventListener("scroll", handleScroll)
return () => window.removeEventListener("scroll", handleScroll)
}, [isMobile])
// Fade swipe hint after a short delay
useEffect(() => {
const t = setTimeout(() => setShowSwipeHint(false), 2500)
return () => clearTimeout(t)
}, [])
// Preserve scroll position when modal opens/closes
useEffect(() => {
if (!selectedImage) return
const y = window.scrollY
const { body } = document
body.style.position = "fixed"
body.style.top = `-${y}px`
body.style.left = "0"
body.style.right = "0"
return () => {
const top = body.style.top
body.style.position = ""
body.style.top = ""
body.style.left = ""
body.style.right = ""
const restoreY = Math.abs(parseInt(top || "0", 10))
window.scrollTo(0, restoreY)
}
}, [selectedImage])
// Carousel indicators state wiring
useEffect(() => {
if (!carouselApi) return
setCarouselCount(carouselApi.scrollSnapList().length)
setCarouselCurrent(carouselApi.selectedScrollSnap())
const onSelect = () => setCarouselCurrent(carouselApi.selectedScrollSnap())
carouselApi.on("select", onSelect)
return () => {
carouselApi.off("select", onSelect)
}
}, [carouselApi])
// Flash carousel scale effect based on position (desktop emphasis)
useEffect(() => {
if (!flashApi) return
const updateScales = () => {
const root = flashApi.rootNode() as HTMLElement | null
const slides = flashApi.slideNodes() as HTMLElement[]
if (!root || !slides?.length) return
const rect = root.getBoundingClientRect()
const centerX = rect.left + rect.width / 2
slides.forEach((slide) => {
const sRect = slide.getBoundingClientRect()
const sCenter = sRect.left + sRect.width / 2
const dist = Math.abs(sCenter - centerX)
const norm = Math.min(dist / (rect.width / 2), 1) // 0 at center, 1 at edge
const scale = 0.92 + (1 - norm) * 0.08 // 0.92 at edge → 1.0 center
slide.style.transition = 'transform 200ms ease'
slide.style.transform = `scale(${scale})`
})
}
updateScales()
flashApi.on('scroll', updateScales)
flashApi.on('reInit', updateScales)
return () => {
flashApi.off('scroll', updateScales)
flashApi.off('reInit', updateScales)
}
}, [flashApi])
// Derived lists (safe when `artist` is undefined during initial renders)
const portfolioImages = artist?.portfolioImages || []
// Exclude profile/non-public images from the displayed gallery
const galleryImages = portfolioImages.filter((img) => img.isPublic !== false && !img.tags.includes('profile'))
// Get unique categories from tags
const allTags = portfolioImages.flatMap(img => img.tags)
// Get unique categories from tags (use gallery images only)
const allTags = galleryImages.flatMap(img => img.tags)
const categories = ["All", ...Array.from(new Set(allTags))]
const filteredPortfolio = selectedCategory === "All"
? portfolioImages
: portfolioImages.filter(img => img.tags.includes(selectedCategory))
? galleryImages
: galleryImages.filter(img => img.tags.includes(selectedCategory))
// keyboard navigation for modal (kept as hooks so they run in same order every render)
const goToIndex = useCallback(
@ -132,25 +215,14 @@ export function ArtistPortfolio({ artistId }: ArtistPortfolioProps) {
const profileImage = portfolioImages.find(img => img.tags.includes('profile'))?.url ||
portfolioImages[0]?.url ||
"/placeholder.svg"
const bioText = artist.bio || ""
return (
<div className="min-h-screen bg-black text-white">
{/* Back Button */}
<div className="fixed top-6 right-8 z-40">
<Button
asChild
variant="ghost"
className="text-white hover:bg-white/20 border border-white/30 backdrop-blur-sm bg-black/40 hover:text-white"
>
<Link href="/artists">
<ArrowLeft className="w-4 h-4 mr-2" />
Back to Artists
</Link>
</Button>
</div>
{/* Removed Back to Artists button per request */}
{/* Hero Section with Split Screen */}
<section className="relative h-screen overflow-hidden -mt-20">
{/* Hero Section with Split Screen (Desktop only) */}
<section className="relative h-screen overflow-hidden -mt-20 hidden md:block">
{/* Left Side - Artist Image */}
<div className="absolute left-0 top-0 w-1/2 h-full" style={{ transform: `translateY(${scrollY * 0.3}px)` }}>
<div className="relative w-full h-full">
@ -162,14 +234,7 @@ export function ArtistPortfolio({ artistId }: ArtistPortfolioProps) {
className="object-cover"
/>
<div className="absolute inset-0 bg-gradient-to-r from-transparent to-black/50" />
<div className="absolute top-28 left-8">
<Badge
variant={artist.isActive ? "default" : "secondary"}
className="bg-white/20 backdrop-blur-sm text-white border-white/30"
>
{artist.isActive ? "Available" : "Unavailable"}
</Badge>
</div>
{/* Availability badge removed */}
</div>
</div>
@ -181,7 +246,6 @@ export function ArtistPortfolio({ artistId }: ArtistPortfolioProps) {
<div className="px-16 py-20">
<div className="mb-8">
<h1 className="font-playfair text-6xl font-bold mb-4 text-balance leading-tight">{artist.name}</h1>
<p className="text-2xl text-gray-300 mb-6">{artist.specialties.join(", ")}</p>
</div>
<p className="text-gray-300 mb-8 leading-relaxed text-lg max-w-lg">{artist.bio}</p>
@ -200,24 +264,9 @@ export function ArtistPortfolio({ artistId }: ArtistPortfolioProps) {
</a>
</div>
)}
{artist.hourlyRate && (
<div className="flex items-center space-x-3">
<DollarSign className="w-5 h-5 text-gray-400" />
<span className="text-gray-300">Starting at ${artist.hourlyRate}/hr</span>
</div>
)}
</div>
<div className="mb-8">
<h3 className="font-semibold mb-4 text-lg">Specializes in:</h3>
<div className="flex flex-wrap gap-2">
{artist.specialties.map((style) => (
<Badge key={style} variant="outline" className="border-white/30 text-white">
{style}
</Badge>
))}
</div>
</div>
{/* Specialties and pricing hidden on desktop per request */}
<div className="flex space-x-4">
<Button asChild size="lg" className="bg-white text-black hover:bg-gray-100 !text-black hover:!text-black">
@ -242,8 +291,42 @@ export function ArtistPortfolio({ artistId }: ArtistPortfolioProps) {
</div>
</section>
{/* Portfolio Section with Split Screen Layout */}
<section className="relative bg-black">
{/* Hero Section - Mobile stacked */}
<section className="md:hidden -mt-16">
<div className="relative w-full h-[55vh]">
<Image
src={profileImage}
alt={artist.name}
fill
sizes="100vw"
className="object-cover"
/>
<div className="absolute inset-0 bg-gradient-to-t from-black/70 via-black/20 to-transparent" />
</div>
<div className="px-6 py-8">
<h1 className="font-playfair text-4xl font-bold mb-2 text-balance">{artist.name}</h1>
<p className="text-white/80 mb-4 text-base">{artist.specialties.join(", ")}</p>
<p className="text-white/80 leading-relaxed mb-2 text-[17px]">
{showFullBio ? bioText : bioText.slice(0, 180)}{bioText.length > 180 && !showFullBio ? "…" : ""}
</p>
{bioText.length > 180 && (
<button onClick={() => setShowFullBio((v) => !v)} className="text-white/70 text-sm underline">
{showFullBio ? "Show less" : "Read more"}
</button>
)}
<div className="flex flex-col sm:flex-row gap-3">
<Button asChild size="lg" className="bg-white text-black hover:bg-gray-100 !text-black hover:!text-black">
<Link href={`/book?artist=${artist.slug}`}>Book Appointment</Link>
</Button>
<Button variant="outline" size="lg" className="border-white/30 text-white hover:bg-white hover:text-black bg-transparent">
Get Consultation
</Button>
</div>
</div>
</section>
{/* Portfolio Section with Split Screen Layout (Desktop only) */}
<section className="relative bg-black hidden md:block">
<div className="flex min-h-screen">
{/* Left Side - Portfolio Grid */}
<div className="w-2/3 p-8 overflow-y-auto">
@ -358,6 +441,97 @@ export function ArtistPortfolio({ artistId }: ArtistPortfolioProps) {
</div>
</section>
{/* Mobile Portfolio: Carousel + Filters (simplified) */}
<section className="md:hidden bg-black">
{/* Removed mobile category filters for simplicity */}
{/* Carousel only */}
<div className="px-2 pb-10">
{filteredPortfolio.length === 0 ? (
<div className="flex items-center justify-center h-64">
<p className="text-gray-400">No portfolio images available</p>
</div>
) : (
<div className="relative" aria-label="Portfolio carousel">
<Carousel opts={{ align: "start", loop: true }} className="w-full" setApi={setCarouselApi}>
<CarouselContent>
{filteredPortfolio.map((item) => (
<CarouselItem key={item.id} className="basis-full">
<div className="w-full h-[70vh] relative">
<Image
src={item.url || "/placeholder.svg"}
alt={item.caption || `${artist.name} portfolio image`}
fill
sizes="100vw"
className="object-contain bg-black"
/>
</div>
</CarouselItem>)
)}
</CarouselContent>
</Carousel>
<div className="pointer-events-none absolute top-2 right-3 rounded-full bg-white/10 backdrop-blur px-2 py-1 text-xs text-white">
{filteredPortfolio.length} pieces
</div>
{/* Swipe hint */}
{showSwipeHint && (
<div className="pointer-events-none absolute bottom-2 left-1/2 -translate-x-1/2 rounded-full bg-white/10 backdrop-blur px-3 py-1 text-xs text-white">
Swipe left or right
</div>
)}
{/* Dots indicators */}
<div className="mt-3 flex items-center justify-center gap-2" role="tablist" aria-label="Carousel indicators">
{Array.from({ length: carouselCount }).map((_, i) => (
<button
key={i}
onClick={() => carouselApi?.scrollTo(i)}
aria-current={carouselCurrent === i}
aria-label={`Go to slide ${i + 1}`}
className={`h-2 w-2 rounded-full ${carouselCurrent === i ? "bg-white" : "bg-white/40"}`}
/>
))}
</div>
</div>
)}
</div>
</section>
{/* Available Flash (carousel) */}
{flashItems && flashItems.length > 0 && (
<section className="bg-black border-t border-white/10 py-10">
<div className="px-4 md:px-0 md:max-w-none md:w-screen">
<h3 className="font-playfair text-3xl md:text-4xl font-bold mb-6">Available Flash</h3>
<div className="relative">
<Carousel opts={{ align: "start", loop: true, skipSnaps: false, dragFree: true }} className="w-full relative" setApi={setFlashApi}>
<CarouselContent>
{flashItems.map((item) => (
<CarouselItem key={item.id} className="basis-full md:basis-1/2 lg:basis-1/3">
<div className="relative w-full aspect-[4/5] bg-black rounded-md overflow-hidden">
<Image src={item.url} alt={item.title || `${artist?.name} flash`} fill sizes="(max-width:768px) 100vw, 33vw" className="object-cover" />
</div>
<div className="flex items-center justify-end mt-3">
<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>
</CarouselItem>
))}
</CarouselContent>
{/* Minimal nav controls */}
<CarouselPrevious className="left-4 top-1/2 -translate-y-1/2 size-12 md:size-14 z-10 bg-black/85 text-white shadow-xl border border-white/30 hover:bg-black rounded-full" aria-label="Previous flash" />
<CarouselNext className="right-4 top-1/2 -translate-y-1/2 size-12 md:size-14 z-10 bg-black/85 text-white shadow-xl border border-white/30 hover:bg-black rounded-full" aria-label="Next flash" />
</Carousel>
{/* Edge fade gradients (desktop) */}
<div className="pointer-events-none hidden md:block absolute inset-y-0 left-0 w-24 bg-gradient-to-r from-black to-transparent" />
<div className="pointer-events-none hidden md:block absolute inset-y-0 right-0 w-24 bg-gradient-to-l from-black to-transparent" />
</div>
{showSwipeHint && (
<div className="pointer-events-none mt-3 text-center text-xs text-white/70">Swipe or use </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">
@ -385,22 +559,7 @@ export function ArtistPortfolio({ artistId }: ArtistPortfolioProps) {
</Button>
</div>
<div className="mt-16 pt-16 border-t border-white/10">
<div className="grid grid-cols-1 md:grid-cols-3 gap-8 text-center">
<div>
<div className="text-3xl font-bold mb-2">{artist.specialties.length}+</div>
<div className="text-gray-400">Specialties</div>
</div>
<div>
<div className="text-3xl font-bold mb-2">{portfolioImages.length}</div>
<div className="text-gray-400">Portfolio Pieces</div>
</div>
<div>
<div className="text-3xl font-bold mb-2">{artist.hourlyRate ? `$${artist.hourlyRate}` : "Contact"}</div>
<div className="text-gray-400">Starting Rate</div>
</div>
</div>
</div>
{/* Desktop stats removed per request */}
</div>
</div>
</section>
@ -417,6 +576,24 @@ export function ArtistPortfolio({ artistId }: ArtistPortfolioProps) {
<div
className="relative max-w-6xl max-h-[90vh] w-full flex items-center justify-center"
onClick={(e) => e.stopPropagation()}
onTouchStart={(e) => {
touchStartX.current = e.touches[0].clientX
}}
onTouchEnd={(e) => {
if (touchStartX.current == null) return
const dx = e.changedTouches[0].clientX - touchStartX.current
const threshold = 40
if (Math.abs(dx) > threshold) {
if (dx < 0) {
const next = (currentIndex + 1) % filteredPortfolio.length
goToIndex(next)
} else {
const prev = (currentIndex - 1 + filteredPortfolio.length) % filteredPortfolio.length
goToIndex(prev)
}
}
touchStartX.current = null
}}
>
{/* Prev */}
<button

View File

@ -106,30 +106,15 @@ export function ArtistsSection() {
style={transitionDelay ? { transitionDelay } : undefined}
>
<div className={`relative w-full ${aspectFor(i)} overflow-hidden rounded-md border border-white/10 bg-black`}>
{/* Imagery */}
{/* Imagery: use only the artist portrait */}
<div className="absolute inset-0 artist-image">
<img
src={artist.workImages?.[0] || "/placeholder.svg"}
alt={`${artist.name} tattoo work`}
className="w-full h-full object-cover"
loading="lazy"
/>
<div className="absolute inset-0 bg-black/30"></div>
{/* Portrait with feathered mask */}
<div className="absolute left-0 top-0 w-3/5 h-full pointer-events-none">
<img
src={artist.faceImage || "/placeholder.svg"}
alt={`${artist.name} portrait`}
className="w-full h-full object-cover"
style={{
maskImage: "linear-gradient(to right, black 0%, black 70%, transparent 100%)",
WebkitMaskImage: "linear-gradient(to right, black 0%, black 70%, transparent 100%)",
}}
loading="lazy"
/>
</div>
</div>
{/* Softer hover wash (replaces heavy overlay) */}
<div className="absolute inset-0 z-10 transition-colors duration-300 group-hover:bg-black/10" />

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(() => {

View File

@ -108,7 +108,7 @@ export const artists: Artist[] = [
{
id: 3,
slug: "amari-rodriguez",
name: "Amari Rodriguez",
name: "Amari Kyss",
title: "",
specialty: "Apprentice Artist",
faceImage: "/artists/amari-rodriguez-portrait.jpg",

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,20 @@ 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) - tolerate missing table in older DBs
let flashRows: any[] = []
try {
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();
flashRows = flashResult.results as any[]
} catch (_err) {
// Table may not exist yet; treat as empty
flashRows = []
}
const artist = artistResult as any;
return {
@ -185,6 +200,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: flashRows.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

@ -7,6 +7,13 @@ export default withAuth(
const token = req.nextauth.token
const { pathname } = req.nextUrl
// Permanent redirect for renamed artist slug
if (pathname === "/artists/amari-rodriguez") {
const url = new URL("/artists/amari-kyss", req.url)
const res = NextResponse.redirect(url, 308)
return res
}
// Allow token-based bypass for admin migrate endpoint (non-interactive deployments)
const migrateToken = process.env.MIGRATE_TOKEN
const headerToken = req.headers.get("x-migrate-token")

745
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -128,8 +128,10 @@
"@vitest/coverage-v8": "^3.2.4",
"eslint": "^8.57.0",
"eslint-config-next": "14.2.16",
"heic-convert": "^2.1.0",
"jsdom": "^27.0.0",
"postcss": "^8.5",
"sharp": "^0.34.4",
"tailwindcss": "^4.1.9",
"tw-animate-css": "1.3.3",
"typescript": "^5",

View File

@ -0,0 +1,138 @@
# Tattoo Artist Portfolio Questionnaire
## Basic Information
**Artist Name/Alias: Amari Kyss**
**Contact Email: grimmtatt@gmail.com**
**Instagram Handle:@grimmtatt**
**Other Social Media/Website:** <https://grimmtatts.glossgenius.com/>
## Background
**How did you get started in tattooing? In my Mothers House**
**Who were your mentors or influences? Christy Lumberg**
**In 2-3 paragraphs, describe your artistic philosophy and what makes your work unique:**
i think what sets me apart isnt just how i tattoo its how i care. i dont want this to feel like a
transaction, like youre ordering a tattoo the way youd order a meal. this isnt fast, or
disposable, or something to rush through. i want every person who sits in my chair to feel like
theyre seen, like their story matters, and like the art we make together is something sacred
even if its small. i know i didnt invent traditional tattooing, and im not pretending to be the
first person to lead with kindness. what i am is genuine. consistent. thoughtful. i approach this
work with deep respect for the history of it, for the people who wear it, and for the trust that
comes with putting something permanent on someones body. id do this for free if the world
let me. because to me, tattooing isnt just a job for me its an exchange of energy, of care, of time. and
i think that intention lives in every piece i put out.
**What do you want potential clients to know about you and your work?**
id want them to know it feels like hanging out with someone they could actually be friends with
outside of the tattoo. like it was easy, comforting, and they didnt have to be anything but
themselves. no pressure to be confident or outgoing or have the perfect idea or body just come
as you are, and thats more than enough. i really try to create a space where people feel safe
and accepted. your body is welcome here. your story is welcome here. i want it to feel like
youre just spending time with someone who sees you, hears you, and wants you to leave
feeling a little more at home in yourself.
**What are your goals for your tattoo career in the next few years?**
**slang insane ink**
## Artistic Style & Specialties
**What tattoo styles do you specialize in?** (Check all that apply)
- \[ x\] Traditional/American Traditional
- \[x \] Neo-Traditional
- \[ \] Realism (Black & Grey)
- \[ \] Realism (Color)
- \[x \] Japanese/Irezumi
- \[x \] Blackwork
- \[x \] Fine Line
- \[ \] Geometric
- \[ \] Watercolor
- \[ \] Tribal
- \[ \] Portrait
- \[ x\] Lettering/Script
- \[ \] Illustrative
- \[x \] Dotwork
- \[ \] Biomechanical
- \[x \] Cover-ups
- \[ \] Other: \________________\_
**What are your top 3 favorite styles to tattoo?**
1. American and Japanese Traditional
2. Floral Black and Grey
3. Color Work
**What types of designs do you most enjoy creating?**
**Anything American Traditional**
**Are there any styles or subjects you prefer NOT to tattoo?**
**Realism**
## Portfolio Pieces
**Please list 5-10 of your best tattoos that represent your work:**
[https://portal.united-tattoos.com/index.php/f/17904](https://portal.united-tattoos.com/index.php/f/17904 (preview))
## Process & Approach
**Describe your consultation process with clients:**
**Talking about the design seeing the space they want it and then going over availability, price ranges and the scheduling with a deposit**
**How do you approach custom design work?**
**with love**
## Availability & Pricing
**Current booking status:**
- \[ x\] Currently booking
- \[ \] Waitlist
- \[ \] By appointment only
- \[x \] Walk-ins welcome
**Typical booking lead time:**
**idk what this means**
**Average session length:**
**depends on the tattoo**
**Hourly rate or price range:**
**I price by piece outside of day sessions**
**Minimum charge:**
**0**
**Do you require a deposit?** If yes, how much? yes depending on how much the tattoo is no more than $100 though

Binary file not shown.

After

Width:  |  Height:  |  Size: 516 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 373 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 517 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 540 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 426 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 564 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 978 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 421 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 552 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 497 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 515 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 405 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 203 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 394 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 372 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 533 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 635 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 541 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 467 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 779 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 333 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 559 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 583 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 538 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 344 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 287 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 166 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 391 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 172 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 176 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 155 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 171 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 739 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 391 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 173 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 183 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 156 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 161 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 139 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 562 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 282 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 196 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 164 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 142 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 476 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 382 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 450 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 166 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 165 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 268 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 498 KiB

Some files were not shown because too many files have changed in this diff Show More