fix: improve mobile UX, polish artist cards, and enhance contact modal with responsive backgrounds
43
.clinerules/cloudflare.md
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
# NextJS + Cloudflare + OpenNext Deployment
|
||||||
|
|
||||||
|
## Setup Requirements
|
||||||
|
- Node.js 18+ and Cloudflare account required
|
||||||
|
- **@opennextjs/cloudflare** adapter mandatory (not edge runtime)
|
||||||
|
- Global Wrangler CLI: `npm install -g wrangler`
|
||||||
|
- All deployments via OpenNext adapter; no direct NextJS builds
|
||||||
|
|
||||||
|
## Project Configuration
|
||||||
|
- **wrangler.toml**: compatibility_date ≥ "2024-09-23", nodejs_compat flag
|
||||||
|
- **package.json**: `pages:build` script runs `npx @opennextjs/cloudflare@latest`
|
||||||
|
- **next.config.js**: `output: 'standalone'`, image optimization configured
|
||||||
|
- Build output directory: `.vercel/output/static`
|
||||||
|
|
||||||
|
## Build & Deploy Process
|
||||||
|
- Build command: `npm run pages:build` (transforms NextJS → Workers)
|
||||||
|
- Local testing: `npm run preview` (required before deploy)
|
||||||
|
- Deploy: `npm run deploy` or Cloudflare Pages Git integration
|
||||||
|
- Never deploy untested builds; preview mimics production runtime
|
||||||
|
|
||||||
|
## Environment & Security
|
||||||
|
- Environment variables in both Cloudflare Dashboard and `wrangler.toml`
|
||||||
|
- Secrets via `wrangler secret put SECRET_NAME` (not in wrangler.toml)
|
||||||
|
- Security headers required in API routes (X-Frame-Options, CSP, etc.)
|
||||||
|
- Cache headers mandatory for API endpoints: `s-maxage=86400, stale-while-revalidate`
|
||||||
|
|
||||||
|
## Performance & Limits
|
||||||
|
- Bundle size limits: 3MB free tier, 15MB paid
|
||||||
|
- Dynamic imports for heavy components to reduce cold starts
|
||||||
|
- Static files in `public/` directory only
|
||||||
|
- Image optimization via Cloudflare Images or custom loader
|
||||||
|
|
||||||
|
## Database & Storage
|
||||||
|
- Cloudflare D1 binding in wrangler.toml for SQL databases
|
||||||
|
- Workers KV for key-value storage
|
||||||
|
- All DB operations via environment bindings (env.DB, env.KV)
|
||||||
|
- No direct database connections; use Cloudflare services
|
||||||
|
|
||||||
|
## CI/CD Integration
|
||||||
|
- GitHub Actions with CLOUDFLARE_API_TOKEN secret
|
||||||
|
- Build step: `npm run pages:build`
|
||||||
|
- Deploy: `wrangler pages deploy .vercel/output/static`
|
||||||
|
- Fail builds on type/compatibility errors
|
||||||
@ -6,8 +6,6 @@ import { ArtistsSection } from "@/components/artists-section"
|
|||||||
import { ServicesSection } from "@/components/services-section"
|
import { ServicesSection } from "@/components/services-section"
|
||||||
import { ContactSection } from "@/components/contact-section"
|
import { ContactSection } from "@/components/contact-section"
|
||||||
import { Footer } from "@/components/footer"
|
import { Footer } from "@/components/footer"
|
||||||
import { MobileBookingBar } from "@/components/mobile-booking-bar"
|
|
||||||
|
|
||||||
export default function HomePage() {
|
export default function HomePage() {
|
||||||
return (
|
return (
|
||||||
<main className="min-h-screen">
|
<main className="min-h-screen">
|
||||||
@ -27,7 +25,6 @@ export default function HomePage() {
|
|||||||
<ContactSection />
|
<ContactSection />
|
||||||
</div>
|
</div>
|
||||||
<Footer />
|
<Footer />
|
||||||
<MobileBookingBar />
|
|
||||||
</main>
|
</main>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -23,7 +23,7 @@ export function ArtistsSection() {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
{ threshold: 0.1, rootMargin: "0px 0px -50px 0px" },
|
{ threshold: 0.2, rootMargin: "0px 0px 0px 0px" },
|
||||||
)
|
)
|
||||||
|
|
||||||
const cards = sectionRef.current?.querySelectorAll("[data-index]")
|
const cards = sectionRef.current?.querySelectorAll("[data-index]")
|
||||||
@ -55,29 +55,30 @@ export function ArtistsSection() {
|
|||||||
const sectionTop = sectionRef.current?.offsetTop || 0
|
const sectionTop = sectionRef.current?.offsetTop || 0
|
||||||
const relativeScroll = scrollY - sectionTop
|
const relativeScroll = scrollY - sectionTop
|
||||||
|
|
||||||
leftColumnRef.current.style.transform = `translateY(${relativeScroll * -0.05}px)`
|
leftColumnRef.current.style.transform = `translateY(${relativeScroll * -0.025}px)`
|
||||||
centerColumnRef.current.style.transform = `translateY(0px)`
|
centerColumnRef.current.style.transform = `translateY(0px)`
|
||||||
rightColumnRef.current.style.transform = `translateY(${relativeScroll * 0.05}px)`
|
rightColumnRef.current.style.transform = `translateY(${relativeScroll * 0.025}px)`
|
||||||
|
|
||||||
const leftImages = leftColumnRef.current.querySelectorAll(".artist-image")
|
const leftImages = leftColumnRef.current.querySelectorAll(".artist-image")
|
||||||
const centerImages = centerColumnRef.current.querySelectorAll(".artist-image")
|
const centerImages = centerColumnRef.current.querySelectorAll(".artist-image")
|
||||||
const rightImages = rightColumnRef.current.querySelectorAll(".artist-image")
|
const rightImages = rightColumnRef.current.querySelectorAll(".artist-image")
|
||||||
|
|
||||||
leftImages.forEach((img) => {
|
leftImages.forEach((img) => {
|
||||||
;(img as HTMLElement).style.transform = `translateY(${relativeScroll * -0.02}px)`
|
;(img as HTMLElement).style.transform = `translateY(${relativeScroll * -0.01}px)`
|
||||||
})
|
})
|
||||||
centerImages.forEach((img) => {
|
centerImages.forEach((img) => {
|
||||||
;(img as HTMLElement).style.transform = `translateY(${relativeScroll * -0.015}px)`
|
;(img as HTMLElement).style.transform = `translateY(${relativeScroll * -0.0075}px)`
|
||||||
})
|
})
|
||||||
rightImages.forEach((img) => {
|
rightImages.forEach((img) => {
|
||||||
;(img as HTMLElement).style.transform = `translateY(${relativeScroll * -0.01}px)`
|
;(img as HTMLElement).style.transform = `translateY(${relativeScroll * -0.005}px)`
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}, [scrollY])
|
}, [scrollY])
|
||||||
|
|
||||||
const leftColumn = artists.filter((_, index) => index % 3 === 0)
|
// Better distribution for visual balance
|
||||||
const centerColumn = artists.filter((_, index) => index % 3 === 1)
|
const leftColumn = [artists[0], artists[3], artists[6]] // Christy, Donovan, John
|
||||||
const rightColumn = artists.filter((_, index) => index % 3 === 2)
|
const centerColumn = [artists[1], artists[4], artists[7]] // Angel, EJ, Pako
|
||||||
|
const rightColumn = [artists[2], artists[5], artists[8]] // Amari, Heather, Sole
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section ref={sectionRef} id="artists" className="relative overflow-hidden bg-black">
|
<section ref={sectionRef} id="artists" className="relative overflow-hidden bg-black">
|
||||||
@ -112,7 +113,7 @@ export function ArtistsSection() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="relative z-10 px-8 lg:px-16 pb-20">
|
<div className="relative z-10 px-8 lg:px-16 pb-32">
|
||||||
<div className="max-w-screen-2xl mx-auto">
|
<div className="max-w-screen-2xl mx-auto">
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
||||||
<div ref={leftColumnRef} className="space-y-8">
|
<div ref={leftColumnRef} className="space-y-8">
|
||||||
@ -126,25 +127,33 @@ export function ArtistsSection() {
|
|||||||
: "opacity-0 translate-y-8"
|
: "opacity-0 translate-y-8"
|
||||||
}`}
|
}`}
|
||||||
style={{
|
style={{
|
||||||
transitionDelay: `${artists.indexOf(artist) * 100}ms`,
|
transitionDelay: `${artists.indexOf(artist) * 50}ms`,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="relative h-[600px] overflow-hidden rounded-lg shadow-2xl">
|
<div className="relative w-full aspect-[4/5] overflow-hidden rounded-lg shadow-2xl">
|
||||||
<div className="absolute inset-0 bg-black artist-image">
|
<div className="absolute inset-0 bg-black artist-image">
|
||||||
<div className="absolute left-0 top-0 w-1/2 h-full">
|
{/* Portfolio background - full width */}
|
||||||
<img
|
<div className="absolute inset-0">
|
||||||
src={artist.faceImage || "/placeholder.svg"}
|
|
||||||
alt={`${artist.name} portrait`}
|
|
||||||
className="w-full h-full object-cover scale-110"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="absolute right-0 top-0 w-1/2 h-full">
|
|
||||||
<img
|
<img
|
||||||
src={artist.workImages?.[0] || "/placeholder.svg"}
|
src={artist.workImages?.[0] || "/placeholder.svg"}
|
||||||
alt={`${artist.name} tattoo work`}
|
alt={`${artist.name} tattoo work`}
|
||||||
className="w-full h-full object-cover scale-110"
|
className="w-full h-full object-cover scale-110"
|
||||||
/>
|
/>
|
||||||
|
{/* Darkening overlay to push background further back */}
|
||||||
|
<div className="absolute inset-0 bg-black/40"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Artist portrait - with proper feathered mask */}
|
||||||
|
<div className="absolute left-0 top-0 w-3/5 h-full">
|
||||||
|
<img
|
||||||
|
src={artist.faceImage || "/placeholder.svg"}
|
||||||
|
alt={`${artist.name} portrait`}
|
||||||
|
className="w-full h-full object-cover scale-110"
|
||||||
|
style={{
|
||||||
|
maskImage: 'linear-gradient(to right, black 0%, black 70%, transparent 100%)',
|
||||||
|
WebkitMaskImage: 'linear-gradient(to right, black 0%, black 70%, transparent 100%)'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -155,7 +164,7 @@ export function ArtistsSection() {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/90 via-black/60 to-transparent p-6 translate-y-full group-hover:translate-y-0 transition-transform duration-500">
|
<div className="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/90 via-black/60 to-transparent p-6 translate-y-0 lg:translate-y-full lg:group-hover:translate-y-0 transition-transform duration-500">
|
||||||
<h3 className="text-2xl font-bold tracking-tight mb-2 text-white">{artist.name}</h3>
|
<h3 className="text-2xl font-bold tracking-tight mb-2 text-white">{artist.name}</h3>
|
||||||
<p className="text-sm font-medium text-white/90 mb-3">{artist.specialty}</p>
|
<p className="text-sm font-medium text-white/90 mb-3">{artist.specialty}</p>
|
||||||
<p className="text-sm text-white/80 mb-4 leading-relaxed">{artist.bio}</p>
|
<p className="text-sm text-white/80 mb-4 leading-relaxed">{artist.bio}</p>
|
||||||
@ -194,25 +203,33 @@ export function ArtistsSection() {
|
|||||||
: "opacity-0 translate-y-8"
|
: "opacity-0 translate-y-8"
|
||||||
}`}
|
}`}
|
||||||
style={{
|
style={{
|
||||||
transitionDelay: `${artists.indexOf(artist) * 100}ms`,
|
transitionDelay: `${artists.indexOf(artist) * 50}ms`,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="relative h-[600px] overflow-hidden rounded-lg shadow-2xl">
|
<div className="relative w-full aspect-[4/5] overflow-hidden rounded-lg shadow-2xl">
|
||||||
<div className="absolute inset-0 bg-black artist-image">
|
<div className="absolute inset-0 bg-black artist-image">
|
||||||
<div className="absolute left-0 top-0 w-1/2 h-full">
|
{/* Portfolio background - full width */}
|
||||||
<img
|
<div className="absolute inset-0">
|
||||||
src={artist.faceImage || "/placeholder.svg"}
|
|
||||||
alt={`${artist.name} portrait`}
|
|
||||||
className="w-full h-full object-cover scale-110"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="absolute right-0 top-0 w-1/2 h-full">
|
|
||||||
<img
|
<img
|
||||||
src={artist.workImages?.[0] || "/placeholder.svg"}
|
src={artist.workImages?.[0] || "/placeholder.svg"}
|
||||||
alt={`${artist.name} tattoo work`}
|
alt={`${artist.name} tattoo work`}
|
||||||
className="w-full h-full object-cover scale-110"
|
className="w-full h-full object-cover scale-110"
|
||||||
/>
|
/>
|
||||||
|
{/* Darkening overlay to push background further back */}
|
||||||
|
<div className="absolute inset-0 bg-black/40"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Artist portrait - with proper feathered mask */}
|
||||||
|
<div className="absolute left-0 top-0 w-3/5 h-full">
|
||||||
|
<img
|
||||||
|
src={artist.faceImage || "/placeholder.svg"}
|
||||||
|
alt={`${artist.name} portrait`}
|
||||||
|
className="w-full h-full object-cover scale-110"
|
||||||
|
style={{
|
||||||
|
maskImage: 'linear-gradient(to right, black 0%, black 70%, transparent 100%)',
|
||||||
|
WebkitMaskImage: 'linear-gradient(to right, black 0%, black 70%, transparent 100%)'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -223,7 +240,7 @@ export function ArtistsSection() {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/90 via-black/60 to-transparent p-6 translate-y-full group-hover:translate-y-0 transition-transform duration-500">
|
<div className="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/90 via-black/60 to-transparent p-6 translate-y-0 lg:translate-y-full lg:group-hover:translate-y-0 transition-transform duration-500">
|
||||||
<h3 className="text-2xl font-bold tracking-tight mb-2 text-white">{artist.name}</h3>
|
<h3 className="text-2xl font-bold tracking-tight mb-2 text-white">{artist.name}</h3>
|
||||||
<p className="text-sm font-medium text-white/90 mb-3">{artist.specialty}</p>
|
<p className="text-sm font-medium text-white/90 mb-3">{artist.specialty}</p>
|
||||||
<p className="text-sm text-white/80 mb-4 leading-relaxed">{artist.bio}</p>
|
<p className="text-sm text-white/80 mb-4 leading-relaxed">{artist.bio}</p>
|
||||||
@ -262,25 +279,33 @@ export function ArtistsSection() {
|
|||||||
: "opacity-0 translate-y-8"
|
: "opacity-0 translate-y-8"
|
||||||
}`}
|
}`}
|
||||||
style={{
|
style={{
|
||||||
transitionDelay: `${artists.indexOf(artist) * 100}ms`,
|
transitionDelay: `${artists.indexOf(artist) * 50}ms`,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="relative h-[600px] overflow-hidden rounded-lg shadow-2xl">
|
<div className="relative w-full aspect-[4/5] overflow-hidden rounded-lg shadow-2xl">
|
||||||
<div className="absolute inset-0 bg-black artist-image">
|
<div className="absolute inset-0 bg-black artist-image">
|
||||||
<div className="absolute left-0 top-0 w-1/2 h-full">
|
{/* Portfolio background - full width */}
|
||||||
<img
|
<div className="absolute inset-0">
|
||||||
src={artist.faceImage || "/placeholder.svg"}
|
|
||||||
alt={`${artist.name} portrait`}
|
|
||||||
className="w-full h-full object-cover scale-110"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="absolute right-0 top-0 w-1/2 h-full">
|
|
||||||
<img
|
<img
|
||||||
src={artist.workImages?.[0] || "/placeholder.svg"}
|
src={artist.workImages?.[0] || "/placeholder.svg"}
|
||||||
alt={`${artist.name} tattoo work`}
|
alt={`${artist.name} tattoo work`}
|
||||||
className="w-full h-full object-cover scale-110"
|
className="w-full h-full object-cover scale-110"
|
||||||
/>
|
/>
|
||||||
|
{/* Darkening overlay to push background further back */}
|
||||||
|
<div className="absolute inset-0 bg-black/40"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Artist portrait - with proper feathered mask */}
|
||||||
|
<div className="absolute left-0 top-0 w-3/5 h-full">
|
||||||
|
<img
|
||||||
|
src={artist.faceImage || "/placeholder.svg"}
|
||||||
|
alt={`${artist.name} portrait`}
|
||||||
|
className="w-full h-full object-cover scale-110"
|
||||||
|
style={{
|
||||||
|
maskImage: 'linear-gradient(to right, black 0%, black 70%, transparent 100%)',
|
||||||
|
WebkitMaskImage: 'linear-gradient(to right, black 0%, black 70%, transparent 100%)'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -291,7 +316,7 @@ export function ArtistsSection() {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/90 via-black/60 to-transparent p-6 translate-y-full group-hover:translate-y-0 transition-transform duration-500">
|
<div className="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/90 via-black/60 to-transparent p-6 translate-y-0 lg:translate-y-full lg:group-hover:translate-y-0 transition-transform duration-500">
|
||||||
<h3 className="text-2xl font-bold tracking-tight mb-2 text-white">{artist.name}</h3>
|
<h3 className="text-2xl font-bold tracking-tight mb-2 text-white">{artist.name}</h3>
|
||||||
<p className="text-sm font-medium text-white/90 mb-3">{artist.specialty}</p>
|
<p className="text-sm font-medium text-white/90 mb-3">{artist.specialty}</p>
|
||||||
<p className="text-sm text-white/80 mb-4 leading-relaxed">{artist.bio}</p>
|
<p className="text-sm text-white/80 mb-4 leading-relaxed">{artist.bio}</p>
|
||||||
@ -322,7 +347,7 @@ export function ArtistsSection() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bg-black text-white py-20 px-8 lg:px-16">
|
<div className="relative z-20 bg-black text-white py-20 px-8 lg:px-16">
|
||||||
<div className="max-w-screen-2xl mx-auto text-center">
|
<div className="max-w-screen-2xl mx-auto text-center">
|
||||||
<h3 className="text-5xl lg:text-7xl font-bold tracking-tight mb-8">READY?</h3>
|
<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">
|
<p className="text-xl text-white/70 mb-12 max-w-2xl mx-auto">
|
||||||
|
|||||||
@ -103,7 +103,9 @@ export function ContactModal({ children }: ContactModalProps) {
|
|||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={setOpen}>
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
<DialogTrigger asChild>{children}</DialogTrigger>
|
<DialogTrigger asChild>{children}</DialogTrigger>
|
||||||
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
|
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto bg-white border-0 shadow-2xl relative">
|
||||||
|
{/* Solid background overlay to block any background images */}
|
||||||
|
<div className="absolute inset-0 bg-white -z-10 rounded-lg"></div>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle className="font-playfair text-2xl">Get In Touch</DialogTitle>
|
<DialogTitle className="font-playfair text-2xl">Get In Touch</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|||||||
@ -36,17 +36,23 @@ export function ContactSection() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<section id="contact" className="min-h-screen bg-black relative overflow-hidden">
|
<section id="contact" className="min-h-screen bg-black relative overflow-hidden">
|
||||||
|
{/* Background logo - desktop only */}
|
||||||
<div
|
<div
|
||||||
className="absolute inset-0 opacity-[0.03] bg-cover bg-center bg-no-repeat blur-sm"
|
className="absolute inset-0 opacity-[0.03] bg-cover bg-center bg-no-repeat blur-sm hidden lg:block"
|
||||||
style={{
|
style={{
|
||||||
backgroundImage: "url('/united-logo-full.jpg')",
|
backgroundImage: "url('/united-logo-full.jpg')",
|
||||||
transform: `translateY(${scrollY * 0.2}px)`,
|
transform: `translateY(${scrollY * 0.2}px)`,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Mobile solid background */}
|
||||||
|
<div className="absolute inset-0 bg-black lg:hidden"></div>
|
||||||
|
|
||||||
<div className="flex flex-col lg:flex-row min-h-screen relative z-10">
|
<div className="flex flex-col lg:flex-row min-h-screen relative z-10">
|
||||||
<div className="w-full lg:w-1/2 bg-black flex items-center justify-center p-8 lg:p-12">
|
<div className="w-full lg:w-1/2 bg-black flex items-center justify-center p-8 lg:p-12 relative">
|
||||||
<div className="w-full max-w-md">
|
{/* Mobile background overlay to hide logo */}
|
||||||
|
<div className="absolute inset-0 bg-black lg:bg-transparent"></div>
|
||||||
|
<div className="w-full max-w-md relative z-10">
|
||||||
<div className="mb-8">
|
<div className="mb-8">
|
||||||
<h2 className="text-4xl font-bold text-white mb-2">Let's Talk</h2>
|
<h2 className="text-4xl font-bold text-white mb-2">Let's Talk</h2>
|
||||||
<p className="text-gray-400">Ready to create something amazing?</p>
|
<p className="text-gray-400">Ready to create something amazing?</p>
|
||||||
|
|||||||
@ -45,7 +45,7 @@ export function Navigation() {
|
|||||||
className={`fixed top-0 left-0 right-0 z-50 transition-all duration-700 ease-out ${
|
className={`fixed top-0 left-0 right-0 z-50 transition-all duration-700 ease-out ${
|
||||||
isScrolled
|
isScrolled
|
||||||
? "bg-black/95 backdrop-blur-md shadow-lg border-b border-white/10 opacity-100"
|
? "bg-black/95 backdrop-blur-md shadow-lg border-b border-white/10 opacity-100"
|
||||||
: "bg-transparent lg:opacity-0 lg:pointer-events-none opacity-100 bg-black/80 backdrop-blur-md"
|
: "bg-black/80 backdrop-blur-md lg:bg-transparent lg:opacity-0 lg:pointer-events-none opacity-100"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className="max-w-screen-2xl mx-auto px-6 lg:px-12">
|
<div className="max-w-screen-2xl mx-auto px-6 lg:px-12">
|
||||||
|
|||||||
@ -61,8 +61,8 @@ export function ServicesMobileCarousel() {
|
|||||||
>
|
>
|
||||||
<CarouselContent className="-ml-2 md:-ml-4">
|
<CarouselContent className="-ml-2 md:-ml-4">
|
||||||
{services.map((service, index) => (
|
{services.map((service, index) => (
|
||||||
<CarouselItem key={index} className="pl-2 md:pl-4 basis-[85%] sm:basis-[75%]">
|
<CarouselItem key={index} className="pl-2 md:pl-4 basis-[85%] sm:basis-[75%] md:basis-[70%]">
|
||||||
<div className="min-h-[70vh] flex items-center justify-center p-6 relative">
|
<div className="min-h-[50vh] flex items-center justify-center p-4 relative">
|
||||||
<div className="max-w-sm relative">
|
<div className="max-w-sm relative">
|
||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
<span className="text-sm font-medium tracking-widest text-white/60 uppercase">
|
<span className="text-sm font-medium tracking-widest text-white/60 uppercase">
|
||||||
|
|||||||
116
components/services-mobile-only.tsx
Normal file
@ -0,0 +1,116 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"
|
||||||
|
import { Carousel, CarouselContent, CarouselItem, CarouselNext, CarouselPrevious } from "@/components/ui/carousel"
|
||||||
|
import Link from "next/link"
|
||||||
|
|
||||||
|
const services = [
|
||||||
|
{
|
||||||
|
title: "Black & Grey Realism",
|
||||||
|
description: "Photorealistic tattoos with incredible depth and detail using black and grey shading techniques.",
|
||||||
|
features: ["Lifelike portraits", "Detailed shading", "3D effects"],
|
||||||
|
price: "Starting at $250",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Cover-ups & Blackout",
|
||||||
|
description: "Transform old tattoos into stunning new pieces with expert cover-up techniques or bold blackout designs.",
|
||||||
|
features: ["Free consultation", "Creative solutions", "Complete coverage"],
|
||||||
|
price: "Starting at $300",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Fine Line & Micro Realism",
|
||||||
|
description: "Delicate, precise linework and tiny realistic designs that showcase incredible detail.",
|
||||||
|
features: ["Single needle work", "Intricate details", "Minimalist aesthetic"],
|
||||||
|
price: "Starting at $150",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Traditional & Neo-Traditional",
|
||||||
|
description: "Bold American traditional and neo-traditional styles with vibrant colors and strong lines.",
|
||||||
|
features: ["Classic designs", "Bold color palettes", "Timeless appeal"],
|
||||||
|
price: "Starting at $200",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Anime & Watercolor",
|
||||||
|
description: "Vibrant anime characters and painterly watercolor effects that bring art to life on skin.",
|
||||||
|
features: ["Character designs", "Soft color blends", "Artistic techniques"],
|
||||||
|
price: "Starting at $250",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
export function ServicesMobileOnly() {
|
||||||
|
return (
|
||||||
|
<section className="lg:hidden bg-black text-white py-16">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="px-6 mb-12 text-center">
|
||||||
|
<div className="mb-4">
|
||||||
|
<span className="text-sm font-medium tracking-widest text-white/60 uppercase">Our Services</span>
|
||||||
|
</div>
|
||||||
|
<h2 className="text-4xl font-bold tracking-tight mb-4">Choose Your Style</h2>
|
||||||
|
<p className="text-white/70 max-w-md mx-auto">
|
||||||
|
From custom designs to cover-ups, we offer comprehensive tattoo services with the highest standards.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Carousel */}
|
||||||
|
<div className="px-4">
|
||||||
|
<Carousel
|
||||||
|
opts={{
|
||||||
|
align: "start",
|
||||||
|
loop: true,
|
||||||
|
}}
|
||||||
|
className="w-full max-w-sm mx-auto"
|
||||||
|
>
|
||||||
|
<CarouselContent className="-ml-2">
|
||||||
|
{services.map((service, index) => (
|
||||||
|
<CarouselItem key={index} className="pl-2 basis-full">
|
||||||
|
<Card className="bg-black border-white/20 text-white h-full">
|
||||||
|
<CardHeader className="pb-4">
|
||||||
|
<div className="text-xs font-medium tracking-widest text-white/60 uppercase mb-2">
|
||||||
|
Service {String(index + 1).padStart(2, "0")}
|
||||||
|
</div>
|
||||||
|
<CardTitle className="text-2xl font-bold leading-tight">
|
||||||
|
{service.title}
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription className="text-white/80 text-base leading-relaxed">
|
||||||
|
{service.description}
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<CardContent className="pb-4">
|
||||||
|
<div className="space-y-2 mb-6">
|
||||||
|
{service.features.map((feature, idx) => (
|
||||||
|
<div key={idx} className="flex items-center text-white/70">
|
||||||
|
<span className="w-1.5 h-1.5 bg-white/40 rounded-full mr-3 flex-shrink-0"></span>
|
||||||
|
<span className="text-sm">{feature}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-xl font-bold text-white mb-4">
|
||||||
|
{service.price}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
|
||||||
|
<CardFooter className="pt-0">
|
||||||
|
<Button
|
||||||
|
asChild
|
||||||
|
className="w-full bg-white text-black hover:bg-gray-100 !text-black font-medium"
|
||||||
|
>
|
||||||
|
<Link href="/book">BOOK NOW</Link>
|
||||||
|
</Button>
|
||||||
|
</CardFooter>
|
||||||
|
</Card>
|
||||||
|
</CarouselItem>
|
||||||
|
))}
|
||||||
|
</CarouselContent>
|
||||||
|
|
||||||
|
<div className="flex justify-center mt-8 gap-4">
|
||||||
|
<CarouselPrevious className="relative translate-y-0 left-0 bg-white/10 border-white/20 text-white hover:bg-white/20" />
|
||||||
|
<CarouselNext className="relative translate-y-0 right-0 bg-white/10 border-white/20 text-white hover:bg-white/20" />
|
||||||
|
</div>
|
||||||
|
</Carousel>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -3,7 +3,7 @@
|
|||||||
import { useEffect, useRef, useState } from "react"
|
import { useEffect, useRef, useState } from "react"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import { ServicesMobileCarousel } from "@/components/services-mobile-carousel"
|
import { ServicesMobileOnly } from "@/components/services-mobile-only"
|
||||||
|
|
||||||
const services = [
|
const services = [
|
||||||
{
|
{
|
||||||
@ -101,10 +101,10 @@ export function ServicesSection() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bg-black text-white relative z-10">
|
<div className="hidden lg:block bg-black text-white relative z-10">
|
||||||
<div className="flex">
|
<div className="flex">
|
||||||
{/* Left Side - Enhanced with split composition styling */}
|
{/* Left Side - Enhanced with split composition styling */}
|
||||||
<div className="hidden lg:block w-1/2 sticky top-0 h-screen bg-black relative">
|
<div className="w-1/2 sticky top-0 h-screen bg-black relative">
|
||||||
<div className="absolute right-0 top-0 w-px h-full bg-white/10"></div>
|
<div className="absolute right-0 top-0 w-px h-full bg-white/10"></div>
|
||||||
<div className="h-full flex flex-col justify-center p-16 relative">
|
<div className="h-full flex flex-col justify-center p-16 relative">
|
||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
@ -201,8 +201,9 @@ export function ServicesSection() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ServicesMobileCarousel />
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<ServicesMobileOnly />
|
||||||
</section>
|
</section>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -269,7 +269,7 @@ export const artists: Artist[] = [
|
|||||||
name: "Steven 'Sole' Cedre",
|
name: "Steven 'Sole' Cedre",
|
||||||
title: "It has to have soul, Sole!",
|
title: "It has to have soul, Sole!",
|
||||||
specialty: "Gritty Realism & Comic Art",
|
specialty: "Gritty Realism & Comic Art",
|
||||||
faceImage: "/artists/sole-cedre-portrait.jpg",
|
faceImage: "/artists/steven-sole-cedre.jpg",
|
||||||
workImages: [
|
workImages: [
|
||||||
"/artists/sole-cedre-work-1.jpg",
|
"/artists/sole-cedre-work-1.jpg",
|
||||||
"/artists/sole-cedre-work-2.jpg",
|
"/artists/sole-cedre-work-2.jpg",
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 51 KiB |
|
Before Width: | Height: | Size: 44 KiB After Width: | Height: | Size: 141 KiB |
BIN
public/artists/dez-portrait.jpg
Normal file
|
After Width: | Height: | Size: 157 KiB |
|
Before Width: | Height: | Size: 164 KiB After Width: | Height: | Size: 56 KiB |
BIN
public/artists/donovan-lankford-portrait.png
Normal file
|
After Width: | Height: | Size: 632 KiB |
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 121 KiB |
BIN
public/artists/john-lapides-portrait.jpg
Normal file
|
After Width: | Height: | Size: 120 KiB |
BIN
public/artists/steven-sole-cedre.jpg
Normal file
|
After Width: | Height: | Size: 100 KiB |