feat(artists): redesign artist cards with wipe effect and database integration

- Remove PORTFOLIO and BOOK buttons, cards now fully clickable
- Add top-to-bottom wipe animation with curved SVG path boundary
- Portfolio images cycle on each hover and come from database
- Integrate useActiveArtists hook to fetch portfolio from D1
- Merge database portfolio images with static artist metadata
- Remove experience pill from cards for cleaner design
- Add framer-motion for smooth animations (0.5s easeInOut)
- Graceful fallback to static data during loading/errors
- Keep profile photos from static data for now

-claude
This commit is contained in:
Nicholai 2025-10-23 04:11:32 +00:00
parent 65dce73681
commit 01dab44695
3 changed files with 287 additions and 162 deletions

View File

@ -2,175 +2,256 @@
import { useEffect, useMemo, useRef, useState } from "react"
import Link from "next/link"
import { motion, AnimatePresence, useMotionValue, useTransform } from "framer-motion"
import { useFeatureFlag } from "@/components/feature-flags-provider"
import { Button } from "@/components/ui/button"
import { artists } from "@/data/artists"
import { artists as staticArtists } from "@/data/artists"
import { useActiveArtists } from "@/hooks/use-artists"
import type { PublicArtist } from "@/types/database"
export function ArtistsSection() {
// Minimal animation: fade-in only (no parallax)
const [visibleCards, setVisibleCards] = useState<number[]>([])
const sectionRef = useRef<HTMLElement>(null)
const advancedNavAnimations = useFeatureFlag("ADVANCED_NAV_SCROLL_ANIMATIONS_ENABLED")
const allArtistIndices = useMemo(() => Array.from({ length: artists.length }, (_, idx) => idx), [])
// Fetch artists from database
const { data: dbArtistsData, isLoading, error } = useActiveArtists()
useEffect(() => {
if (!advancedNavAnimations) {
setVisibleCards(allArtistIndices)
return
}
setVisibleCards([])
}, [advancedNavAnimations, allArtistIndices])
// Merge static and database data
const artists = useMemo(() => {
// If still loading or error, use static data
if (isLoading || error || !dbArtistsData) {
return staticArtists
}
useEffect(() => {
if (!advancedNavAnimations) return
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
const cardIndex = Number.parseInt(entry.target.getAttribute("data-index") || "0")
setVisibleCards((prev) => [...new Set([...prev, cardIndex])])
}
// Merge: use database portfolio images, keep static metadata
return staticArtists.map(staticArtist => {
const dbArtist = dbArtistsData.artists.find(
(db) => db.slug === staticArtist.slug || db.name === staticArtist.name
)
// If found in database, use its portfolio images
if (dbArtist && dbArtist.portfolioImages.length > 0) {
return {
...staticArtist,
workImages: dbArtist.portfolioImages.map(img => img.url)
}
}
// Fall back to static data
return staticArtist
})
},
{ threshold: 0.2, rootMargin: "0px 0px -10% 0px" },
}, [dbArtistsData, isLoading, error])
// Minimal animation: fade-in only (no parallax)
const [visibleCards, setVisibleCards] = useState<number[]>([])
const [hoveredCard, setHoveredCard] = useState<number | null>(null)
const [portfolioIndices, setPortfolioIndices] = useState<Record<number, number>>({})
const sectionRef = useRef<HTMLElement>(null)
const advancedNavAnimations = useFeatureFlag("ADVANCED_NAV_SCROLL_ANIMATIONS_ENABLED")
const allArtistIndices = useMemo(() => Array.from({ length: artists.length }, (_, idx) => idx), [artists.length])
useEffect(() => {
if (!advancedNavAnimations) {
setVisibleCards(allArtistIndices)
return
}
setVisibleCards([])
}, [advancedNavAnimations, allArtistIndices])
useEffect(() => {
if (!advancedNavAnimations) return
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
const cardIndex = Number.parseInt(entry.target.getAttribute("data-index") || "0")
setVisibleCards((prev) => [...new Set([...prev, cardIndex])])
}
})
},
{ threshold: 0.2, rootMargin: "0px 0px -10% 0px" },
)
const cards = sectionRef.current?.querySelectorAll("[data-index]")
cards?.forEach((card) => observer.observe(card))
return () => observer.disconnect()
}, [advancedNavAnimations])
const cardVisibilityClass = (index: number) => {
if (!advancedNavAnimations) return "opacity-100 translate-y-0"
return visibleCards.includes(index) ? "opacity-100 translate-y-0" : "opacity-0 translate-y-6"
}
const cardTransitionDelay = (index: number) => {
if (!advancedNavAnimations) return undefined
return `${index * 40}ms`
}
// Vary aspect ratio to create a subtle masonry rhythm
const aspectFor = (i: number) => {
const variants = ["aspect-[3/4]", "aspect-[4/5]", "aspect-square"]
return variants[i % variants.length]
}
// Handle hover to cycle through portfolio images
const handleHoverStart = (artistIndex: number) => {
setHoveredCard(artistIndex)
const artist = artists[artistIndex]
if (artist.workImages.length > 0) {
setPortfolioIndices((prev) => {
const currentIndex = prev[artistIndex] ?? 0
const nextIndex = (currentIndex + 1) % artist.workImages.length
return { ...prev, [artistIndex]: nextIndex }
})
}
}
const handleHoverEnd = () => {
setHoveredCard(null)
}
const getPortfolioImage = (artistIndex: number) => {
const artist = artists[artistIndex]
if (artist.workImages.length === 0) return null
const imageIndex = portfolioIndices[artistIndex] ?? 0
return artist.workImages[imageIndex]
}
return (
<section ref={sectionRef} id="artists" className="relative overflow-hidden bg-black">
{/* Faint logo texture */}
<div className="absolute inset-0 opacity-[0.03]">
<img
src="/united-logo-full.jpg"
alt=""
className="w-full h-full object-cover object-center scale-150 blur-[2px]"
/>
<div className="absolute inset-0 bg-black/80 backdrop-blur-sm" />
</div>
{/* Header */}
<div className="relative z-10 py-14 px-6 lg:px-10">
<div className="max-w-[1800px] mx-auto">
<div className="grid lg:grid-cols-3 gap-10 items-end mb-10">
<div className="lg:col-span-2">
<h2 className="text-6xl lg:text-8xl font-bold tracking-tight mb-4 text-white">ARTISTS</h2>
<p className="text-lg lg:text-xl text-gray-200/90 leading-relaxed max-w-2xl">
Our exceptional team of tattoo artists, each bringing unique expertise and artistic vision to create your perfect
tattoo.
</p>
</div>
<div className="text-right">
<Button
asChild
className="bg-white text-black hover:bg-gray-100 px-7 py-3 text-base font-medium tracking-wide shadow-sm rounded-md"
>
<Link href="/book">BOOK CONSULTATION</Link>
</Button>
</div>
</div>
</div>
</div>
{/* Masonry grid */}
<div className="relative z-10 px-6 lg:px-10 pb-24">
<div className="max-w-[1800px] mx-auto">
{/* columns-based masonry; tighter spacing and wider section */}
<div className="columns-1 sm:columns-2 lg:columns-3 gap-4 lg:gap-5 [column-fill:_balance]">
{artists.map((artist, i) => {
const transitionDelay = cardTransitionDelay(i)
const portfolioImage = getPortfolioImage(i)
const isHovered = hoveredCard === i
return (
<article
key={artist.id}
data-index={i}
className={`group mb-4 break-inside-avoid transition-all duration-700 ${cardVisibilityClass(i)}`}
style={transitionDelay ? { transitionDelay } : undefined}
>
<Link href={`/artists/${artist.slug}`}>
<motion.div
className={`relative w-full ${aspectFor(i)} overflow-hidden rounded-md border border-white/10 bg-black cursor-pointer`}
onHoverStart={() => handleHoverStart(i)}
onHoverEnd={handleHoverEnd}
>
{/* Base layer: artist portrait */}
<div className="absolute inset-0 artist-image">
<img
src={artist.faceImage || "/placeholder.svg"}
alt={`${artist.name} portrait`}
className="w-full h-full object-cover"
loading="lazy"
/>
</div>
{/* Wipe overlay: portfolio image with curved boundary */}
<AnimatePresence>
{isHovered && portfolioImage && (
<>
{/* SVG clipPath with pronounced wave */}
<svg className="absolute w-0 h-0">
<defs>
<clipPath id={`wipe-curve-${i}`} clipPathUnits="objectBoundingBox">
<motion.path
initial={{
d: "M 0,0 L 1,0 L 1,0 Q 0.75,0 0.5,0 Q 0.25,0 0,0 Z"
}}
animate={{
d: "M 0,0 L 1,0 L 1,1.1 Q 0.75,1.02 0.5,1.1 Q 0.25,1.18 0,1.1 Z"
}}
exit={{
d: "M 0,0 L 1,0 L 1,0 Q 0.75,0 0.5,0 Q 0.25,0 0,0 Z"
}}
transition={{ duration: 0.5, ease: "easeInOut" }}
/>
</clipPath>
</defs>
</svg>
{/* Portfolio image with curved clip */}
<div
className="absolute inset-0 z-10"
style={{
clipPath: `url(#wipe-curve-${i})`,
}}
>
<img
src={portfolioImage}
alt={`${artist.name} work`}
className="w-full h-full object-cover"
/>
</div>
</>
)}
</AnimatePresence>
{/* Minimal footer - only name */}
<div className="absolute bottom-0 left-0 right-0 z-20 bg-gradient-to-t from-black/80 via-black/20 to-transparent p-4">
<h3 className="text-xl font-semibold tracking-tight text-white">{artist.name}</h3>
<p className="text-xs font-medium text-white/80">{artist.specialty}</p>
</div>
</motion.div>
</Link>
</article>
)
})}
</div>
</div>
</div>
{/* CTA Footer */}
<div className="relative z-20 bg-black text-white py-20 px-6 lg:px-10">
<div className="max-w-[1800px] mx-auto text-center">
<h3 className="text-5xl lg:text-7xl font-bold tracking-tight mb-8">READY?</h3>
<p className="text-xl text-white/70 mb-12 max-w-2xl mx-auto">
Choose your artist and start your tattoo journey with United Tattoo.
</p>
<Button
asChild
className="bg-white text-black hover:bg-gray-100 hover:text-black px-12 py-6 text-xl font-medium tracking-wide shadow-lg border border-white rounded-md"
>
<Link href="/book">START NOW</Link>
</Button>
</div>
</div>
</section>
)
const cards = sectionRef.current?.querySelectorAll("[data-index]")
cards?.forEach((card) => observer.observe(card))
return () => observer.disconnect()
}, [advancedNavAnimations])
const cardVisibilityClass = (index: number) => {
if (!advancedNavAnimations) return "opacity-100 translate-y-0"
return visibleCards.includes(index) ? "opacity-100 translate-y-0" : "opacity-0 translate-y-6"
}
const cardTransitionDelay = (index: number) => {
if (!advancedNavAnimations) return undefined
return `${index * 40}ms`
}
// Vary aspect ratio to create a subtle masonry rhythm
const aspectFor = (i: number) => {
const variants = ["aspect-[3/4]", "aspect-[4/5]", "aspect-square"]
return variants[i % variants.length]
}
return (
<section ref={sectionRef} id="artists" className="relative overflow-hidden bg-black">
{/* Faint logo texture */}
<div className="absolute inset-0 opacity-[0.03]">
<img
src="/united-logo-full.jpg"
alt=""
className="w-full h-full object-cover object-center scale-150 blur-[2px]"
/>
<div className="absolute inset-0 bg-black/80 backdrop-blur-sm" />
</div>
{/* Header */}
<div className="relative z-10 py-14 px-6 lg:px-10">
<div className="max-w-[1800px] mx-auto">
<div className="grid lg:grid-cols-3 gap-10 items-end mb-10">
<div className="lg:col-span-2">
<h2 className="text-6xl lg:text-8xl font-bold tracking-tight mb-4 text-white">ARTISTS</h2>
<p className="text-lg lg:text-xl text-gray-200/90 leading-relaxed max-w-2xl">
Our exceptional team of tattoo artists, each bringing unique expertise and artistic vision to create your perfect
tattoo.
</p>
</div>
<div className="text-right">
<Button
asChild
className="bg-white text-black hover:bg-gray-100 px-7 py-3 text-base font-medium tracking-wide shadow-sm rounded-md"
>
<Link href="/book">BOOK CONSULTATION</Link>
</Button>
</div>
</div>
</div>
</div>
{/* Masonry grid */}
<div className="relative z-10 px-6 lg:px-10 pb-24">
<div className="max-w-[1800px] mx-auto">
{/* columns-based masonry; tighter spacing and wider section */}
<div className="columns-1 sm:columns-2 lg:columns-3 gap-4 lg:gap-5 [column-fill:_balance]">
{artists.map((artist, i) => {
const transitionDelay = cardTransitionDelay(i)
return (
<article
key={artist.id}
data-index={i}
className={`group mb-4 break-inside-avoid transition-all duration-700 ${cardVisibilityClass(i)}`}
style={transitionDelay ? { transitionDelay } : undefined}
>
<div className={`relative w-full ${aspectFor(i)} overflow-hidden rounded-md border border-white/10 bg-black`}>
{/* Imagery: use only the artist portrait */}
<div className="absolute inset-0 artist-image">
<img
src={artist.faceImage || "/placeholder.svg"}
alt={`${artist.name} portrait`}
className="w-full h-full object-cover"
loading="lazy"
/>
</div>
{/* Softer hover wash (replaces heavy overlay) */}
<div className="absolute inset-0 z-10 transition-colors duration-300 group-hover:bg-black/10" />
{/* Top-left experience pill */}
<div className="absolute top-3 left-3 z-20">
<span className="text-[10px] font-medium tracking-widest text-white uppercase bg-black/70 backdrop-blur-sm px-2.5 py-0.5 rounded-full">
{artist.experience}
</span>
</div>
{/* Minimal footer */}
<div className="absolute bottom-0 left-0 right-0 z-20 bg-gradient-to-t from-black/80 via-black/20 to-transparent p-4">
<h3 className="text-xl font-semibold tracking-tight text-white">{artist.name}</h3>
<p className="text-xs font-medium text-white/80 mb-3">{artist.specialty}</p>
<div className="flex gap-2">
<Button
asChild
size="sm"
className="h-8 rounded-md px-3 bg-white/90 text-black hover:bg-white text-xs font-medium tracking-wide"
>
<Link href={`/artists/${artist.slug}`}>PORTFOLIO</Link>
</Button>
<Button
asChild
size="sm"
className="h-8 rounded-md px-3 bg-transparent text-white border border-white/25 hover:bg-white/10 text-xs font-medium tracking-wide"
>
<Link href={`/book?artist=${artist.slug}`}>BOOK</Link>
</Button>
</div>
</div>
</div>
</article>
)
})}
</div>
</div>
</div>
{/* CTA Footer */}
<div className="relative z-20 bg-black text-white py-20 px-6 lg:px-10">
<div className="max-w-[1800px] mx-auto text-center">
<h3 className="text-5xl lg:text-7xl font-bold tracking-tight mb-8">READY?</h3>
<p className="text-xl text-white/70 mb-12 max-w-2xl mx-auto">
Choose your artist and start your tattoo journey with United Tattoo.
</p>
<Button
asChild
className="bg-white text-black hover:bg-gray-100 hover:text-black px-12 py-6 text-xl font-medium tracking-wide shadow-lg border border-white rounded-md"
>
<Link href="/book">START NOW</Link>
</Button>
</div>
</div>
</section>
)
}

43
package-lock.json generated
View File

@ -52,6 +52,7 @@
"cmdk": "latest",
"date-fns": "latest",
"embla-carousel-react": "8.5.1",
"framer-motion": "^12.23.24",
"geist": "^1.3.1",
"ical.js": "^1.5.0",
"input-otp": "latest",
@ -16895,6 +16896,33 @@
"url": "https://github.com/sponsors/rawify"
}
},
"node_modules/framer-motion": {
"version": "12.23.24",
"resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.23.24.tgz",
"integrity": "sha512-HMi5HRoRCTou+3fb3h9oTLyJGBxHfW+HnNE25tAXOvVx/IvwMHK0cx7IR4a2ZU6sh3IX1Z+4ts32PcYBOqka8w==",
"license": "MIT",
"dependencies": {
"motion-dom": "^12.23.23",
"motion-utils": "^12.23.6",
"tslib": "^2.4.0"
},
"peerDependencies": {
"@emotion/is-prop-valid": "*",
"react": "^18.0.0 || ^19.0.0",
"react-dom": "^18.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"@emotion/is-prop-valid": {
"optional": true
},
"react": {
"optional": true
},
"react-dom": {
"optional": true
}
}
},
"node_modules/fresh": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz",
@ -19006,6 +19034,21 @@
"node": "*"
}
},
"node_modules/motion-dom": {
"version": "12.23.23",
"resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.23.23.tgz",
"integrity": "sha512-n5yolOs0TQQBRUFImrRfs/+6X4p3Q4n1dUEqt/H58Vx7OW6RF+foWEgmTVDhIWJIMXOuNNL0apKH2S16en9eiA==",
"license": "MIT",
"dependencies": {
"motion-utils": "^12.23.6"
}
},
"node_modules/motion-utils": {
"version": "12.23.6",
"resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.23.6.tgz",
"integrity": "sha512-eAWoPgr4eFEOFfg2WjIsMoqJTW6Z8MTUCgn/GZ3VRpClWBdnbjryiA3ZSNLyxCTmCQx4RmYX6jX1iWHbenUPNQ==",
"license": "MIT"
},
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",

View File

@ -92,6 +92,7 @@
"cmdk": "latest",
"date-fns": "latest",
"embla-carousel-react": "8.5.1",
"framer-motion": "^12.23.24",
"geist": "^1.3.1",
"ical.js": "^1.5.0",
"input-otp": "latest",