feat(mobile): add services carousel, persistent booking bar, and improved navigation while preserving desktop layouts
This commit is contained in:
parent
06abb52024
commit
d925ab75cf
24
.clinerules/authrules.md
Normal file
24
.clinerules/authrules.md
Normal file
@ -0,0 +1,24 @@
|
||||
# Security, Auth, Headers, Validation, Rate‑Limiting, Secrets
|
||||
|
||||
## Authentication & RBAC
|
||||
- **NextAuth (Auth.js)** mandatory
|
||||
- Sessions: pick JWT or DB, document choice
|
||||
- Route/Server Action guards via middleware; role model documented
|
||||
|
||||
## Security Headers
|
||||
- CSP (nonce/hash) + `Referrer-Policy: strict-origin-when-cross-origin`
|
||||
- `X-Frame-Options: DENY`; `Permissions-Policy` scoped
|
||||
- COOP/COEP where SharedArrayBuffer needed
|
||||
- Cookies: HttpOnly, Secure, SameSite=Strict
|
||||
|
||||
## Validation
|
||||
- **Zod everywhere** (server actions, routes, forms)
|
||||
- `react-hook-form` + zod resolver
|
||||
|
||||
## Rate Limiting
|
||||
- Redis (Upstash/self-hosted)
|
||||
- Enforce on auth, forms, APIs (middleware/handlers)
|
||||
|
||||
## Secrets Policy
|
||||
- `.env.example` is canonical list; validate at boot (`lib/env.ts` with Zod)
|
||||
- Use SOPS/Age, 1Password, or Docker secrets; never commit secrets
|
||||
17
.clinerules/cicdrules.md
Normal file
17
.clinerules/cicdrules.md
Normal file
@ -0,0 +1,17 @@
|
||||
# CI/CD, Budgets, Required Workflow
|
||||
|
||||
## Pipeline (Gitea)
|
||||
1) Lint, Typecheck, Biome/Prettier
|
||||
2) Unit tests (Vitest) + Component (RTL)
|
||||
3) Build
|
||||
4) Migration dry‑run
|
||||
5) E2E (Playwright) on preview env
|
||||
6) Bundle size budgets enforced (fail on overage)
|
||||
7) Release tagging (semver) + notes
|
||||
|
||||
## Required Workflow
|
||||
- Run Context7 checks for new deps, upgrades, DS changes
|
||||
- Check shadcn registry before custom components
|
||||
- Use Supabase MCP for all DB ops (incl. migrations)
|
||||
- Plan & Act for complex features; reference existing patterns
|
||||
- Clarify ambiguous requirements early; provide confidence rating
|
||||
6
.clinerules/dockerrules.md
Normal file
6
.clinerules/dockerrules.md
Normal file
@ -0,0 +1,6 @@
|
||||
# Docker & Deployment
|
||||
- Multi‑stage builds; `next build` with `output: standalone`
|
||||
- Non‑root user; healthcheck endpoint (`/health`)
|
||||
- Volumes for persistence (DB/cache)
|
||||
- Pin base images to minor; scan for vulns
|
||||
- Node vs Edge runtime documented per route; default Node
|
||||
20
.clinerules/infrarules.md
Normal file
20
.clinerules/infrarules.md
Normal file
@ -0,0 +1,20 @@
|
||||
# Data, MCP, Codegen, Migrations, File Uploads
|
||||
|
||||
## MCP Requirements
|
||||
- All DB access (dev/prod/migrations/scripts) via **Supabase MCP**
|
||||
- Context7 MCP required for: new deps, framework upgrades, DS changes
|
||||
- Cache/pin Context7 outputs; PRs require justification to override
|
||||
|
||||
## Data Layer & Codegen (choose one)
|
||||
- **Prisma**: schema as SSoT; generated client/types committed
|
||||
- **or Kysely**: typed SQL builder; generate DB types; commit outputs
|
||||
- PRs fail on type/codegen drift
|
||||
|
||||
## Migrations
|
||||
- Source of truth in `sql/`
|
||||
- Executed via MCP; CI does migration dry‑run on ephemeral DB
|
||||
|
||||
## File Uploads
|
||||
- S3‑compatible storage with signed URLs (no direct multipart to app)
|
||||
- MCP writes file metadata (size/mime/checksum) to DB
|
||||
- Resumable uploads (TUS) allowed when needed
|
||||
36
.clinerules/nextjsrules.md
Normal file
36
.clinerules/nextjsrules.md
Normal file
@ -0,0 +1,36 @@
|
||||
# Cline Next.js Development Rules
|
||||
|
||||
## Core Technology Stack
|
||||
- Next.js 14+ App Router (no Pages Router)
|
||||
- Tailwind + shadcn/ui (mandatory)
|
||||
- TypeScript only (.ts/.tsx)
|
||||
- State: Zustand (local UI) + React Query (server state)
|
||||
- DB: Postgres (Docker) **via Supabase MCP only**
|
||||
- VCS: Gitea
|
||||
- MCP: Supabase MCP (DB), Context7 MCP (patterns/updates)
|
||||
|
||||
## Project Structure (no `src/`)
|
||||
app/ | components/ (ui/, custom/) | lib/ | hooks/ | types/ | constants/ | docker/ | sql/
|
||||
|
||||
## Next.js Rules
|
||||
- App Router only; organize with `(group)`
|
||||
- Implement `loading.tsx` and `error.tsx` in segments
|
||||
- **Server Actions** for authenticated same‑origin mutations
|
||||
- **Route Handlers** for webhooks, cross‑origin, streaming, public APIs
|
||||
- Image component required; dynamic import heavy modules
|
||||
- Tag‑based caching + `revalidateTag` policy
|
||||
|
||||
## Cross‑refs
|
||||
- UI & shadcn → `UI_RULES.md`
|
||||
- Security, Auth, Headers, Rate‑limit, Secrets → `SECURITY_AUTH.md`
|
||||
- Data, Migrations, File Uploads, MCP usage → `DATA_INFRA.md`
|
||||
- CI/CD, Budgets, Workflow → `CI_CD.md`
|
||||
- Testing → `TESTING.md`
|
||||
- Observability → `OBSERVABILITY.md`
|
||||
- Docker & Deployment → `DOCKER_DEPLOY.md`
|
||||
|
||||
## Forbidden
|
||||
- Direct DB access (MCP only)
|
||||
- Bypass Context7 for upgrades/pattern changes
|
||||
- Override shadcn internals or use inline styles
|
||||
- Custom tokens outside DS; committing secrets
|
||||
4
.clinerules/observabilityrules.md
Normal file
4
.clinerules/observabilityrules.md
Normal file
@ -0,0 +1,4 @@
|
||||
# Observability
|
||||
- **OpenTelemetry**: traces/metrics/logs for Next.js, server actions, MCP DB calls
|
||||
- **Sentry**: exceptions + release tracking
|
||||
- Log redaction: PII/secrets never leave process
|
||||
7
.clinerules/testing.md
Normal file
7
.clinerules/testing.md
Normal file
@ -0,0 +1,7 @@
|
||||
# Testing Strategy
|
||||
- **Vitest + RTL** for unit/component
|
||||
- **Playwright** for e2e
|
||||
- **Testcontainers** for DB integration
|
||||
- Contract tests for MCP responses (shape/status)
|
||||
- a11y checks (eslint-plugin-jsx-a11y + automated tooling)
|
||||
- Responsive checks across breakpoints
|
||||
16
.clinerules/ui-rules.md
Normal file
16
.clinerules/ui-rules.md
Normal file
@ -0,0 +1,16 @@
|
||||
# UI & shadcn/ui Rules
|
||||
|
||||
## Usage Order
|
||||
1) Check shadcn registry (verify via Context7)
|
||||
2) Compose/extend with variants
|
||||
3) Custom only if primitives can’t express it
|
||||
|
||||
## Constraints
|
||||
- Do not hack internal classes or override CSS
|
||||
- Use `cva()` and `cn()` utilities
|
||||
- Follow shadcn prop/naming conventions
|
||||
|
||||
## Variants & Composition
|
||||
```tsx
|
||||
<Button variant="destructive" size="lg" className="w-full">Delete</Button>
|
||||
<Card><CardHeader>Title</CardHeader><CardContent><Button>Go</Button></CardContent></Card>
|
||||
@ -6,6 +6,7 @@ import { ArtistsSection } from "@/components/artists-section"
|
||||
import { ServicesSection } from "@/components/services-section"
|
||||
import { ContactSection } from "@/components/contact-section"
|
||||
import { Footer } from "@/components/footer"
|
||||
import { MobileBookingBar } from "@/components/mobile-booking-bar"
|
||||
|
||||
export default function HomePage() {
|
||||
return (
|
||||
@ -26,6 +27,7 @@ export default function HomePage() {
|
||||
<ContactSection />
|
||||
</div>
|
||||
<Footer />
|
||||
<MobileBookingBar />
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
@ -124,18 +124,18 @@ export function ArtistsGrid() {
|
||||
</div>
|
||||
|
||||
{/* Artists Grid */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{filteredArtists.map((artist) => (
|
||||
<Card key={artist.id} className="group hover:shadow-xl transition-all duration-300 overflow-hidden">
|
||||
<div className="md:flex">
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Artist Image */}
|
||||
<div className="relative md:w-1/3 h-64 md:h-auto overflow-hidden">
|
||||
<div className="relative w-full h-48 sm:h-56 overflow-hidden">
|
||||
<img
|
||||
src={artist.image || "/placeholder.svg"}
|
||||
alt={artist.name}
|
||||
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300"
|
||||
/>
|
||||
<div className="absolute top-4 left-4">
|
||||
<div className="absolute top-3 left-3">
|
||||
<Badge variant={artist.availability === "Available" ? "default" : "secondary"}>
|
||||
{artist.availability}
|
||||
</Badge>
|
||||
@ -143,43 +143,43 @@ export function ArtistsGrid() {
|
||||
</div>
|
||||
|
||||
{/* Artist Info */}
|
||||
<CardContent className="md:w-2/3 p-6">
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<CardContent className="p-4 flex-grow flex flex-col">
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div>
|
||||
<h3 className="font-playfair text-2xl font-bold mb-1">{artist.name}</h3>
|
||||
<p className="text-primary font-medium">{artist.specialty}</p>
|
||||
<h3 className="font-playfair text-xl font-bold mb-1">{artist.name}</h3>
|
||||
<p className="text-primary font-medium text-sm">{artist.specialty}</p>
|
||||
</div>
|
||||
<div className="flex items-center space-x-1 text-sm">
|
||||
<Star className="w-4 h-4 fill-yellow-400 text-yellow-400" />
|
||||
<div className="flex items-center space-x-1 text-xs">
|
||||
<Star className="w-3 h-3 fill-yellow-400 text-yellow-400" />
|
||||
<span className="font-medium">{artist.rating}</span>
|
||||
<span className="text-muted-foreground">({artist.reviews})</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-muted-foreground mb-4 text-sm leading-relaxed">{artist.bio}</p>
|
||||
<p className="text-muted-foreground mb-3 text-xs leading-relaxed line-clamp-3">{artist.bio}</p>
|
||||
|
||||
<div className="space-y-3 mb-6">
|
||||
<div className="flex items-center space-x-2 text-sm">
|
||||
<Calendar className="w-4 h-4 text-muted-foreground" />
|
||||
<div className="space-y-2 mb-4">
|
||||
<div className="flex items-center space-x-1 text-xs">
|
||||
<Calendar className="w-3 h-3 text-muted-foreground" />
|
||||
<span>{artist.experience} experience</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2 text-sm">
|
||||
<MapPin className="w-4 h-4 text-muted-foreground" />
|
||||
<div className="flex items-center space-x-1 text-xs">
|
||||
<MapPin className="w-3 h-3 text-muted-foreground" />
|
||||
<span>{artist.location}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Styles */}
|
||||
<div className="mb-6">
|
||||
<p className="text-sm font-medium mb-2">Specializes in:</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<div className="mb-4">
|
||||
<p className="text-xs font-medium mb-1">Specializes in:</p>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{artist.styles.slice(0, 3).map((style) => (
|
||||
<Badge key={style} variant="outline" className="text-xs">
|
||||
<Badge key={style} variant="outline" className="text-xs px-2 py-1">
|
||||
{style}
|
||||
</Badge>
|
||||
))}
|
||||
{artist.styles.length > 3 && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
<Badge variant="outline" className="text-xs px-2 py-1">
|
||||
+{artist.styles.length - 3} more
|
||||
</Badge>
|
||||
)}
|
||||
@ -187,14 +187,14 @@ export function ArtistsGrid() {
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex space-x-3">
|
||||
<Button asChild className="flex-1">
|
||||
<div className="flex space-x-2 mt-auto">
|
||||
<Button asChild className="flex-1 text-xs py-2">
|
||||
<Link href={`/artists/${artist.id}`}>View Portfolio</Link>
|
||||
</Button>
|
||||
<Button
|
||||
asChild
|
||||
variant="outline"
|
||||
className="flex-1 bg-white text-black !text-black border-gray-300 hover:bg-gray-50 hover:!text-black"
|
||||
className="flex-1 bg-white text-black !text-black border-gray-300 hover:bg-gray-50 hover:!text-black text-xs py-2"
|
||||
>
|
||||
<Link href={`/artists/${artist.id}/book`}>Book Now</Link>
|
||||
</Button>
|
||||
|
||||
@ -44,8 +44,8 @@ export function ContactSection() {
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="flex min-h-screen relative z-10">
|
||||
<div className="w-1/2 bg-black flex items-center justify-center p-12">
|
||||
<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 max-w-md">
|
||||
<div className="mb-8">
|
||||
<h2 className="text-4xl font-bold text-white mb-2">Let's Talk</h2>
|
||||
@ -126,7 +126,7 @@ export function ContactSection() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="w-1/2 bg-gray-50 relative flex items-center justify-center">
|
||||
<div className="w-full lg:w-1/2 bg-gray-50 relative flex items-center justify-center">
|
||||
{/* Brand asset as decorative element */}
|
||||
<div
|
||||
className="absolute inset-0 opacity-20 bg-cover bg-center bg-no-repeat"
|
||||
|
||||
@ -59,7 +59,7 @@ export function HeroSection() {
|
||||
>
|
||||
<Button
|
||||
size="lg"
|
||||
className="bg-gray-50 text-gray-900 hover:bg-gray-100 px-12 py-6 text-lg font-medium rounded-lg"
|
||||
className="bg-gray-50 text-gray-900 hover:bg-gray-100 px-8 py-4 text-lg font-medium rounded-lg w-full sm:w-auto"
|
||||
>
|
||||
Book Consultation
|
||||
</Button>
|
||||
|
||||
19
components/mobile-booking-bar.tsx
Normal file
19
components/mobile-booking-bar.tsx
Normal file
@ -0,0 +1,19 @@
|
||||
"use client"
|
||||
|
||||
import Link from "next/link"
|
||||
import { Button } from "@/components/ui/button"
|
||||
|
||||
export function MobileBookingBar() {
|
||||
return (
|
||||
<div className="lg:hidden fixed bottom-0 left-0 right-0 z-50 bg-black/95 backdrop-blur-md border-t border-white/10 pb-safe">
|
||||
<div className="px-4 py-3">
|
||||
<Button
|
||||
asChild
|
||||
className="w-full bg-white text-black hover:bg-gray-100 !text-black py-4 text-lg font-semibold tracking-[0.05em] uppercase shadow-xl"
|
||||
>
|
||||
<Link href="/book">Book Now</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -45,7 +45,7 @@ export function Navigation() {
|
||||
className={`fixed top-0 left-0 right-0 z-50 transition-all duration-700 ease-out ${
|
||||
isScrolled
|
||||
? "bg-black/95 backdrop-blur-md shadow-lg border-b border-white/10 opacity-100"
|
||||
: "bg-transparent opacity-0 pointer-events-none"
|
||||
: "bg-transparent lg:opacity-0 lg:pointer-events-none opacity-100 bg-black/80 backdrop-blur-md"
|
||||
}`}
|
||||
>
|
||||
<div className="max-w-screen-2xl mx-auto px-6 lg:px-12">
|
||||
@ -83,7 +83,7 @@ export function Navigation() {
|
||||
</div>
|
||||
|
||||
<button
|
||||
className="lg:hidden p-3 rounded-lg transition-all duration-300 text-white hover:bg-white/10"
|
||||
className="lg:hidden p-4 rounded-lg transition-all duration-300 text-white hover:bg-white/10"
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
aria-label="Toggle menu"
|
||||
>
|
||||
@ -93,12 +93,12 @@ export function Navigation() {
|
||||
|
||||
{isOpen && (
|
||||
<div className="lg:hidden bg-black/98 backdrop-blur-md border-t border-white/10">
|
||||
<div className="px-6 py-8 space-y-6">
|
||||
<div className="px-6 py-8 space-y-5">
|
||||
{navItems.map((item) => (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className={`block text-lg font-semibold tracking-[0.1em] uppercase transition-all duration-300 ${
|
||||
className={`px-4 py-4 block text-lg font-semibold tracking-[0.1em] uppercase transition-all duration-300 ${
|
||||
activeSection === item.id
|
||||
? "text-white border-l-4 border-white pl-4"
|
||||
: "text-white/70 hover:text-white hover:pl-2"
|
||||
@ -110,7 +110,7 @@ export function Navigation() {
|
||||
))}
|
||||
<Button
|
||||
asChild
|
||||
className="w-full bg-white hover:bg-gray-100 text-black !text-black py-4 text-lg font-semibold tracking-[0.05em] uppercase shadow-xl mt-8"
|
||||
className="w-full bg-white hover:bg-gray-100 text-black !text-black py-5 text-lg font-semibold tracking-[0.05em] uppercase shadow-xl mt-8"
|
||||
>
|
||||
<Link href="/book" onClick={() => setIsOpen(false)}>
|
||||
Book Now
|
||||
|
||||
125
components/services-mobile-carousel.tsx
Normal file
125
components/services-mobile-carousel.tsx
Normal file
@ -0,0 +1,125 @@
|
||||
"use client"
|
||||
|
||||
import { Button } from "@/components/ui/button"
|
||||
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",
|
||||
bgColor: "bg-gray-100",
|
||||
},
|
||||
{
|
||||
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",
|
||||
bgColor: "bg-black",
|
||||
},
|
||||
{
|
||||
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",
|
||||
bgColor: "bg-purple-100",
|
||||
},
|
||||
{
|
||||
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",
|
||||
bgColor: "bg-red-100",
|
||||
},
|
||||
{
|
||||
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",
|
||||
bgColor: "bg-blue-100",
|
||||
},
|
||||
]
|
||||
|
||||
export function ServicesMobileCarousel() {
|
||||
return (
|
||||
<div className="lg:hidden bg-black text-white py-12">
|
||||
<div className="px-4 mb-8">
|
||||
<div className="mb-6">
|
||||
<span className="text-sm font-medium tracking-widest text-white/60 uppercase">Our Services</span>
|
||||
</div>
|
||||
<h3 className="text-4xl font-bold tracking-tight text-balance">Choose Your Style</h3>
|
||||
</div>
|
||||
|
||||
<Carousel
|
||||
opts={{
|
||||
align: "start",
|
||||
loop: true,
|
||||
}}
|
||||
className="w-full"
|
||||
>
|
||||
<CarouselContent className="-ml-2 md:-ml-4">
|
||||
{services.map((service, index) => (
|
||||
<CarouselItem key={index} className="pl-2 md:pl-4 basis-[85%] sm:basis-[75%]">
|
||||
<div className="min-h-[70vh] flex items-center justify-center p-6 relative">
|
||||
<div className="max-w-sm relative">
|
||||
<div className="mb-6">
|
||||
<span className="text-sm font-medium tracking-widest text-white/60 uppercase">
|
||||
Service {String(index + 1).padStart(2, "0")}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<h3 className="text-3xl font-bold tracking-tight mb-6 text-balance">
|
||||
{service.title.split(" ").map((word, i) => (
|
||||
<span key={i} className="block">
|
||||
{word}
|
||||
</span>
|
||||
))}
|
||||
</h3>
|
||||
|
||||
<div className="space-y-6 mb-8">
|
||||
<p className="text-lg text-white/80 leading-relaxed">{service.description}</p>
|
||||
|
||||
<div className="space-y-2">
|
||||
{service.features.map((feature, idx) => (
|
||||
<p key={idx} className="text-white/70 flex items-center">
|
||||
<span className="w-1 h-1 bg-white/40 rounded-full mr-3"></span>
|
||||
{feature}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<p className="text-2xl font-bold text-white">{service.price}</p>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
asChild
|
||||
className="bg-white text-black hover:bg-white/90 !text-black px-8 py-4 text-lg font-medium tracking-wide transition-all duration-300 hover:scale-105"
|
||||
>
|
||||
<Link href="/book">BOOK NOW</Link>
|
||||
</Button>
|
||||
|
||||
<div className="mt-8">
|
||||
<div className="relative">
|
||||
<img
|
||||
src={`/abstract-geometric-shapes.png?height=250&width=300&query=${service.title.toLowerCase()} tattoo example`}
|
||||
alt={service.title}
|
||||
className="w-full max-w-xs h-auto object-cover rounded-lg shadow-2xl"
|
||||
/>
|
||||
<div className="absolute -bottom-2 -right-2 w-12 h-12 bg-white/5 rounded-lg"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CarouselItem>
|
||||
))}
|
||||
</CarouselContent>
|
||||
<div className="flex justify-center mt-8 space-x-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>
|
||||
)
|
||||
}
|
||||
@ -3,6 +3,7 @@
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import Link from "next/link"
|
||||
import { ServicesMobileCarousel } from "@/components/services-mobile-carousel"
|
||||
|
||||
const services = [
|
||||
{
|
||||
@ -200,55 +201,7 @@ export function ServicesSection() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="lg:hidden">
|
||||
{services.map((service, index) => (
|
||||
<div
|
||||
key={index}
|
||||
data-index={index}
|
||||
className={`min-h-screen flex items-center justify-center p-8 transition-all duration-1000 ${
|
||||
visibleItems.includes(index) ? "opacity-100 translate-y-0" : "opacity-0 translate-y-8"
|
||||
}`}
|
||||
style={{ transitionDelay: `${index * 200}ms` }}
|
||||
>
|
||||
<div className="max-w-lg">
|
||||
<div className="mb-6">
|
||||
<span className="text-sm font-medium tracking-widest text-white/60 uppercase">
|
||||
Service {String(index + 1).padStart(2, "0")}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<h3 className="text-4xl lg:text-6xl font-bold tracking-tight mb-6 text-balance">
|
||||
{service.title.split(" ").map((word, i) => (
|
||||
<span key={i} className="block">
|
||||
{word}
|
||||
</span>
|
||||
))}
|
||||
</h3>
|
||||
|
||||
<div className="space-y-6 mb-8">
|
||||
<p className="text-lg text-white/80 leading-relaxed">{service.description}</p>
|
||||
|
||||
<div className="space-y-2">
|
||||
{service.features.map((feature, idx) => (
|
||||
<p key={idx} className="text-white/70">
|
||||
• {feature}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<p className="text-2xl font-bold text-white">{service.price}</p>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
asChild
|
||||
className="bg-white text-black hover:bg-white/90 !text-black px-8 py-4 text-lg font-medium tracking-wide"
|
||||
>
|
||||
<Link href="/book">BOOK NOW</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<ServicesMobileCarousel />
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
|
||||
72
et -e
Normal file
72
et -e
Normal file
@ -0,0 +1,72 @@
|
||||
[38;2;131;148;150m───────┬─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────[0m
|
||||
[38;2;131;148;150m│ [0m[1mSTDIN[0m
|
||||
[38;2;131;148;150m───────┼─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────[0m
|
||||
[38;2;131;148;150m 1[0m [38;2;131;148;150m│[0m [38;2;117;113;94m---[0m[38;2;117;113;94m FILE: app/page.tsx ---[0m
|
||||
[38;2;131;148;150m 2[0m [38;2;131;148;150m│[0m [38;2;248;248;242mimport { Navigation } from "@/components/navigation"[0m
|
||||
[38;2;131;148;150m 3[0m [38;2;131;148;150m│[0m [38;2;248;248;242mimport { ScrollProgress } from "@/components/scroll-progress"[0m
|
||||
[38;2;131;148;150m 4[0m [38;2;131;148;150m│[0m [38;2;248;248;242mimport { ScrollToSection } from "@/components/scroll-to-section"[0m
|
||||
[38;2;131;148;150m 5[0m [38;2;131;148;150m│[0m [38;2;248;248;242mimport { HeroSection } from "@/components/hero-section"[0m
|
||||
[38;2;131;148;150m 6[0m [38;2;131;148;150m│[0m [38;2;248;248;242mimport { ArtistsSection } from "@/components/artists-section"[0m
|
||||
[38;2;131;148;150m 7[0m [38;2;131;148;150m│[0m [38;2;248;248;242mimport { ServicesSection } from "@/components/services-section"[0m
|
||||
[38;2;131;148;150m 8[0m [38;2;131;148;150m│[0m [38;2;248;248;242mimport { ContactSection } from "@/components/contact-section"[0m
|
||||
[38;2;131;148;150m 9[0m [38;2;131;148;150m│[0m [38;2;248;248;242mimport { Footer } from "@/components/footer"[0m
|
||||
[38;2;131;148;150m 10[0m [38;2;131;148;150m│[0m
|
||||
[38;2;131;148;150m 11[0m [38;2;131;148;150m│[0m [38;2;248;248;242mexport default function HomePage() {[0m
|
||||
[38;2;131;148;150m 12[0m [38;2;131;148;150m│[0m [38;2;248;248;242m return ([0m
|
||||
[38;2;131;148;150m 13[0m [38;2;131;148;150m│[0m [38;2;248;248;242m <main className="min-h-screen">[0m
|
||||
[38;2;131;148;150m 14[0m [38;2;131;148;150m│[0m [38;2;248;248;242m <ScrollProgress />[0m
|
||||
[38;2;131;148;150m 15[0m [38;2;131;148;150m│[0m [38;2;248;248;242m <ScrollToSection />[0m
|
||||
[38;2;131;148;150m 16[0m [38;2;131;148;150m│[0m [38;2;248;248;242m <Navigation />[0m
|
||||
[38;2;131;148;150m 17[0m [38;2;131;148;150m│[0m [38;2;248;248;242m <div id="home">[0m
|
||||
[38;2;131;148;150m 18[0m [38;2;131;148;150m│[0m [38;2;248;248;242m <HeroSection />[0m
|
||||
[38;2;131;148;150m 19[0m [38;2;131;148;150m│[0m [38;2;248;248;242m </div>[0m
|
||||
[38;2;131;148;150m 20[0m [38;2;131;148;150m│[0m [38;2;248;248;242m <div id="artists">[0m
|
||||
[38;2;131;148;150m 21[0m [38;2;131;148;150m│[0m [38;2;248;248;242m <ArtistsSection />[0m
|
||||
[38;2;131;148;150m 22[0m [38;2;131;148;150m│[0m [38;2;248;248;242m </div>[0m
|
||||
[38;2;131;148;150m 23[0m [38;2;131;148;150m│[0m [38;2;248;248;242m <div id="services">[0m
|
||||
[38;2;131;148;150m 24[0m [38;2;131;148;150m│[0m [38;2;248;248;242m <ServicesSection />[0m
|
||||
[38;2;131;148;150m 25[0m [38;2;131;148;150m│[0m [38;2;248;248;242m </div>[0m
|
||||
[38;2;131;148;150m 26[0m [38;2;131;148;150m│[0m [38;2;248;248;242m <div id="contact">[0m
|
||||
[38;2;131;148;150m 27[0m [38;2;131;148;150m│[0m [38;2;248;248;242m <ContactSection />[0m
|
||||
[38;2;131;148;150m 28[0m [38;2;131;148;150m│[0m [38;2;248;248;242m </div>[0m
|
||||
[38;2;131;148;150m 29[0m [38;2;131;148;150m│[0m [38;2;248;248;242m <Footer />[0m
|
||||
[38;2;131;148;150m 30[0m [38;2;131;148;150m│[0m [38;2;248;248;242m </main>[0m
|
||||
[38;2;131;148;150m 31[0m [38;2;131;148;150m│[0m [38;2;248;248;242m )[0m
|
||||
[38;2;131;148;150m 32[0m [38;2;131;148;150m│[0m [38;2;248;248;242m}[0m
|
||||
[38;2;131;148;150m 33[0m [38;2;131;148;150m│[0m
|
||||
[38;2;131;148;150m 34[0m [38;2;131;148;150m│[0m [38;2;117;113;94m---[0m[38;2;117;113;94m FILE: app/layout.tsx ---[0m
|
||||
[38;2;131;148;150m 35[0m [38;2;131;148;150m│[0m [38;2;248;248;242mimport type React from "react"[0m
|
||||
[38;2;131;148;150m 36[0m [38;2;131;148;150m│[0m [38;2;248;248;242mimport type { Metadata } from "next"[0m
|
||||
[38;2;131;148;150m 37[0m [38;2;131;148;150m│[0m [38;2;248;248;242mimport { Playfair_Display, Source_Sans_3 } from "next/font/google"[0m
|
||||
[38;2;131;148;150m 38[0m [38;2;131;148;150m│[0m [38;2;248;248;242mimport "./globals.css"[0m
|
||||
[38;2;131;148;150m 39[0m [38;2;131;148;150m│[0m [38;2;248;248;242mimport ClientLayout from "./ClientLayout"[0m
|
||||
[38;2;131;148;150m 40[0m [38;2;131;148;150m│[0m
|
||||
[38;2;131;148;150m 41[0m [38;2;131;148;150m│[0m [38;2;248;248;242mconst playfairDisplay = Playfair_Display({[0m
|
||||
[38;2;131;148;150m 42[0m [38;2;131;148;150m│[0m [38;2;248;248;242m subsets: ["latin"],[0m
|
||||
[38;2;131;148;150m 43[0m [38;2;131;148;150m│[0m [38;2;248;248;242m variable: "--font-playfair",[0m
|
||||
[38;2;131;148;150m 44[0m [38;2;131;148;150m│[0m [38;2;248;248;242m display: "swap",[0m
|
||||
[38;2;131;148;150m 45[0m [38;2;131;148;150m│[0m [38;2;248;248;242m})[0m
|
||||
[38;2;131;148;150m 46[0m [38;2;131;148;150m│[0m
|
||||
[38;2;131;148;150m 47[0m [38;2;131;148;150m│[0m [38;2;248;248;242mconst sourceSans = Source_Sans_3({[0m
|
||||
[38;2;131;148;150m 48[0m [38;2;131;148;150m│[0m [38;2;248;248;242m subsets: ["latin"],[0m
|
||||
[38;2;131;148;150m 49[0m [38;2;131;148;150m│[0m [38;2;248;248;242m variable: "--font-source-sans",[0m
|
||||
[38;2;131;148;150m 50[0m [38;2;131;148;150m│[0m [38;2;248;248;242m display: "swap",[0m
|
||||
[38;2;131;148;150m 51[0m [38;2;131;148;150m│[0m [38;2;248;248;242m})[0m
|
||||
[38;2;131;148;150m 52[0m [38;2;131;148;150m│[0m
|
||||
[38;2;131;148;150m 53[0m [38;2;131;148;150m│[0m [38;2;248;248;242mexport const metadata: Metadata = {[0m
|
||||
[38;2;131;148;150m 54[0m [38;2;131;148;150m│[0m [38;2;248;248;242m title: "United Tattoo - Professional Tattoo Studio",[0m
|
||||
[38;2;131;148;150m 55[0m [38;2;131;148;150m│[0m [38;2;248;248;242m description: "Book appointments with our talented artists and explore stunning tattoo portfolios at United Tattoo.",[0m
|
||||
[38;2;131;148;150m 56[0m [38;2;131;148;150m│[0m [38;2;248;248;242m generator: "v0.app",[0m
|
||||
[38;2;131;148;150m 57[0m [38;2;131;148;150m│[0m [38;2;248;248;242m}[0m
|
||||
[38;2;131;148;150m 58[0m [38;2;131;148;150m│[0m
|
||||
[38;2;131;148;150m 59[0m [38;2;131;148;150m│[0m [38;2;248;248;242mexport default function RootLayout({[0m
|
||||
[38;2;131;148;150m 60[0m [38;2;131;148;150m│[0m [38;2;248;248;242m children,[0m
|
||||
[38;2;131;148;150m 61[0m [38;2;131;148;150m│[0m [38;2;248;248;242m}: Readonly<{[0m
|
||||
[38;2;131;148;150m 62[0m [38;2;131;148;150m│[0m [38;2;248;248;242m children: React.ReactNode[0m
|
||||
[38;2;131;148;150m 63[0m [38;2;131;148;150m│[0m [38;2;248;248;242m}>) {[0m
|
||||
[38;2;131;148;150m 64[0m [38;2;131;148;150m│[0m [38;2;248;248;242m return ([0m
|
||||
[38;2;131;148;150m 65[0m [38;2;131;148;150m│[0m [38;2;248;248;242m <html lang="en" className={`${playfairDisplay.variable} ${sourceSans.variable}`}>[0m
|
||||
[38;2;131;148;150m 66[0m [38;2;131;148;150m│[0m [38;2;248;248;242m <body className="font-sans antialiased">[0m
|
||||
[38;2;131;148;150m 67[0m [38;2;131;148;150m│[0m [38;2;248;248;242m <ClientLayout>{children}</ClientLayout>[0m
|
||||
[38;2;131;148;150m 68[0m [38;2;131;148;150m│[0m [38;2;248;248;242m </body>[0m
|
||||
[38;2;131;148;150m 69[0m
|
||||
125
implementation_plan.md
Normal file
125
implementation_plan.md
Normal file
@ -0,0 +1,125 @@
|
||||
# Implementation Plan
|
||||
|
||||
[Overview]
|
||||
Improve the mobile experience of the homepage while preserving the current desktop appearance, focusing on easier navigation and a beautiful, performant presentation.
|
||||
|
||||
The current homepage uses large full-screen sections, a parallax hero, and multi-column artist/service layouts that look strong on desktop. On small screens, some patterns (e.g., full-screen stacked service sections, two-column contact layout, and a top nav that hides until scrolling) reduce wayfinding and increase friction. This plan adds a mobile-first layer: a persistent bottom “Book Now” bar, a swipeable carousel for Services, stacked Contact content, and mobile visibility improvements to the top navigation—all gated behind responsive Tailwind classes (lg: variants) to ensure desktop remains unchanged.
|
||||
|
||||
We will leverage existing shadcn/ui primitives, the shipped Embla Carousel, and Tailwind responsive utilities. No new dependencies are required. Changes are scoped to the homepage and components it renders. All animations and transforms are limited on mobile to maintain performance and reduce jank.
|
||||
|
||||
[Types]
|
||||
Introduce lightweight UI types to improve clarity for mobile-only components.
|
||||
|
||||
- export type SectionId = "home" | "artists" | "services" | "contact"
|
||||
- export interface Service {
|
||||
title: string
|
||||
description: string
|
||||
features: string[]
|
||||
price: string
|
||||
bgColor?: string
|
||||
image?: string
|
||||
}
|
||||
- export interface MobileBookingBarProps {
|
||||
label?: string
|
||||
href: string
|
||||
show?: boolean
|
||||
}
|
||||
|
||||
Notes:
|
||||
- The `services` data already lives inline in components/services-section.tsx; we’ll co-locate the `Service` interface in the new mobile carousel component or in that file to keep cohesion.
|
||||
- SectionId is useful for navigation/active state logic if needed in future refactors.
|
||||
- Props are optional and defaulted to keep components ergonomic.
|
||||
|
||||
[Files]
|
||||
Add mobile-only components and apply responsive modifications to existing files. No deletions; all desktop code paths remain intact.
|
||||
|
||||
New files:
|
||||
- components/mobile-booking-bar.tsx
|
||||
- Purpose: Persistent bottom CTA bar (only on small screens) linking to /book. Uses safe-area insets and elevated z-index. Hidden on lg and up.
|
||||
- components/services-mobile-carousel.tsx
|
||||
- Purpose: Mobile-only swipeable carousel rendering the same services content in a compact, card-first format using shadcn/ui Carousel (Embla).
|
||||
- Accepts `services: Service[]` or imports from the same module if kept inline for a single source of truth.
|
||||
|
||||
Existing files to modify:
|
||||
- app/page.tsx
|
||||
- Add `<MobileBookingBar />` just after `<Navigation />` so it’s globally available on the homepage. Guard with `lg:hidden`.
|
||||
- components/navigation.tsx
|
||||
- Ensure the top nav is visible and interactive at the top of the page on mobile (currently hidden until scroll due to `opacity-0 pointer-events-none`).
|
||||
- Add small-screen behavior: always visible container on mobile with subtle transparent backdrop; keep existing behavior on desktop.
|
||||
- components/services-section.tsx
|
||||
- Replace current mobile `lg:hidden` full-screen stack with the new mobile carousel.
|
||||
- Keep desktop split composition and scrolling experience unchanged (guard with `lg:`).
|
||||
- components/contact-section.tsx
|
||||
- Convert the fixed `w-1/2` two-column layout into a stacked mobile layout (`flex-col`, `w-full lg:w-1/2`) while preserving the current desktop (`lg:flex-row`).
|
||||
- Keep brand background and parallax but dampen/limit on mobile to reduce jank.
|
||||
- styles/globals.css (no structural changes required)
|
||||
- Optional: add CSS note for safe-area usage reference; final implementation will rely on inline styles/Tailwind utilities.
|
||||
|
||||
Files to delete or move:
|
||||
- None.
|
||||
|
||||
Configuration updates:
|
||||
- None required. Tailwind and shadcn/ui already configured. `embla-carousel-react` is present.
|
||||
|
||||
[Functions]
|
||||
Add mobile-only components and adjust existing components with mobile-guarded branches. All changes are responsive-only to avoid altering desktop.
|
||||
|
||||
New functions/components:
|
||||
- components/mobile-booking-bar.tsx
|
||||
- export function MobileBookingBar(props: MobileBookingBarProps): JSX.Element
|
||||
- Renders fixed bottom bar (safe-area aware) with a prominent “Book Now” button linking to /book. `className` uses `lg:hidden` to ensure desktop is unaffected.
|
||||
- components/services-mobile-carousel.tsx
|
||||
- export function ServicesMobileCarousel({ services }: { services: Service[] }): JSX.Element
|
||||
- Uses shadcn/ui Carousel primitives: `Carousel`, `CarouselContent`, `CarouselItem`, `CarouselNext`, `CarouselPrevious`.
|
||||
- Card layout: service title, short copy, features, price, and a “Book Now” button. Optimized tap targets.
|
||||
|
||||
Modified functions/components:
|
||||
- app/page.tsx (default export HomePage)
|
||||
- Insert `<MobileBookingBar />` after `<Navigation />`.
|
||||
- components/navigation.tsx (export function Navigation)
|
||||
- Add `isMobile` calculation (e.g., via matchMedia or an existing `use-mobile` hook) and adjust the className logic:
|
||||
- On mobile: show nav at top by default with interactive controls (remove `pointer-events-none`/`opacity-0` at top of page).
|
||||
- On desktop: preserve current behavior and styles.
|
||||
- components/services-section.tsx (export function ServicesSection)
|
||||
- Import and render `<ServicesMobileCarousel />` in a `lg:hidden` block.
|
||||
- Keep existing left menu + right scroller solely in `lg:` scope.
|
||||
- components/contact-section.tsx (export function ContactSection)
|
||||
- Wrap main two columns with `flex-col lg:flex-row`; change each child to `w-full lg:w-1/2`.
|
||||
- Ensure spacing/padding on mobile is comfortable; no changes to desktop classes.
|
||||
|
||||
Removed functions/components:
|
||||
- None. We only replace the mobile branch of Services with a carousel but keep the desktop branch untouched.
|
||||
|
||||
[Classes]
|
||||
No class-based components. All are function components.
|
||||
- New components use React function components with TypeScript props.
|
||||
- No inheritance changes.
|
||||
|
||||
[Dependencies]
|
||||
No new dependencies.
|
||||
- `embla-carousel-react` already present and shadcn/ui `components/ui/carousel.tsx` is in the repo.
|
||||
- No package.json updates required.
|
||||
|
||||
[Testing]
|
||||
Targeted mobile validation across breakpoints without impacting desktop.
|
||||
- Manual QA on common device widths (e.g., 360, 390, 414, 768).
|
||||
- Verify:
|
||||
- Navigation is visible at top on mobile and toggles the drawer correctly.
|
||||
- Persistent bottom “Book Now” bar is present on the homepage and does not overlap footer content (safe-area works on iOS).
|
||||
- Services are swipeable; arrows and swipe gestures function; CTAs navigate to /book.
|
||||
- Contact section stacks vertically and fields remain accessible.
|
||||
- Desktop (≥ lg) remains pixel-identical to current implementation.
|
||||
- Optional: lightweight React Testing Library tests for rendering/mobile-only presence toggles.
|
||||
- Optional: Playwright e2e smoke for scroll, nav toggle, carousel swipe on a preview environment.
|
||||
|
||||
[Implementation Order]
|
||||
Implement mobile-only components first, then integrate into the homepage and adjust existing sections, verifying desktop remains unchanged after each step.
|
||||
|
||||
1) Create components/mobile-booking-bar.tsx (persistent bottom CTA; `lg:hidden`; safe-area support).
|
||||
2) Create components/services-mobile-carousel.tsx (Embla-based; card layout; `lg:hidden` wrapper usage).
|
||||
3) Integrate MobileBookingBar into app/page.tsx (just after Navigation).
|
||||
4) Modify components/services-section.tsx to use the new mobile carousel and keep the existing desktop implementation behind `lg:` guards. Remove/replace the current mobile `lg:hidden` full-screen stack.
|
||||
5) Adjust components/contact-section.tsx to stack on mobile (`flex-col`, `w-full lg:w-1/2`) and retain current desktop layout.
|
||||
6) Update components/navigation.tsx to ensure the nav is visible and interactive at top on mobile (leave desktop behavior intact).
|
||||
7) QA: Verify mobile behaviors across breakpoints, then confirm desktop visual/behavioral parity.
|
||||
8) Polish: Confirm safe-area, hit-target sizes, and accessible labels; check scroll-to-section offsets still correct on mobile.
|
||||
Loading…
x
Reference in New Issue
Block a user