diff --git a/app/page.tsx b/app/page.tsx index c9aacada6..2bc331dd8 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -7,10 +7,12 @@ import { ArtistsSection } from "@/components/artists-section" import { ServicesSection } from "@/components/services-section" import { ContactSection } from "@/components/contact-section" import { Footer } from "@/components/footer" +import { BackgroundStrata } from "@/components/background-strata" export default function HomePage() { return ( -
+
+ diff --git a/components/artists-section.tsx b/components/artists-section.tsx index 70758ce7e..f7e91c54d 100644 --- a/components/artists-section.tsx +++ b/components/artists-section.tsx @@ -1,257 +1,311 @@ "use client" -import { useEffect, useMemo, useRef, useState } from "react" +import { useCallback, 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 as staticArtists } from "@/data/artists" import { useActiveArtists } from "@/hooks/use-artists" import type { PublicArtist } from "@/types/database" +import { cn } from "@/lib/utils" + +type ArtistGridSet = { + key: string + items: PublicArtist[] +} + +const GRID_SIZE = 16 +const GRID_INTERVAL = 12000 export function ArtistsSection() { - // Fetch artists from database - const { data: dbArtistsData, isLoading, error } = useActiveArtists() + const { data: dbArtistsData, isLoading, error } = useActiveArtists() - // Merge static and database data - const artists = useMemo(() => { - // If still loading or error, use static data - if (isLoading || error || !dbArtistsData) { - return staticArtists + const artists = useMemo(() => { + if (isLoading || error || !dbArtistsData) { + return staticArtists + } + + return staticArtists.map((staticArtist) => { + const dbArtist = dbArtistsData.artists.find( + (db) => db.slug === staticArtist.slug || db.name === staticArtist.name + ) + + if (dbArtist && dbArtist.portfolioImages.length > 0) { + return { + ...staticArtist, + workImages: dbArtist.portfolioImages.map((img) => img.url), } + } - // 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 - ) + return staticArtist + }) + }, [dbArtistsData, error, isLoading]) - // If found in database, use its portfolio images - if (dbArtist && dbArtist.portfolioImages.length > 0) { - return { - ...staticArtist, - workImages: dbArtist.portfolioImages.map(img => img.url) + const artistsRef = useRef(artists) + const gridSetsRef = useRef([]) + const activeSetRef = useRef(0) + + const [gridSets, setGridSets] = useState([]) + const [activeSetIndex, setActiveSetIndex] = useState(0) + const [previousSetIndex, setPreviousSetIndex] = useState(null) + + artistsRef.current = artists + gridSetsRef.current = gridSets + activeSetRef.current = activeSetIndex + + const shuffleArtists = useCallback((input: PublicArtist[]) => { + const array = [...input] + for (let i = array.length - 1; i > 0; i -= 1) { + const j = Math.floor(Math.random() * (i + 1)) + ;[array[i], array[j]] = [array[j], array[i]] + } + return array + }, []) + + const ensureGridCount = useCallback( + (pool: PublicArtist[], chunk: PublicArtist[]) => { + if (chunk.length >= GRID_SIZE) { + return chunk.slice(0, GRID_SIZE) + } + const topUpSource = shuffleArtists(pool) + const needed = GRID_SIZE - chunk.length + return [...chunk, ...topUpSource.slice(0, needed)] + }, + [shuffleArtists] + ) + + const createKey = () => Math.random().toString(36).slice(2) + + const regenerateSets = useCallback(() => { + const pool = artistsRef.current + if (pool.length === 0) { + setGridSets([]) + setActiveSetIndex(0) + setPreviousSetIndex(null) + return + } + + const shuffled = shuffleArtists(pool) + const batches: ArtistGridSet[] = [] + + for (let i = 0; i < shuffled.length; i += GRID_SIZE) { + const slice = ensureGridCount(pool, shuffled.slice(i, i + GRID_SIZE)) + batches.push({ key: `${createKey()}-${i}`, items: slice }) + } + + if (batches.length === 1) { + const alternate = ensureGridCount(pool, shuffleArtists(pool)) + batches.push({ key: `${createKey()}-alt`, items: alternate }) + } + + setGridSets(batches) + setActiveSetIndex(0) + setPreviousSetIndex(null) + }, [ensureGridCount, shuffleArtists]) + + useEffect(() => { + regenerateSets() + }, [artists, regenerateSets]) + + const advanceSet = useCallback(() => { + if (gridSetsRef.current.length === 0) { + return + } + + setPreviousSetIndex(activeSetRef.current) + setActiveSetIndex((prev) => { + const next = prev + 1 + if (next >= gridSetsRef.current.length) { + regenerateSets() + return 0 + } + return next + }) + }, [regenerateSets]) + + useEffect(() => { + if (gridSets.length === 0) { + return + } + + const interval = window.setInterval(() => { + advanceSet() + }, GRID_INTERVAL) + + return () => window.clearInterval(interval) + }, [advanceSet, gridSets.length]) + + const displayIndices = useMemo(() => { + const indices = new Set() + indices.add(activeSetIndex) + if (previousSetIndex !== null && previousSetIndex !== activeSetIndex) { + indices.add(previousSetIndex) + } + return Array.from(indices) + }, [activeSetIndex, previousSetIndex]) + + const getArtistImage = (artist: PublicArtist) => { + const candidate = (artist as any).faceImage || artist.workImages?.[0] + if (candidate) { + return candidate + } + return "/placeholder.svg" + } + + return ( +
+
+
+
+
+
+
+ +
+
+
+ + Resident & Guest Artists + +

+ A Collective of Story-Driven Tattoo Artists +

+

+ United Tattoo is home to cover-up virtuosos, illustrative explorers, anime specialists, and fine line minimalists. + Every artist curates their chair with intention—offering custom narratives, flash experiments, and collaborative pieces + that evolve with you. +

+
+ +
+
+

What to Expect

+

Consultation-first Process

+

+ Artist pairing • Mood-boards • Aftercare guides • CalDAV-synced scheduling +

+
+
+

Specialties

+

Layered Stylescapes

+

+ Black & grey realism • Neo-traditional color • Bold cover-ups • Fine line botanicals +

+
+
+ +
+ + +
+
+ +
+
+
- ) +
+

Let's Plan Your Piece

+

+ Choose your artist, share your story, and build a tattoo ritual around intentional ink. +

+ +
+ + ) } diff --git a/components/background-strata.tsx b/components/background-strata.tsx new file mode 100644 index 000000000..ba444688e --- /dev/null +++ b/components/background-strata.tsx @@ -0,0 +1,51 @@ +"use client" + +import { useEffect, useRef } from "react" + +import { useReducedMotion } from "@/hooks/use-parallax" + +export function BackgroundStrata() { + const layerRef = useRef(null) + const reducedMotion = useReducedMotion() + + useEffect(() => { + if (reducedMotion) { + return + } + + const target = layerRef.current + if (!target) { + return + } + + let frame = 0 + const animate = () => { + const offset = window.scrollY * 0.08 + target.style.transform = `translate3d(0, ${offset}px, 0)` + frame = requestAnimationFrame(animate) + } + + frame = requestAnimationFrame(animate) + return () => cancelAnimationFrame(frame) + }, [reducedMotion]) + + return ( +
+
+
+
+
+
+ ) +} + diff --git a/components/contact-section.tsx b/components/contact-section.tsx index 44c0d05ea..b9781fde2 100644 --- a/components/contact-section.tsx +++ b/components/contact-section.tsx @@ -35,10 +35,10 @@ export function ContactSection() { } return ( -
+
{/* Background logo - desktop only */}
{/* Mobile solid background */} -
+
-
-
+
+
{/* Mobile background overlay to hide logo */} -
-
+
+
-

Let's Talk

-

Ready to create something amazing?

+

Let's Talk

+

Ready to create something amazing?

-
-
-
-