v05 push to origin

This commit is contained in:
Nicholai 2025-09-16 21:36:20 -06:00
parent ae8d4a6dd1
commit 06abb52024
194 changed files with 18635 additions and 5264 deletions

29
.dockerignore Normal file
View File

@ -0,0 +1,29 @@
# Dependencies
node_modules
.pnpm-store
# Build output
.next
out
dist
# Logs
npm-debug.log*
yarn-debug.log*
pnpm-debug.log*
*.log
# Environment files
.env
.env.local
.env.*
.envrc
# VCS
.git
.gitignore
# Editor/misc
.vscode
.DS_Store
*.swp

28
.gitignore vendored
View File

@ -2,12 +2,6 @@
# dependencies
/node_modules
/.pnp
.pnp.js
.yarn/install-state.gz
# testing
/coverage
# next.js
/.next/
@ -16,17 +10,14 @@
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# local env files
.env*.local
# env files
.env*
# vercel
.vercel
@ -34,3 +25,16 @@ yarn-error.log*
# typescript
*.tsbuildinfo
next-env.d.ts
# project temp and large binary assets (avoid committing raw media dumps)
temp/
temp/**
*.mp4
*.mov
*.avi
*.mkv
*.psd
*.ai
*.zip
*.7z
*.rar

30
Dockerfile Normal file
View File

@ -0,0 +1,30 @@
# syntax=docker/dockerfile:1
# 1) Install dependencies
FROM node:20-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --legacy-peer-deps
# 2) Build the Next.js app (standalone output is enabled in next.config.mjs)
FROM node:20-alpine AS builder
WORKDIR /app
ENV NEXT_TELEMETRY_DISABLED=1
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build
# 3) Production runner (small image using standalone output)
FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV PORT=3000
ENV HOSTNAME=0.0.0.0
# Copy standalone server and static files
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
COPY --from=builder /app/public ./public
EXPOSE 3000
CMD ["node", "server.js"]

135
README.md
View File

@ -1,36 +1,125 @@
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
# United Tattoo — Official Website (Next.js + ShadCN UI)
## Getting Started
Hi, Im Nicholai. I built this site for my friend Christy (aka Ink Mama) and the United Tattoo crew in Fountain, CO. The goal was simple: give the studio a site that actually reflects the art, the people, and the experience — not the stiff, generic stuff you usually see. This is also a thank you for everything Christy has done for Amari (my girlfriend and soulmate), who was her apprentice. So yeah, this is personal — and it shows.
First, run the development server:
This repo powers the official United Tattoo website, built with:
- Next.js App Router
- TypeScript
- Tailwind CSS
- ShadCN UI components (used across all pages)
- Lenis (smooth scroll)
- Lucide (icons)
```bash
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
```
Live dev server: http://localhost:3000
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
## Project Structure
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
- app/
- page.tsx — homepage (Hero, Artists, Services, Contact sections)
- aftercare/page.tsx — aftercare instructions (ShadCN-driven)
- deposit/page.tsx — deposit policy + payment options (Afterpay, Stripe)
- terms/page.tsx — terms of service
- privacy/page.tsx — privacy policy
- artists/ — artists listing + dynamic routes for profiles (coming from data)
- book/page.tsx — booking flow
- specials/page.tsx — promotions (monthly specials, VIP list)
- contact/page.tsx — contact
- gift-cards/page.tsx — gift card info
## Learn More
- components/
- hero-section.tsx, artists-section.tsx, services-section.tsx, contact-section.tsx
- aftercare-page.tsx, deposit-page.tsx, terms-page.tsx, privacy-page.tsx
- booking-form.tsx — multi-step form using ShadCN components
- footer.tsx — contains direct links to Aftercare, Deposit Policy, Terms, and Privacy
- ui/ — ShadCN UI primitives
To learn more about Next.js, take a look at the following resources:
- data/
- artists.ts — single source of truth for artist metadata and images used across pages
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
- public/
- united-logo-*.png/jpg, artists/, and other stable assets
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
## Deploy on Vercel
## Content & Assets
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
- All the “real” content, bios, and images are now wired in (not seed placeholders).
- Artist portraits and tattoo samples live under public/artists/.
- Pages like Aftercare, Deposit, Terms, and Privacy use consistent styling patterned after the homepage and portfolio pages — powered by ShadCN components.
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
If you need to re-copy images from the temp folder into public (on your machine), theres a helper script:
- ./copy-artist-images.sh
Note: This script expects the temp directory structure to exist locally; it silently skips if a source is missing.
## Getting Started (Local Dev)
- Install deps:
- npm install
- Run dev server:
- npm run dev
- Open http://localhost:3000
- Lint (optional):
- npm run lint
Build:
- npm run build
- npm start
## Docker
This repo is docker-ready. We build a standalone Next.js app for a smaller runtime image.
Build image:
- docker build -t united-tattoo:latest .
Run container (port 3000):
- docker run --rm -p 3000:3000 -e PORT=3000 united-tattoo:latest
- Open http://localhost:3000
Notes:
- next.config.mjs sets output: "standalone"
- The Dockerfile copies .next/standalone + .next/static and runs the server with HOSTNAME=0.0.0.0
## Pages Overview
- Home — Bold, high-contrast, split imagery, parallax accents. This sets the identity.
- Artists — Grid and profile surfaces wired to data/artists.ts. Each artist shows image, specialties, and sample work.
- Aftercare — Two flows: General Aftercare and Transparent Bandage Aftercare (accurate, readable, ShadCN cards + alerts).
- Deposit — Clear policy, payment options, and compliance notes (LW2 Investments, LLC oversight).
- Terms & Privacy — Straightforward, legally sound, human-readable. Both accessible from the footer.
- Booking — Multi-step form with ShadCN components and validation-friendly structure.
- Specials — Marketing surface for time-bound promotions and membership-like advantages.
## Design Language
- ShadCN components everywhere possible
- Monochrome foundation with high contrast and cinematic image splits
- Type scales + spacing match the homepage/portfolio feeling
- Lucide icons for affordances
## Tech Notes
- TypeScript errors are ignored during build in CI to allow non-blocking content/design iteration (next.config.mjs).
- Images are unoptimized (no Next image loader), served statically; change if you plan to put this behind a CDN with transforms.
- Smooth scroll and parallax-style offsets are kept subtle to let the work shine.
## Deployment
- Standard Next.js deploys work (Vercel, Node server, Docker)
- For self-hosting or VPS, use the Dockerfile in the repo
- The site runs on port 3000 by default
## Why This Exists
Because Christy deserved a proper site — and because the previous one was, bluntly, not it. United Tattoo is more than a shop. Its a community with real people and real art. This site tries to honor that.
— Nicholai

23
app/ClientLayout.tsx Normal file
View File

@ -0,0 +1,23 @@
"use client"
import type React from "react"
import { SmoothScrollProvider } from "@/components/smooth-scroll-provider"
import { useSearchParams } from "next/navigation"
import { Suspense } from "react"
import "./globals.css"
export default function ClientLayout({
children,
}: Readonly<{
children: React.ReactNode
}>) {
const searchParams = useSearchParams()
return (
<>
<Suspense fallback={<div>Loading...</div>}>
<SmoothScrollProvider>{children}</SmoothScrollProvider>
</Suspense>
</>
)
}

15
app/aftercare/page.tsx Normal file
View File

@ -0,0 +1,15 @@
import { Navigation } from "@/components/navigation"
import { AftercarePage } from "@/components/aftercare-page"
import { Footer } from "@/components/footer"
export default function Aftercare() {
return (
<main className="min-h-screen">
<Navigation />
<div className="pt-16">
<AftercarePage />
</div>
<Footer />
</main>
)
}

View File

@ -0,0 +1,21 @@
import { Navigation } from "@/components/navigation"
import { BookingForm } from "@/components/booking-form"
import { Footer } from "@/components/footer"
interface BookingPageProps {
params: {
id: string
}
}
export default function BookingPage({ params }: BookingPageProps) {
return (
<main className="min-h-screen">
<Navigation />
<div className="pt-16">
<BookingForm artistId={params.id} />
</div>
<Footer />
</main>
)
}

21
app/artists/[id]/page.tsx Normal file
View File

@ -0,0 +1,21 @@
import { Navigation } from "@/components/navigation"
import { ArtistPortfolio } from "@/components/artist-portfolio"
import { Footer } from "@/components/footer"
interface ArtistPageProps {
params: {
id: string
}
}
export default function ArtistPage({ params }: ArtistPageProps) {
return (
<main className="min-h-screen">
<Navigation />
<div className="pt-16">
<ArtistPortfolio artistId={params.id} />
</div>
<Footer />
</main>
)
}

13
app/artists/page.tsx Normal file
View File

@ -0,0 +1,13 @@
import { Navigation } from "@/components/navigation"
import { ArtistsPageSection } from "@/components/artists-page-section"
import { Footer } from "@/components/footer"
export default function ArtistsPage() {
return (
<main className="min-h-screen">
<Navigation />
<ArtistsPageSection />
<Footer />
</main>
)
}

15
app/book/page.tsx Normal file
View File

@ -0,0 +1,15 @@
import { Navigation } from "@/components/navigation"
import { BookingForm } from "@/components/booking-form"
import { Footer } from "@/components/footer"
export default function BookPage() {
return (
<main className="min-h-screen">
<Navigation />
<div className="pt-16">
<BookingForm />
</div>
<Footer />
</main>
)
}

15
app/contact/page.tsx Normal file
View File

@ -0,0 +1,15 @@
import { Navigation } from "@/components/navigation"
import { ContactPage } from "@/components/contact-page"
import { Footer } from "@/components/footer"
export default function Contact() {
return (
<main className="min-h-screen">
<Navigation />
<div className="pt-16">
<ContactPage />
</div>
<Footer />
</main>
)
}

15
app/deposit/page.tsx Normal file
View File

@ -0,0 +1,15 @@
import { Navigation } from "@/components/navigation"
import { DepositPage } from "@/components/deposit-page"
import { Footer } from "@/components/footer"
export default function Deposit() {
return (
<main className="min-h-screen">
<Navigation />
<div className="pt-16">
<DepositPage />
</div>
<Footer />
</main>
)
}

15
app/gift-cards/page.tsx Normal file
View File

@ -0,0 +1,15 @@
import { Navigation } from "@/components/navigation"
import { GiftCardsPage } from "@/components/gift-cards-page"
import { Footer } from "@/components/footer"
export default function GiftCards() {
return (
<main className="min-h-screen">
<Navigation />
<div className="pt-16">
<GiftCardsPage />
</div>
<Footer />
</main>
)
}

View File

@ -1,27 +1,296 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@import "tailwindcss";
@import "tw-animate-css";
@custom-variant dark (&:is(.dark *));
:root {
--background: #ffffff;
--foreground: #171717;
/* Updated color tokens to match United Tattoo design brief */
--background: oklch(1 0 0); /* White */
--foreground: oklch(0.145 0 0); /* Dark Slate Gray */
--card: oklch(1 0 0); /* Light Gray */
--card-foreground: oklch(0.145 0 0); /* Dark text for cards */
--popover: oklch(1 0 0); /* White */
--popover-foreground: oklch(0.145 0 0); /* Dark text */
--primary: oklch(0.205 0 0); /* Emerald-600 #059669 */
--primary-foreground: oklch(0.985 0 0); /* White text on primary */
--secondary: oklch(0.97 0 0); /* Emerald accent #10b981 */
--secondary-foreground: oklch(0.205 0 0); /* White text on secondary */
--muted: oklch(0.97 0 0); /* Light Gray */
--muted-foreground: oklch(0.556 0 0); /* Muted text */
--accent: oklch(0.97 0 0); /* Emerald accent */
--accent-foreground: oklch(0.205 0 0); /* White text on accent */
--destructive: oklch(0.577 0.245 27.325); /* Red for destructive actions */
--destructive-foreground: oklch(0.985 0 0); /* White text */
--border: oklch(0.922 0 0); /* Light border */
--input: oklch(0.922 0 0); /* Input background */
--ring: oklch(0.708 0 0); /* Focus ring */
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--radius: 0.625rem;
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
}
@media (prefers-color-scheme: dark) {
:root {
--background: #0a0a0a;
--foreground: #ededed;
}
.dark {
--background: oklch(0.145 0 0); /* Very dark background */
--foreground: oklch(0.985 0 0); /* Light text */
--card: oklch(0.205 0 0); /* Dark card background */
--card-foreground: oklch(0.985 0 0); /* Light text on cards */
--popover: oklch(0.269 0 0); /* Dark popover */
--popover-foreground: oklch(0.985 0 0); /* Light text */
--primary: oklch(0.922 0 0); /* Brighter emerald for dark mode */
--primary-foreground: oklch(0.205 0 0); /* Dark text on primary */
--secondary: oklch(0.269 0 0); /* Dark secondary */
--secondary-foreground: oklch(0.985 0 0); /* Light text */
--muted: oklch(0.269 0 0); /* Dark muted */
--muted-foreground: oklch(0.708 0 0); /* Muted text */
--accent: oklch(0.371 0 0); /* Dark accent */
--accent-foreground: oklch(0.985 0 0); /* Light text */
--destructive: oklch(0.704 0.191 22.216); /* Darker red */
--destructive-foreground: oklch(0.985 0 0); /* Lighter red text */
--border: oklch(1 0 0 / 10%); /* Dark border */
--input: oklch(1 0 0 / 15%); /* Dark input */
--ring: oklch(0.556 0 0); /* Dark focus ring */
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.439 0 0);
}
@theme inline {
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
color: var(--foreground);
background: var(--background);
font-family: Arial, Helvetica, sans-serif;
@apply bg-background text-foreground;
}
@layer utilities {
.text-balance {
text-wrap: balance;
/* Added Lenis smooth scrolling styles */
html.lenis,
html.lenis body {
height: auto;
}
.lenis.lenis-smooth {
scroll-behavior: auto !important;
}
.lenis.lenis-smooth [data-lenis-prevent] {
overscroll-behavior: contain;
}
.lenis.lenis-stopped {
overflow: hidden;
}
.lenis.lenis-scrolling iframe {
pointer-events: none;
}
/* Added smooth scrolling and custom animations */
html {
scroll-behavior: smooth;
}
/* Custom scroll animations */
@keyframes fade-in-up {
from {
opacity: 0;
transform: translateY(30px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes fade-in-left {
from {
opacity: 0;
transform: translateX(-30px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
@keyframes fade-in-right {
from {
opacity: 0;
transform: translateX(30px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
@keyframes scale-in {
from {
opacity: 0;
transform: scale(0.9);
}
to {
opacity: 1;
transform: scale(1);
}
}
.animate-fade-in-up {
animation: fade-in-up 0.6s ease-out forwards;
}
.animate-fade-in-left {
animation: fade-in-left 0.6s ease-out forwards;
}
.animate-fade-in-right {
animation: fade-in-right 0.6s ease-out forwards;
}
.animate-scale-in {
animation: scale-in 0.6s ease-out forwards;
}
/* Scroll-triggered animations */
.scroll-animate {
opacity: 0;
transform: translateY(20px);
transition: all 0.6s ease-out;
}
.scroll-animate.visible {
opacity: 1;
transform: translateY(0);
}
/* Enhanced scrollbar styling */
::-webkit-scrollbar {
width: 8px;
}
::-webkit-scrollbar-track {
background: var(--muted);
}
::-webkit-scrollbar-thumb {
background: var(--primary);
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--primary);
opacity: 0.8;
}
/* Smooth transitions for all interactive elements */
button,
a,
input,
textarea {
transition: all 0.2s ease-in-out;
}
/* Adding marquee animation for scrolling reviews */
@keyframes marquee {
0% {
transform: translateX(0);
}
100% {
transform: translateX(-100%);
}
}
.animate-marquee {
animation: marquee 30s linear infinite;
}
.animate-marquee:hover {
animation-play-state: paused;
}
/* Enhanced marquee animation with smooth transitions */
.animate-marquee-smooth {
animation: marquee 40s linear infinite;
transition: animation-play-state 0.5s ease-in-out;
}
.animate-marquee-smooth:hover {
animation-play-state: paused;
}
/* Enhanced hover pause with smooth transitions */
.hover\:pause:hover {
animation-play-state: paused !important;
}
.hover\:pause-smooth:hover {
animation-play-state: paused !important;
transition: all 0.5s ease-in-out;
}
/* Adding radial gradient utility for spotlight effect */
.bg-gradient-radial {
background: radial-gradient(circle, var(--tw-gradient-stops));
}
}

View File

@ -1,35 +1,37 @@
import type { Metadata } from "next";
import localFont from "next/font/local";
import "./globals.css";
import type React from "react"
import type { Metadata } from "next"
import { Playfair_Display, Source_Sans_3 } from "next/font/google"
import "./globals.css"
import ClientLayout from "./ClientLayout"
const geistSans = localFont({
src: "./fonts/GeistVF.woff",
variable: "--font-geist-sans",
weight: "100 900",
});
const geistMono = localFont({
src: "./fonts/GeistMonoVF.woff",
variable: "--font-geist-mono",
weight: "100 900",
});
const playfairDisplay = Playfair_Display({
subsets: ["latin"],
variable: "--font-playfair",
display: "swap",
})
const sourceSans = Source_Sans_3({
subsets: ["latin"],
variable: "--font-source-sans",
display: "swap",
})
export const metadata: Metadata = {
title: "Create Next App",
description: "Generated by create next app",
};
title: "United Tattoo - Professional Tattoo Studio",
description: "Book appointments with our talented artists and explore stunning tattoo portfolios at United Tattoo.",
generator: "v0.app",
}
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
children: React.ReactNode
}>) {
return (
<html lang="en">
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
>
{children}
<html lang="en" className={`${playfairDisplay.variable} ${sourceSans.variable}`}>
<body className="font-sans antialiased">
<ClientLayout>{children}</ClientLayout>
</body>
</html>
);
)
}

View File

@ -1,101 +1,31 @@
import Image from "next/image";
import { Navigation } from "@/components/navigation"
import { ScrollProgress } from "@/components/scroll-progress"
import { ScrollToSection } from "@/components/scroll-to-section"
import { HeroSection } from "@/components/hero-section"
import { ArtistsSection } from "@/components/artists-section"
import { ServicesSection } from "@/components/services-section"
import { ContactSection } from "@/components/contact-section"
import { Footer } from "@/components/footer"
export default function Home() {
export default function HomePage() {
return (
<div className="grid grid-rows-[20px_1fr_20px] items-center justify-items-center min-h-screen p-8 pb-20 gap-16 sm:p-20 font-[family-name:var(--font-geist-sans)]">
<main className="flex flex-col gap-8 row-start-2 items-center sm:items-start">
<Image
className="dark:invert"
src="https://nextjs.org/icons/next.svg"
alt="Next.js logo"
width={180}
height={38}
priority
/>
<ol className="list-inside list-decimal text-sm text-center sm:text-left font-[family-name:var(--font-geist-mono)]">
<li className="mb-2">
Get started by editing{" "}
<code className="bg-black/[.05] dark:bg-white/[.06] px-1 py-0.5 rounded font-semibold">
app/page.tsx
</code>
.
</li>
<li>Save and see your changes instantly.</li>
</ol>
<div className="flex gap-4 items-center flex-col sm:flex-row">
<a
className="rounded-full border border-solid border-transparent transition-colors flex items-center justify-center bg-foreground text-background gap-2 hover:bg-[#383838] dark:hover:bg-[#ccc] text-sm sm:text-base h-10 sm:h-12 px-4 sm:px-5"
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
className="dark:invert"
src="https://nextjs.org/icons/vercel.svg"
alt="Vercel logomark"
width={20}
height={20}
/>
Deploy now
</a>
<a
className="rounded-full border border-solid border-black/[.08] dark:border-white/[.145] transition-colors flex items-center justify-center hover:bg-[#f2f2f2] dark:hover:bg-[#1a1a1a] hover:border-transparent text-sm sm:text-base h-10 sm:h-12 px-4 sm:px-5 sm:min-w-44"
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
Read our docs
</a>
<main className="min-h-screen">
<ScrollProgress />
<ScrollToSection />
<Navigation />
<div id="home">
<HeroSection />
</div>
<div id="artists">
<ArtistsSection />
</div>
<div id="services">
<ServicesSection />
</div>
<div id="contact">
<ContactSection />
</div>
<Footer />
</main>
<footer className="row-start-3 flex gap-6 flex-wrap items-center justify-center">
<a
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
aria-hidden
src="https://nextjs.org/icons/file.svg"
alt="File icon"
width={16}
height={16}
/>
Learn
</a>
<a
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
aria-hidden
src="https://nextjs.org/icons/window.svg"
alt="Window icon"
width={16}
height={16}
/>
Examples
</a>
<a
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
href="https://nextjs.org?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
aria-hidden
src="https://nextjs.org/icons/globe.svg"
alt="Globe icon"
width={16}
height={16}
/>
Go to nextjs.org
</a>
</footer>
</div>
);
)
}

15
app/privacy/page.tsx Normal file
View File

@ -0,0 +1,15 @@
import { Navigation } from "@/components/navigation"
import { PrivacyPage } from "@/components/privacy-page"
import { Footer } from "@/components/footer"
export default function Privacy() {
return (
<main className="min-h-screen">
<Navigation />
<div className="pt-16">
<PrivacyPage />
</div>
<Footer />
</main>
)
}

15
app/specials/page.tsx Normal file
View File

@ -0,0 +1,15 @@
import { Navigation } from "@/components/navigation"
import { SpecialsPage } from "@/components/specials-page"
import { Footer } from "@/components/footer"
export default function Specials() {
return (
<main className="min-h-screen">
<Navigation />
<div className="pt-16">
<SpecialsPage />
</div>
<Footer />
</main>
)
}

15
app/terms/page.tsx Normal file
View File

@ -0,0 +1,15 @@
import { Navigation } from "@/components/navigation"
import { TermsPage } from "@/components/terms-page"
import { Footer } from "@/components/footer"
export default function Terms() {
return (
<main className="min-h-screen">
<Navigation />
<div className="pt-16">
<TermsPage />
</div>
<Footer />
</main>
)
}

21
components.json Normal file
View File

@ -0,0 +1,21 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "",
"css": "app/globals.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"iconLibrary": "lucide"
}

View File

@ -0,0 +1,352 @@
"use client"
import { useState } from "react"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { Badge } from "@/components/ui/badge"
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import {
CheckCircle,
Clock,
Shield,
AlertTriangle,
Droplets,
Phone,
Mail,
Heart,
} from "lucide-react"
import Link from "next/link"
type Phase = {
phase: string
icon: any
color: string
bgColor: string
steps: string[]
}
const generalAftercare: Record<string, Phase> = {
immediate: {
phase: "Immediate Aftercare",
icon: Clock,
color: "text-red-400",
bgColor: "bg-red-950/20 border-red-900/30",
steps: [
"Keep the bandage or dressing on for 1 to 4 hours to prevent exposure to airborne bacteria.",
"Wash your hands thoroughly before removing the bandage.",
"Remove the bandage gently and cleanse your tattoo using lukewarm water and mild, unscented antibacterial soap.",
"Pat dry with a clean paper towel — never touch your tattoo unless you have just washed your hands.",
"Apply a very light layer of the recommended aftercare product or fragrance-free lotion.",
],
},
general: {
phase: "General Aftercare",
icon: Shield,
color: "text-yellow-400",
bgColor: "bg-yellow-950/20 border-yellow-900/30",
steps: [
"Cleanse your tattoo multiple times a day with lukewarm water and antibacterial soap.",
"Apply a thin layer of ointment or lotion to keep your tattoo moisturized.",
"After the first few days, transition to a non-scented lotion.",
"Avoid wearing tight clothing over your tattoo.",
"Avoid immersing your tattoo in pools, oceans, lakes, or hot tubs for 24 weeks.",
"Minimize activities that lead to excessive sweating and sun exposure.",
"Do not pick, peel, or scratch scabbing or hardened layers.",
],
},
longterm: {
phase: "Long-term Aftercare",
icon: Heart,
color: "text-green-400",
bgColor: "bg-green-950/20 border-green-900/30",
steps: [
"Always use a minimum of SPF 30 sunblock to protect your tattoo from UV rays.",
"Keep your tattoos well-moisturized, especially in areas prone to fading (hands, feet, knees, elbows).",
"The outermost layer of skin typically takes 23 weeks to heal.",
"Complete healing may take up to 6 months.",
"Ongoing care will contribute to the longevity and vibrancy of your tattoo.",
],
},
}
const transparentBandage: Record<string, Phase> = {
removal: {
phase: "Bandage Removal",
icon: Droplets,
color: "text-blue-400",
bgColor: "bg-blue-950/20 border-blue-900/30",
steps: [
"Remove bandage in the shower for added comfort — running water helps adhesive detachment.",
"Peel back in the direction of hair growth.",
"Wash hands before handling your tattoo.",
"Cleanse with lukewarm water and mild antibacterial soap multiple times a day.",
"If the tattoo feels slippery, carefully remove excess plasma to avoid scab formation.",
"Air dry or gently pat with a paper towel.",
],
},
reapply: {
phase: "Bandage Reapplication (If Advised)",
icon: Shield,
color: "text-purple-400",
bgColor: "bg-purple-950/20 border-purple-900/30",
steps: [
"DO NOT apply ointments or lotions unless directed by your artist.",
"Apply the bandage only to the tattoo, avoiding surrounding skin.",
"Cut and trim to fit with ~1 inch around all sides (rounded edges adhere better).",
"Keep the new bandage on for 36 days unless your artist advises otherwise.",
"Remove earlier if irritation, fluid buildup, or loosening occurs.",
"Avoid reapplying once the tattoo enters the scabbing or flaking phase.",
],
},
}
const infectionWarning = [
"Increased redness or swelling that spreads beyond the tattoo",
"Pain when touching the tattoo or a throbbing sensation",
"Sensation of heat from the tattoo area",
"Yellow or green discharge with offensive odor",
"Fever or chills",
"Red streaking from the tattoo",
"Excessive swelling after the first day",
"Signs of allergic reaction",
]
export function AftercarePage() {
const [tab, setTab] = useState<"general" | "transparent">("general")
return (
<div className="min-h-screen bg-black text-white">
{/* Hero / Header */}
<section className="relative overflow-hidden">
<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>
<div className="relative z-10 pt-28 pb-16 px-8 lg:px-16">
<div className="max-w-4xl mx-auto text-center">
<h1 className="font-playfair text-5xl lg:text-7xl font-bold mb-6 tracking-tight">
Tattoo Aftercare
</h1>
<p className="text-xl text-gray-300 leading-relaxed max-w-3xl mx-auto">
Proper aftercare is crucial for the healing and longevity of your new tattoo. Follow these
instructions carefully to ensure the best results.
</p>
</div>
</div>
</section>
{/* Licensing Notice */}
<section className="px-8 lg:px-16">
<div className="max-w-4xl mx-auto">
<Alert className="bg-white/5 border-white/10">
<Shield className="h-5 w-5 text-white" />
<AlertDescription className="text-gray-300">
United Tattoo is proudly licensed by the El Paso County Health Department and fully supports
health department regulations to protect the health of our customers.
</AlertDescription>
</Alert>
</div>
</section>
{/* Tabs: General vs Transparent Bandage */}
<section className="px-8 lg:px-16 mt-12">
<div className="max-w-6xl mx-auto">
<Tabs value={tab} onValueChange={(v) => setTab(v as any)} className="w-full">
<TabsList className="grid w-full grid-cols-2 bg-white/5 border border-white/10">
<TabsTrigger
value="general"
className="data-[state=active]:bg-white data-[state=active]:text-black text-white"
>
General Tattoo Aftercare
</TabsTrigger>
<TabsTrigger
value="transparent"
className="data-[state=active]:bg-white data-[state=active]:text-black text-white"
>
Transparent Bandage Aftercare
</TabsTrigger>
</TabsList>
{/* General Aftercare */}
<TabsContent value="general" className="mt-10">
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{Object.values(generalAftercare).map((phase, idx) => {
const Icon = phase.icon
return (
<Card key={idx} className={`${phase.bgColor} border`}>
<CardHeader>
<CardTitle className="flex items-center gap-3">
<Icon className={`w-5 h-5 ${phase.color}`} />
<span className="font-playfair text-xl">{phase.phase}</span>
</CardTitle>
</CardHeader>
<CardContent>
<ul className="space-y-2 text-sm text-gray-200">
{phase.steps.map((s, i) => (
<li key={i} className="flex items-start gap-2">
<CheckCircle className="w-4 h-4 text-white/70 mt-0.5 flex-shrink-0" />
<span>{s}</span>
</li>
))}
</ul>
</CardContent>
</Card>
)
})}
</div>
</TabsContent>
{/* Transparent Bandage */}
<TabsContent value="transparent" className="mt-10">
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{Object.values(transparentBandage).map((phase, idx) => {
const Icon = phase.icon
return (
<Card key={idx} className={`${phase.bgColor} border`}>
<CardHeader>
<CardTitle className="flex items-center gap-3">
<Icon className={`w-5 h-5 ${phase.color}`} />
<span className="font-playfair text-xl">{phase.phase}</span>
</CardTitle>
</CardHeader>
<CardContent>
<ul className="space-y-2 text-sm text-gray-200">
{phase.steps.map((s, i) => (
<li key={i} className="flex items-start gap-2">
<CheckCircle className="w-4 h-4 text-white/70 mt-0.5 flex-shrink-0" />
<span>{s}</span>
</li>
))}
</ul>
</CardContent>
</Card>
)
})}
</div>
</TabsContent>
</Tabs>
</div>
</section>
{/* Infection Warning */}
<section className="px-8 lg:px-16 mt-16">
<div className="max-w-6xl mx-auto">
<Card className="bg-orange-950/20 border-orange-900/30">
<CardHeader className="bg-orange-900/10">
<CardTitle className="flex items-center gap-3 text-orange-200">
<AlertTriangle className="w-5 h-5" />
Signs of Infection Seek Medical Attention
</CardTitle>
</CardHeader>
<CardContent className="pt-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{infectionWarning.map((sign, i) => (
<div key={i} className="flex items-start gap-2 text-sm text-gray-200">
<AlertTriangle className="w-4 h-4 text-orange-300 mt-0.5 flex-shrink-0" />
<span>{sign}</span>
</div>
))}
</div>
<Alert className="mt-6 bg-white/5 border-white/10">
<AlertTriangle className="h-4 w-4 text-white" />
<AlertTitle>Important</AlertTitle>
<AlertDescription className="text-gray-300">
If you experience any of these symptoms, contact our studio immediately at{" "}
<Link href="tel:+17196989004" className="underline">
(719) 698-9004
</Link>{" "}
or seek urgent medical attention.
</AlertDescription>
</Alert>
</CardContent>
</Card>
</div>
</section>
{/* Healing Timeline */}
<section className="px-8 lg:px-16 mt-16">
<div className="max-w-6xl mx-auto">
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<Card className="bg-white/5 border-white/10">
<CardHeader>
<CardTitle className="text-white/90">Surface Healing</CardTitle>
</CardHeader>
<CardContent>
<p className="text-2xl font-bold mb-2">23 Weeks</p>
<p className="text-sm text-gray-300">
The outermost layer of skin typically heals in 23 weeks. Continue following aftercare during this time.
</p>
</CardContent>
</Card>
<Card className="bg-white/5 border-white/10">
<CardHeader>
<CardTitle className="text-white/90">Deep Healing</CardTitle>
</CardHeader>
<CardContent>
<p className="text-2xl font-bold mb-2">24 Months</p>
<p className="text-sm text-gray-300">
Deeper layers of skin continue healing. Maintain a consistent moisturizing routine.
</p>
</CardContent>
</Card>
<Card className="bg-white/5 border-white/10">
<CardHeader>
<CardTitle className="text-white/90">Complete Healing</CardTitle>
</CardHeader>
<CardContent>
<p className="text-2xl font-bold mb-2">Up to 6 Months</p>
<p className="text-sm text-gray-300">
Full healing may take up to 6 months. Protect with SPF and keep moisturized.
</p>
</CardContent>
</Card>
</div>
</div>
</section>
{/* Contact / Help */}
<section className="px-8 lg:px-16 my-16 pb-20">
<div className="max-w-4xl mx-auto">
<Card className="bg-white/5 border-white/10">
<CardContent className="p-8 text-center">
<h3 className="font-playfair text-3xl font-bold mb-2">Questions?</h3>
<p className="text-gray-300 mb-6">
Reach out if you have any aftercare questions or concerns. Were here to help.
</p>
<div className="flex flex-col sm:flex-row gap-4 justify-center">
<Button
variant="outline"
className="border-white/30 text-white hover:bg-white hover:text-black bg-transparent"
asChild
>
<Link href="tel:+17196989004" className="flex items-center gap-2">
<Phone className="w-4 h-4" />
(719) 698-9004
</Link>
</Button>
<Button
variant="outline"
className="border-white/30 text-white hover:bg-white hover:text-black bg-transparent"
asChild
>
<Link href="mailto:appts@united-tattoo.com" className="flex items-center gap-2">
<Mail className="w-4 h-4" />
appts@united-tattoo.com
</Link>
</Button>
</div>
</CardContent>
</Card>
</div>
</section>
</div>
)
}

View File

@ -0,0 +1,413 @@
"use client"
import { useState, useEffect } from "react"
import { Button } from "@/components/ui/button"
import { Badge } from "@/components/ui/badge"
import Link from "next/link"
import { ArrowLeft, Star, MapPin, Calendar, Instagram, ExternalLink } from "lucide-react"
// Mock data - in a real app, this would come from a database
const artistsData = {
"1": {
id: "1",
name: "Sarah Chen",
specialty: "Traditional & Neo-Traditional",
image: "/professional-female-tattoo-artist-with-traditional.jpg",
bio: "Specializing in bold traditional designs with a modern twist. Sarah brings 8 years of experience creating vibrant, timeless tattoos that honor the classic American traditional style while incorporating contemporary elements.",
experience: "8 years",
rating: 4.9,
reviews: 127,
location: "Studio A",
availability: "Available",
styles: ["Traditional", "Neo-Traditional", "American Traditional", "Color Work"],
instagram: "@sarahchen_tattoo",
portfolio: [
{
id: 1,
image: "/traditional-rose-tattoo-with-bold-colors.jpg",
title: "Traditional Rose",
category: "Traditional",
},
{
id: 2,
image: "/neo-traditional-wolf-tattoo-design.jpg",
title: "Neo-Traditional Wolf",
category: "Neo-Traditional",
},
{
id: 3,
image: "/american-traditional-anchor-tattoo.jpg",
title: "American Traditional Anchor",
category: "Traditional",
},
{ id: 4, image: "/colorful-traditional-bird-tattoo.jpg", title: "Traditional Bird", category: "Color Work" },
{ id: 5, image: "/placeholder-jmey3.png", title: "Traditional Eagle", category: "Traditional" },
{ id: 6, image: "/placeholder-ah8n2.png", title: "Neo-Traditional Snake", category: "Neo-Traditional" },
{ id: 7, image: "/placeholder-s803z.png", title: "Traditional Panther", category: "Color Work" },
{ id: 8, image: "/placeholder-e6fqm.png", title: "Traditional Ship", category: "Traditional" },
{ id: 9, image: "/placeholder-qrydh.png", title: "Neo-Traditional Fox", category: "Neo-Traditional" },
{ id: 10, image: "/placeholder-s31fj.png", title: "Traditional Dagger", category: "Traditional" },
{ id: 11, image: "/placeholder-xzjye.png", title: "Traditional Butterfly", category: "Color Work" },
{ id: 12, image: "/placeholder-mjx9t.png", title: "Neo-Traditional Deer", category: "Neo-Traditional" },
{ id: 13, image: "/placeholder-882fw.png", title: "Traditional Skull", category: "Traditional" },
{ id: 14, image: "/placeholder-0h0qb.png", title: "Traditional Lighthouse", category: "Traditional" },
{ id: 15, image: "/placeholder-mykqu.png", title: "Neo-Traditional Octopus", category: "Neo-Traditional" },
{ id: 16, image: "/placeholder-jk026.png", title: "Traditional Tiger", category: "Color Work" },
{ id: 17, image: "/placeholder-ju7df.png", title: "Traditional Swallow", category: "Traditional" },
{ id: 18, image: "/placeholder-r6l7b.png", title: "Neo-Traditional Moon", category: "Neo-Traditional" },
{ id: 19, image: "/placeholder-lh3ki.png", title: "Traditional Heart", category: "Traditional" },
{ id: 20, image: "/placeholder.svg?height=400&width=300", title: "Traditional Koi", category: "Color Work" },
],
testimonials: [
{
name: "Jessica M.",
rating: 5,
text: "Sarah created the most beautiful traditional rose tattoo for me. Her attention to detail and color work is incredible!",
},
{
name: "Mike R.",
rating: 5,
text: "Amazing artist! The neo-traditional piece she did exceeded all my expectations. Highly recommend!",
},
],
},
// Add other artists data here...
}
interface ArtistPortfolioProps {
artistId: string
}
export function ArtistPortfolio({ artistId }: ArtistPortfolioProps) {
const [selectedCategory, setSelectedCategory] = useState("All")
const [selectedImage, setSelectedImage] = useState<number | null>(null)
const [scrollY, setScrollY] = useState(0)
const artist = artistsData[artistId as keyof typeof artistsData]
useEffect(() => {
const handleScroll = () => setScrollY(window.scrollY)
window.addEventListener("scroll", handleScroll)
return () => window.removeEventListener("scroll", handleScroll)
}, [])
if (!artist) {
return (
<div className="container mx-auto px-4 py-20 text-center">
<h1 className="text-2xl font-bold mb-4">Artist not found</h1>
<Button asChild>
<Link href="/artists">Back to Artists</Link>
</Button>
</div>
)
}
const categories = ["All", ...Array.from(new Set(artist.portfolio.map((item) => item.category)))]
const filteredPortfolio =
selectedCategory === "All"
? artist.portfolio
: artist.portfolio.filter((item) => item.category === selectedCategory)
return (
<div className="min-h-screen bg-black text-white">
{/* Back Button */}
<div className="fixed top-6 right-8 z-40">
<Button
asChild
variant="ghost"
className="text-white hover:bg-white/20 border border-white/30 backdrop-blur-sm bg-black/40 hover:text-white"
>
<Link href="/artists">
<ArrowLeft className="w-4 h-4 mr-2" />
Back to Artists
</Link>
</Button>
</div>
{/* Hero Section with Split Screen */}
<section className="relative h-screen overflow-hidden -mt-20">
{/* Left Side - Artist Image */}
<div className="absolute left-0 top-0 w-1/2 h-full" style={{ transform: `translateY(${scrollY * 0.3}px)` }}>
<div className="relative w-full h-full">
<img src={artist.image || "/placeholder.svg"} alt={artist.name} className="w-full h-full object-cover" />
<div className="absolute inset-0 bg-gradient-to-r from-transparent to-black/50" />
<div className="absolute top-28 left-8">
<Badge
variant={artist.availability === "Available" ? "default" : "secondary"}
className="bg-white/20 backdrop-blur-sm text-white border-white/30"
>
{artist.availability}
</Badge>
</div>
</div>
</div>
{/* Right Side - Artist Info */}
<div
className="absolute right-0 top-0 w-1/2 h-full flex items-center"
style={{ transform: `translateY(${scrollY * -0.2}px)` }}
>
<div className="px-16 py-20">
<div className="mb-8">
<h1 className="font-playfair text-6xl font-bold mb-4 text-balance leading-tight">{artist.name}</h1>
<p className="text-2xl text-gray-300 mb-6">{artist.specialty}</p>
<div className="flex items-center space-x-2 mb-6">
<Star className="w-6 h-6 fill-yellow-400 text-yellow-400" />
<span className="font-medium text-xl">{artist.rating}</span>
<span className="text-gray-400">({artist.reviews} reviews)</span>
</div>
</div>
<p className="text-gray-300 mb-8 leading-relaxed text-lg max-w-lg">{artist.bio}</p>
<div className="grid grid-cols-1 gap-4 mb-8">
<div className="flex items-center space-x-3">
<Calendar className="w-5 h-5 text-gray-400" />
<span className="text-gray-300">{artist.experience} experience</span>
</div>
<div className="flex items-center space-x-3">
<MapPin className="w-5 h-5 text-gray-400" />
<span className="text-gray-300">{artist.location}</span>
</div>
<div className="flex items-center space-x-3">
<Instagram className="w-5 h-5 text-gray-400" />
<span className="text-gray-300">{artist.instagram}</span>
</div>
</div>
<div className="mb-8">
<h3 className="font-semibold mb-4 text-lg">Specializes in:</h3>
<div className="flex flex-wrap gap-2">
{artist.styles.map((style) => (
<Badge key={style} variant="outline" className="border-white/30 text-white">
{style}
</Badge>
))}
</div>
</div>
<div className="flex space-x-4">
<Button asChild size="lg" className="bg-white text-black hover:bg-gray-100 !text-black hover:!text-black">
<Link href={`/artists/${artist.id}/book`}>Book Appointment</Link>
</Button>
<Button
variant="outline"
size="lg"
className="border-white/30 text-white hover:bg-white hover:text-black bg-transparent"
>
Get Consultation
</Button>
</div>
</div>
</div>
{/* Curved Border */}
<div className="absolute bottom-0 left-0 right-0 h-32 bg-black">
<svg className="absolute top-0 left-0 w-full h-32" viewBox="0 0 1200 120" preserveAspectRatio="none">
<path d="M0,0 C300,120 900,120 1200,0 L1200,120 L0,120 Z" fill="black" />
</svg>
</div>
</section>
{/* Portfolio Section with Split Screen Layout */}
<section className="relative bg-black">
<div className="flex min-h-screen">
{/* Left Side - Portfolio Grid */}
<div className="w-2/3 p-8 overflow-y-auto">
<div className="grid grid-cols-2 gap-6">
{filteredPortfolio.map((item, index) => (
<div key={item.id} className="group cursor-pointer" onClick={() => setSelectedImage(item.id)}>
<div className="relative overflow-hidden bg-gray-900 aspect-[4/5] hover:scale-[1.02] transition-all duration-500">
<img
src={item.image || "/placeholder.svg"}
alt={item.title}
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-700"
/>
<div className="absolute inset-0 bg-black/60 opacity-0 group-hover:opacity-100 transition-all duration-500 flex items-center justify-center">
<div className="text-center">
<ExternalLink className="w-8 h-8 text-white mb-2 mx-auto" />
<p className="text-white font-medium">{item.title}</p>
</div>
</div>
</div>
</div>
))}
</div>
</div>
{/* Right Side - Sticky Header and Info */}
<div className="w-1/3 sticky top-0 h-screen flex flex-col justify-center p-12 bg-black border-l border-white/10">
<div>
<div className="flex items-baseline justify-between mb-8">
<h2 className="font-playfair text-5xl font-bold text-balance">Featured Work</h2>
<span className="text-6xl font-light text-gray-500">{filteredPortfolio.length}</span>
</div>
<div className="mb-12">
<Button
variant="outline"
className="border-white/30 text-white hover:bg-white hover:text-black bg-transparent mb-8"
>
View All
</Button>
<p className="text-gray-300 leading-relaxed text-lg mb-8">
Explore {artist.name}'s portfolio showcasing {artist.experience} of expertise in{" "}
{artist.specialty.toLowerCase()}. Each piece represents a unique collaboration between artist and
client.
</p>
</div>
{/* Category Filter */}
<div className="mb-8">
<h3 className="font-semibold mb-4 text-lg">Filter by Style</h3>
<div className="flex flex-col gap-2">
{categories.map((category) => (
<Button
key={category}
variant="ghost"
onClick={() => setSelectedCategory(category)}
className={`justify-start text-left hover:bg-white/10 ${
selectedCategory === category ? "text-white bg-white/10" : "text-gray-400 hover:text-white"
}`}
>
{category}
<span className="ml-auto text-sm">
{category === "All"
? artist.portfolio.length
: artist.portfolio.filter((item) => item.category === category).length}
</span>
</Button>
))}
</div>
</div>
{/* Quick Stats */}
<div className="border-t border-white/10 pt-8">
<div className="grid grid-cols-2 gap-4 text-center">
<div>
<div className="text-2xl font-bold">{artist.portfolio.length}</div>
<div className="text-sm text-gray-400">Pieces</div>
</div>
<div>
<div className="text-2xl font-bold">{artist.rating}</div>
<div className="text-sm text-gray-400">Rating</div>
</div>
</div>
</div>
</div>
</div>
</div>
</section>
{/* Reviews Section */}
<section className="relative py-32 bg-black border-t border-white/10 overflow-hidden">
<div className="container mx-auto px-8 mb-16">
<div className="text-center">
<h2 className="font-playfair text-5xl font-bold mb-4 text-balance">What Clients Say</h2>
<div className="w-16 h-0.5 bg-white mx-auto" />
</div>
</div>
<div className="relative">
<div className="flex animate-marquee-smooth space-x-16 hover:pause-smooth">
{/* Duplicate testimonials for seamless loop */}
{[...artist.testimonials, ...artist.testimonials, ...artist.testimonials, ...artist.testimonials].map(
(testimonial, index) => (
<div key={index} className="flex-shrink-0 min-w-[500px] px-8">
{/* Enhanced spotlight background with stronger separation */}
<div className="relative group">
<div className="absolute inset-0 bg-gradient-radial from-white/8 via-white/3 to-transparent rounded-2xl blur-lg scale-110" />
<div className="absolute inset-0 bg-gradient-to-br from-white/5 to-transparent rounded-2xl" />
<div className="relative bg-black/40 backdrop-blur-sm border border-white/10 rounded-2xl p-8 hover:border-white/20 transition-all duration-500 hover:bg-black/60">
<div className="flex items-center space-x-1 mb-4">
{[...Array(testimonial.rating)].map((_, i) => (
<Star key={i} className="w-4 h-4 fill-white text-white" />
))}
</div>
<blockquote className="text-white text-xl font-light leading-relaxed mb-4 italic">
"{testimonial.text}"
</blockquote>
<cite className="text-gray-400 text-sm font-medium not-italic"> {testimonial.name}</cite>
</div>
</div>
</div>
),
)}
</div>
</div>
</section>
{/* Contact Section */}
<section className="relative py-32 bg-black">
<div className="container mx-auto px-8 text-center">
<div className="max-w-3xl mx-auto">
<h2 className="font-playfair text-5xl font-bold mb-6 text-balance">Ready to Get Started?</h2>
<p className="text-gray-300 text-xl leading-relaxed mb-12">
Book a consultation with {artist.name} to discuss your next tattoo. Whether you're looking for a
traditional piece or something with a modern twist, let's bring your vision to life.
</p>
<div className="flex flex-col sm:flex-row gap-6 justify-center items-center">
<Button
asChild
size="lg"
className="bg-white text-black hover:bg-gray-100 !text-black hover:!text-black px-12 py-4 text-lg"
>
<Link href={`/artists/${artist.id}/book`}>Book Now</Link>
</Button>
<Button
variant="outline"
size="lg"
className="border-white/30 text-white hover:bg-white hover:text-black bg-transparent px-12 py-4 text-lg"
>
Get Consultation
</Button>
</div>
<div className="mt-16 pt-16 border-t border-white/10">
<div className="grid grid-cols-1 md:grid-cols-3 gap-8 text-center">
<div>
<div className="text-3xl font-bold mb-2">{artist.experience}</div>
<div className="text-gray-400">Experience</div>
</div>
<div>
<div className="text-3xl font-bold mb-2">{artist.reviews}+</div>
<div className="text-gray-400">Happy Clients</div>
</div>
<div>
<div className="text-3xl font-bold mb-2">{artist.rating}/5</div>
<div className="text-gray-400">Average Rating</div>
</div>
</div>
</div>
</div>
</div>
</section>
{/* Image Modal */}
{selectedImage && (
<div
className="fixed inset-0 bg-black/95 z-50 flex items-center justify-center p-4"
onClick={() => setSelectedImage(null)}
>
<div className="relative max-w-6xl max-h-full">
<img
src={filteredPortfolio.find((item) => item.id === selectedImage)?.image || "/placeholder.svg"}
alt="Portfolio piece"
className="max-w-full max-h-full object-contain"
/>
<Button
variant="ghost"
size="sm"
className="absolute top-4 right-4 text-white hover:bg-white/20 text-2xl"
onClick={() => setSelectedImage(null)}
>
</Button>
</div>
</div>
)}
</div>
)
}

210
components/artists-grid.tsx Normal file
View File

@ -0,0 +1,210 @@
"use client"
import { useState } from "react"
import { Button } from "@/components/ui/button"
import { Card, CardContent } from "@/components/ui/card"
import { Badge } from "@/components/ui/badge"
import Link from "next/link"
import { Star, MapPin, Calendar } from "lucide-react"
const artists = [
{
id: "1",
name: "Sarah Chen",
specialty: "Traditional & Neo-Traditional",
image: "/professional-female-tattoo-artist-with-traditional.jpg",
bio: "Specializing in bold traditional designs with a modern twist. Sarah brings 8 years of experience creating vibrant, timeless tattoos.",
experience: "8 years",
rating: 4.9,
reviews: 127,
location: "Studio A",
availability: "Available",
styles: ["Traditional", "Neo-Traditional", "American Traditional", "Color Work"],
portfolio: [
"/traditional-rose-tattoo-with-bold-colors.jpg",
"/neo-traditional-wolf-tattoo-design.jpg",
"/american-traditional-anchor-tattoo.jpg",
"/colorful-traditional-bird-tattoo.jpg",
],
},
{
id: "2",
name: "Marcus Rodriguez",
specialty: "Realism & Portraits",
image: "/professional-male-tattoo-artist-specializing-in-re.jpg",
bio: "Master of photorealistic tattoos and detailed portrait work. Marcus has perfected the art of bringing photographs to life on skin.",
experience: "12 years",
rating: 5.0,
reviews: 89,
location: "Studio B",
availability: "Booked until March",
styles: ["Realism", "Portraits", "Black & Grey", "Photorealism"],
portfolio: [
"/photorealistic-portrait-tattoo-black-and-grey.jpg",
"/realistic-animal-tattoo-detailed-shading.jpg",
"/black-and-grey-portrait-tattoo-masterpiece.jpg",
"/hyperrealistic-eye-tattoo-design.jpg",
],
},
{
id: "3",
name: "Luna Kim",
specialty: "Fine Line & Minimalist",
image: "/professional-female-tattoo-artist-with-delicate-fi.jpg",
bio: "Creating elegant, minimalist designs with precision and grace. Luna's delicate touch brings subtle beauty to every piece.",
experience: "6 years",
rating: 4.8,
reviews: 156,
location: "Studio C",
availability: "Available",
styles: ["Fine Line", "Minimalist", "Geometric", "Botanical"],
portfolio: [
"/delicate-fine-line-flower-tattoo.jpg",
"/minimalist-geometric-tattoo-design.jpg",
"/fine-line-botanical-tattoo-elegant.jpg",
"/simple-line-work-tattoo-artistic.jpg",
],
},
{
id: "4",
name: "Jake Thompson",
specialty: "Japanese & Oriental",
image: "/professional-male-tattoo-artist-with-japanese-styl.jpg",
bio: "Traditional Japanese tattooing with authentic techniques passed down through generations. Jake honors the ancient art form.",
experience: "15 years",
rating: 4.9,
reviews: 203,
location: "Studio D",
availability: "Limited slots",
styles: ["Japanese", "Oriental", "Irezumi", "Traditional Japanese"],
portfolio: [
"/traditional-japanese-dragon-tattoo-sleeve.jpg",
"/japanese-koi-fish-tattoo-colorful.jpg",
"/oriental-cherry-blossom-tattoo-design.jpg",
"/japanese-samurai-tattoo-traditional.jpg",
],
},
]
const specialties = ["All", "Traditional", "Realism", "Fine Line", "Japanese", "Portraits", "Minimalist"]
export function ArtistsGrid() {
const [selectedSpecialty, setSelectedSpecialty] = useState("All")
const filteredArtists =
selectedSpecialty === "All"
? artists
: artists.filter((artist) =>
artist.styles.some((style) => style.toLowerCase().includes(selectedSpecialty.toLowerCase())),
)
return (
<section className="py-20">
<div className="container mx-auto px-4">
<div className="text-center mb-16">
<h1 className="font-playfair text-4xl md:text-6xl font-bold mb-6 text-balance">Our Artists</h1>
<p className="text-lg text-muted-foreground max-w-2xl mx-auto text-balance">
Meet our talented team of tattoo artists, each bringing their unique style and years of experience to create
your perfect tattoo.
</p>
</div>
{/* Filter Buttons */}
<div className="flex flex-wrap justify-center gap-4 mb-12">
{specialties.map((specialty) => (
<Button
key={specialty}
variant={selectedSpecialty === specialty ? "default" : "outline"}
onClick={() => setSelectedSpecialty(specialty)}
className="px-6 py-2"
>
{specialty}
</Button>
))}
</div>
{/* Artists Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
{filteredArtists.map((artist) => (
<Card key={artist.id} className="group hover:shadow-xl transition-all duration-300 overflow-hidden">
<div className="md:flex">
{/* Artist Image */}
<div className="relative md:w-1/3 h-64 md:h-auto 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">
<Badge variant={artist.availability === "Available" ? "default" : "secondary"}>
{artist.availability}
</Badge>
</div>
</div>
{/* Artist Info */}
<CardContent className="md:w-2/3 p-6">
<div className="flex items-start justify-between mb-4">
<div>
<h3 className="font-playfair text-2xl font-bold mb-1">{artist.name}</h3>
<p className="text-primary font-medium">{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" />
<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>
<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" />
<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" />
<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">
{artist.styles.slice(0, 3).map((style) => (
<Badge key={style} variant="outline" className="text-xs">
{style}
</Badge>
))}
{artist.styles.length > 3 && (
<Badge variant="outline" className="text-xs">
+{artist.styles.length - 3} more
</Badge>
)}
</div>
</div>
{/* Action Buttons */}
<div className="flex space-x-3">
<Button asChild className="flex-1">
<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"
>
<Link href={`/artists/${artist.id}/book`}>Book Now</Link>
</Button>
</div>
</CardContent>
</div>
</Card>
))}
</div>
</div>
</section>
)
}

View File

@ -0,0 +1,432 @@
"use client"
import { useState, useEffect, useRef } from "react"
import { Button } from "@/components/ui/button"
import { Badge } from "@/components/ui/badge"
import Link from "next/link"
import { artists } from "@/data/artists"
const specialties = [
"All",
"Traditional",
"Realism",
"Fine Line",
"Japanese",
"Geometric",
"Blackwork",
"Watercolor",
"Illustrative",
"Cover-ups",
"Neo-Traditional",
"Anime",
]
export function ArtistsPageSection() {
const [selectedSpecialty, setSelectedSpecialty] = useState("All")
const [visibleCards, setVisibleCards] = useState<number[]>([])
const [scrollY, setScrollY] = useState(0)
const sectionRef = useRef<HTMLElement>(null)
const leftColumnRef = useRef<HTMLDivElement>(null)
const centerColumnRef = useRef<HTMLDivElement>(null)
const rightColumnRef = useRef<HTMLDivElement>(null)
const filteredArtists =
selectedSpecialty === "All"
? artists
: artists.filter((artist) =>
artist.styles.some((style) => style.toLowerCase().includes(selectedSpecialty.toLowerCase())),
)
useEffect(() => {
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.1, rootMargin: "0px 0px -50px 0px" },
)
const cards = sectionRef.current?.querySelectorAll("[data-index]")
cards?.forEach((card) => observer.observe(card))
return () => observer.disconnect()
}, [filteredArtists])
useEffect(() => {
let ticking = false
const handleScroll = () => {
if (!ticking) {
requestAnimationFrame(() => {
const scrollTop = window.pageYOffset
setScrollY(scrollTop)
ticking = false
})
ticking = true
}
}
window.addEventListener("scroll", handleScroll, { passive: true })
return () => window.removeEventListener("scroll", handleScroll)
}, [])
useEffect(() => {
if (leftColumnRef.current && centerColumnRef.current && rightColumnRef.current) {
const sectionTop = sectionRef.current?.offsetTop || 0
const relativeScroll = scrollY - sectionTop
leftColumnRef.current.style.transform = `translateY(${relativeScroll * -0.05}px)`
centerColumnRef.current.style.transform = `translateY(0px)`
rightColumnRef.current.style.transform = `translateY(${relativeScroll * 0.05}px)`
const leftImages = leftColumnRef.current.querySelectorAll(".artist-image")
const centerImages = centerColumnRef.current.querySelectorAll(".artist-image")
const rightImages = rightColumnRef.current.querySelectorAll(".artist-image")
leftImages.forEach((img) => {
;(img as HTMLElement).style.transform = `translateY(${relativeScroll * -0.02}px)`
})
centerImages.forEach((img) => {
;(img as HTMLElement).style.transform = `translateY(${relativeScroll * -0.015}px)`
})
rightImages.forEach((img) => {
;(img as HTMLElement).style.transform = `translateY(${relativeScroll * -0.01}px)`
})
}
}, [scrollY])
const leftColumn = filteredArtists.filter((_, index) => index % 3 === 0)
const centerColumn = filteredArtists.filter((_, index) => index % 3 === 1)
const rightColumn = filteredArtists.filter((_, index) => index % 3 === 2)
return (
<section ref={sectionRef} className="relative overflow-hidden bg-black min-h-screen">
{/* Background */}
<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 pt-24 pb-16 px-8 lg:px-16">
<div className="max-w-screen-2xl mx-auto">
<div className="grid lg:grid-cols-3 gap-12 items-end mb-16">
<div className="lg:col-span-2">
<h1 className="text-6xl lg:text-8xl font-bold tracking-tight mb-6 text-white">OUR ARTISTS</h1>
<p className="text-xl text-gray-200 leading-relaxed max-w-2xl">
Meet 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-8 py-4 text-lg font-medium tracking-wide shadow-lg"
>
<Link href="/book">BOOK CONSULTATION</Link>
</Button>
</div>
</div>
{/* Filter Buttons */}
<div className="flex flex-wrap justify-center gap-4 mb-12">
{specialties.map((specialty) => (
<Button
key={specialty}
variant={selectedSpecialty === specialty ? "default" : "outline"}
onClick={() => setSelectedSpecialty(specialty)}
className={`px-6 py-2 ${
selectedSpecialty === specialty
? "bg-white text-black hover:bg-gray-100"
: "border-white/30 text-white hover:bg-white hover:text-black bg-transparent"
}`}
>
{specialty}
</Button>
))}
</div>
</div>
</div>
{/* Artists Grid with Parallax */}
<div className="relative z-10 px-8 lg:px-16 pb-20">
<div className="max-w-screen-2xl mx-auto">
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
<div ref={leftColumnRef} className="space-y-8">
{leftColumn.map((artist, index) => (
<div
key={artist.id}
data-index={filteredArtists.indexOf(artist)}
className={`group transition-all duration-700 ${
visibleCards.includes(filteredArtists.indexOf(artist))
? "opacity-100 translate-y-0"
: "opacity-0 translate-y-8"
}`}
style={{
transitionDelay: `${filteredArtists.indexOf(artist) * 100}ms`,
}}
>
<div className="relative h-[600px] overflow-hidden rounded-lg shadow-2xl">
<div className="absolute inset-0 bg-black artist-image">
<div className="absolute left-0 top-0 w-1/2 h-full">
<img
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
src={artist.workImages?.[0] || "/placeholder.svg"}
alt={`${artist.name} tattoo work`}
className="w-full h-full object-cover scale-110"
/>
</div>
</div>
<div className="absolute inset-0 z-20 group-hover:bg-black/20 transition-all duration-500">
<div className="absolute top-4 left-4 flex gap-2">
<Badge className="text-xs font-medium tracking-widest text-white uppercase bg-black/80 backdrop-blur-sm border-0">
{artist.experience}
</Badge>
<Badge
className={`text-xs font-medium tracking-widest uppercase backdrop-blur-sm border-0 ${
artist.availability === "Available"
? "bg-green-600/80 text-white"
: "bg-red-600/80 text-white"
}`}
>
{artist.availability}
</Badge>
</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">
<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 text-white/80 mb-4 leading-relaxed">{artist.bio}</p>
<div className="flex items-center gap-4 mb-4 text-sm text-white/70">
<span>
{artist.rating} ({artist.reviews} reviews)
</span>
</div>
<div className="flex gap-2">
<Button
asChild
size="sm"
className="bg-white text-black hover:bg-gray-100 text-xs font-medium tracking-wide flex-1"
>
<Link href={`/artists/${artist.id}`}>PORTFOLIO</Link>
</Button>
<Button
asChild
size="sm"
className="bg-white text-black hover:bg-gray-100 text-xs font-medium tracking-wide flex-1"
>
<Link href="/book">BOOK</Link>
</Button>
</div>
</div>
</div>
</div>
</div>
))}
</div>
<div ref={centerColumnRef} className="space-y-8">
{centerColumn.map((artist, index) => (
<div
key={artist.id}
data-index={filteredArtists.indexOf(artist)}
className={`group transition-all duration-700 ${
visibleCards.includes(filteredArtists.indexOf(artist))
? "opacity-100 translate-y-0"
: "opacity-0 translate-y-8"
}`}
style={{
transitionDelay: `${filteredArtists.indexOf(artist) * 100}ms`,
}}
>
<div className="relative h-[600px] overflow-hidden rounded-lg shadow-2xl">
<div className="absolute inset-0 bg-black artist-image">
<div className="absolute left-0 top-0 w-1/2 h-full">
<img
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
src={artist.workImages?.[0] || "/placeholder.svg"}
alt={`${artist.name} tattoo work`}
className="w-full h-full object-cover scale-110"
/>
</div>
</div>
<div className="absolute inset-0 z-20 group-hover:bg-black/20 transition-all duration-500">
<div className="absolute top-4 left-4 flex gap-2">
<Badge className="text-xs font-medium tracking-widest text-white uppercase bg-black/80 backdrop-blur-sm border-0">
{artist.experience}
</Badge>
<Badge
className={`text-xs font-medium tracking-widest uppercase backdrop-blur-sm border-0 ${
artist.availability === "Available"
? "bg-green-600/80 text-white"
: "bg-red-600/80 text-white"
}`}
>
{artist.availability}
</Badge>
</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">
<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 text-white/80 mb-4 leading-relaxed">{artist.bio}</p>
<div className="flex items-center gap-4 mb-4 text-sm text-white/70">
<span>
{artist.rating} ({artist.reviews} reviews)
</span>
</div>
<div className="flex gap-2">
<Button
asChild
size="sm"
className="bg-white text-black hover:bg-gray-100 text-xs font-medium tracking-wide flex-1"
>
<Link href={`/artists/${artist.id}`}>PORTFOLIO</Link>
</Button>
<Button
asChild
size="sm"
className="bg-white text-black hover:bg-gray-100 text-xs font-medium tracking-wide flex-1"
>
<Link href="/book">BOOK</Link>
</Button>
</div>
</div>
</div>
</div>
</div>
))}
</div>
<div ref={rightColumnRef} className="space-y-8">
{rightColumn.map((artist, index) => (
<div
key={artist.id}
data-index={filteredArtists.indexOf(artist)}
className={`group transition-all duration-700 ${
visibleCards.includes(filteredArtists.indexOf(artist))
? "opacity-100 translate-y-0"
: "opacity-0 translate-y-8"
}`}
style={{
transitionDelay: `${filteredArtists.indexOf(artist) * 100}ms`,
}}
>
<div className="relative h-[600px] overflow-hidden rounded-lg shadow-2xl">
<div className="absolute inset-0 bg-black artist-image">
<div className="absolute left-0 top-0 w-1/2 h-full">
<img
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
src={artist.workImages?.[0] || "/placeholder.svg"}
alt={`${artist.name} tattoo work`}
className="w-full h-full object-cover scale-110"
/>
</div>
</div>
<div className="absolute inset-0 z-20 group-hover:bg-black/20 transition-all duration-500">
<div className="absolute top-4 left-4 flex gap-2">
<Badge className="text-xs font-medium tracking-widest text-white uppercase bg-black/80 backdrop-blur-sm border-0">
{artist.experience}
</Badge>
<Badge
className={`text-xs font-medium tracking-widest uppercase backdrop-blur-sm border-0 ${
artist.availability === "Available"
? "bg-green-600/80 text-white"
: "bg-red-600/80 text-white"
}`}
>
{artist.availability}
</Badge>
</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">
<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 text-white/80 mb-4 leading-relaxed">{artist.bio}</p>
<div className="flex items-center gap-4 mb-4 text-sm text-white/70">
<span>
{artist.rating} ({artist.reviews} reviews)
</span>
</div>
<div className="flex gap-2">
<Button
asChild
size="sm"
className="bg-white text-black hover:bg-gray-100 text-xs font-medium tracking-wide flex-1"
>
<Link href={`/artists/${artist.id}`}>PORTFOLIO</Link>
</Button>
<Button
asChild
size="sm"
className="bg-white text-black hover:bg-gray-100 text-xs font-medium tracking-wide flex-1"
>
<Link href="/book">BOOK</Link>
</Button>
</div>
</div>
</div>
</div>
</div>
))}
</div>
</div>
</div>
</div>
{/* Call to Action */}
<div className="bg-black text-white py-20 px-8 lg:px-16">
<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>
<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"
>
<Link href="/book">START NOW</Link>
</Button>
</div>
</div>
</section>
)
}

View File

@ -0,0 +1,341 @@
"use client"
import { useState, useEffect, useRef } from "react"
import { Button } from "@/components/ui/button"
import Link from "next/link"
import { artists } from "@/data/artists"
export function ArtistsSection() {
const [visibleCards, setVisibleCards] = useState<number[]>([])
const [scrollY, setScrollY] = useState(0)
const sectionRef = useRef<HTMLElement>(null)
const leftColumnRef = useRef<HTMLDivElement>(null)
const centerColumnRef = useRef<HTMLDivElement>(null)
const rightColumnRef = useRef<HTMLDivElement>(null)
useEffect(() => {
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.1, rootMargin: "0px 0px -50px 0px" },
)
const cards = sectionRef.current?.querySelectorAll("[data-index]")
cards?.forEach((card) => observer.observe(card))
return () => observer.disconnect()
}, [])
useEffect(() => {
let ticking = false
const handleScroll = () => {
if (!ticking) {
requestAnimationFrame(() => {
const scrollTop = window.pageYOffset
setScrollY(scrollTop)
ticking = false
})
ticking = true
}
}
window.addEventListener("scroll", handleScroll, { passive: true })
return () => window.removeEventListener("scroll", handleScroll)
}, [])
useEffect(() => {
if (leftColumnRef.current && centerColumnRef.current && rightColumnRef.current) {
const sectionTop = sectionRef.current?.offsetTop || 0
const relativeScroll = scrollY - sectionTop
leftColumnRef.current.style.transform = `translateY(${relativeScroll * -0.05}px)`
centerColumnRef.current.style.transform = `translateY(0px)`
rightColumnRef.current.style.transform = `translateY(${relativeScroll * 0.05}px)`
const leftImages = leftColumnRef.current.querySelectorAll(".artist-image")
const centerImages = centerColumnRef.current.querySelectorAll(".artist-image")
const rightImages = rightColumnRef.current.querySelectorAll(".artist-image")
leftImages.forEach((img) => {
;(img as HTMLElement).style.transform = `translateY(${relativeScroll * -0.02}px)`
})
centerImages.forEach((img) => {
;(img as HTMLElement).style.transform = `translateY(${relativeScroll * -0.015}px)`
})
rightImages.forEach((img) => {
;(img as HTMLElement).style.transform = `translateY(${relativeScroll * -0.01}px)`
})
}
}, [scrollY])
const leftColumn = artists.filter((_, index) => index % 3 === 0)
const centerColumn = artists.filter((_, index) => index % 3 === 1)
const rightColumn = artists.filter((_, index) => index % 3 === 2)
return (
<section ref={sectionRef} id="artists" className="relative overflow-hidden bg-black">
<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>
<div className="relative z-10 py-16 px-8 lg:px-16">
<div className="max-w-screen-2xl mx-auto">
<div className="grid lg:grid-cols-3 gap-12 items-end mb-16">
<div className="lg:col-span-2">
<h2 className="text-6xl lg:text-8xl font-bold tracking-tight mb-6 text-white">ARTISTS</h2>
<p className="text-xl text-gray-200 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-8 py-4 text-lg font-medium tracking-wide shadow-lg"
>
<Link href="/book">BOOK CONSULTATION</Link>
</Button>
</div>
</div>
</div>
</div>
<div className="relative z-10 px-8 lg:px-16 pb-20">
<div className="max-w-screen-2xl mx-auto">
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
<div ref={leftColumnRef} className="space-y-8">
{leftColumn.map((artist, index) => (
<div
key={artist.id}
data-index={artists.indexOf(artist)}
className={`group transition-all duration-700 ${
visibleCards.includes(artists.indexOf(artist))
? "opacity-100 translate-y-0"
: "opacity-0 translate-y-8"
}`}
style={{
transitionDelay: `${artists.indexOf(artist) * 100}ms`,
}}
>
<div className="relative h-[600px] overflow-hidden rounded-lg shadow-2xl">
<div className="absolute inset-0 bg-black artist-image">
<div className="absolute left-0 top-0 w-1/2 h-full">
<img
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
src={artist.workImages?.[0] || "/placeholder.svg"}
alt={`${artist.name} tattoo work`}
className="w-full h-full object-cover scale-110"
/>
</div>
</div>
<div className="absolute inset-0 z-20 group-hover:bg-black/20 transition-all duration-500">
<div className="absolute top-4 left-4">
<span className="text-xs font-medium tracking-widest text-white uppercase bg-black/80 backdrop-blur-sm px-3 py-1 rounded-full">
{artist.experience}
</span>
</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">
<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 text-white/80 mb-4 leading-relaxed">{artist.bio}</p>
<div className="flex gap-2">
<Button
asChild
size="sm"
className="bg-white text-black hover:bg-gray-100 text-xs font-medium tracking-wide flex-1"
>
<Link href={`/artists/${artist.id}`}>PORTFOLIO</Link>
</Button>
<Button
asChild
size="sm"
className="bg-white text-black hover:bg-gray-100 text-xs font-medium tracking-wide flex-1"
>
<Link href="/book">BOOK</Link>
</Button>
</div>
</div>
</div>
</div>
</div>
))}
</div>
<div ref={centerColumnRef} className="space-y-8">
{centerColumn.map((artist, index) => (
<div
key={artist.id}
data-index={artists.indexOf(artist)}
className={`group transition-all duration-700 ${
visibleCards.includes(artists.indexOf(artist))
? "opacity-100 translate-y-0"
: "opacity-0 translate-y-8"
}`}
style={{
transitionDelay: `${artists.indexOf(artist) * 100}ms`,
}}
>
<div className="relative h-[600px] overflow-hidden rounded-lg shadow-2xl">
<div className="absolute inset-0 bg-black artist-image">
<div className="absolute left-0 top-0 w-1/2 h-full">
<img
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
src={artist.workImages?.[0] || "/placeholder.svg"}
alt={`${artist.name} tattoo work`}
className="w-full h-full object-cover scale-110"
/>
</div>
</div>
<div className="absolute inset-0 z-20 group-hover:bg-black/20 transition-all duration-500">
<div className="absolute top-4 left-4">
<span className="text-xs font-medium tracking-widest text-white uppercase bg-black/80 backdrop-blur-sm px-3 py-1 rounded-full">
{artist.experience}
</span>
</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">
<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 text-white/80 mb-4 leading-relaxed">{artist.bio}</p>
<div className="flex gap-2">
<Button
asChild
size="sm"
className="bg-white text-black hover:bg-gray-100 text-xs font-medium tracking-wide flex-1"
>
<Link href={`/artists/${artist.id}`}>PORTFOLIO</Link>
</Button>
<Button
asChild
size="sm"
className="bg-white text-black hover:bg-gray-100 text-xs font-medium tracking-wide flex-1"
>
<Link href="/book">BOOK</Link>
</Button>
</div>
</div>
</div>
</div>
</div>
))}
</div>
<div ref={rightColumnRef} className="space-y-8">
{rightColumn.map((artist, index) => (
<div
key={artist.id}
data-index={artists.indexOf(artist)}
className={`group transition-all duration-700 ${
visibleCards.includes(artists.indexOf(artist))
? "opacity-100 translate-y-0"
: "opacity-0 translate-y-8"
}`}
style={{
transitionDelay: `${artists.indexOf(artist) * 100}ms`,
}}
>
<div className="relative h-[600px] overflow-hidden rounded-lg shadow-2xl">
<div className="absolute inset-0 bg-black artist-image">
<div className="absolute left-0 top-0 w-1/2 h-full">
<img
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
src={artist.workImages?.[0] || "/placeholder.svg"}
alt={`${artist.name} tattoo work`}
className="w-full h-full object-cover scale-110"
/>
</div>
</div>
<div className="absolute inset-0 z-20 group-hover:bg-black/20 transition-all duration-500">
<div className="absolute top-4 left-4">
<span className="text-xs font-medium tracking-widest text-white uppercase bg-black/80 backdrop-blur-sm px-3 py-1 rounded-full">
{artist.experience}
</span>
</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">
<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 text-white/80 mb-4 leading-relaxed">{artist.bio}</p>
<div className="flex gap-2">
<Button
asChild
size="sm"
className="bg-white text-black hover:bg-gray-100 text-xs font-medium tracking-wide flex-1"
>
<Link href={`/artists/${artist.id}`}>PORTFOLIO</Link>
</Button>
<Button
asChild
size="sm"
className="bg-white text-black hover:bg-gray-100 text-xs font-medium tracking-wide flex-1"
>
<Link href="/book">BOOK</Link>
</Button>
</div>
</div>
</div>
</div>
</div>
))}
</div>
</div>
</div>
</div>
<div className="bg-black text-white py-20 px-8 lg:px-16">
<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>
<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"
>
<Link href="/book">START NOW</Link>
</Button>
</div>
</div>
</section>
)
}

579
components/booking-form.tsx Normal file
View File

@ -0,0 +1,579 @@
"use client"
import type React from "react"
import { useState } from "react"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Input } from "@/components/ui/input"
import { Textarea } from "@/components/ui/textarea"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Checkbox } from "@/components/ui/checkbox"
import { Calendar } from "@/components/ui/calendar"
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"
import { CalendarIcon, DollarSign, User, MessageSquare } from "lucide-react"
import { format } from "date-fns"
import Link from "next/link"
import { artists } from "@/data/artists"
const timeSlots = ["10:00 AM", "11:00 AM", "12:00 PM", "1:00 PM", "2:00 PM", "3:00 PM", "4:00 PM", "5:00 PM", "6:00 PM"]
const tattooSizes = [
{ size: "Small (2-4 inches)", duration: "1-2 hours", price: "150-300" },
{ size: "Medium (4-6 inches)", duration: "2-4 hours", price: "300-600" },
{ size: "Large (6+ inches)", duration: "4-6 hours", price: "600-1000" },
{ size: "Full Session", duration: "6-8 hours", price: "1000-1500" },
]
interface BookingFormProps {
artistId?: string
}
export function BookingForm({ artistId }: BookingFormProps) {
const [step, setStep] = useState(1)
const [selectedDate, setSelectedDate] = useState<Date>()
const [formData, setFormData] = useState({
// Personal Info
firstName: "",
lastName: "",
email: "",
phone: "",
age: "",
// Appointment Details
artistId: artistId || "",
preferredDate: "",
preferredTime: "",
alternateDate: "",
alternateTime: "",
// Tattoo Details
tattooDescription: "",
tattooSize: "",
placement: "",
isFirstTattoo: false,
hasAllergies: false,
allergyDetails: "",
referenceImages: "",
// Additional Info
specialRequests: "",
depositAmount: 100,
agreeToTerms: false,
agreeToDeposit: false,
})
const selectedArtist = artists.find((a) => String(a.id) === formData.artistId || a.slug === formData.artistId)
const selectedSize = tattooSizes.find((size) => size.size === formData.tattooSize)
const handleInputChange = (field: string, value: any) => {
setFormData((prev) => ({ ...prev, [field]: value }))
}
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
// Handle form submission
console.log("Booking submitted:", formData)
// In a real app, this would send data to your backend
}
const nextStep = () => setStep((prev) => Math.min(prev + 1, 4))
const prevStep = () => setStep((prev) => Math.max(prev - 1, 1))
return (
<div className="container mx-auto px-4 py-8">
<div className="max-w-4xl mx-auto">
{/* Header */}
<div className="text-center mb-8">
<h1 className="font-playfair text-4xl md:text-5xl font-bold mb-4">Book Your Appointment</h1>
<p className="text-lg text-muted-foreground">
Let's create something amazing together. Fill out the form below to schedule your tattoo session.
</p>
</div>
{/* Progress Indicator */}
<div className="flex justify-center mb-8">
<div className="flex items-center space-x-4">
{[1, 2, 3, 4].map((stepNumber) => (
<div key={stepNumber} className="flex items-center">
<div
className={`w-8 h-8 rounded-full flex items-center justify-center text-sm font-medium ${
step >= stepNumber ? "bg-primary text-primary-foreground" : "bg-muted text-muted-foreground"
}`}
>
{stepNumber}
</div>
{stepNumber < 4 && (
<div className={`w-12 h-0.5 mx-2 ${step > stepNumber ? "bg-primary" : "bg-muted"}`} />
)}
</div>
))}
</div>
</div>
<form onSubmit={handleSubmit}>
{/* Step 1: Personal Information */}
{step === 1 && (
<Card>
<CardHeader>
<CardTitle className="flex items-center space-x-2">
<User className="w-5 h-5" />
<span>Personal Information</span>
</CardTitle>
</CardHeader>
<CardContent className="space-y-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium mb-2">First Name *</label>
<Input
value={formData.firstName}
onChange={(e) => handleInputChange("firstName", e.target.value)}
required
/>
</div>
<div>
<label className="block text-sm font-medium mb-2">Last Name *</label>
<Input
value={formData.lastName}
onChange={(e) => handleInputChange("lastName", e.target.value)}
required
/>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium mb-2">Email *</label>
<Input
type="email"
value={formData.email}
onChange={(e) => handleInputChange("email", e.target.value)}
required
/>
</div>
<div>
<label className="block text-sm font-medium mb-2">Phone *</label>
<Input
type="tel"
value={formData.phone}
onChange={(e) => handleInputChange("phone", e.target.value)}
required
/>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium mb-2">Age *</label>
<Input
type="number"
min="18"
value={formData.age}
onChange={(e) => handleInputChange("age", e.target.value)}
required
/>
<p className="text-xs text-muted-foreground mt-1">Must be 18 or older</p>
</div>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="firstTattoo"
checked={formData.isFirstTattoo}
onCheckedChange={(checked) => handleInputChange("isFirstTattoo", checked)}
/>
<label htmlFor="firstTattoo" className="text-sm">
This is my first tattoo
</label>
</div>
<div className="space-y-4">
<div className="flex items-center space-x-2">
<Checkbox
id="allergies"
checked={formData.hasAllergies}
onCheckedChange={(checked) => handleInputChange("hasAllergies", checked)}
/>
<label htmlFor="allergies" className="text-sm">
I have allergies or medical conditions
</label>
</div>
{formData.hasAllergies && (
<div>
<label className="block text-sm font-medium mb-2">Please specify:</label>
<Textarea
value={formData.allergyDetails}
onChange={(e) => handleInputChange("allergyDetails", e.target.value)}
placeholder="Please describe any allergies, medical conditions, or medications..."
/>
</div>
)}
</div>
</CardContent>
</Card>
)}
{/* Step 2: Artist & Scheduling */}
{step === 2 && (
<Card>
<CardHeader>
<CardTitle className="flex items-center space-x-2">
<CalendarIcon className="w-5 h-5" />
<span>Artist & Scheduling</span>
</CardTitle>
</CardHeader>
<CardContent className="space-y-6">
<div>
<label className="block text-sm font-medium mb-2">Select Artist *</label>
<Select value={formData.artistId} onValueChange={(value) => handleInputChange("artistId", value)}>
<SelectTrigger>
<SelectValue placeholder="Choose your preferred artist" />
</SelectTrigger>
<SelectContent>
{artists.map((artist) => (
<SelectItem key={artist.slug} value={artist.slug}>
<div className="flex items-center justify-between w-full">
<div>
<p className="font-medium">{artist.name}</p>
<p className="text-sm text-muted-foreground">{artist.specialty}</p>
</div>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{selectedArtist && (
<div className="p-4 bg-muted/50 rounded-lg">
<h4 className="font-medium mb-2">{selectedArtist.name}</h4>
<p className="text-sm text-muted-foreground mb-2">{selectedArtist.specialty}</p>
<p className="text-sm">
Experience: <span className="font-medium">{selectedArtist.experience}</span>
</p>
</div>
)}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label className="block text-sm font-medium mb-2">Preferred Date *</label>
<Popover>
<PopoverTrigger asChild>
<Button variant="outline" className="w-full justify-start text-left font-normal bg-transparent">
<CalendarIcon className="mr-2 h-4 w-4" />
{selectedDate ? format(selectedDate, "PPP") : "Pick a date"}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0">
<Calendar
mode="single"
selected={selectedDate}
onSelect={setSelectedDate}
initialFocus
disabled={(date) => date < new Date() || date.getDay() === 0} // Disable past dates and Sundays
/>
</PopoverContent>
</Popover>
</div>
<div>
<label className="block text-sm font-medium mb-2">Preferred Time *</label>
<Select
value={formData.preferredTime}
onValueChange={(value) => handleInputChange("preferredTime", value)}
>
<SelectTrigger>
<SelectValue placeholder="Select time" />
</SelectTrigger>
<SelectContent>
{timeSlots.map((time) => (
<SelectItem key={time} value={time}>
{time}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="p-4 bg-blue-50 rounded-lg">
<h4 className="font-medium mb-2 text-blue-900">Alternative Date & Time</h4>
<p className="text-sm text-blue-700 mb-4">
Please provide an alternative in case your preferred slot is unavailable.
</p>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium mb-2">Alternative Date</label>
<Input
type="date"
value={formData.alternateDate}
onChange={(e) => handleInputChange("alternateDate", e.target.value)}
min={new Date().toISOString().split("T")[0]}
/>
</div>
<div>
<label className="block text-sm font-medium mb-2">Alternative Time</label>
<Select
value={formData.alternateTime}
onValueChange={(value) => handleInputChange("alternateTime", value)}
>
<SelectTrigger>
<SelectValue placeholder="Select time" />
</SelectTrigger>
<SelectContent>
{timeSlots.map((time) => (
<SelectItem key={time} value={time}>
{time}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
</div>
</CardContent>
</Card>
)}
{/* Step 3: Tattoo Details */}
{step === 3 && (
<Card>
<CardHeader>
<CardTitle className="flex items-center space-x-2">
<MessageSquare className="w-5 h-5" />
<span>Tattoo Details</span>
</CardTitle>
</CardHeader>
<CardContent className="space-y-6">
<div>
<label className="block text-sm font-medium mb-2">Tattoo Description *</label>
<Textarea
value={formData.tattooDescription}
onChange={(e) => handleInputChange("tattooDescription", e.target.value)}
placeholder="Describe your tattoo idea in detail. Include style, colors, themes, and any specific elements you want..."
rows={4}
required
/>
</div>
<div>
<label className="block text-sm font-medium mb-2">Estimated Size & Duration *</label>
<Select value={formData.tattooSize} onValueChange={(value) => handleInputChange("tattooSize", value)}>
<SelectTrigger>
<SelectValue placeholder="Select tattoo size" />
</SelectTrigger>
<SelectContent>
{tattooSizes.map((size) => (
<SelectItem key={size.size} value={size.size}>
<div className="flex flex-col">
<span className="font-medium">{size.size}</span>
<span className="text-sm text-muted-foreground">
{size.duration} ${size.price}
</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{selectedSize && (
<div className="p-4 bg-muted/50 rounded-lg">
<h4 className="font-medium mb-2">Size Details</h4>
<div className="grid grid-cols-3 gap-4 text-sm">
<div>
<p className="text-muted-foreground">Size</p>
<p className="font-medium">{selectedSize.size}</p>
</div>
<div>
<p className="text-muted-foreground">Duration</p>
<p className="font-medium">{selectedSize.duration}</p>
</div>
<div>
<p className="text-muted-foreground">Price Range</p>
<p className="font-medium">${selectedSize.price}</p>
</div>
</div>
</div>
)}
<div>
<label className="block text-sm font-medium mb-2">Placement on Body *</label>
<Input
value={formData.placement}
onChange={(e) => handleInputChange("placement", e.target.value)}
placeholder="e.g., Upper arm, forearm, shoulder, back, etc."
required
/>
</div>
<div>
<label className="block text-sm font-medium mb-2">Reference Images</label>
<Input
type="file"
multiple
accept="image/*"
onChange={(e) => handleInputChange("referenceImages", e.target.files)}
/>
<p className="text-xs text-muted-foreground mt-1">
Upload reference images to help your artist understand your vision
</p>
</div>
<div>
<label className="block text-sm font-medium mb-2">Special Requests</label>
<Textarea
value={formData.specialRequests}
onChange={(e) => handleInputChange("specialRequests", e.target.value)}
placeholder="Any special requests, concerns, or additional information..."
rows={3}
/>
</div>
</CardContent>
</Card>
)}
{/* Step 4: Review & Deposit */}
{step === 4 && (
<Card>
<CardHeader>
<CardTitle className="flex items-center space-x-2">
<DollarSign className="w-5 h-5" />
<span>Review & Deposit</span>
</CardTitle>
</CardHeader>
<CardContent className="space-y-6">
{/* Booking Summary */}
<div className="p-6 bg-muted/50 rounded-lg">
<h3 className="font-playfair text-xl font-bold mb-4">Booking Summary</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-3">
<div>
<p className="text-sm text-muted-foreground">Client</p>
<p className="font-medium">
{formData.firstName} {formData.lastName}
</p>
</div>
<div>
<p className="text-sm text-muted-foreground">Email</p>
<p className="font-medium">{formData.email}</p>
</div>
<div>
<p className="text-sm text-muted-foreground">Phone</p>
<p className="font-medium">{formData.phone}</p>
</div>
</div>
<div className="space-y-3">
<div>
<p className="text-sm text-muted-foreground">Artist</p>
<p className="font-medium">{selectedArtist?.name}</p>
</div>
<div>
<p className="text-sm text-muted-foreground">Preferred Date</p>
<p className="font-medium">{selectedDate ? format(selectedDate, "PPP") : "Not selected"}</p>
</div>
<div>
<p className="text-sm text-muted-foreground">Preferred Time</p>
<p className="font-medium">{formData.preferredTime || "Not selected"}</p>
</div>
</div>
</div>
<div className="mt-6 pt-6 border-t">
<div>
<p className="text-sm text-muted-foreground">Tattoo Description</p>
<p className="font-medium">{formData.tattooDescription}</p>
</div>
<div className="mt-3">
<p className="text-sm text-muted-foreground">Size & Placement</p>
<p className="font-medium">
{formData.tattooSize} {formData.placement}
</p>
</div>
</div>
</div>
{/* Deposit Information */}
<div className="p-6 border-2 border-primary/20 rounded-lg">
<h3 className="font-semibold mb-4 flex items-center">
<DollarSign className="w-5 h-5 mr-2 text-primary" />
Deposit Required
</h3>
<p className="text-muted-foreground mb-4">
A deposit of <span className="font-bold text-primary">${formData.depositAmount}</span> is required
to secure your appointment. This deposit will be applied to your final tattoo cost.
</p>
<ul className="text-sm text-muted-foreground space-y-1">
<li> Deposit is non-refundable but transferable to future appointments</li>
<li> 48-hour notice required for rescheduling</li>
<li> Final pricing will be discussed during consultation</li>
</ul>
</div>
{/* Terms and Conditions */}
<div className="space-y-4">
<div className="flex items-start space-x-2">
<Checkbox
id="terms"
checked={formData.agreeToTerms}
onCheckedChange={(checked) => handleInputChange("agreeToTerms", checked)}
required
/>
<label htmlFor="terms" className="text-sm leading-relaxed">
I agree to the{" "}
<Link href="/terms" className="text-primary hover:underline">
Terms and Conditions
</Link>{" "}
and{" "}
<Link href="/privacy" className="text-primary hover:underline">
Privacy Policy
</Link>
</label>
</div>
<div className="flex items-start space-x-2">
<Checkbox
id="deposit"
checked={formData.agreeToDeposit}
onCheckedChange={(checked) => handleInputChange("agreeToDeposit", checked)}
required
/>
<label htmlFor="deposit" className="text-sm leading-relaxed">
I understand and agree to the deposit policy outlined above
</label>
</div>
</div>
</CardContent>
</Card>
)}
{/* Navigation Buttons */}
<div className="flex justify-between mt-8">
<Button type="button" variant="outline" onClick={prevStep} disabled={step === 1}>
Previous
</Button>
{step < 4 ? (
<Button type="button" onClick={nextStep}>
Next Step
</Button>
) : (
<Button
type="submit"
className="bg-primary hover:bg-primary/90"
disabled={!formData.agreeToTerms || !formData.agreeToDeposit}
>
Submit Booking & Pay Deposit
</Button>
)}
</div>
</form>
</div>
</div>
)
}

View File

@ -0,0 +1,459 @@
"use client"
import type React from "react"
import { useState } from "react"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Input } from "@/components/ui/input"
import { Textarea } from "@/components/ui/textarea"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
import { User, MessageSquare, CheckCircle, Phone, Mail, Clock } from "lucide-react"
const inquiryTypes = [
"General Question",
"Booking Consultation",
"Pricing Information",
"Aftercare Support",
"Portfolio Inquiry",
"Custom Design",
"Touch-up Request",
"Other",
]
const urgencyLevels = [
{ value: "low", label: "Low - General inquiry", description: "Response within 24-48 hours" },
{ value: "medium", label: "Medium - Need info soon", description: "Response within 12-24 hours" },
{ value: "high", label: "High - Time sensitive", description: "Response within 2-6 hours" },
]
interface ContactModalProps {
children: React.ReactNode
}
export function ContactModal({ children }: ContactModalProps) {
const [open, setOpen] = useState(false)
const [step, setStep] = useState(1)
const [isSubmitting, setIsSubmitting] = useState(false)
const [isSubmitted, setIsSubmitted] = useState(false)
const [formData, setFormData] = useState({
// Step 1: Contact Info
name: "",
email: "",
phone: "",
preferredContact: "email",
// Step 2: Inquiry Details
inquiryType: "",
urgency: "medium",
subject: "",
message: "",
hasDeadline: false,
deadline: "",
// Step 3: Additional Info
isExistingClient: false,
referralSource: "",
specialRequests: "",
})
const handleInputChange = (field: string, value: any) => {
setFormData((prev) => ({ ...prev, [field]: value }))
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setIsSubmitting(true)
// Simulate form submission
await new Promise((resolve) => setTimeout(resolve, 2000))
setIsSubmitted(true)
setIsSubmitting(false)
// Reset form after 3 seconds and close modal
setTimeout(() => {
setIsSubmitted(false)
setStep(1)
setFormData({
name: "",
email: "",
phone: "",
preferredContact: "email",
inquiryType: "",
urgency: "medium",
subject: "",
message: "",
hasDeadline: false,
deadline: "",
isExistingClient: false,
referralSource: "",
specialRequests: "",
})
setOpen(false)
}, 3000)
}
const nextStep = () => setStep((prev) => Math.min(prev + 1, 3))
const prevStep = () => setStep((prev) => Math.max(prev - 1, 1))
const selectedUrgency = urgencyLevels.find((level) => level.value === formData.urgency)
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>{children}</DialogTrigger>
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="font-playfair text-2xl">Get In Touch</DialogTitle>
</DialogHeader>
{/* Progress Indicator */}
<div className="flex justify-center mb-6">
<div className="flex items-center space-x-4">
{[1, 2, 3].map((stepNumber) => (
<div key={stepNumber} className="flex items-center">
<div
className={`w-8 h-8 rounded-full flex items-center justify-center text-sm font-medium ${
step >= stepNumber ? "bg-primary text-primary-foreground" : "bg-muted text-muted-foreground"
}`}
>
{stepNumber}
</div>
{stepNumber < 3 && (
<div className={`w-12 h-0.5 mx-2 ${step > stepNumber ? "bg-primary" : "bg-muted"}`} />
)}
</div>
))}
</div>
</div>
{isSubmitted ? (
<div className="text-center py-8">
<div className="mx-auto w-16 h-16 bg-green-100 rounded-full flex items-center justify-center mb-4">
<CheckCircle className="w-8 h-8 text-green-600" />
</div>
<h3 className="font-semibold text-lg mb-2">Message Sent!</h3>
<p className="text-muted-foreground mb-4">
Thank you for contacting us. We'll get back to you within {selectedUrgency?.description.toLowerCase()}.
</p>
<div className="text-sm text-muted-foreground">
<p>Reference ID: #{Math.random().toString(36).substr(2, 9).toUpperCase()}</p>
</div>
</div>
) : (
<form onSubmit={handleSubmit} className="space-y-6">
{/* Step 1: Contact Information */}
{step === 1 && (
<Card>
<CardHeader>
<CardTitle className="flex items-center space-x-2">
<User className="w-5 h-5" />
<span>Contact Information</span>
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div>
<label className="block text-sm font-medium mb-2">Full Name *</label>
<Input
value={formData.name}
onChange={(e) => handleInputChange("name", e.target.value)}
placeholder="Your full name"
required
/>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium mb-2">Email *</label>
<Input
type="email"
value={formData.email}
onChange={(e) => handleInputChange("email", e.target.value)}
placeholder="your@email.com"
required
/>
</div>
<div>
<label className="block text-sm font-medium mb-2">Phone</label>
<Input
type="tel"
value={formData.phone}
onChange={(e) => handleInputChange("phone", e.target.value)}
placeholder="(555) 123-4567"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium mb-2">Preferred Contact Method</label>
<div className="grid grid-cols-3 gap-3">
{[
{ value: "email", label: "Email", icon: Mail },
{ value: "phone", label: "Phone", icon: Phone },
{ value: "text", label: "Text", icon: MessageSquare },
].map((method) => {
const Icon = method.icon
return (
<label
key={method.value}
className={`flex items-center space-x-2 p-3 rounded-lg border cursor-pointer transition-colors ${
formData.preferredContact === method.value
? "border-primary bg-primary/5"
: "border-muted hover:border-primary/50"
}`}
>
<input
type="radio"
name="preferredContact"
value={method.value}
checked={formData.preferredContact === method.value}
onChange={(e) => handleInputChange("preferredContact", e.target.value)}
className="sr-only"
/>
<Icon className="w-4 h-4" />
<span className="text-sm font-medium">{method.label}</span>
</label>
)
})}
</div>
</div>
</CardContent>
</Card>
)}
{/* Step 2: Inquiry Details */}
{step === 2 && (
<Card>
<CardHeader>
<CardTitle className="flex items-center space-x-2">
<MessageSquare className="w-5 h-5" />
<span>Inquiry Details</span>
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium mb-2">Inquiry Type *</label>
<Select
value={formData.inquiryType}
onValueChange={(value) => handleInputChange("inquiryType", value)}
>
<SelectTrigger>
<SelectValue placeholder="Select inquiry type" />
</SelectTrigger>
<SelectContent>
{inquiryTypes.map((type) => (
<SelectItem key={type} value={type}>
{type}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<label className="block text-sm font-medium mb-2">Urgency Level</label>
<Select value={formData.urgency} onValueChange={(value) => handleInputChange("urgency", value)}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{urgencyLevels.map((level) => (
<SelectItem key={level.value} value={level.value}>
<div className="flex flex-col">
<span className="font-medium">{level.label}</span>
<span className="text-xs text-muted-foreground">{level.description}</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
{selectedUrgency && (
<div className="p-3 bg-muted/50 rounded-lg">
<div className="flex items-center space-x-2">
<Clock className="w-4 h-4 text-primary" />
<span className="text-sm font-medium">Expected Response Time:</span>
<span className="text-sm text-muted-foreground">{selectedUrgency.description}</span>
</div>
</div>
)}
<div>
<label className="block text-sm font-medium mb-2">Subject</label>
<Input
value={formData.subject}
onChange={(e) => handleInputChange("subject", e.target.value)}
placeholder="Brief description of your inquiry"
/>
</div>
<div>
<label className="block text-sm font-medium mb-2">Message *</label>
<Textarea
rows={4}
value={formData.message}
onChange={(e) => handleInputChange("message", e.target.value)}
placeholder="Tell us about your tattoo idea, questions, or how we can help you..."
required
/>
</div>
<div className="space-y-3">
<label className="flex items-center space-x-2">
<input
type="checkbox"
checked={formData.hasDeadline}
onChange={(e) => handleInputChange("hasDeadline", e.target.checked)}
className="rounded"
/>
<span className="text-sm">I have a specific deadline or event date</span>
</label>
{formData.hasDeadline && (
<div>
<label className="block text-sm font-medium mb-2">Deadline/Event Date</label>
<Input
type="date"
value={formData.deadline}
onChange={(e) => handleInputChange("deadline", e.target.value)}
min={new Date().toISOString().split("T")[0]}
/>
</div>
)}
</div>
</CardContent>
</Card>
)}
{/* Step 3: Review & Additional Info */}
{step === 3 && (
<Card>
<CardHeader>
<CardTitle className="flex items-center space-x-2">
<CheckCircle className="w-5 h-5" />
<span>Review & Submit</span>
</CardTitle>
</CardHeader>
<CardContent className="space-y-6">
{/* Contact Summary */}
<div className="p-4 bg-muted/50 rounded-lg">
<h4 className="font-semibold mb-3">Contact Summary</h4>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 text-sm">
<div>
<p className="text-muted-foreground">Name</p>
<p className="font-medium">{formData.name}</p>
</div>
<div>
<p className="text-muted-foreground">Email</p>
<p className="font-medium">{formData.email}</p>
</div>
<div>
<p className="text-muted-foreground">Phone</p>
<p className="font-medium">{formData.phone || "Not provided"}</p>
</div>
<div>
<p className="text-muted-foreground">Preferred Contact</p>
<p className="font-medium capitalize">{formData.preferredContact}</p>
</div>
<div>
<p className="text-muted-foreground">Inquiry Type</p>
<p className="font-medium">{formData.inquiryType}</p>
</div>
<div>
<p className="text-muted-foreground">Urgency</p>
<p className="font-medium">{selectedUrgency?.label}</p>
</div>
</div>
{formData.subject && (
<div className="mt-4 pt-4 border-t">
<p className="text-muted-foreground">Subject</p>
<p className="font-medium">{formData.subject}</p>
</div>
)}
<div className="mt-4 pt-4 border-t">
<p className="text-muted-foreground">Message</p>
<p className="font-medium">{formData.message}</p>
</div>
</div>
{/* Additional Information */}
<div className="space-y-4">
<h4 className="font-semibold">Additional Information (Optional)</h4>
<label className="flex items-center space-x-2">
<input
type="checkbox"
checked={formData.isExistingClient}
onChange={(e) => handleInputChange("isExistingClient", e.target.checked)}
className="rounded"
/>
<span className="text-sm">I'm an existing client</span>
</label>
<div>
<label className="block text-sm font-medium mb-2">How did you hear about us?</label>
<Select
value={formData.referralSource}
onValueChange={(value) => handleInputChange("referralSource", value)}
>
<SelectTrigger>
<SelectValue placeholder="Select source (optional)" />
</SelectTrigger>
<SelectContent>
<SelectItem value="google">Google Search</SelectItem>
<SelectItem value="instagram">Instagram</SelectItem>
<SelectItem value="facebook">Facebook</SelectItem>
<SelectItem value="friend">Friend/Family</SelectItem>
<SelectItem value="existing-client">Existing Client</SelectItem>
<SelectItem value="walk-by">Walked by the shop</SelectItem>
<SelectItem value="other">Other</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<label className="block text-sm font-medium mb-2">Special Requests or Notes</label>
<Textarea
rows={3}
value={formData.specialRequests}
onChange={(e) => handleInputChange("specialRequests", e.target.value)}
placeholder="Any special accommodations, accessibility needs, or additional information..."
/>
</div>
</div>
</CardContent>
</Card>
)}
{/* Navigation Buttons */}
<div className="flex justify-between pt-4">
<Button type="button" variant="outline" onClick={prevStep} disabled={step === 1}>
Previous
</Button>
{step < 3 ? (
<Button type="button" onClick={nextStep}>
Next Step
</Button>
) : (
<Button
type="submit"
className="bg-primary hover:bg-primary/90"
disabled={isSubmitting || !formData.name || !formData.email || !formData.message}
>
{isSubmitting ? "Sending..." : "Send Message"}
</Button>
)}
</div>
</form>
)}
</DialogContent>
</Dialog>
)
}

489
components/contact-page.tsx Normal file
View File

@ -0,0 +1,489 @@
"use client"
import type React from "react"
import { useState } from "react"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Input } from "@/components/ui/input"
import { Textarea } from "@/components/ui/textarea"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Badge } from "@/components/ui/badge"
import { MapPin, Phone, Mail, Clock, Instagram, Facebook, MessageSquare, Calendar } from "lucide-react"
import Link from "next/link"
const contactMethods = [
{
icon: Phone,
title: "Phone",
value: "(555) 123-TATT",
description: "Call us during business hours",
action: "tel:+15551238288",
},
{
icon: Mail,
title: "Email",
value: "info@unitedtattoo.com",
description: "We respond within 24 hours",
action: "mailto:info@unitedtattoo.com",
},
{
icon: Instagram,
title: "Instagram",
value: "@unitedtattoo",
description: "Follow for latest work",
action: "https://instagram.com/unitedtattoo",
},
{
icon: MessageSquare,
title: "Text/SMS",
value: "(555) 123-TATT",
description: "Text for quick questions",
action: "sms:+15551238288",
},
]
const businessHours = [
{ day: "Monday", hours: "12:00 PM - 8:00 PM", status: "open" },
{ day: "Tuesday", hours: "12:00 PM - 8:00 PM", status: "open" },
{ day: "Wednesday", hours: "12:00 PM - 8:00 PM", status: "open" },
{ day: "Thursday", hours: "12:00 PM - 8:00 PM", status: "open" },
{ day: "Friday", hours: "12:00 PM - 8:00 PM", status: "open" },
{ day: "Saturday", hours: "10:00 AM - 6:00 PM", status: "open" },
{ day: "Sunday", hours: "Closed", status: "closed" },
]
const inquiryTypes = [
"General Question",
"Booking Consultation",
"Pricing Information",
"Aftercare Support",
"Portfolio Inquiry",
"Custom Design",
"Touch-up Request",
"Other",
]
export function ContactPage() {
const [formData, setFormData] = useState({
name: "",
email: "",
phone: "",
inquiryType: "",
subject: "",
message: "",
preferredContact: "email",
})
const [isSubmitting, setIsSubmitting] = useState(false)
const [isSubmitted, setIsSubmitted] = useState(false)
const handleInputChange = (field: string, value: string) => {
setFormData((prev) => ({ ...prev, [field]: value }))
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setIsSubmitting(true)
// Simulate form submission
await new Promise((resolve) => setTimeout(resolve, 2000))
setIsSubmitted(true)
setIsSubmitting(false)
// Reset form after 3 seconds
setTimeout(() => {
setIsSubmitted(false)
setFormData({
name: "",
email: "",
phone: "",
inquiryType: "",
subject: "",
message: "",
preferredContact: "email",
})
}, 3000)
}
return (
<div className="container mx-auto px-4 py-8">
<div className="max-w-6xl mx-auto">
{/* Header */}
<div className="text-center mb-12">
<h1 className="font-playfair text-4xl md:text-5xl font-bold mb-6">Get In Touch</h1>
<p className="text-lg text-muted-foreground max-w-2xl mx-auto text-balance">
Ready to start your tattoo journey? Have questions about our services? We're here to help. Reach out using
any of the methods below.
</p>
</div>
{/* Quick Contact Methods */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-6 mb-12">
{contactMethods.map((method, index) => {
const Icon = method.icon
return (
<Card key={index} className="text-center hover:shadow-lg transition-shadow duration-300">
<CardContent className="p-6">
<div className="mx-auto w-12 h-12 bg-primary/10 rounded-full flex items-center justify-center mb-4">
<Icon className="w-6 h-6 text-primary" />
</div>
<h3 className="font-semibold mb-1">{method.title}</h3>
<p className="text-primary font-medium mb-2">{method.value}</p>
<p className="text-sm text-muted-foreground mb-4">{method.description}</p>
<Button asChild size="sm" variant="outline">
<Link href={method.action}>Contact</Link>
</Button>
</CardContent>
</Card>
)
})}
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-12">
{/* Contact Form */}
<div>
<Card>
<CardHeader>
<CardTitle className="font-playfair text-2xl">Send us a Message</CardTitle>
<p className="text-muted-foreground">
Fill out the form below and we'll get back to you as soon as possible.
</p>
</CardHeader>
<CardContent>
{isSubmitted ? (
<div className="text-center py-8">
<div className="mx-auto w-16 h-16 bg-green-100 rounded-full flex items-center justify-center mb-4">
<MessageSquare className="w-8 h-8 text-green-600" />
</div>
<h3 className="font-semibold text-lg mb-2">Message Sent!</h3>
<p className="text-muted-foreground">
Thank you for contacting us. We'll get back to you within 24 hours.
</p>
</div>
) : (
<form onSubmit={handleSubmit} className="space-y-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label htmlFor="name" className="block text-sm font-medium mb-2">
Name *
</label>
<Input
id="name"
value={formData.name}
onChange={(e) => handleInputChange("name", e.target.value)}
required
/>
</div>
<div>
<label htmlFor="phone" className="block text-sm font-medium mb-2">
Phone
</label>
<Input
id="phone"
type="tel"
value={formData.phone}
onChange={(e) => handleInputChange("phone", e.target.value)}
/>
</div>
</div>
<div>
<label htmlFor="email" className="block text-sm font-medium mb-2">
Email *
</label>
<Input
id="email"
type="email"
value={formData.email}
onChange={(e) => handleInputChange("email", e.target.value)}
required
/>
</div>
<div>
<label htmlFor="inquiryType" className="block text-sm font-medium mb-2">
Inquiry Type
</label>
<Select
value={formData.inquiryType}
onValueChange={(value) => handleInputChange("inquiryType", value)}
>
<SelectTrigger>
<SelectValue placeholder="Select inquiry type" />
</SelectTrigger>
<SelectContent>
{inquiryTypes.map((type) => (
<SelectItem key={type} value={type}>
{type}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<label htmlFor="subject" className="block text-sm font-medium mb-2">
Subject
</label>
<Input
id="subject"
value={formData.subject}
onChange={(e) => handleInputChange("subject", e.target.value)}
placeholder="Brief description of your inquiry"
/>
</div>
<div>
<label htmlFor="message" className="block text-sm font-medium mb-2">
Message *
</label>
<Textarea
id="message"
rows={5}
value={formData.message}
onChange={(e) => handleInputChange("message", e.target.value)}
placeholder="Tell us about your tattoo idea, questions, or how we can help you..."
required
/>
</div>
<div>
<label className="block text-sm font-medium mb-2">Preferred Contact Method</label>
<div className="flex space-x-4">
<label className="flex items-center">
<input
type="radio"
name="preferredContact"
value="email"
checked={formData.preferredContact === "email"}
onChange={(e) => handleInputChange("preferredContact", e.target.value)}
className="mr-2"
/>
Email
</label>
<label className="flex items-center">
<input
type="radio"
name="preferredContact"
value="phone"
checked={formData.preferredContact === "phone"}
onChange={(e) => handleInputChange("preferredContact", e.target.value)}
className="mr-2"
/>
Phone
</label>
<label className="flex items-center">
<input
type="radio"
name="preferredContact"
value="text"
checked={formData.preferredContact === "text"}
onChange={(e) => handleInputChange("preferredContact", e.target.value)}
className="mr-2"
/>
Text
</label>
</div>
</div>
<Button type="submit" className="w-full bg-primary hover:bg-primary/90" disabled={isSubmitting}>
{isSubmitting ? "Sending..." : "Send Message"}
</Button>
</form>
)}
</CardContent>
</Card>
</div>
{/* Studio Info & Map */}
<div className="space-y-8">
{/* Studio Information */}
<Card>
<CardHeader>
<CardTitle className="font-playfair text-2xl">Visit Our Studio</CardTitle>
</CardHeader>
<CardContent className="space-y-6">
<div className="flex items-start space-x-3">
<MapPin className="w-5 h-5 text-primary mt-1" />
<div>
<p className="font-medium">Address</p>
<p className="text-muted-foreground">
123 Ink Street
<br />
Downtown District
<br />
City, State 12345
</p>
<Button asChild variant="link" className="p-0 h-auto text-primary">
<Link href="https://maps.google.com" target="_blank">
Get Directions
</Link>
</Button>
</div>
</div>
<div className="flex items-start space-x-3">
<Phone className="w-5 h-5 text-primary mt-1" />
<div>
<p className="font-medium">Phone</p>
<p className="text-muted-foreground">(555) 123-TATT</p>
</div>
</div>
<div className="flex items-start space-x-3">
<Mail className="w-5 h-5 text-primary mt-1" />
<div>
<p className="font-medium">Email</p>
<p className="text-muted-foreground">info@unitedtattoo.com</p>
</div>
</div>
<div className="flex items-start space-x-3">
<Clock className="w-5 h-5 text-primary mt-1" />
<div>
<p className="font-medium mb-3">Business Hours</p>
<div className="space-y-2">
{businessHours.map((schedule, index) => (
<div key={index} className="flex justify-between items-center text-sm">
<span className="font-medium">{schedule.day}</span>
<div className="flex items-center space-x-2">
<span className={schedule.status === "closed" ? "text-muted-foreground" : ""}>
{schedule.hours}
</span>
{schedule.status === "open" && (
<Badge variant="outline" className="text-xs bg-green-50 text-green-700 border-green-200">
Open
</Badge>
)}
</div>
</div>
))}
</div>
</div>
</div>
{/* Social Media */}
<div>
<p className="font-medium mb-3">Follow Us</p>
<div className="flex space-x-4">
<Button asChild variant="outline" size="sm">
<Link href="https://instagram.com/unitedtattoo" target="_blank">
<Instagram className="w-4 h-4 mr-2" />
Instagram
</Link>
</Button>
<Button asChild variant="outline" size="sm">
<Link href="https://facebook.com/unitedtattoo" target="_blank">
<Facebook className="w-4 h-4 mr-2" />
Facebook
</Link>
</Button>
</div>
</div>
</CardContent>
</Card>
{/* Google Maps */}
<Card>
<CardHeader>
<CardTitle className="font-playfair text-xl">Find Us</CardTitle>
</CardHeader>
<CardContent className="p-0">
<div className="w-full h-80 bg-muted rounded-lg overflow-hidden">
<iframe
src="https://www.google.com/maps/embed?pb=!1m18!1m12!1m3!1d3024.1234567890123!2d-74.0059413!3d40.7127753!2m3!1f0!2f0!3f0!3m2!1i1024!2i768!4f13.1!3m3!1m2!1s0x0%3A0x0!2zNDDCsDQyJzQ2LjAiTiA3NMKwMDAnMjEuNCJX!5e0!3m2!1sen!2sus!4v1234567890123"
width="100%"
height="320"
style={{ border: 0 }}
allowFullScreen
loading="lazy"
referrerPolicy="no-referrer-when-downgrade"
title="United Tattoo Location"
/>
</div>
</CardContent>
</Card>
{/* Quick Actions */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Card className="bg-primary text-primary-foreground">
<CardContent className="p-4 text-center">
<Calendar className="w-6 h-6 mx-auto mb-2" />
<h4 className="font-semibold mb-1">Book Appointment</h4>
<p className="text-xs opacity-90 mb-3">Schedule your tattoo session</p>
<Button asChild className="bg-white text-black hover:bg-gray-100 !text-black" size="sm">
<Link href="/book">Book Now</Link>
</Button>
</CardContent>
</Card>
<Card className="bg-secondary text-secondary-foreground">
<CardContent className="p-4 text-center">
<MessageSquare className="w-6 h-6 mx-auto mb-2" />
<h4 className="font-semibold mb-1">Quick Question?</h4>
<p className="text-xs opacity-90 mb-3">Text us for fast answers</p>
<Button
asChild
variant="outline"
size="sm"
className="border-white text-white hover:bg-white hover:text-secondary bg-transparent"
>
<Link href="sms:+15551238288">Text Us</Link>
</Button>
</CardContent>
</Card>
</div>
</div>
</div>
{/* FAQ Section */}
<div className="mt-16">
<h2 className="font-playfair text-3xl font-bold mb-8 text-center">Frequently Asked Questions</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<Card>
<CardContent className="p-6">
<h3 className="font-semibold mb-2">How do I book an appointment?</h3>
<p className="text-muted-foreground text-sm">
You can book online through our booking form, call us during business hours, or visit the studio in
person. A deposit is required to secure your appointment.
</p>
</CardContent>
</Card>
<Card>
<CardContent className="p-6">
<h3 className="font-semibold mb-2">Do you accept walk-ins?</h3>
<p className="text-muted-foreground text-sm">
We have limited walk-in availability on Tuesdays and Thursdays from 2-6 PM for small tattoos and
consultations. Appointments are recommended.
</p>
</CardContent>
</Card>
<Card>
<CardContent className="p-6">
<h3 className="font-semibold mb-2">What should I bring to my appointment?</h3>
<p className="text-muted-foreground text-sm">
Bring a valid ID, reference images if you have them, and wear comfortable clothing that provides easy
access to the tattoo area.
</p>
</CardContent>
</Card>
<Card>
<CardContent className="p-6">
<h3 className="font-semibold mb-2">How far in advance should I book?</h3>
<p className="text-muted-foreground text-sm">
We recommend booking 2-4 weeks in advance, especially for larger pieces or specific artists. Popular
time slots fill up quickly.
</p>
</CardContent>
</Card>
</div>
</div>
</div>
</div>
)
}

View File

@ -0,0 +1,188 @@
"use client"
import type React from "react"
import { useState, useEffect } from "react"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Textarea } from "@/components/ui/textarea"
import { MapPin, Phone, Mail, Clock } from "lucide-react"
export function ContactSection() {
const [formData, setFormData] = useState({
name: "",
email: "",
phone: "",
message: "",
})
const [scrollY, setScrollY] = useState(0)
useEffect(() => {
const handleScroll = () => setScrollY(window.scrollY)
window.addEventListener("scroll", handleScroll)
return () => window.removeEventListener("scroll", handleScroll)
}, [])
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
console.log("Form submitted:", formData)
}
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
setFormData((prev) => ({
...prev,
[e.target.name]: e.target.value,
}))
}
return (
<section id="contact" className="min-h-screen bg-black relative overflow-hidden">
<div
className="absolute inset-0 opacity-[0.03] bg-cover bg-center bg-no-repeat blur-sm"
style={{
backgroundImage: "url('/united-logo-full.jpg')",
transform: `translateY(${scrollY * 0.2}px)`,
}}
/>
<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="w-full max-w-md">
<div className="mb-8">
<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>
</div>
<form onSubmit={handleSubmit} className="space-y-6">
<div className="grid grid-cols-2 gap-4">
<div>
<label htmlFor="name" className="block text-sm font-medium text-white mb-2">
Name
</label>
<Input
id="name"
name="name"
value={formData.name}
onChange={handleChange}
required
className="bg-white/10 border-white/20 text-white placeholder:text-gray-400 focus:border-white focus:bg-white/15 transition-all"
placeholder="Your name"
/>
</div>
<div>
<label htmlFor="phone" className="block text-sm font-medium text-white mb-2">
Phone
</label>
<Input
id="phone"
name="phone"
type="tel"
value={formData.phone}
onChange={handleChange}
className="bg-white/10 border-white/20 text-white placeholder:text-gray-400 focus:border-white focus:bg-white/15 transition-all"
placeholder="(555) 123-4567"
/>
</div>
</div>
<div>
<label htmlFor="email" className="block text-sm font-medium text-white mb-2">
Email
</label>
<Input
id="email"
name="email"
type="email"
value={formData.email}
onChange={handleChange}
required
className="bg-white/10 border-white/20 text-white placeholder:text-gray-400 focus:border-white focus:bg-white/15 transition-all"
placeholder="your@email.com"
/>
</div>
<div>
<label htmlFor="message" className="block text-sm font-medium text-white mb-2">
Message
</label>
<Textarea
id="message"
name="message"
rows={4}
value={formData.message}
onChange={handleChange}
placeholder="Tell us about your tattoo idea..."
required
className="bg-white/10 border-white/20 text-white placeholder:text-gray-400 focus:border-white focus:bg-white/15 transition-all resize-none"
/>
</div>
<Button
type="submit"
className="w-full bg-white text-black hover:bg-gray-100 py-3 text-base font-medium transition-all"
>
Send Message
</Button>
</form>
</div>
</div>
<div className="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"
style={{
backgroundImage: "url('/united-logo-text.png')",
transform: `translateY(${scrollY * -0.1}px)`,
}}
/>
<div className="relative z-10 p-12 text-center">
<div className="mb-12">
<h2 className="text-5xl font-bold text-black mb-4">UNITED</h2>
<h3 className="text-3xl font-bold text-gray-600 mb-6">TATTOO</h3>
<p className="text-gray-700 text-lg max-w-md mx-auto leading-relaxed">
Where artistry, culture, and custom tattoos meet. Located in Fountain, just minutes from Colorado Springs.
</p>
</div>
<div className="space-y-6 max-w-sm mx-auto">
{[
{
icon: MapPin,
title: "Visit Us",
content: "5160 Fontaine Blvd, Fountain, CO 80817",
},
{
icon: Phone,
title: "Call Us",
content: "(719) 698-9004",
},
{
icon: Mail,
title: "Email Us",
content: "info@united-tattoo.com",
},
{
icon: Clock,
title: "Hours",
content: "Mon-Wed: 10AM-6PM, Thu-Sat: 10AM-8PM, Sun: 10AM-6PM",
},
].map((item, index) => {
const Icon = item.icon
return (
<div key={index} className="flex items-start space-x-4 text-left">
<Icon className="w-5 h-5 text-black mt-1 flex-shrink-0" />
<div>
<p className="text-black font-medium text-sm">{item.title}</p>
<p className="text-gray-600 text-sm">{item.content}</p>
</div>
</div>
)
})}
</div>
</div>
</div>
</div>
</section>
)
}

378
components/deposit-page.tsx Normal file
View File

@ -0,0 +1,378 @@
"use client"
import { useState } from "react"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { Badge } from "@/components/ui/badge"
import { Alert, AlertDescription } from "@/components/ui/alert"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import {
DollarSign,
Calendar,
CreditCard,
RefreshCw,
AlertCircle,
CheckCircle,
ShoppingBag,
Shield,
Clock,
X
} from "lucide-react"
import Link from "next/link"
export function DepositPage() {
const [activeTab, setActiveTab] = useState("policy")
return (
<div className="min-h-screen bg-black text-white">
{/* Hero Section */}
<section className="relative overflow-hidden">
<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>
<div className="relative z-10 pt-32 pb-20 px-8 lg:px-16">
<div className="max-w-4xl mx-auto text-center">
<h1 className="font-playfair text-5xl lg:text-7xl font-bold mb-6 tracking-tight">
LET'S MAKE IT OFFICIAL...
</h1>
<h2 className="text-2xl lg:text-3xl font-bold mb-8 text-gray-300">
Make your appointment deposit now!
</h2>
<p className="text-xl text-gray-400 leading-relaxed max-w-3xl mx-auto">
Secure your tattoo appointment hassle-free with United Tattoo's deposit payment page.
Pay conveniently via Square, accepting all major credit and debit cards, including
American Express and Discover, along with mobile payment options like Apple Pay and
Google Pay. You can even use Afterpay.
</p>
</div>
</div>
</section>
{/* Payment Options Section */}
<section className="relative bg-black py-20 px-8 lg:px-16">
<div className="max-w-6xl mx-auto">
<div className="text-center mb-12">
<p className="text-2xl font-bold text-white mb-2">
Design now, pay your way, and ink later
</p>
<p className="text-xl text-gray-400">
your tattoo journey, your terms
</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-8 max-w-4xl mx-auto">
<Card className="bg-white/5 border-white/10 hover:border-white/20 transition-all duration-300">
<CardHeader className="text-center">
<div className="mx-auto w-20 h-20 bg-white/10 rounded-full flex items-center justify-center mb-4">
<ShoppingBag className="w-10 h-10 text-white" />
</div>
<CardTitle className="text-2xl font-playfair text-white">Pay with Afterpay</CardTitle>
</CardHeader>
<CardContent className="text-center">
<p className="text-gray-400 mb-6">Split your deposit into easy installments</p>
<Button
className="bg-white text-black hover:bg-gray-100 w-full py-6 text-lg font-medium"
asChild
>
<Link href="/book">Pay with Afterpay</Link>
</Button>
</CardContent>
</Card>
<Card className="bg-white/5 border-white/10 hover:border-white/20 transition-all duration-300">
<CardHeader className="text-center">
<div className="mx-auto w-20 h-20 bg-white/10 rounded-full flex items-center justify-center mb-4">
<CreditCard className="w-10 h-10 text-white" />
</div>
<CardTitle className="text-2xl font-playfair text-white">Credit/Debit Cards</CardTitle>
</CardHeader>
<CardContent className="text-center">
<p className="text-gray-400 mb-6">VISA, Mastercard & more (powered by Stripe)</p>
<Button
className="bg-white text-black hover:bg-gray-100 w-full py-6 text-lg font-medium"
asChild
>
<Link href="/book">Pay with Card</Link>
</Button>
</CardContent>
</Card>
</div>
</div>
</section>
{/* Deposit Policy Section */}
<section className="relative py-20 px-8 lg:px-16 border-t border-white/10">
<div className="max-w-6xl mx-auto">
<div className="text-center mb-16">
<h2 className="font-playfair text-5xl font-bold mb-4 text-white">Deposit Policy</h2>
<div className="w-16 h-0.5 bg-white mx-auto"></div>
</div>
{/* Policy Overview */}
<Card className="bg-white/5 border-white/10 mb-12">
<CardContent className="p-8">
<p className="text-gray-300 leading-relaxed mb-6">
At United Tattoo, we understand that life is unpredictable, and circumstances may
necessitate changes. This policy was created to foster fairness and understanding
among all parties involved. Our artists dedicate considerable time to the studio,
prioritizing their craft above all else.
</p>
<p className="text-gray-300 leading-relaxed mb-6">
The United Tattoo Deposit Policy is designed to honor their commitment and respect
your time as our valued client. Adhering to this policy ensures that scheduled
appointments are upheld with care and consideration.
</p>
<Alert className="bg-black/50 border-white/20">
<AlertCircle className="h-4 w-4 text-white" />
<AlertDescription className="text-gray-300 text-sm">
All deposits and rescheduling requests are subject to review and approval by
LW2 Investments, LLC, which oversees the financial and legal policies of United Tattoo.
</AlertDescription>
</Alert>
</CardContent>
</Card>
{/* Policy Tabs */}
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
<TabsList className="grid w-full grid-cols-4 bg-white/5 border border-white/10">
<TabsTrigger
value="policy"
className="data-[state=active]:bg-white data-[state=active]:text-black text-white"
>
Non-Refundable
</TabsTrigger>
<TabsTrigger
value="transfer"
className="data-[state=active]:bg-white data-[state=active]:text-black text-white"
>
Transferability
</TabsTrigger>
<TabsTrigger
value="reschedule"
className="data-[state=active]:bg-white data-[state=active]:text-black text-white"
>
Rescheduling
</TabsTrigger>
<TabsTrigger
value="tiered"
className="data-[state=active]:bg-white data-[state=active]:text-black text-white"
>
Tiered Policy
</TabsTrigger>
</TabsList>
<TabsContent value="policy" className="mt-8">
<Card className="bg-red-950/20 border-red-900/30">
<CardHeader>
<CardTitle className="flex items-center gap-3 text-2xl text-white">
<AlertCircle className="w-6 h-6 text-red-400" />
NON-REFUNDABLE DEPOSIT
</CardTitle>
</CardHeader>
<CardContent>
<ul className="space-y-4">
<li className="flex items-start gap-3">
<CheckCircle className="w-5 h-5 text-red-400 mt-1 flex-shrink-0" />
<span className="text-gray-300">
All deposits are non-refundable, no exception. This ensures that our artists'
time, preparation, and custom artwork are fairly compensated.
</span>
</li>
<li className="flex items-start gap-3">
<CheckCircle className="w-5 h-5 text-red-400 mt-1 flex-shrink-0" />
<span className="text-gray-300">
By placing a deposit, you agree to this policy and understand that refund
requests will not be considered unless reviewed and approved by LW2 Investments, LLC.
</span>
</li>
</ul>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="transfer" className="mt-8">
<Card className="bg-yellow-950/20 border-yellow-900/30">
<CardHeader>
<CardTitle className="flex items-center gap-3 text-2xl text-white">
<RefreshCw className="w-6 h-6 text-yellow-400" />
DEPOSIT TRANSFERABILITY
</CardTitle>
</CardHeader>
<CardContent>
<ul className="space-y-4">
<li className="flex items-start gap-3">
<CheckCircle className="w-5 h-5 text-yellow-400 mt-1 flex-shrink-0" />
<span className="text-gray-300">
While deposits are non-refundable, we recognize that unforeseen
circumstances may arise.
</span>
</li>
<li className="flex items-start gap-3">
<CheckCircle className="w-5 h-5 text-yellow-400 mt-1 flex-shrink-0" />
<span className="text-gray-300">
Deposits can be transferred once to a rescheduled appointment, provided
proper notice is given (see Rescheduling Policy).
</span>
</li>
</ul>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="reschedule" className="mt-8">
<Card className="bg-blue-950/20 border-blue-900/30">
<CardHeader>
<CardTitle className="flex items-center gap-3 text-2xl text-white">
<Calendar className="w-6 h-6 text-blue-400" />
RESCHEDULING POLICY
</CardTitle>
</CardHeader>
<CardContent>
<ul className="space-y-4">
<li className="flex items-start gap-3">
<CheckCircle className="w-5 h-5 text-blue-400 mt-1 flex-shrink-0" />
<span className="text-gray-300">
One free reschedule is allowed if notice is given at least 48 hours before
the scheduled appointment.
</span>
</li>
<li className="flex items-start gap-3">
<CheckCircle className="w-5 h-5 text-blue-400 mt-1 flex-shrink-0" />
<span className="text-gray-300">
A rescheduling fee of up to 25% of your deposit may apply to cover
administrative costs and ensure our artists' time is respected.
</span>
</li>
<li className="flex items-start gap-3">
<CheckCircle className="w-5 h-5 text-blue-400 mt-1 flex-shrink-0" />
<span className="text-gray-300">
If you reschedule within 48 hours of your appointment, your deposit is
forfeited, and a new deposit will be required.
</span>
</li>
<li className="flex items-start gap-3">
<CheckCircle className="w-5 h-5 text-blue-400 mt-1 flex-shrink-0" />
<span className="text-gray-300">
Deposits transferred to rescheduled appointments will be credited toward
the final cost of the tattoo service.
</span>
</li>
</ul>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="tiered" className="mt-8">
<Card className="bg-green-950/20 border-green-900/30">
<CardHeader>
<CardTitle className="flex items-center gap-3 text-2xl text-white">
<Shield className="w-6 h-6 text-green-400" />
TIERED DEPOSIT RETENTION POLICY
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-gray-400 mb-6 italic">
(Reviewed on a case-by-case basis by LW2 Investments, LLC)
</p>
<div className="space-y-4">
<div className="flex items-start gap-3">
<Badge className="bg-green-900/30 text-green-400 border-green-900/50">
30+ Days
</Badge>
<span className="text-gray-300 flex-1">
The deposit can be held as shop credit toward a future appointment (not refunded).
</span>
</div>
<div className="flex items-start gap-3">
<Badge className="bg-yellow-900/30 text-yellow-400 border-yellow-900/50">
14-29 Days
</Badge>
<span className="text-gray-300 flex-1">
50% of the deposit may be credited toward a future appointment; the remaining
50% is forfeited.
</span>
</div>
<div className="flex items-start gap-3">
<Badge className="bg-red-900/30 text-red-400 border-red-900/50">
&lt; 14 Days
</Badge>
<span className="text-gray-300 flex-1">
The deposit is fully forfeited unless the time slot is rebooked.
</span>
</div>
</div>
</CardContent>
</Card>
</TabsContent>
</Tabs>
{/* No-Show Policy */}
<Card className="mt-12 bg-red-950/20 border-red-900/30">
<CardHeader>
<CardTitle className="flex items-center gap-3 text-xl text-white">
<X className="w-5 h-5 text-red-400" />
NO-CALL & NO-SHOW POLICY
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-gray-300">
Failure to show up for your appointment without calling or emailing in advance results
in the loss of 100% of your deposit. Clients with a no-show history may be required
to pay in full before booking future appointments.
</p>
</CardContent>
</Card>
{/* Final Note */}
<Alert className="mt-12 bg-white/5 border-white/10">
<Shield className="h-5 w-5 text-white" />
<AlertDescription className="text-gray-300">
<strong>FINAL DECISIONS & LEGAL OVERSIGHT:</strong> All deposit-related decisions,
refund requests, and disputes will be reviewed by LW2 Investments, LLC. United Tattoo
staff cannot override or approve deposit refunds outside the scope of this policy.
</AlertDescription>
</Alert>
</div>
</section>
{/* Contact Section */}
<section className="relative py-20 px-8 lg:px-16 border-t border-white/10">
<div className="max-w-4xl mx-auto text-center">
<p className="text-gray-300 mb-8 text-lg">
By adhering to these policies, we aim to provide consistent, professional, and respectful
experience for both our clients and our talented artists. We look forward to creating an
exceptional tattoo experience with you!
</p>
<p className="text-gray-400 mb-12">
If you have any questions or concerns, please contact us directly:
</p>
<div className="flex flex-col sm:flex-row gap-4 justify-center">
<Button
variant="outline"
className="border-white/30 text-white hover:bg-white hover:text-black bg-transparent"
asChild
>
<Link href="mailto:appts@united-tattoo.com">
appts@united-tattoo.com
</Link>
</Button>
<Button
variant="outline"
className="border-white/30 text-white hover:bg-white hover:text-black bg-transparent"
asChild
>
<Link href="tel:+17196989004">
(719) 698-9004
</Link>
</Button>
</div>
</div>
</section>
</div>
)
}

207
components/footer.tsx Normal file
View File

@ -0,0 +1,207 @@
"use client"
import { useEffect, useState } from "react"
import Link from "next/link"
import { ArrowUp } from "lucide-react"
import { Button } from "@/components/ui/button"
export function Footer() {
const [showScrollTop, setShowScrollTop] = useState(false)
useEffect(() => {
const handleScroll = () => {
const scrolled = window.scrollY
const threshold = window.innerHeight * 0.5
setShowScrollTop(scrolled > threshold)
}
window.addEventListener("scroll", handleScroll)
handleScroll()
return () => window.removeEventListener("scroll", handleScroll)
}, [])
const scrollToTop = () => {
window.scrollTo({ top: 0, behavior: "smooth" })
}
return (
<>
<Button
onClick={scrollToTop}
className={`fixed bottom-8 right-8 z-50 rounded-full w-12 h-12 p-0 bg-white text-black hover:bg-gray-100 shadow-lg transition-all duration-300 ${
showScrollTop ? "opacity-100 translate-y-0" : "opacity-0 translate-y-4 pointer-events-none"
}`}
aria-label="Scroll to top"
>
<ArrowUp size={20} />
</Button>
<footer className="bg-black text-white py-16 font-mono">
<div className="container mx-auto px-8">
<div className="grid grid-cols-1 md:grid-cols-12 gap-8 items-start">
<div className="md:col-span-3">
<div className="flex items-center gap-2 mb-6">
<span className="text-white"></span>
<h4 className="text-white font-medium tracking-wide text-lg">SERVICES</h4>
</div>
<ul className="space-y-3 text-base">
{[
{ name: "TRADITIONAL", count: "" },
{ name: "REALISM", count: "" },
{ name: "BLACKWORK", count: "" },
{ name: "FINE LINE", count: "" },
{ name: "WATERCOLOR", count: "" },
{ name: "COVER-UPS", count: "" },
{ name: "ANIME", count: "" },
].map((service, index) => (
<li key={index}>
<Link href="/book" className="text-gray-400 hover:text-white transition-colors duration-200">
{service.name}
{service.count && <span className="text-white ml-2">{service.count}</span>}
</Link>
</li>
))}
</ul>
</div>
<div className="md:col-span-3">
<div className="flex items-center gap-2 mb-6">
<span className="text-white"></span>
<h4 className="text-white font-medium tracking-wide text-lg">ARTISTS</h4>
</div>
<ul className="space-y-3 text-base">
{[
{ name: "CHRISTY_LUMBERG", count: "" },
{ name: "ANGEL_ANDRADE", count: "" },
{ name: "STEVEN_SOLE", count: "" },
{ name: "DONOVAN_L", count: "" },
{ name: "VIEW_ALL", count: "" },
].map((artist, index) => (
<li key={index}>
<Link href="/artists" className="text-gray-400 hover:text-white transition-colors duration-200">
{artist.name}
{artist.count && <span className="text-white ml-2">{artist.count}</span>}
</Link>
</li>
))}
</ul>
</div>
<div className="md:col-span-3">
<div className="text-gray-500 text-sm leading-relaxed mb-4">
© <span className="text-white underline">UNITED.TATTOO</span> LLC 2025
<br />
ALL RIGHTS RESERVED.
</div>
<div className="text-gray-400 text-sm">
5160 FONTAINE BLVD
<br />
FOUNTAIN, CO 80817
<br />
<Link href="tel:+17196989004" className="hover:text-white transition-colors">
(719) 698-9004
</Link>
</div>
</div>
<div className="md:col-span-3 space-y-8">
{/* Legal */}
<div>
<div className="flex items-center gap-2 mb-4">
<span className="text-white"></span>
<h4 className="text-white font-medium tracking-wide text-lg">LEGAL</h4>
</div>
<ul className="space-y-2 text-base">
<li>
<Link
href="/aftercare"
className="text-gray-400 hover:text-white transition-colors duration-200 underline"
>
AFTERCARE
</Link>
</li>
<li>
<Link
href="/deposit"
className="text-gray-400 hover:text-white transition-colors duration-200 underline"
>
DEPOSIT POLICY
</Link>
</li>
<li>
<Link
href="/terms"
className="text-gray-400 hover:text-white transition-colors duration-200 underline"
>
TERMS OF SERVICE
</Link>
</li>
<li>
<Link
href="/privacy"
className="text-gray-400 hover:text-white transition-colors duration-200 underline"
>
PRIVACY POLICY
</Link>
</li>
<li>
<Link
href="#"
className="text-gray-400 hover:text-white transition-colors duration-200 underline"
>
WAIVER
</Link>
</li>
</ul>
</div>
{/* Social */}
<div>
<div className="flex items-center gap-2 mb-4">
<span className="text-white"></span>
<h4 className="text-white font-medium tracking-wide text-lg">SOCIAL</h4>
</div>
<ul className="space-y-2 text-base">
<li>
<Link href="https://www.instagram.com/unitedtattoo719" target="_blank" rel="noopener noreferrer" className="text-gray-400 hover:text-white transition-colors duration-200 underline">
INSTAGRAM
</Link>
</li>
<li>
<Link href="https://www.facebook.com/unitedtattoo719" target="_blank" rel="noopener noreferrer" className="text-gray-400 hover:text-white transition-colors duration-200 underline">
FACEBOOK
</Link>
</li>
<li>
<Link href="https://www.tiktok.com/@united.tattoo" target="_blank" rel="noopener noreferrer" className="text-gray-400 hover:text-white transition-colors duration-200 underline">
TIKTOK
</Link>
</li>
</ul>
</div>
{/* Contact */}
<div>
<div className="flex items-center gap-2 mb-4">
<span className="text-white"></span>
<h4 className="text-white font-medium tracking-wide text-lg">CONTACT</h4>
</div>
<Link
href="mailto:info@united-tattoo.com"
className="text-gray-400 hover:text-white transition-colors duration-200 underline text-base"
>
INFO@UNITED-TATTOO.COM
</Link>
</div>
</div>
</div>
<div className="flex justify-end mt-8 gap-2">
<div className="w-3 h-3 rounded-full bg-gray-400"></div>
<div className="w-3 h-3 rounded-full bg-white"></div>
</div>
</div>
</footer>
</>
)
}

View File

@ -0,0 +1,84 @@
"use client"
import { useState } from "react"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Input } from "@/components/ui/input"
import { Alert, AlertDescription } from "@/components/ui/alert"
import { CreditCard, Search, CheckCircle, XCircle } from "lucide-react"
export function GiftCardBalanceChecker() {
const [giftCardCode, setGiftCardCode] = useState("")
const [balance, setBalance] = useState<number | null>(null)
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState("")
const checkBalance = async () => {
if (!giftCardCode.trim()) {
setError("Please enter a gift card code")
return
}
setIsLoading(true)
setError("")
setBalance(null)
try {
const response = await fetch(`/api/gift-cards?code=${encodeURIComponent(giftCardCode)}`)
const data = await response.json()
if (data.success) {
setBalance(data.giftCard.remainingBalance)
} else {
setError(data.error || "Gift card not found")
}
} catch (err) {
setError("Failed to check balance. Please try again.")
} finally {
setIsLoading(false)
}
}
return (
<Card className="max-w-md mx-auto">
<CardHeader>
<CardTitle className="flex items-center space-x-2">
<CreditCard className="w-5 h-5" />
<span>Check Gift Card Balance</span>
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div>
<label className="block text-sm font-medium mb-2">Gift Card Code</label>
<Input
value={giftCardCode}
onChange={(e) => setGiftCardCode(e.target.value.toUpperCase())}
placeholder="XXXX-XXXX-XXXX"
maxLength={14}
/>
</div>
<Button onClick={checkBalance} disabled={isLoading} className="w-full">
<Search className="w-4 h-4 mr-2" />
{isLoading ? "Checking..." : "Check Balance"}
</Button>
{balance !== null && (
<Alert className="border-green-200 bg-green-50">
<CheckCircle className="h-4 w-4 text-green-600" />
<AlertDescription className="text-green-800">
<strong>Balance: ${balance}</strong>
</AlertDescription>
</Alert>
)}
{error && (
<Alert className="border-red-200 bg-red-50">
<XCircle className="h-4 w-4 text-red-600" />
<AlertDescription className="text-red-800">{error}</AlertDescription>
</Alert>
)}
</CardContent>
</Card>
)
}

View File

@ -0,0 +1,488 @@
"use client"
import { useState } from "react"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Input } from "@/components/ui/input"
import { Textarea } from "@/components/ui/textarea"
import { Badge } from "@/components/ui/badge"
import { Alert, AlertDescription } from "@/components/ui/alert"
import { Gift, CreditCard, Mail, Star, Check } from "lucide-react"
const giftCardAmounts = [
{ amount: 100, popular: false, description: "Perfect for small tattoos" },
{ amount: 200, popular: true, description: "Great for medium pieces" },
{ amount: 300, popular: false, description: "Ideal for larger tattoos" },
{ amount: 500, popular: false, description: "Perfect for full sessions" },
]
const deliveryMethods = [
{
method: "email",
title: "Email Delivery",
description: "Instant delivery to recipient's email",
icon: Mail,
time: "Instant",
},
{
method: "physical",
title: "Physical Card",
description: "Beautiful printed card mailed to address",
icon: Gift,
time: "3-5 business days",
},
]
const giftCardFeatures = [
"No expiration date",
"Transferable to others",
"Can be used for any service",
"Remaining balance carries over",
"Lost card replacement available",
"Online balance checking",
]
export function GiftCardsPage() {
const [selectedAmount, setSelectedAmount] = useState<number | null>(200)
const [customAmount, setCustomAmount] = useState("")
const [deliveryMethod, setDeliveryMethod] = useState("email")
const [formData, setFormData] = useState({
// Purchaser Info
buyerName: "",
buyerEmail: "",
buyerPhone: "",
// Recipient Info
recipientName: "",
recipientEmail: "",
recipientPhone: "",
recipientAddress: "",
// Gift Card Details
personalMessage: "",
deliveryDate: "",
isGift: true,
})
const [isProcessing, setIsProcessing] = useState(false)
const handleInputChange = (field: string, value: string) => {
setFormData((prev) => ({ ...prev, [field]: value }))
}
const finalAmount = selectedAmount || Number.parseInt(customAmount) || 0
const handlePurchase = async () => {
setIsProcessing(true)
await new Promise((resolve) => setTimeout(resolve, 2000))
// Simulate successful purchase
console.log("Simulated gift card purchase:", {
amount: finalAmount,
delivery: deliveryMethod,
...formData,
})
setIsProcessing(false)
alert(
`Gift card purchase successful! A ${finalAmount >= 200 ? `$${finalAmount + 25}` : `$${finalAmount}`} gift card will be ${deliveryMethod === "email" ? "emailed" : "mailed"} ${formData.isGift === "true" ? `to ${formData.recipientName}` : "to you"}.`,
)
}
return (
<div className="container mx-auto px-4 py-8">
<div className="max-w-6xl mx-auto">
{/* Header */}
<div className="text-center mb-12">
<h1 className="font-playfair text-4xl md:text-5xl font-bold mb-6">Gift Cards</h1>
<p className="text-lg text-muted-foreground max-w-2xl mx-auto text-balance">
Give the gift of exceptional tattoo artistry. Perfect for birthdays, holidays, or any special occasion. Our
gift cards never expire and can be used for any service.
</p>
</div>
{/* Special Offer Alert */}
<Alert className="mb-8 border-primary/20 bg-primary/5">
<Gift className="h-4 w-4 text-primary" />
<AlertDescription>
<strong>Holiday Special:</strong> Purchase a $200+ gift card and receive a $25 bonus card free! Limited time
offer.
</AlertDescription>
</Alert>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
{/* Gift Card Selection */}
<div className="lg:col-span-2 space-y-8">
{/* Amount Selection */}
<Card>
<CardHeader>
<CardTitle className="font-playfair text-2xl">Choose Amount</CardTitle>
<p className="text-muted-foreground">Select a preset amount or enter a custom value</p>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
{giftCardAmounts.map((option) => (
<div
key={option.amount}
className={`relative p-4 border-2 rounded-lg cursor-pointer transition-all duration-200 ${
selectedAmount === option.amount
? "border-primary bg-primary/5"
: "border-border hover:border-primary/50"
}`}
onClick={() => {
setSelectedAmount(option.amount)
setCustomAmount("")
}}
>
{option.popular && (
<Badge className="absolute -top-2 left-1/2 transform -translate-x-1/2 bg-primary">
Popular
</Badge>
)}
<div className="text-center">
<div className="text-2xl font-bold text-primary">${option.amount}</div>
<p className="text-xs text-muted-foreground mt-1">{option.description}</p>
</div>
{selectedAmount === option.amount && (
<Check className="absolute top-2 right-2 w-5 h-5 text-primary" />
)}
</div>
))}
</div>
<div className="border-t pt-6">
<label className="block text-sm font-medium mb-2">Custom Amount</label>
<div className="flex items-center space-x-2">
<span className="text-lg font-medium">$</span>
<Input
type="number"
min="25"
max="1000"
placeholder="Enter amount"
value={customAmount}
onChange={(e) => {
setCustomAmount(e.target.value)
setSelectedAmount(null)
}}
className="max-w-32"
/>
<span className="text-sm text-muted-foreground">($25 minimum)</span>
</div>
</div>
</CardContent>
</Card>
{/* Delivery Method */}
<Card>
<CardHeader>
<CardTitle className="font-playfair text-2xl">Delivery Method</CardTitle>
<p className="text-muted-foreground">How would you like to send the gift card?</p>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{deliveryMethods.map((method) => {
const Icon = method.icon
return (
<div
key={method.method}
className={`p-4 border-2 rounded-lg cursor-pointer transition-all duration-200 ${
deliveryMethod === method.method
? "border-primary bg-primary/5"
: "border-border hover:border-primary/50"
}`}
onClick={() => setDeliveryMethod(method.method)}
>
<div className="flex items-start space-x-3">
<div className="p-2 bg-primary/10 rounded-full">
<Icon className="w-5 h-5 text-primary" />
</div>
<div className="flex-1">
<h3 className="font-semibold">{method.title}</h3>
<p className="text-sm text-muted-foreground mb-2">{method.description}</p>
<Badge variant="outline" className="text-xs">
{method.time}
</Badge>
</div>
{deliveryMethod === method.method && <Check className="w-5 h-5 text-primary" />}
</div>
</div>
)
})}
</div>
</CardContent>
</Card>
{/* Recipient Information */}
<Card>
<CardHeader>
<CardTitle className="font-playfair text-2xl">Recipient Information</CardTitle>
<p className="text-muted-foreground">Who is this gift card for?</p>
</CardHeader>
<CardContent className="space-y-6">
<div className="flex items-center space-x-2 mb-4">
<input
type="checkbox"
id="isGift"
checked={formData.isGift}
onChange={(e) => handleInputChange("isGift", e.target.checked.toString())}
className="rounded"
/>
<label htmlFor="isGift" className="text-sm">
This is a gift for someone else
</label>
</div>
{formData.isGift === "true" ? (
<>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium mb-2">Recipient Name *</label>
<Input
value={formData.recipientName}
onChange={(e) => handleInputChange("recipientName", e.target.value)}
placeholder="Gift recipient's name"
required
/>
</div>
<div>
<label className="block text-sm font-medium mb-2">Recipient Email *</label>
<Input
type="email"
value={formData.recipientEmail}
onChange={(e) => handleInputChange("recipientEmail", e.target.value)}
placeholder="recipient@email.com"
required
/>
</div>
</div>
{deliveryMethod === "physical" && (
<div>
<label className="block text-sm font-medium mb-2">Mailing Address *</label>
<Textarea
value={formData.recipientAddress}
onChange={(e) => handleInputChange("recipientAddress", e.target.value)}
placeholder="Full mailing address for physical card delivery"
rows={3}
required
/>
</div>
)}
<div>
<label className="block text-sm font-medium mb-2">Personal Message</label>
<Textarea
value={formData.personalMessage}
onChange={(e) => handleInputChange("personalMessage", e.target.value)}
placeholder="Add a personal message to the gift card (optional)"
rows={3}
maxLength={200}
/>
<p className="text-xs text-muted-foreground mt-1">
{formData.personalMessage.length}/200 characters
</p>
</div>
<div>
<label className="block text-sm font-medium mb-2">Delivery Date (Optional)</label>
<Input
type="date"
value={formData.deliveryDate}
onChange={(e) => handleInputChange("deliveryDate", e.target.value)}
min={new Date().toISOString().split("T")[0]}
/>
<p className="text-xs text-muted-foreground mt-1">Leave blank for immediate delivery</p>
</div>
</>
) : (
<div className="p-4 bg-muted/50 rounded-lg">
<p className="text-sm text-muted-foreground">
The gift card will be sent to your email address after purchase.
</p>
</div>
)}
</CardContent>
</Card>
{/* Purchaser Information */}
<Card>
<CardHeader>
<CardTitle className="font-playfair text-2xl">Your Information</CardTitle>
<p className="text-muted-foreground">We need your details for the purchase</p>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium mb-2">Your Name *</label>
<Input
value={formData.buyerName}
onChange={(e) => handleInputChange("buyerName", e.target.value)}
required
/>
</div>
<div>
<label className="block text-sm font-medium mb-2">Your Email *</label>
<Input
type="email"
value={formData.buyerEmail}
onChange={(e) => handleInputChange("buyerEmail", e.target.value)}
required
/>
</div>
</div>
<div className="mt-4">
<label className="block text-sm font-medium mb-2">Phone Number</label>
<Input
type="tel"
value={formData.buyerPhone}
onChange={(e) => handleInputChange("buyerPhone", e.target.value)}
placeholder="For order confirmation"
/>
</div>
</CardContent>
</Card>
</div>
{/* Order Summary & Features */}
<div className="space-y-6">
{/* Order Summary */}
<Card className="sticky top-4">
<CardHeader>
<CardTitle className="font-playfair text-xl">Order Summary</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex justify-between items-center">
<span>Gift Card Amount</span>
<span className="font-semibold">${finalAmount}</span>
</div>
<div className="flex justify-between items-center">
<span>Delivery Method</span>
<span className="text-sm text-muted-foreground">
{deliveryMethod === "email" ? "Email" : "Physical Card"}
</span>
</div>
{deliveryMethod === "physical" && (
<div className="flex justify-between items-center">
<span>Shipping</span>
<span className="text-sm">Free</span>
</div>
)}
{finalAmount >= 200 && (
<div className="flex justify-between items-center text-green-600">
<span>Bonus Card</span>
<span className="font-semibold">+$25</span>
</div>
)}
<div className="border-t pt-4">
<div className="flex justify-between items-center text-lg font-bold">
<span>Total Value</span>
<span>${finalAmount >= 200 ? finalAmount + 25 : finalAmount}</span>
</div>
</div>
<Button
className="w-full bg-primary hover:bg-primary/90"
size="lg"
onClick={handlePurchase}
disabled={!finalAmount || finalAmount < 25 || isProcessing}
>
<CreditCard className="w-4 h-4 mr-2" />
{isProcessing ? "Processing..." : `Purchase Gift Card - $${finalAmount}`}
</Button>
<p className="text-xs text-muted-foreground text-center">Secure payment integration (demo mode)</p>
</CardContent>
</Card>
{/* Gift Card Features */}
<Card>
<CardHeader>
<CardTitle className="font-playfair text-xl">Gift Card Features</CardTitle>
</CardHeader>
<CardContent>
<ul className="space-y-3">
{giftCardFeatures.map((feature, index) => (
<li key={index} className="flex items-start space-x-2">
<Star className="w-4 h-4 text-primary mt-1 flex-shrink-0" />
<span className="text-sm">{feature}</span>
</li>
))}
</ul>
</CardContent>
</Card>
{/* Help Section */}
<Card>
<CardHeader>
<CardTitle className="font-playfair text-xl">Need Help?</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground mb-4">
Have questions about gift cards? We're here to help!
</p>
<div className="space-y-2">
<Button variant="outline" size="sm" className="w-full bg-transparent">
<Mail className="w-4 h-4 mr-2" />
Email Support
</Button>
<Button variant="outline" size="sm" className="w-full bg-transparent">
<Gift className="w-4 h-4 mr-2" />
Call (555) 123-TATT
</Button>
</div>
</CardContent>
</Card>
</div>
</div>
{/* FAQ Section */}
<div className="mt-16">
<h2 className="font-playfair text-3xl font-bold mb-8 text-center">Gift Card FAQ</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<Card>
<CardContent className="p-6">
<h3 className="font-semibold mb-2">Do gift cards expire?</h3>
<p className="text-muted-foreground text-sm">
No! Our gift cards never expire and can be used at any time for any of our services.
</p>
</CardContent>
</Card>
<Card>
<CardContent className="p-6">
<h3 className="font-semibold mb-2">Can gift cards be transferred?</h3>
<p className="text-muted-foreground text-sm">
Yes, gift cards can be transferred to another person. Just contact us with the details.
</p>
</CardContent>
</Card>
<Card>
<CardContent className="p-6">
<h3 className="font-semibold mb-2">What if I lose my gift card?</h3>
<p className="text-muted-foreground text-sm">
We can replace lost gift cards with proof of purchase. Keep your confirmation email safe!
</p>
</CardContent>
</Card>
<Card>
<CardContent className="p-6">
<h3 className="font-semibold mb-2">Can I check my gift card balance?</h3>
<p className="text-muted-foreground text-sm">
Yes! You can check your balance online using your gift card number or call us anytime.
</p>
</CardContent>
</Card>
</div>
</div>
</div>
</div>
)
}

View File

@ -0,0 +1,70 @@
"use client"
import { useEffect, useState } from "react"
import { Button } from "@/components/ui/button"
export function HeroSection() {
const [isVisible, setIsVisible] = useState(false)
const [scrollY, setScrollY] = useState(0)
useEffect(() => {
const timer = setTimeout(() => setIsVisible(true), 300)
return () => clearTimeout(timer)
}, [])
useEffect(() => {
const handleScroll = () => {
setScrollY(window.scrollY)
}
window.addEventListener("scroll", handleScroll, { passive: true })
return () => window.removeEventListener("scroll", handleScroll)
}, [])
return (
<section id="home" className="min-h-screen flex items-center justify-center relative overflow-hidden">
<div
className="absolute inset-0 bg-cover bg-center bg-no-repeat"
style={{
backgroundImage: "url(/united-logo-full.jpg)",
transform: `translateY(${scrollY * 0.5}px)`,
}}
/>
<div className="absolute inset-0 bg-black/70" />
<div
className="relative z-10 text-center max-w-4xl px-8"
style={{ transform: `translateY(${scrollY * -0.1}px)` }}
>
<div
className={`transition-all duration-1000 ${
isVisible ? "opacity-100 translate-y-0" : "opacity-0 translate-y-8"
}`}
>
<h1 className="text-5xl lg:text-7xl font-bold text-white mb-6 tracking-tight">UNITED TATTOO</h1>
</div>
<div
className={`transition-all duration-1000 delay-300 ${
isVisible ? "opacity-100 translate-y-0" : "opacity-0 translate-y-8"
}`}
>
<p className="text-xl lg:text-2xl text-gray-200 mb-12 font-light">Where artistry meets precision</p>
</div>
<div
className={`transition-all duration-1000 delay-500 ${
isVisible ? "opacity-100 translate-y-0" : "opacity-0 translate-y-8"
}`}
>
<Button
size="lg"
className="bg-gray-50 text-gray-900 hover:bg-gray-100 px-12 py-6 text-lg font-medium rounded-lg"
>
Book Consultation
</Button>
</div>
</div>
</section>
)
}

125
components/navigation.tsx Normal file
View File

@ -0,0 +1,125 @@
"use client"
import { useState, useEffect } from "react"
import Link from "next/link"
import { Button } from "@/components/ui/button"
import { Menu, X } from "lucide-react"
export function Navigation() {
const [isOpen, setIsOpen] = useState(false)
const [isScrolled, setIsScrolled] = useState(false)
const [activeSection, setActiveSection] = useState("home")
useEffect(() => {
const handleScroll = () => {
setIsScrolled(window.scrollY > 50)
const sections = ["home", "artists", "services", "contact"]
const scrollPosition = window.scrollY + 100
for (const section of sections) {
const element = document.getElementById(section)
if (element) {
const { offsetTop, offsetHeight } = element
if (scrollPosition >= offsetTop && scrollPosition < offsetTop + offsetHeight) {
setActiveSection(section)
break
}
}
}
}
window.addEventListener("scroll", handleScroll)
return () => window.removeEventListener("scroll", handleScroll)
}, [])
const navItems = [
{ href: "#home", label: "Home", id: "home" },
{ href: "#artists", label: "Artists", id: "artists" },
{ href: "#services", label: "Services", id: "services" },
{ href: "#contact", label: "Contact", id: "contact" },
]
return (
<nav
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"
}`}
>
<div className="max-w-screen-2xl mx-auto px-6 lg:px-12">
<div className="flex items-center justify-between h-20">
<Link
href="/"
className="font-bold text-xl lg:text-2xl tracking-[0.2em] transition-all duration-500 drop-shadow-lg text-white"
>
UNITED TATTOO
</Link>
<div className="hidden lg:flex items-center space-x-12">
{navItems.map((item) => (
<Link
key={item.href}
href={item.href}
className={`relative text-sm font-semibold tracking-[0.1em] uppercase transition-all duration-300 group ${
activeSection === item.id ? "text-white" : "text-white/80 hover:text-white"
}`}
>
{item.label}
<span
className={`absolute -bottom-1 left-0 h-0.5 bg-white transition-all duration-300 ${
activeSection === item.id ? "w-full" : "w-0 group-hover:w-full"
}`}
/>
</Link>
))}
<Button
asChild
className="bg-white hover:bg-gray-100 text-black !text-black px-8 py-3 text-sm font-semibold tracking-[0.05em] uppercase shadow-xl hover:shadow-2xl transition-all duration-300 hover:scale-105"
>
<Link href="/book">Book Now</Link>
</Button>
</div>
<button
className="lg:hidden p-3 rounded-lg transition-all duration-300 text-white hover:bg-white/10"
onClick={() => setIsOpen(!isOpen)}
aria-label="Toggle menu"
>
{isOpen ? <X size={24} /> : <Menu size={24} />}
</button>
</div>
{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">
{navItems.map((item) => (
<Link
key={item.href}
href={item.href}
className={`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"
}`}
onClick={() => setIsOpen(false)}
>
{item.label}
</Link>
))}
<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"
>
<Link href="/book" onClick={() => setIsOpen(false)}>
Book Now
</Link>
</Button>
</div>
</div>
)}
</div>
</nav>
)
}

173
components/privacy-page.tsx Normal file
View File

@ -0,0 +1,173 @@
"use client"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Alert, AlertDescription } from "@/components/ui/alert"
import { Badge } from "@/components/ui/badge"
import { Shield, Lock, Cookie, Globe, Mail, Info } from "lucide-react"
import Link from "next/link"
export function PrivacyPage() {
return (
<div className="min-h-screen bg-black text-white">
{/* Hero / Header */}
<section className="relative overflow-hidden">
<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>
<div className="relative z-10 pt-28 pb-16 px-8 lg:px-16">
<div className="max-w-4xl mx-auto text-center">
<h1 className="font-playfair text-5xl lg:text-7xl font-bold mb-6 tracking-tight">Privacy Policy</h1>
<p className="text-xl text-gray-300 leading-relaxed max-w-3xl mx-auto">
We respect your privacy. This policy explains what information we collect, how we use it, and the choices
you have. We keep it practical and transparent.
</p>
<div className="mt-6">
<Badge variant="outline" className="border-white/30 text-white">Last updated: 2025-09-16</Badge>
</div>
</div>
</div>
</section>
{/* Summary Notice */}
<section className="px-8 lg:px-16">
<div className="max-w-4xl mx-auto">
<Alert className="bg-white/5 border-white/10">
<Info className="h-5 w-5 text-white" />
<AlertDescription className="text-gray-300">
This Privacy Policy applies to united-tattoo.com and services offered by United Tattoo. For questions, email{" "}
<Link href="mailto:info@united-tattoo.com" className="underline">info@united-tattoo.com</Link> or call{" "}
<Link href="tel:+17196989004" className="underline">(719) 698-9004</Link>.
</AlertDescription>
</Alert>
</div>
</section>
{/* Sections */}
<section className="px-8 lg:px-16 mt-12">
<div className="max-w-6xl mx-auto grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Information We Collect */}
<Card className="bg-white/5 border-white/10">
<CardHeader>
<CardTitle className="text-white/90 flex items-center gap-2">
<Shield className="w-5 h-5" /> Information We Collect
</CardTitle>
</CardHeader>
<CardContent className="text-gray-300 space-y-3">
<p> Contact details (name, email, phone) when booking or contacting us.</p>
<p> Tattoo consultation details you provide (style, size, placement, references).</p>
<p> Basic device/browser data for site functionality and security.</p>
<p> Optional social media links you share for portfolio references.</p>
</CardContent>
</Card>
{/* How We Use Information */}
<Card className="bg-white/5 border-white/10">
<CardHeader>
<CardTitle className="text-white/90 flex items-center gap-2">
<Lock className="w-5 h-5" /> How We Use Your Info
</CardTitle>
</CardHeader>
<CardContent className="text-gray-300 space-y-3">
<p> To schedule appointments and communicate about your booking.</p>
<p> To match you with an artist that fits your style and timeline.</p>
<p> To improve the website experience and studio operations.</p>
<p> To comply with health and safety regulations where applicable.</p>
</CardContent>
</Card>
{/* Cookies */}
<Card className="bg-white/5 border-white/10">
<CardHeader>
<CardTitle className="text-white/90 flex items-center gap-2">
<Cookie className="w-5 h-5" /> Cookies & Analytics
</CardTitle>
</CardHeader>
<CardContent className="text-gray-300 space-y-3">
<p> We may use basic cookies for site functionality (e.g., forms, navigation).</p>
<p> We may use privacy-friendly analytics to understand site usage at an aggregate level.</p>
<p> You can control cookies via your browser settings.</p>
</CardContent>
</Card>
{/* Data Sharing */}
<Card className="bg-white/5 border-white/10">
<CardHeader>
<CardTitle className="text-white/90 flex items-center gap-2">
<Globe className="w-5 h-5" /> Sharing & Third Parties
</CardTitle>
</CardHeader>
<CardContent className="text-gray-300 space-y-3">
<p> We do not sell your personal information.</p>
<p> We may share information with service providers (e.g., payment processors) to complete your request.</p>
<p> If legally required, we may disclose information to comply with applicable laws.</p>
</CardContent>
</Card>
{/* Retention & Security */}
<Card className="bg-white/5 border-white/10">
<CardHeader>
<CardTitle className="text-white/90 flex items-center gap-2">
<Lock className="w-5 h-5" /> Retention & Security
</CardTitle>
</CardHeader>
<CardContent className="text-gray-300 space-y-3">
<p> We retain information only as long as necessary for the purpose it was collected.</p>
<p> We implement reasonable safeguards to protect your information.</p>
<p> No method of transmission or storage is 100% secure, but we take your privacy seriously.</p>
</CardContent>
</Card>
{/* Your Choices */}
<Card className="bg-white/5 border-white/10">
<CardHeader>
<CardTitle className="text-white/90 flex items-center gap-2">
<Mail className="w-5 h-5" /> Your Choices & Contact
</CardTitle>
</CardHeader>
<CardContent className="text-gray-300 space-y-3">
<p> You can request updates, corrections, or deletion of your information where applicable.</p>
<p>
To exercise your choices, contact us at{" "}
<Link href="mailto:info@united-tattoo.com" className="underline">info@united-tattoo.com</Link>{" "}
or call{" "}
<Link href="tel:+17196989004" className="underline">(719) 698-9004</Link>.
</p>
<p> Well respond within a reasonable timeframe.</p>
</CardContent>
</Card>
{/* Changes */}
<Card className="bg-white/5 border-white/10 lg:col-span-2">
<CardHeader>
<CardTitle className="text-white/90 flex items-center gap-2">
<Info className="w-5 h-5" /> Updates to This Policy
</CardTitle>
</CardHeader>
<CardContent className="text-gray-300 space-y-3">
<p>
We may update this Privacy Policy as our practices evolve. Well post the latest version on this page
with the updated date. Continued use of our services means you accept any changes.
</p>
</CardContent>
</Card>
</div>
</section>
{/* Footer Note */}
<section className="px-8 lg:px-16 mt-12 pb-24">
<div className="max-w-4xl mx-auto">
<Card className="bg-white/5 border-white/10">
<CardContent className="p-6 text-gray-300">
<p>If you have privacy concerns, reach out. Were real humans and well help you out.</p>
</CardContent>
</Card>
</div>
</section>
</div>
)
}

View File

@ -0,0 +1,24 @@
"use client"
import { useState, useEffect } from "react"
export function ScrollProgress() {
const [scrollProgress, setScrollProgress] = useState(0)
useEffect(() => {
const handleScroll = () => {
const totalHeight = document.documentElement.scrollHeight - window.innerHeight
const progress = (window.scrollY / totalHeight) * 100
setScrollProgress(Math.min(progress, 100))
}
window.addEventListener("scroll", handleScroll)
return () => window.removeEventListener("scroll", handleScroll)
}, [])
return (
<div className="fixed top-0 left-0 right-0 z-[60] h-1 bg-background/20">
<div className="h-full bg-primary transition-all duration-150 ease-out" style={{ width: `${scrollProgress}%` }} />
</div>
)
}

View File

@ -0,0 +1,32 @@
"use client"
import { useEffect } from "react"
export function ScrollToSection() {
useEffect(() => {
const handleAnchorClick = (e: Event) => {
const target = e.target as HTMLAnchorElement
if (target.tagName === "A" && target.getAttribute("href")?.startsWith("#")) {
e.preventDefault()
const id = target.getAttribute("href")?.slice(1)
const element = document.getElementById(id || "")
if (element) {
const navHeight = 80 // Account for fixed navigation
const elementPosition = element.getBoundingClientRect().top + window.pageYOffset
const offsetPosition = elementPosition - navHeight
window.scrollTo({
top: offsetPosition,
behavior: "smooth",
})
}
}
}
document.addEventListener("click", handleAnchorClick)
return () => document.removeEventListener("click", handleAnchorClick)
}, [])
return null
}

View File

@ -0,0 +1,55 @@
"use client"
import { useEffect, useRef, useState } from "react"
interface SectionHeaderProps {
title: string
subtitle?: string
className?: string
}
export function SectionHeader({ title, subtitle, className = "" }: SectionHeaderProps) {
const [isVisible, setIsVisible] = useState(false)
const [isSticky, setIsSticky] = useState(false)
const headerRef = useRef<HTMLDivElement>(null)
useEffect(() => {
const observer = new IntersectionObserver(
([entry]) => {
setIsVisible(entry.isIntersecting)
},
{ threshold: 0.1 },
)
const stickyObserver = new IntersectionObserver(
([entry]) => {
setIsSticky(!entry.isIntersecting)
},
{ rootMargin: "-80px 0px 0px 0px" },
)
if (headerRef.current) {
observer.observe(headerRef.current)
stickyObserver.observe(headerRef.current)
}
return () => {
observer.disconnect()
stickyObserver.disconnect()
}
}, [])
return (
<div
ref={headerRef}
className={`transition-all duration-700 ${
isVisible ? "opacity-100 translate-y-0" : "opacity-0 translate-y-8"
} ${className}`}
>
<div className={`transition-all duration-300 ${isSticky ? "scale-95" : "scale-100"}`}>
<h2 className="font-playfair text-4xl md:text-5xl font-bold text-center mb-4">{title}</h2>
{subtitle && <p className="text-lg text-muted-foreground text-center max-w-2xl mx-auto">{subtitle}</p>}
</div>
</div>
)
}

View File

@ -0,0 +1,255 @@
"use client"
import { useEffect, useRef, useState } from "react"
import { Button } from "@/components/ui/button"
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 ServicesSection() {
const [activeService, setActiveService] = useState(0)
const [visibleItems, setVisibleItems] = useState<number[]>([])
const sectionRef = useRef<HTMLElement>(null)
useEffect(() => {
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
const itemIndex = Number.parseInt(entry.target.getAttribute("data-service-index") || "0")
setVisibleItems((prev) => [...new Set([...prev, itemIndex])])
setActiveService(itemIndex)
}
})
},
{ threshold: 0.5, rootMargin: "0px 0px -50% 0px" },
)
const items = sectionRef.current?.querySelectorAll("[data-service-index]")
items?.forEach((item) => observer.observe(item))
return () => observer.disconnect()
}, [])
return (
<section ref={sectionRef} id="services" className="min-h-screen relative">
<div className="absolute inset-x-0 top-0 h-16 bg-black rounded-b-[100px]"></div>
<div className="absolute inset-x-0 bottom-0 h-16 bg-black rounded-t-[100px]"></div>
<div className="bg-white py-20 px-8 lg:px-16 relative z-10">
<div className="max-w-screen-2xl mx-auto">
<div className="grid lg:grid-cols-2 gap-16 items-center">
<div className="relative">
<div className="absolute -left-4 top-0 w-1 h-32 bg-black/10"></div>
<div className="mb-8">
<span className="text-sm font-medium tracking-widest text-black/60 uppercase">What We Offer</span>
</div>
<h2 className="text-6xl lg:text-8xl font-bold tracking-tight mb-8 text-balance">SERVICES</h2>
<p className="text-xl text-black/70 leading-relaxed max-w-lg">
From custom designs to cover-ups, we offer comprehensive tattoo services with the highest standards of
quality and safety.
</p>
</div>
<div className="relative">
<div className="bg-black/5 h-96 rounded-2xl overflow-hidden shadow-2xl">
<img
src="/tattoo-equipment-and-tools.jpg"
alt="Tattoo Equipment"
className="w-full h-full object-cover"
/>
</div>
<div className="absolute -bottom-4 -right-4 w-24 h-24 bg-black/5 rounded-full"></div>
</div>
</div>
</div>
</div>
<div className="bg-black text-white relative z-10">
<div className="flex">
{/* 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="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="space-y-8">
<div className="mb-12">
<div className="w-12 h-px bg-white/40 mb-6"></div>
<span className="text-sm font-medium tracking-widest text-white/60 uppercase">Our Services</span>
<h3 className="text-4xl font-bold tracking-tight mt-4 text-balance">Choose Your Style</h3>
</div>
{services.map((service, index) => (
<div
key={index}
className={`transition-all duration-500 cursor-pointer group ${
activeService === index ? "opacity-100" : "opacity-50 hover:opacity-75"
}`}
onClick={() => {
const element = document.querySelector(`[data-service-index="${index}"]`)
element?.scrollIntoView({ behavior: "smooth" })
}}
>
<div
className={`border-l-2 pl-6 py-4 transition-all duration-300 ${
activeService === index ? "border-white" : "border-white/20 group-hover:border-white/40"
}`}
>
<h4 className="text-2xl font-bold mb-2">{service.title}</h4>
<p className="text-white/70 text-sm">{service.price}</p>
</div>
</div>
))}
</div>
</div>
</div>
{/* Right Side - Enhanced with split composition styling */}
<div className="w-full lg:w-1/2 bg-gradient-to-b from-black to-gray-900">
{services.map((service, index) => (
<div
key={index}
data-service-index={index}
className="min-h-screen flex items-center justify-center p-8 lg:p-16 relative"
>
<div className="absolute left-0 top-1/2 w-px h-32 bg-white/10 -translate-y-1/2"></div>
<div className="max-w-lg 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-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 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-12">
<div className="relative">
<img
src={`/abstract-geometric-shapes.png?height=300&width=400&query=${service.title.toLowerCase()} tattoo example`}
alt={service.title}
className="w-full max-w-sm h-auto object-cover rounded-lg shadow-2xl"
/>
<div className="absolute -bottom-2 -right-2 w-16 h-16 bg-white/5 rounded-lg"></div>
</div>
</div>
</div>
</div>
))}
</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>
</div>
</section>
)
}

View File

@ -0,0 +1,44 @@
"use client"
import type React from "react"
import { useEffect } from "react"
export function SmoothScrollProvider({ children }: { children: React.ReactNode }) {
useEffect(() => {
let lenis: any
const initLenis = async () => {
const Lenis = (await import("@studio-freight/lenis")).default
lenis = new Lenis({
duration: 1.2,
easing: (t: number) => Math.min(1, 1.001 - Math.pow(2, -10 * t)),
direction: "vertical",
gestureDirection: "vertical",
smooth: true,
mouseMultiplier: 1,
smoothTouch: false,
touchMultiplier: 2,
infinite: false,
})
function raf(time: number) {
lenis.raf(time)
requestAnimationFrame(raf)
}
requestAnimationFrame(raf)
}
initLenis()
return () => {
if (lenis) {
lenis.destroy()
}
}
}, [])
return <>{children}</>
}

View File

@ -0,0 +1,284 @@
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { Badge } from "@/components/ui/badge"
import { Alert, AlertDescription } from "@/components/ui/alert"
import { Percent, Calendar, Users, Star, Clock, Gift } from "lucide-react"
import Link from "next/link"
const currentSpecials = [
{
title: "First Tattoo Special",
discount: "20% OFF",
description: "Perfect for first-time clients ready to start their tattoo journey",
details: [
"Valid for tattoos under 4 hours",
"Includes free consultation",
"Must mention at booking",
"Cannot combine with other offers",
],
validUntil: "March 31, 2024",
icon: Star,
color: "bg-primary",
},
{
title: "Flash Friday",
discount: "$50 OFF",
description: "Choose from our curated flash designs every Friday",
details: [
"Pre-designed flash sheets available",
"Walk-ins welcome 2-6 PM",
"First come, first served",
"Small to medium sizes only",
],
validUntil: "Every Friday",
icon: Clock,
color: "bg-secondary",
},
{
title: "Referral Reward",
discount: "$75 CREDIT",
description: "Refer a friend and both get rewarded",
details: [
"Friend must complete their tattoo",
"Credit applied to your next session",
"No limit on referrals",
"Friend gets 10% off their first tattoo",
],
validUntil: "Ongoing",
icon: Users,
color: "bg-accent",
},
]
const seasonalOffers = [
{
title: "Spring Touch-Up Special",
description: "Refresh your existing tattoos for the warmer months",
offer: "Free consultation + 15% off touch-ups",
period: "March - May",
},
{
title: "Summer Color Pop",
description: "Add vibrant colors to existing black and grey pieces",
offer: "20% off color additions",
period: "June - August",
},
{
title: "Fall Portfolio Building",
description: "Help our apprentices build their portfolios",
offer: "Discounted rates on select designs",
period: "September - November",
},
{
title: "Holiday Gift Cards",
description: "Perfect gifts for tattoo enthusiasts",
offer: "Buy $200+ gift card, get $25 bonus",
period: "December - January",
},
]
const membershipBenefits = [
{
title: "VIP Membership",
price: "$50/year",
benefits: [
"10% off all tattoos",
"Priority booking",
"Free touch-ups within 6 months",
"Exclusive flash designs",
"Birthday month special",
],
},
{
title: "Collector's Club",
price: "$100/year",
benefits: [
"15% off all tattoos",
"Skip the deposit on bookings",
"Free aftercare products",
"Private portfolio previews",
"Annual appreciation event invite",
],
},
]
export function SpecialsPage() {
return (
<div className="container mx-auto px-4 py-8">
<div className="max-w-6xl mx-auto">
{/* Header */}
<div className="text-center mb-12">
<h1 className="font-playfair text-4xl md:text-5xl font-bold mb-6">Current Specials & Offers</h1>
<p className="text-lg text-muted-foreground max-w-2xl mx-auto text-balance">
Take advantage of our current promotions and special offers. Save on your next tattoo while getting the same
high-quality work from our talented artists.
</p>
</div>
{/* Important Notice */}
<Alert className="mb-8 border-primary/20 bg-primary/5">
<Percent className="h-4 w-4 text-primary" />
<AlertDescription>
<strong>Limited Time:</strong> All specials are subject to availability and cannot be combined with other
offers unless specified. Book early to secure your spot!
</AlertDescription>
</Alert>
{/* Current Specials */}
<div className="mb-12">
<h2 className="font-playfair text-3xl font-bold mb-8 text-center">Featured Specials</h2>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
{currentSpecials.map((special, index) => {
const Icon = special.icon
return (
<Card key={index} className="relative overflow-hidden hover:shadow-xl transition-all duration-300">
<div className={`absolute top-0 right-0 ${special.color} text-white px-3 py-1 text-sm font-bold`}>
{special.discount}
</div>
<CardHeader className="pb-4">
<div className="flex items-center space-x-3 mb-3">
<div className={`p-2 rounded-full ${special.color} text-white`}>
<Icon className="w-5 h-5" />
</div>
<CardTitle className="font-playfair text-xl">{special.title}</CardTitle>
</div>
<p className="text-muted-foreground">{special.description}</p>
</CardHeader>
<CardContent>
<ul className="space-y-2 mb-4">
{special.details.map((detail, idx) => (
<li key={idx} className="text-sm flex items-start space-x-2">
<span className="w-1.5 h-1.5 bg-primary rounded-full mt-2 flex-shrink-0" />
<span>{detail}</span>
</li>
))}
</ul>
<div className="flex items-center justify-between">
<Badge variant="outline" className="text-xs">
Valid until {special.validUntil}
</Badge>
<Button size="sm" className="bg-white text-black hover:bg-gray-100 !text-black">
Book Now
</Button>
</div>
</CardContent>
</Card>
)
})}
</div>
</div>
{/* Seasonal Offers */}
<div className="mb-12">
<h2 className="font-playfair text-3xl font-bold mb-8 text-center">Seasonal Offers</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{seasonalOffers.map((offer, index) => (
<Card key={index} className="hover:shadow-lg transition-shadow duration-300">
<CardContent className="p-6">
<div className="flex items-start justify-between mb-4">
<h3 className="font-playfair text-xl font-bold">{offer.title}</h3>
<Badge variant="secondary">{offer.period}</Badge>
</div>
<p className="text-muted-foreground mb-3">{offer.description}</p>
<div className="bg-primary/10 p-3 rounded-lg">
<p className="font-semibold text-primary">{offer.offer}</p>
</div>
</CardContent>
</Card>
))}
</div>
</div>
{/* Membership Programs */}
<div className="mb-12">
<h2 className="font-playfair text-3xl font-bold mb-8 text-center">Membership Programs</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
{membershipBenefits.map((membership, index) => (
<Card key={index} className="relative hover:shadow-xl transition-all duration-300">
<CardHeader className="text-center pb-4">
<div className="mx-auto w-16 h-16 bg-primary/10 rounded-full flex items-center justify-center mb-4">
<Gift className="w-8 h-8 text-primary" />
</div>
<CardTitle className="font-playfair text-2xl">{membership.title}</CardTitle>
<div className="text-3xl font-bold text-primary">{membership.price}</div>
</CardHeader>
<CardContent>
<ul className="space-y-3">
{membership.benefits.map((benefit, idx) => (
<li key={idx} className="flex items-start space-x-2">
<Star className="w-4 h-4 text-primary mt-1 flex-shrink-0" />
<span className="text-sm">{benefit}</span>
</li>
))}
</ul>
<Button className="w-full mt-6 bg-primary hover:bg-primary/90">Join Now</Button>
</CardContent>
</Card>
))}
</div>
</div>
{/* Terms and Conditions */}
<Card className="mb-12 border-muted">
<CardHeader>
<CardTitle className="font-playfair text-xl">Terms & Conditions</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 text-sm text-muted-foreground">
<div>
<h4 className="font-semibold text-foreground mb-2">General Terms</h4>
<ul className="space-y-1">
<li> Specials cannot be combined unless stated</li>
<li> Valid ID required for all appointments</li>
<li> Deposits still required for all bookings</li>
<li> Subject to artist availability</li>
</ul>
</div>
<div>
<h4 className="font-semibold text-foreground mb-2">Booking Requirements</h4>
<ul className="space-y-1">
<li> Must mention special at time of booking</li>
<li> Cannot be applied to existing bookings</li>
<li> Some restrictions may apply</li>
<li> Management reserves right to modify offers</li>
</ul>
</div>
</div>
</CardContent>
</Card>
{/* Call to Action */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<Card className="bg-primary text-primary-foreground">
<CardContent className="p-6 text-center">
<Calendar className="w-8 h-8 mx-auto mb-4" />
<h3 className="font-playfair text-xl font-bold mb-2">Ready to Save?</h3>
<p className="mb-4 opacity-90">Book your appointment and mention your preferred special</p>
<Button
asChild
className="bg-white !bg-white text-black !text-black hover:bg-gray-100 hover:!text-black border border-gray-200"
>
<Link href="/book">Book Now</Link>
</Button>
</CardContent>
</Card>
<Card className="bg-secondary text-secondary-foreground">
<CardContent className="p-6 text-center">
<Gift className="w-8 h-8 mx-auto mb-4" />
<h3 className="font-playfair text-xl font-bold mb-2">Gift Cards Available</h3>
<p className="mb-4 opacity-90">Perfect for tattoo enthusiasts in your life</p>
<Button
asChild
variant="outline"
className="border-white text-white hover:bg-white hover:text-black bg-transparent"
>
<Link href="/gift-cards">Buy Gift Cards</Link>
</Button>
</CardContent>
</Card>
</div>
</div>
</div>
)
}

173
components/terms-page.tsx Normal file
View File

@ -0,0 +1,173 @@
"use client"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Alert, AlertDescription } from "@/components/ui/alert"
import { Badge } from "@/components/ui/badge"
import { Shield, Scale, Info } from "lucide-react"
import Link from "next/link"
export function TermsPage() {
return (
<div className="min-h-screen bg-black text-white">
{/* Hero / Header */}
<section className="relative overflow-hidden">
<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>
<div className="relative z-10 pt-28 pb-16 px-8 lg:px-16">
<div className="max-w-4xl mx-auto text-center">
<h1 className="font-playfair text-5xl lg:text-7xl font-bold mb-6 tracking-tight">Terms of Service</h1>
<p className="text-xl text-gray-300 leading-relaxed max-w-3xl mx-auto">
The following Terms of Service outline how we operate, how bookings work, and what you can expect when
working with United Tattoo. We try to keep it fair, simple, and respectful for everyone involved.
</p>
<div className="mt-6">
<Badge variant="outline" className="border-white/30 text-white">Last updated: 2025-09-16</Badge>
</div>
</div>
</div>
</section>
{/* Notice */}
<section className="px-8 lg:px-16">
<div className="max-w-4xl mx-auto">
<Alert className="bg-white/5 border-white/10">
<Info className="h-5 w-5 text-white" />
<AlertDescription className="text-gray-300">
By booking an appointment or placing a deposit with United Tattoo, you agree to the terms outlined below.
If anything is unclear, please reach out at{" "}
<Link href="mailto:appts@united-tattoo.com" className="underline">
appts@united-tattoo.com
</Link>{" "}
or{" "}
<Link href="tel:+17196989004" className="underline">
(719) 698-9004
</Link>
.
</AlertDescription>
</Alert>
</div>
</section>
{/* Terms Sections */}
<section className="px-8 lg:px-16 mt-12">
<div className="max-w-6xl mx-auto grid grid-cols-1 lg:grid-cols-2 gap-6">
<Card className="bg-white/5 border-white/10">
<CardHeader>
<CardTitle className="text-white/90 flex items-center gap-2">
<Shield className="w-5 h-5" /> Appointments & Consultations
</CardTitle>
</CardHeader>
<CardContent className="text-gray-300 space-y-3">
<p> Consultations may be required for larger or custom pieces.</p>
<p> We review requests and match you with the best available artist for your style and timeline.</p>
<p> Pricing depends on size, detail, placement, and the artist's rate.</p>
<p>
Walk-ins are welcome based on availabilitycall ahead for current openings:
{" "}
<Link className="underline" href="tel:+17196989004">(719) 698-9004</Link>.
</p>
</CardContent>
</Card>
<Card className="bg-white/5 border-white/10">
<CardHeader>
<CardTitle className="text-white/90 flex items-center gap-2">
<Shield className="w-5 h-5" /> Deposits & Rescheduling
</CardTitle>
</CardHeader>
<CardContent className="text-gray-300 space-y-3">
<p> Deposits are required to secure appointments and are applied to the final cost.</p>
<p> Deposits are non-refundable. One transfer may be allowed with proper notice.</p>
<p> Rescheduling within 48 hours may forfeit the deposit per policy.</p>
<p>
Full deposit terms are available on our{" "}
<Link href="/deposit" className="underline">
Deposit Policy
</Link>{" "}
page.
</p>
</CardContent>
</Card>
<Card className="bg-white/5 border-white/10">
<CardHeader>
<CardTitle className="text-white/90 flex items-center gap-2">
<Scale className="w-5 h-5" /> Studio Policies & Safety
</CardTitle>
</CardHeader>
<CardContent className="text-gray-300 space-y-3">
<p> Valid government ID is required for all clients. You must be 18+ for tattoos.</p>
<p> United Tattoo is licensed by the El Paso County Health Department.</p>
<p> We follow strict sanitation standards for the safety of clients and artists.</p>
<p>
Please review our{" "}
<Link href="/aftercare" className="underline">
Aftercare
</Link>{" "}
guidelines to help your tattoo heal properly.
</p>
</CardContent>
</Card>
<Card className="bg-white/5 border-white/10">
<CardHeader>
<CardTitle className="text-white/90 flex items-center gap-2">
<Scale className="w-5 h-5" /> Artwork, Copyright & Revisions
</CardTitle>
</CardHeader>
<CardContent className="text-gray-300 space-y-3">
<p> All custom artwork remains the intellectual property of the artist.</p>
<p> Reference images help guide your piece, but we do not copy other artists' work.</p>
<p> Minor revisions to design are typically included; extensive changes may incur extra charges.</p>
<p> We reserve the right to refuse service for inappropriate or unsafe requests.</p>
</CardContent>
</Card>
<Card className="bg-white/5 border-white/10 lg:col-span-2">
<CardHeader>
<CardTitle className="text-white/90 flex items-center gap-2">
<Info className="w-5 h-5" /> Liability, Allergies & Medical Concerns
</CardTitle>
</CardHeader>
<CardContent className="text-gray-300 space-y-3">
<p>
Please disclose any allergies, skin sensitivities, or medical conditions prior to your appointment.
</p>
<p> Follow all pre-appointment guidance: rest well, hydrate, avoid alcohol/blood thinners for 24 hours.</p>
<p> Adherence to aftercare instructions is essentialcomplications may occur if not followed.</p>
<p>
If you experience signs of infection, contact us immediately at{" "}
<Link href="tel:+17196989004" className="underline">
(719) 698-9004
</Link>{" "}
or seek urgent medical care.
</p>
</CardContent>
</Card>
</div>
</section>
{/* Footer Note */}
<section className="px-8 lg:px-16 mt-12 pb-24">
<div className="max-w-4xl mx-auto">
<Card className="bg-white/5 border-white/10">
<CardContent className="p-6 text-gray-300">
<p className="mb-2">
Final decisions, refund requests, and disputes are reviewed by <strong>LW2 Investments, LLC</strong>.
</p>
<p className="text-sm text-gray-400">
These Terms may be updated periodically. Continued use of our services constitutes acceptance of the latest version.
</p>
</CardContent>
</Card>
</div>
</section>
</div>
)
}

View File

@ -0,0 +1,11 @@
'use client'
import * as React from 'react'
import {
ThemeProvider as NextThemesProvider,
type ThemeProviderProps,
} from 'next-themes'
export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
return <NextThemesProvider {...props}>{children}</NextThemesProvider>
}

View File

@ -0,0 +1,53 @@
"use client"
import type * as React from "react"
import * as AccordionPrimitive from "@radix-ui/react-accordion"
import { ChevronDown } from "lucide-react"
import { cn } from "@/lib/utils"
function Accordion({ ...props }: React.ComponentProps<typeof AccordionPrimitive.Root>) {
return <AccordionPrimitive.Root data-slot="accordion" {...props} />
}
function AccordionItem({ className, ...props }: React.ComponentProps<typeof AccordionPrimitive.Item>) {
return (
<AccordionPrimitive.Item
data-slot="accordion-item"
className={cn("border-b last:border-b-0", className)}
{...props}
/>
)
}
function AccordionTrigger({ className, children, ...props }: React.ComponentProps<typeof AccordionPrimitive.Trigger>) {
return (
<AccordionPrimitive.Header className="flex">
<AccordionPrimitive.Trigger
data-slot="accordion-trigger"
className={cn(
"focus-visible:border-ring focus-visible:ring-ring/50 flex flex-1 items-start justify-between gap-4 rounded-md py-4 text-left text-sm font-medium transition-all outline-none hover:underline focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 [&[data-state=open]>svg]:rotate-180",
className,
)}
{...props}
>
{children}
<ChevronDown className="text-muted-foreground pointer-events-none size-4 shrink-0 translate-y-0.5 transition-transform duration-200" />
</AccordionPrimitive.Trigger>
</AccordionPrimitive.Header>
)
}
function AccordionContent({ className, children, ...props }: React.ComponentProps<typeof AccordionPrimitive.Content>) {
return (
<AccordionPrimitive.Content
data-slot="accordion-content"
className="data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down overflow-hidden text-sm"
{...props}
>
<div className={cn("pt-0 pb-4", className)}>{children}</div>
</AccordionPrimitive.Content>
)
}
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }

View File

@ -0,0 +1,157 @@
'use client'
import * as React from 'react'
import * as AlertDialogPrimitive from '@radix-ui/react-alert-dialog'
import { cn } from '@/lib/utils'
import { buttonVariants } from '@/components/ui/button'
function AlertDialog({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Root>) {
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />
}
function AlertDialogTrigger({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Trigger>) {
return (
<AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />
)
}
function AlertDialogPortal({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Portal>) {
return (
<AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />
)
}
function AlertDialogOverlay({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Overlay>) {
return (
<AlertDialogPrimitive.Overlay
data-slot="alert-dialog-overlay"
className={cn(
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50',
className,
)}
{...props}
/>
)
}
function AlertDialogContent({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Content>) {
return (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
data-slot="alert-dialog-content"
className={cn(
'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg',
className,
)}
{...props}
/>
</AlertDialogPortal>
)
}
function AlertDialogHeader({
className,
...props
}: React.ComponentProps<'div'>) {
return (
<div
data-slot="alert-dialog-header"
className={cn('flex flex-col gap-2 text-center sm:text-left', className)}
{...props}
/>
)
}
function AlertDialogFooter({
className,
...props
}: React.ComponentProps<'div'>) {
return (
<div
data-slot="alert-dialog-footer"
className={cn(
'flex flex-col-reverse gap-2 sm:flex-row sm:justify-end',
className,
)}
{...props}
/>
)
}
function AlertDialogTitle({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
return (
<AlertDialogPrimitive.Title
data-slot="alert-dialog-title"
className={cn('text-lg font-semibold', className)}
{...props}
/>
)
}
function AlertDialogDescription({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {
return (
<AlertDialogPrimitive.Description
data-slot="alert-dialog-description"
className={cn('text-muted-foreground text-sm', className)}
{...props}
/>
)
}
function AlertDialogAction({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Action>) {
return (
<AlertDialogPrimitive.Action
className={cn(buttonVariants(), className)}
{...props}
/>
)
}
function AlertDialogCancel({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Cancel>) {
return (
<AlertDialogPrimitive.Cancel
className={cn(buttonVariants({ variant: 'outline' }), className)}
{...props}
/>
)
}
export {
AlertDialog,
AlertDialogPortal,
AlertDialogOverlay,
AlertDialogTrigger,
AlertDialogContent,
AlertDialogHeader,
AlertDialogFooter,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogAction,
AlertDialogCancel,
}

66
components/ui/alert.tsx Normal file
View File

@ -0,0 +1,66 @@
import * as React from 'react'
import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from '@/lib/utils'
const alertVariants = cva(
'relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current',
{
variants: {
variant: {
default: 'bg-card text-card-foreground',
destructive:
'text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90',
},
},
defaultVariants: {
variant: 'default',
},
},
)
function Alert({
className,
variant,
...props
}: React.ComponentProps<'div'> & VariantProps<typeof alertVariants>) {
return (
<div
data-slot="alert"
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
)
}
function AlertTitle({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="alert-title"
className={cn(
'col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight',
className,
)}
{...props}
/>
)
}
function AlertDescription({
className,
...props
}: React.ComponentProps<'div'>) {
return (
<div
data-slot="alert-description"
className={cn(
'text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed',
className,
)}
{...props}
/>
)
}
export { Alert, AlertTitle, AlertDescription }

View File

@ -0,0 +1,11 @@
'use client'
import * as AspectRatioPrimitive from '@radix-ui/react-aspect-ratio'
function AspectRatio({
...props
}: React.ComponentProps<typeof AspectRatioPrimitive.Root>) {
return <AspectRatioPrimitive.Root data-slot="aspect-ratio" {...props} />
}
export { AspectRatio }

53
components/ui/avatar.tsx Normal file
View File

@ -0,0 +1,53 @@
'use client'
import * as React from 'react'
import * as AvatarPrimitive from '@radix-ui/react-avatar'
import { cn } from '@/lib/utils'
function Avatar({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Root>) {
return (
<AvatarPrimitive.Root
data-slot="avatar"
className={cn(
'relative flex size-8 shrink-0 overflow-hidden rounded-full',
className,
)}
{...props}
/>
)
}
function AvatarImage({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Image>) {
return (
<AvatarPrimitive.Image
data-slot="avatar-image"
className={cn('aspect-square size-full', className)}
{...props}
/>
)
}
function AvatarFallback({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Fallback>) {
return (
<AvatarPrimitive.Fallback
data-slot="avatar-fallback"
className={cn(
'bg-muted flex size-full items-center justify-center rounded-full',
className,
)}
{...props}
/>
)
}
export { Avatar, AvatarImage, AvatarFallback }

46
components/ui/badge.tsx Normal file
View File

@ -0,0 +1,46 @@
import * as React from 'react'
import { Slot } from '@radix-ui/react-slot'
import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from '@/lib/utils'
const badgeVariants = cva(
'inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden',
{
variants: {
variant: {
default:
'border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90',
secondary:
'border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90',
destructive:
'border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60',
outline:
'text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground',
},
},
defaultVariants: {
variant: 'default',
},
},
)
function Badge({
className,
variant,
asChild = false,
...props
}: React.ComponentProps<'span'> &
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot : 'span'
return (
<Comp
data-slot="badge"
className={cn(badgeVariants({ variant }), className)}
{...props}
/>
)
}
export { Badge, badgeVariants }

View File

@ -0,0 +1,109 @@
import * as React from 'react'
import { Slot } from '@radix-ui/react-slot'
import { ChevronRight, MoreHorizontal } from 'lucide-react'
import { cn } from '@/lib/utils'
function Breadcrumb({ ...props }: React.ComponentProps<'nav'>) {
return <nav aria-label="breadcrumb" data-slot="breadcrumb" {...props} />
}
function BreadcrumbList({ className, ...props }: React.ComponentProps<'ol'>) {
return (
<ol
data-slot="breadcrumb-list"
className={cn(
'text-muted-foreground flex flex-wrap items-center gap-1.5 text-sm break-words sm:gap-2.5',
className,
)}
{...props}
/>
)
}
function BreadcrumbItem({ className, ...props }: React.ComponentProps<'li'>) {
return (
<li
data-slot="breadcrumb-item"
className={cn('inline-flex items-center gap-1.5', className)}
{...props}
/>
)
}
function BreadcrumbLink({
asChild,
className,
...props
}: React.ComponentProps<'a'> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot : 'a'
return (
<Comp
data-slot="breadcrumb-link"
className={cn('hover:text-foreground transition-colors', className)}
{...props}
/>
)
}
function BreadcrumbPage({ className, ...props }: React.ComponentProps<'span'>) {
return (
<span
data-slot="breadcrumb-page"
role="link"
aria-disabled="true"
aria-current="page"
className={cn('text-foreground font-normal', className)}
{...props}
/>
)
}
function BreadcrumbSeparator({
children,
className,
...props
}: React.ComponentProps<'li'>) {
return (
<li
data-slot="breadcrumb-separator"
role="presentation"
aria-hidden="true"
className={cn('[&>svg]:size-3.5', className)}
{...props}
>
{children ?? <ChevronRight />}
</li>
)
}
function BreadcrumbEllipsis({
className,
...props
}: React.ComponentProps<'span'>) {
return (
<span
data-slot="breadcrumb-ellipsis"
role="presentation"
aria-hidden="true"
className={cn('flex size-9 items-center justify-center', className)}
{...props}
>
<MoreHorizontal className="size-4" />
<span className="sr-only">More</span>
</span>
)
}
export {
Breadcrumb,
BreadcrumbList,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbPage,
BreadcrumbSeparator,
BreadcrumbEllipsis,
}

59
components/ui/button.tsx Normal file
View File

@ -0,0 +1,59 @@
import * as React from 'react'
import { Slot } from '@radix-ui/react-slot'
import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from '@/lib/utils'
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
{
variants: {
variant: {
default:
'bg-primary text-primary-foreground shadow-xs hover:bg-primary/90',
destructive:
'bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60',
outline:
'border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50',
secondary:
'bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80',
ghost:
'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50',
link: 'text-primary underline-offset-4 hover:underline',
},
size: {
default: 'h-9 px-4 py-2 has-[>svg]:px-3',
sm: 'h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5',
lg: 'h-10 rounded-md px-6 has-[>svg]:px-4',
icon: 'size-9',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
},
)
function Button({
className,
variant,
size,
asChild = false,
...props
}: React.ComponentProps<'button'> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot : 'button'
return (
<Comp
data-slot="button"
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Button, buttonVariants }

158
components/ui/calendar.tsx Normal file
View File

@ -0,0 +1,158 @@
"use client"
import * as React from "react"
import { ChevronDown, ChevronLeft, ChevronRight } from "lucide-react"
import { type DayButton, DayPicker, getDefaultClassNames } from "react-day-picker"
import { cn } from "@/lib/utils"
import { Button, buttonVariants } from "@/components/ui/button"
function Calendar({
className,
classNames,
showOutsideDays = true,
captionLayout = "label",
buttonVariant = "ghost",
formatters,
components,
...props
}: React.ComponentProps<typeof DayPicker> & {
buttonVariant?: React.ComponentProps<typeof Button>["variant"]
}) {
const defaultClassNames = getDefaultClassNames()
return (
<DayPicker
showOutsideDays={showOutsideDays}
className={cn(
"bg-background group/calendar p-3 [--cell-size:--spacing(8)] [[data-slot=card-content]_&]:bg-transparent [[data-slot=popover-content]_&]:bg-transparent",
String.raw`rtl:**:[.rdp-button\_next>svg]:rotate-180`,
String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`,
className,
)}
captionLayout={captionLayout}
formatters={{
formatMonthDropdown: (date) => date.toLocaleString("default", { month: "short" }),
...formatters,
}}
classNames={{
root: cn("w-fit", defaultClassNames.root),
months: cn("flex gap-4 flex-col md:flex-row relative", defaultClassNames.months),
month: cn("flex flex-col w-full gap-4", defaultClassNames.month),
nav: cn("flex items-center gap-1 w-full absolute top-0 inset-x-0 justify-between", defaultClassNames.nav),
button_previous: cn(
buttonVariants({ variant: buttonVariant }),
"size-(--cell-size) aria-disabled:opacity-50 p-0 select-none",
defaultClassNames.button_previous,
),
button_next: cn(
buttonVariants({ variant: buttonVariant }),
"size-(--cell-size) aria-disabled:opacity-50 p-0 select-none",
defaultClassNames.button_next,
),
month_caption: cn(
"flex items-center justify-center h-(--cell-size) w-full px-(--cell-size)",
defaultClassNames.month_caption,
),
dropdowns: cn(
"w-full flex items-center text-sm font-medium justify-center h-(--cell-size) gap-1.5",
defaultClassNames.dropdowns,
),
dropdown_root: cn(
"relative has-focus:border-ring border border-input shadow-xs has-focus:ring-ring/50 has-focus:ring-[3px] rounded-md",
defaultClassNames.dropdown_root,
),
dropdown: cn("absolute bg-popover inset-0 opacity-0", defaultClassNames.dropdown),
caption_label: cn(
"select-none font-medium",
captionLayout === "label"
? "text-sm"
: "rounded-md pl-2 pr-1 flex items-center gap-1 text-sm h-8 [&>svg]:text-muted-foreground [&>svg]:size-3.5",
defaultClassNames.caption_label,
),
table: "w-full border-collapse",
weekdays: cn("flex", defaultClassNames.weekdays),
weekday: cn(
"text-muted-foreground rounded-md flex-1 font-normal text-[0.8rem] select-none",
defaultClassNames.weekday,
),
week: cn("flex w-full mt-2", defaultClassNames.week),
week_number_header: cn("select-none w-(--cell-size)", defaultClassNames.week_number_header),
week_number: cn("text-[0.8rem] select-none text-muted-foreground", defaultClassNames.week_number),
day: cn(
"relative w-full h-full p-0 text-center [&:first-child[data-selected=true]_button]:rounded-l-md [&:last-child[data-selected=true]_button]:rounded-r-md group/day aspect-square select-none",
defaultClassNames.day,
),
range_start: cn("rounded-l-md bg-accent", defaultClassNames.range_start),
range_middle: cn("rounded-none", defaultClassNames.range_middle),
range_end: cn("rounded-r-md bg-accent", defaultClassNames.range_end),
today: cn(
"bg-accent text-accent-foreground rounded-md data-[selected=true]:rounded-none",
defaultClassNames.today,
),
outside: cn("text-muted-foreground aria-selected:text-muted-foreground", defaultClassNames.outside),
disabled: cn("text-muted-foreground opacity-50", defaultClassNames.disabled),
hidden: cn("invisible", defaultClassNames.hidden),
...classNames,
}}
components={{
Root: ({ className, rootRef, ...props }) => {
return <div data-slot="calendar" ref={rootRef} className={cn(className)} {...props} />
},
Chevron: ({ className, orientation, ...props }) => {
if (orientation === "left") {
return <ChevronLeft className={cn("size-4", className)} {...props} />
}
if (orientation === "right") {
return <ChevronRight className={cn("size-4", className)} {...props} />
}
return <ChevronDown className={cn("size-4", className)} {...props} />
},
DayButton: CalendarDayButton,
WeekNumber: ({ children, ...props }) => {
return (
<td {...props}>
<div className="flex size-(--cell-size) items-center justify-center text-center">{children}</div>
</td>
)
},
...components,
}}
{...props}
/>
)
}
function CalendarDayButton({ className, day, modifiers, ...props }: React.ComponentProps<typeof DayButton>) {
const defaultClassNames = getDefaultClassNames()
const ref = React.useRef<HTMLButtonElement>(null)
React.useEffect(() => {
if (modifiers.focused) ref.current?.focus()
}, [modifiers.focused])
return (
<Button
ref={ref}
variant="ghost"
size="icon"
data-day={day.date.toLocaleDateString()}
data-selected-single={
modifiers.selected && !modifiers.range_start && !modifiers.range_end && !modifiers.range_middle
}
data-range-start={modifiers.range_start}
data-range-end={modifiers.range_end}
data-range-middle={modifiers.range_middle}
className={cn(
"data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground data-[range-middle=true]:bg-accent data-[range-middle=true]:text-accent-foreground data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-ring/50 dark:hover:text-accent-foreground flex aspect-square size-auto w-full min-w-(--cell-size) flex-col gap-1 leading-none font-normal group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:ring-[3px] data-[range-end=true]:rounded-md data-[range-end=true]:rounded-r-md data-[range-middle=true]:rounded-none data-[range-start=true]:rounded-md data-[range-start=true]:rounded-l-md [&>span]:text-xs [&>span]:opacity-70",
defaultClassNames.day,
className,
)}
{...props}
/>
)
}
export { Calendar, CalendarDayButton }

92
components/ui/card.tsx Normal file
View File

@ -0,0 +1,92 @@
import * as React from 'react'
import { cn } from '@/lib/utils'
function Card({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="card"
className={cn(
'bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm',
className,
)}
{...props}
/>
)
}
function CardHeader({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="card-header"
className={cn(
'@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6',
className,
)}
{...props}
/>
)
}
function CardTitle({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="card-title"
className={cn('leading-none font-semibold', className)}
{...props}
/>
)
}
function CardDescription({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="card-description"
className={cn('text-muted-foreground text-sm', className)}
{...props}
/>
)
}
function CardAction({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="card-action"
className={cn(
'col-start-2 row-span-2 row-start-1 self-start justify-self-end',
className,
)}
{...props}
/>
)
}
function CardContent({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="card-content"
className={cn('px-6', className)}
{...props}
/>
)
}
function CardFooter({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="card-footer"
className={cn('flex items-center px-6 [.border-t]:pt-6', className)}
{...props}
/>
)
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
}

241
components/ui/carousel.tsx Normal file
View File

@ -0,0 +1,241 @@
'use client'
import * as React from 'react'
import useEmblaCarousel, {
type UseEmblaCarouselType,
} from 'embla-carousel-react'
import { ArrowLeft, ArrowRight } from 'lucide-react'
import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
type CarouselApi = UseEmblaCarouselType[1]
type UseCarouselParameters = Parameters<typeof useEmblaCarousel>
type CarouselOptions = UseCarouselParameters[0]
type CarouselPlugin = UseCarouselParameters[1]
type CarouselProps = {
opts?: CarouselOptions
plugins?: CarouselPlugin
orientation?: 'horizontal' | 'vertical'
setApi?: (api: CarouselApi) => void
}
type CarouselContextProps = {
carouselRef: ReturnType<typeof useEmblaCarousel>[0]
api: ReturnType<typeof useEmblaCarousel>[1]
scrollPrev: () => void
scrollNext: () => void
canScrollPrev: boolean
canScrollNext: boolean
} & CarouselProps
const CarouselContext = React.createContext<CarouselContextProps | null>(null)
function useCarousel() {
const context = React.useContext(CarouselContext)
if (!context) {
throw new Error('useCarousel must be used within a <Carousel />')
}
return context
}
function Carousel({
orientation = 'horizontal',
opts,
setApi,
plugins,
className,
children,
...props
}: React.ComponentProps<'div'> & CarouselProps) {
const [carouselRef, api] = useEmblaCarousel(
{
...opts,
axis: orientation === 'horizontal' ? 'x' : 'y',
},
plugins,
)
const [canScrollPrev, setCanScrollPrev] = React.useState(false)
const [canScrollNext, setCanScrollNext] = React.useState(false)
const onSelect = React.useCallback((api: CarouselApi) => {
if (!api) return
setCanScrollPrev(api.canScrollPrev())
setCanScrollNext(api.canScrollNext())
}, [])
const scrollPrev = React.useCallback(() => {
api?.scrollPrev()
}, [api])
const scrollNext = React.useCallback(() => {
api?.scrollNext()
}, [api])
const handleKeyDown = React.useCallback(
(event: React.KeyboardEvent<HTMLDivElement>) => {
if (event.key === 'ArrowLeft') {
event.preventDefault()
scrollPrev()
} else if (event.key === 'ArrowRight') {
event.preventDefault()
scrollNext()
}
},
[scrollPrev, scrollNext],
)
React.useEffect(() => {
if (!api || !setApi) return
setApi(api)
}, [api, setApi])
React.useEffect(() => {
if (!api) return
onSelect(api)
api.on('reInit', onSelect)
api.on('select', onSelect)
return () => {
api?.off('select', onSelect)
}
}, [api, onSelect])
return (
<CarouselContext.Provider
value={{
carouselRef,
api: api,
opts,
orientation:
orientation || (opts?.axis === 'y' ? 'vertical' : 'horizontal'),
scrollPrev,
scrollNext,
canScrollPrev,
canScrollNext,
}}
>
<div
onKeyDownCapture={handleKeyDown}
className={cn('relative', className)}
role="region"
aria-roledescription="carousel"
data-slot="carousel"
{...props}
>
{children}
</div>
</CarouselContext.Provider>
)
}
function CarouselContent({ className, ...props }: React.ComponentProps<'div'>) {
const { carouselRef, orientation } = useCarousel()
return (
<div
ref={carouselRef}
className="overflow-hidden"
data-slot="carousel-content"
>
<div
className={cn(
'flex',
orientation === 'horizontal' ? '-ml-4' : '-mt-4 flex-col',
className,
)}
{...props}
/>
</div>
)
}
function CarouselItem({ className, ...props }: React.ComponentProps<'div'>) {
const { orientation } = useCarousel()
return (
<div
role="group"
aria-roledescription="slide"
data-slot="carousel-item"
className={cn(
'min-w-0 shrink-0 grow-0 basis-full',
orientation === 'horizontal' ? 'pl-4' : 'pt-4',
className,
)}
{...props}
/>
)
}
function CarouselPrevious({
className,
variant = 'outline',
size = 'icon',
...props
}: React.ComponentProps<typeof Button>) {
const { orientation, scrollPrev, canScrollPrev } = useCarousel()
return (
<Button
data-slot="carousel-previous"
variant={variant}
size={size}
className={cn(
'absolute size-8 rounded-full',
orientation === 'horizontal'
? 'top-1/2 -left-12 -translate-y-1/2'
: '-top-12 left-1/2 -translate-x-1/2 rotate-90',
className,
)}
disabled={!canScrollPrev}
onClick={scrollPrev}
{...props}
>
<ArrowLeft />
<span className="sr-only">Previous slide</span>
</Button>
)
}
function CarouselNext({
className,
variant = 'outline',
size = 'icon',
...props
}: React.ComponentProps<typeof Button>) {
const { orientation, scrollNext, canScrollNext } = useCarousel()
return (
<Button
data-slot="carousel-next"
variant={variant}
size={size}
className={cn(
'absolute size-8 rounded-full',
orientation === 'horizontal'
? 'top-1/2 -right-12 -translate-y-1/2'
: '-bottom-12 left-1/2 -translate-x-1/2 rotate-90',
className,
)}
disabled={!canScrollNext}
onClick={scrollNext}
{...props}
>
<ArrowRight />
<span className="sr-only">Next slide</span>
</Button>
)
}
export {
type CarouselApi,
Carousel,
CarouselContent,
CarouselItem,
CarouselPrevious,
CarouselNext,
}

353
components/ui/chart.tsx Normal file
View File

@ -0,0 +1,353 @@
'use client'
import * as React from 'react'
import * as RechartsPrimitive from 'recharts'
import { cn } from '@/lib/utils'
// Format: { THEME_NAME: CSS_SELECTOR }
const THEMES = { light: '', dark: '.dark' } as const
export type ChartConfig = {
[k in string]: {
label?: React.ReactNode
icon?: React.ComponentType
} & (
| { color?: string; theme?: never }
| { color?: never; theme: Record<keyof typeof THEMES, string> }
)
}
type ChartContextProps = {
config: ChartConfig
}
const ChartContext = React.createContext<ChartContextProps | null>(null)
function useChart() {
const context = React.useContext(ChartContext)
if (!context) {
throw new Error('useChart must be used within a <ChartContainer />')
}
return context
}
function ChartContainer({
id,
className,
children,
config,
...props
}: React.ComponentProps<'div'> & {
config: ChartConfig
children: React.ComponentProps<
typeof RechartsPrimitive.ResponsiveContainer
>['children']
}) {
const uniqueId = React.useId()
const chartId = `chart-${id || uniqueId.replace(/:/g, '')}`
return (
<ChartContext.Provider value={{ config }}>
<div
data-slot="chart"
data-chart={chartId}
className={cn(
"[&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border flex aspect-video justify-center text-xs [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-hidden [&_.recharts-sector]:outline-hidden [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-surface]:outline-hidden",
className,
)}
{...props}
>
<ChartStyle id={chartId} config={config} />
<RechartsPrimitive.ResponsiveContainer>
{children}
</RechartsPrimitive.ResponsiveContainer>
</div>
</ChartContext.Provider>
)
}
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
const colorConfig = Object.entries(config).filter(
([, config]) => config.theme || config.color,
)
if (!colorConfig.length) {
return null
}
return (
<style
dangerouslySetInnerHTML={{
__html: Object.entries(THEMES)
.map(
([theme, prefix]) => `
${prefix} [data-chart=${id}] {
${colorConfig
.map(([key, itemConfig]) => {
const color =
itemConfig.theme?.[theme as keyof typeof itemConfig.theme] ||
itemConfig.color
return color ? ` --color-${key}: ${color};` : null
})
.join('\n')}
}
`,
)
.join('\n'),
}}
/>
)
}
const ChartTooltip = RechartsPrimitive.Tooltip
function ChartTooltipContent({
active,
payload,
className,
indicator = 'dot',
hideLabel = false,
hideIndicator = false,
label,
labelFormatter,
labelClassName,
formatter,
color,
nameKey,
labelKey,
}: React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
React.ComponentProps<'div'> & {
hideLabel?: boolean
hideIndicator?: boolean
indicator?: 'line' | 'dot' | 'dashed'
nameKey?: string
labelKey?: string
}) {
const { config } = useChart()
const tooltipLabel = React.useMemo(() => {
if (hideLabel || !payload?.length) {
return null
}
const [item] = payload
const key = `${labelKey || item?.dataKey || item?.name || 'value'}`
const itemConfig = getPayloadConfigFromPayload(config, item, key)
const value =
!labelKey && typeof label === 'string'
? config[label as keyof typeof config]?.label || label
: itemConfig?.label
if (labelFormatter) {
return (
<div className={cn('font-medium', labelClassName)}>
{labelFormatter(value, payload)}
</div>
)
}
if (!value) {
return null
}
return <div className={cn('font-medium', labelClassName)}>{value}</div>
}, [
label,
labelFormatter,
payload,
hideLabel,
labelClassName,
config,
labelKey,
])
if (!active || !payload?.length) {
return null
}
const nestLabel = payload.length === 1 && indicator !== 'dot'
return (
<div
className={cn(
'border-border/50 bg-background grid min-w-[8rem] items-start gap-1.5 rounded-lg border px-2.5 py-1.5 text-xs shadow-xl',
className,
)}
>
{!nestLabel ? tooltipLabel : null}
<div className="grid gap-1.5">
{payload.map((item, index) => {
const key = `${nameKey || item.name || item.dataKey || 'value'}`
const itemConfig = getPayloadConfigFromPayload(config, item, key)
const indicatorColor = color || item.payload.fill || item.color
return (
<div
key={item.dataKey}
className={cn(
'[&>svg]:text-muted-foreground flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5',
indicator === 'dot' && 'items-center',
)}
>
{formatter && item?.value !== undefined && item.name ? (
formatter(item.value, item.name, item, index, item.payload)
) : (
<>
{itemConfig?.icon ? (
<itemConfig.icon />
) : (
!hideIndicator && (
<div
className={cn(
'shrink-0 rounded-[2px] border-(--color-border) bg-(--color-bg)',
{
'h-2.5 w-2.5': indicator === 'dot',
'w-1': indicator === 'line',
'w-0 border-[1.5px] border-dashed bg-transparent':
indicator === 'dashed',
'my-0.5': nestLabel && indicator === 'dashed',
},
)}
style={
{
'--color-bg': indicatorColor,
'--color-border': indicatorColor,
} as React.CSSProperties
}
/>
)
)}
<div
className={cn(
'flex flex-1 justify-between leading-none',
nestLabel ? 'items-end' : 'items-center',
)}
>
<div className="grid gap-1.5">
{nestLabel ? tooltipLabel : null}
<span className="text-muted-foreground">
{itemConfig?.label || item.name}
</span>
</div>
{item.value && (
<span className="text-foreground font-mono font-medium tabular-nums">
{item.value.toLocaleString()}
</span>
)}
</div>
</>
)}
</div>
)
})}
</div>
</div>
)
}
const ChartLegend = RechartsPrimitive.Legend
function ChartLegendContent({
className,
hideIcon = false,
payload,
verticalAlign = 'bottom',
nameKey,
}: React.ComponentProps<'div'> &
Pick<RechartsPrimitive.LegendProps, 'payload' | 'verticalAlign'> & {
hideIcon?: boolean
nameKey?: string
}) {
const { config } = useChart()
if (!payload?.length) {
return null
}
return (
<div
className={cn(
'flex items-center justify-center gap-4',
verticalAlign === 'top' ? 'pb-3' : 'pt-3',
className,
)}
>
{payload.map((item) => {
const key = `${nameKey || item.dataKey || 'value'}`
const itemConfig = getPayloadConfigFromPayload(config, item, key)
return (
<div
key={item.value}
className={cn(
'[&>svg]:text-muted-foreground flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3',
)}
>
{itemConfig?.icon && !hideIcon ? (
<itemConfig.icon />
) : (
<div
className="h-2 w-2 shrink-0 rounded-[2px]"
style={{
backgroundColor: item.color,
}}
/>
)}
{itemConfig?.label}
</div>
)
})}
</div>
)
}
// Helper to extract item config from a payload.
function getPayloadConfigFromPayload(
config: ChartConfig,
payload: unknown,
key: string,
) {
if (typeof payload !== 'object' || payload === null) {
return undefined
}
const payloadPayload =
'payload' in payload &&
typeof payload.payload === 'object' &&
payload.payload !== null
? payload.payload
: undefined
let configLabelKey: string = key
if (
key in payload &&
typeof payload[key as keyof typeof payload] === 'string'
) {
configLabelKey = payload[key as keyof typeof payload] as string
} else if (
payloadPayload &&
key in payloadPayload &&
typeof payloadPayload[key as keyof typeof payloadPayload] === 'string'
) {
configLabelKey = payloadPayload[
key as keyof typeof payloadPayload
] as string
}
return configLabelKey in config
? config[configLabelKey]
: config[key as keyof typeof config]
}
export {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
ChartLegend,
ChartLegendContent,
ChartStyle,
}

View File

@ -0,0 +1,29 @@
"use client"
import type * as React from "react"
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
import { Check } from "lucide-react"
import { cn } from "@/lib/utils"
function Checkbox({ className, ...props }: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
return (
<CheckboxPrimitive.Root
data-slot="checkbox"
className={cn(
"peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
{...props}
>
<CheckboxPrimitive.Indicator
data-slot="checkbox-indicator"
className="flex items-center justify-center text-current transition-none"
>
<Check className="size-3.5" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
)
}
export { Checkbox }

View File

@ -0,0 +1,33 @@
'use client'
import * as CollapsiblePrimitive from '@radix-ui/react-collapsible'
function Collapsible({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.Root>) {
return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />
}
function CollapsibleTrigger({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleTrigger>) {
return (
<CollapsiblePrimitive.CollapsibleTrigger
data-slot="collapsible-trigger"
{...props}
/>
)
}
function CollapsibleContent({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleContent>) {
return (
<CollapsiblePrimitive.CollapsibleContent
data-slot="collapsible-content"
{...props}
/>
)
}
export { Collapsible, CollapsibleTrigger, CollapsibleContent }

137
components/ui/command.tsx Normal file
View File

@ -0,0 +1,137 @@
"use client"
import type * as React from "react"
import { Command as CommandPrimitive } from "cmdk"
import { Search } from "lucide-react"
import { cn } from "@/lib/utils"
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"
function Command({ className, ...props }: React.ComponentProps<typeof CommandPrimitive>) {
return (
<CommandPrimitive
data-slot="command"
className={cn(
"bg-popover text-popover-foreground flex h-full w-full flex-col overflow-hidden rounded-md",
className,
)}
{...props}
/>
)
}
function CommandDialog({
title = "Command Palette",
description = "Search for a command to run...",
children,
className,
showCloseButton = true,
...props
}: React.ComponentProps<typeof Dialog> & {
title?: string
description?: string
className?: string
showCloseButton?: boolean
}) {
return (
<Dialog {...props}>
<DialogHeader className="sr-only">
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<DialogContent className={cn("overflow-hidden p-0", className)} showCloseButton={showCloseButton}>
<Command className="[&_[cmdk-group-heading]]:text-muted-foreground **:data-[slot=command-input-wrapper]:h-12 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group]]:px-2 [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
{children}
</Command>
</DialogContent>
</Dialog>
)
}
function CommandInput({ className, ...props }: React.ComponentProps<typeof CommandPrimitive.Input>) {
return (
<div data-slot="command-input-wrapper" className="flex h-9 items-center gap-2 border-b px-3">
<Search className="size-4 shrink-0 opacity-50" />
<CommandPrimitive.Input
data-slot="command-input"
className={cn(
"placeholder:text-muted-foreground flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
{...props}
/>
</div>
)
}
function CommandList({ className, ...props }: React.ComponentProps<typeof CommandPrimitive.List>) {
return (
<CommandPrimitive.List
data-slot="command-list"
className={cn("max-h-[300px] scroll-py-1 overflow-x-hidden overflow-y-auto", className)}
{...props}
/>
)
}
function CommandEmpty({ ...props }: React.ComponentProps<typeof CommandPrimitive.Empty>) {
return <CommandPrimitive.Empty data-slot="command-empty" className="py-6 text-center text-sm" {...props} />
}
function CommandGroup({ className, ...props }: React.ComponentProps<typeof CommandPrimitive.Group>) {
return (
<CommandPrimitive.Group
data-slot="command-group"
className={cn(
"text-foreground [&_[cmdk-group-heading]]:text-muted-foreground overflow-hidden p-1 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium",
className,
)}
{...props}
/>
)
}
function CommandSeparator({ className, ...props }: React.ComponentProps<typeof CommandPrimitive.Separator>) {
return (
<CommandPrimitive.Separator
data-slot="command-separator"
className={cn("bg-border -mx-1 h-px", className)}
{...props}
/>
)
}
function CommandItem({ className, ...props }: React.ComponentProps<typeof CommandPrimitive.Item>) {
return (
<CommandPrimitive.Item
data-slot="command-item"
className={cn(
"data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
/>
)
}
function CommandShortcut({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
data-slot="command-shortcut"
className={cn("text-muted-foreground ml-auto text-xs tracking-widest", className)}
{...props}
/>
)
}
export {
Command,
CommandDialog,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
CommandShortcut,
CommandSeparator,
}

View File

@ -0,0 +1,211 @@
"use client"
import type * as React from "react"
import * as ContextMenuPrimitive from "@radix-ui/react-context-menu"
import { Check, ChevronRight, Circle } from "lucide-react"
import { cn } from "@/lib/utils"
function ContextMenu({ ...props }: React.ComponentProps<typeof ContextMenuPrimitive.Root>) {
return <ContextMenuPrimitive.Root data-slot="context-menu" {...props} />
}
function ContextMenuTrigger({ ...props }: React.ComponentProps<typeof ContextMenuPrimitive.Trigger>) {
return <ContextMenuPrimitive.Trigger data-slot="context-menu-trigger" {...props} />
}
function ContextMenuGroup({ ...props }: React.ComponentProps<typeof ContextMenuPrimitive.Group>) {
return <ContextMenuPrimitive.Group data-slot="context-menu-group" {...props} />
}
function ContextMenuPortal({ ...props }: React.ComponentProps<typeof ContextMenuPrimitive.Portal>) {
return <ContextMenuPrimitive.Portal data-slot="context-menu-portal" {...props} />
}
function ContextMenuSub({ ...props }: React.ComponentProps<typeof ContextMenuPrimitive.Sub>) {
return <ContextMenuPrimitive.Sub data-slot="context-menu-sub" {...props} />
}
function ContextMenuRadioGroup({ ...props }: React.ComponentProps<typeof ContextMenuPrimitive.RadioGroup>) {
return <ContextMenuPrimitive.RadioGroup data-slot="context-menu-radio-group" {...props} />
}
function ContextMenuSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.SubTrigger> & {
inset?: boolean
}) {
return (
<ContextMenuPrimitive.SubTrigger
data-slot="context-menu-sub-trigger"
data-inset={inset}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
>
{children}
<ChevronRight className="ml-auto" />
</ContextMenuPrimitive.SubTrigger>
)
}
function ContextMenuSubContent({ className, ...props }: React.ComponentProps<typeof ContextMenuPrimitive.SubContent>) {
return (
<ContextMenuPrimitive.SubContent
data-slot="context-menu-sub-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-context-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
className,
)}
{...props}
/>
)
}
function ContextMenuContent({ className, ...props }: React.ComponentProps<typeof ContextMenuPrimitive.Content>) {
return (
<ContextMenuPrimitive.Portal>
<ContextMenuPrimitive.Content
data-slot="context-menu-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-context-menu-content-available-height) min-w-[8rem] origin-(--radix-context-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
className,
)}
{...props}
/>
</ContextMenuPrimitive.Portal>
)
}
function ContextMenuItem({
className,
inset,
variant = "default",
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Item> & {
inset?: boolean
variant?: "default" | "destructive"
}) {
return (
<ContextMenuPrimitive.Item
data-slot="context-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
/>
)
}
function ContextMenuCheckboxItem({
className,
children,
checked,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.CheckboxItem>) {
return (
<ContextMenuPrimitive.CheckboxItem
data-slot="context-menu-checkbox-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
checked={checked}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<ContextMenuPrimitive.ItemIndicator>
<Check className="size-4" />
</ContextMenuPrimitive.ItemIndicator>
</span>
{children}
</ContextMenuPrimitive.CheckboxItem>
)
}
function ContextMenuRadioItem({
className,
children,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.RadioItem>) {
return (
<ContextMenuPrimitive.RadioItem
data-slot="context-menu-radio-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<ContextMenuPrimitive.ItemIndicator>
<Circle className="size-2 fill-current" />
</ContextMenuPrimitive.ItemIndicator>
</span>
{children}
</ContextMenuPrimitive.RadioItem>
)
}
function ContextMenuLabel({
className,
inset,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Label> & {
inset?: boolean
}) {
return (
<ContextMenuPrimitive.Label
data-slot="context-menu-label"
data-inset={inset}
className={cn("text-foreground px-2 py-1.5 text-sm font-medium data-[inset]:pl-8", className)}
{...props}
/>
)
}
function ContextMenuSeparator({ className, ...props }: React.ComponentProps<typeof ContextMenuPrimitive.Separator>) {
return (
<ContextMenuPrimitive.Separator
data-slot="context-menu-separator"
className={cn("bg-border -mx-1 my-1 h-px", className)}
{...props}
/>
)
}
function ContextMenuShortcut({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
data-slot="context-menu-shortcut"
className={cn("text-muted-foreground ml-auto text-xs tracking-widest", className)}
{...props}
/>
)
}
export {
ContextMenu,
ContextMenuTrigger,
ContextMenuContent,
ContextMenuItem,
ContextMenuCheckboxItem,
ContextMenuRadioItem,
ContextMenuLabel,
ContextMenuSeparator,
ContextMenuShortcut,
ContextMenuGroup,
ContextMenuPortal,
ContextMenuSub,
ContextMenuSubContent,
ContextMenuSubTrigger,
ContextMenuRadioGroup,
}

123
components/ui/dialog.tsx Normal file
View File

@ -0,0 +1,123 @@
"use client"
import type * as React from "react"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { X } from "lucide-react"
import { cn } from "@/lib/utils"
function Dialog({ ...props }: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />
}
function DialogTrigger({ ...props }: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
}
function DialogPortal({ ...props }: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
}
function DialogClose({ ...props }: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
}
function DialogOverlay({ className, ...props }: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
return (
<DialogPrimitive.Overlay
data-slot="dialog-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className,
)}
{...props}
/>
)
}
function DialogContent({
className,
children,
showCloseButton = true,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean
}) {
return (
<DialogPortal data-slot="dialog-portal">
<DialogOverlay />
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
className,
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close
data-slot="dialog-close"
className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
>
<X />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
</DialogPortal>
)
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-header"
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
{...props}
/>
)
}
function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-footer"
className={cn("flex flex-col-reverse gap-2 sm:flex-row sm:justify-end", className)}
{...props}
/>
)
}
function DialogTitle({ className, ...props }: React.ComponentProps<typeof DialogPrimitive.Title>) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn("text-lg leading-none font-semibold", className)}
{...props}
/>
)
}
function DialogDescription({ className, ...props }: React.ComponentProps<typeof DialogPrimitive.Description>) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
}

135
components/ui/drawer.tsx Normal file
View File

@ -0,0 +1,135 @@
'use client'
import * as React from 'react'
import { Drawer as DrawerPrimitive } from 'vaul'
import { cn } from '@/lib/utils'
function Drawer({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Root>) {
return <DrawerPrimitive.Root data-slot="drawer" {...props} />
}
function DrawerTrigger({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Trigger>) {
return <DrawerPrimitive.Trigger data-slot="drawer-trigger" {...props} />
}
function DrawerPortal({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Portal>) {
return <DrawerPrimitive.Portal data-slot="drawer-portal" {...props} />
}
function DrawerClose({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Close>) {
return <DrawerPrimitive.Close data-slot="drawer-close" {...props} />
}
function DrawerOverlay({
className,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Overlay>) {
return (
<DrawerPrimitive.Overlay
data-slot="drawer-overlay"
className={cn(
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50',
className,
)}
{...props}
/>
)
}
function DrawerContent({
className,
children,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Content>) {
return (
<DrawerPortal data-slot="drawer-portal">
<DrawerOverlay />
<DrawerPrimitive.Content
data-slot="drawer-content"
className={cn(
'group/drawer-content bg-background fixed z-50 flex h-auto flex-col',
'data-[vaul-drawer-direction=top]:inset-x-0 data-[vaul-drawer-direction=top]:top-0 data-[vaul-drawer-direction=top]:mb-24 data-[vaul-drawer-direction=top]:max-h-[80vh] data-[vaul-drawer-direction=top]:rounded-b-lg data-[vaul-drawer-direction=top]:border-b',
'data-[vaul-drawer-direction=bottom]:inset-x-0 data-[vaul-drawer-direction=bottom]:bottom-0 data-[vaul-drawer-direction=bottom]:mt-24 data-[vaul-drawer-direction=bottom]:max-h-[80vh] data-[vaul-drawer-direction=bottom]:rounded-t-lg data-[vaul-drawer-direction=bottom]:border-t',
'data-[vaul-drawer-direction=right]:inset-y-0 data-[vaul-drawer-direction=right]:right-0 data-[vaul-drawer-direction=right]:w-3/4 data-[vaul-drawer-direction=right]:border-l data-[vaul-drawer-direction=right]:sm:max-w-sm',
'data-[vaul-drawer-direction=left]:inset-y-0 data-[vaul-drawer-direction=left]:left-0 data-[vaul-drawer-direction=left]:w-3/4 data-[vaul-drawer-direction=left]:border-r data-[vaul-drawer-direction=left]:sm:max-w-sm',
className,
)}
{...props}
>
<div className="bg-muted mx-auto mt-4 hidden h-2 w-[100px] shrink-0 rounded-full group-data-[vaul-drawer-direction=bottom]/drawer-content:block" />
{children}
</DrawerPrimitive.Content>
</DrawerPortal>
)
}
function DrawerHeader({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="drawer-header"
className={cn(
'flex flex-col gap-0.5 p-4 group-data-[vaul-drawer-direction=bottom]/drawer-content:text-center group-data-[vaul-drawer-direction=top]/drawer-content:text-center md:gap-1.5 md:text-left',
className,
)}
{...props}
/>
)
}
function DrawerFooter({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="drawer-footer"
className={cn('mt-auto flex flex-col gap-2 p-4', className)}
{...props}
/>
)
}
function DrawerTitle({
className,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Title>) {
return (
<DrawerPrimitive.Title
data-slot="drawer-title"
className={cn('text-foreground font-semibold', className)}
{...props}
/>
)
}
function DrawerDescription({
className,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Description>) {
return (
<DrawerPrimitive.Description
data-slot="drawer-description"
className={cn('text-muted-foreground text-sm', className)}
{...props}
/>
)
}
export {
Drawer,
DrawerPortal,
DrawerOverlay,
DrawerTrigger,
DrawerClose,
DrawerContent,
DrawerHeader,
DrawerFooter,
DrawerTitle,
DrawerDescription,
}

View File

@ -0,0 +1,219 @@
"use client"
import type * as React from "react"
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
import { Check, ChevronRight, Circle } from "lucide-react"
import { cn } from "@/lib/utils"
function DropdownMenu({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
}
function DropdownMenuPortal({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
return <DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
}
function DropdownMenuTrigger({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
return <DropdownMenuPrimitive.Trigger data-slot="dropdown-menu-trigger" {...props} />
}
function DropdownMenuContent({
className,
sideOffset = 4,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
return (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
data-slot="dropdown-menu-content"
sideOffset={sideOffset}
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
className,
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
)
}
function DropdownMenuGroup({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
return <DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
}
function DropdownMenuItem({
className,
inset,
variant = "default",
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean
variant?: "default" | "destructive"
}) {
return (
<DropdownMenuPrimitive.Item
data-slot="dropdown-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
/>
)
}
function DropdownMenuCheckboxItem({
className,
children,
checked,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
return (
<DropdownMenuPrimitive.CheckboxItem
data-slot="dropdown-menu-checkbox-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
checked={checked}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Check className="size-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
)
}
function DropdownMenuRadioGroup({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
return <DropdownMenuPrimitive.RadioGroup data-slot="dropdown-menu-radio-group" {...props} />
}
function DropdownMenuRadioItem({
className,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
return (
<DropdownMenuPrimitive.RadioItem
data-slot="dropdown-menu-radio-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Circle className="size-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
)
}
function DropdownMenuLabel({
className,
inset,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.Label
data-slot="dropdown-menu-label"
data-inset={inset}
className={cn("px-2 py-1.5 text-sm font-medium data-[inset]:pl-8", className)}
{...props}
/>
)
}
function DropdownMenuSeparator({ className, ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
return (
<DropdownMenuPrimitive.Separator
data-slot="dropdown-menu-separator"
className={cn("bg-border -mx-1 my-1 h-px", className)}
{...props}
/>
)
}
function DropdownMenuShortcut({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
data-slot="dropdown-menu-shortcut"
className={cn("text-muted-foreground ml-auto text-xs tracking-widest", className)}
{...props}
/>
)
}
function DropdownMenuSub({ ...props }: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />
}
function DropdownMenuSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.SubTrigger
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8",
className,
)}
{...props}
>
{children}
<ChevronRight className="ml-auto size-4" />
</DropdownMenuPrimitive.SubTrigger>
)
}
function DropdownMenuSubContent({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
return (
<DropdownMenuPrimitive.SubContent
data-slot="dropdown-menu-sub-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
className,
)}
{...props}
/>
)
}
export {
DropdownMenu,
DropdownMenuPortal,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
}

167
components/ui/form.tsx Normal file
View File

@ -0,0 +1,167 @@
'use client'
import * as React from 'react'
import * as LabelPrimitive from '@radix-ui/react-label'
import { Slot } from '@radix-ui/react-slot'
import {
Controller,
FormProvider,
useFormContext,
useFormState,
type ControllerProps,
type FieldPath,
type FieldValues,
} from 'react-hook-form'
import { cn } from '@/lib/utils'
import { Label } from '@/components/ui/label'
const Form = FormProvider
type FormFieldContextValue<
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
> = {
name: TName
}
const FormFieldContext = React.createContext<FormFieldContextValue>(
{} as FormFieldContextValue,
)
const FormField = <
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
>({
...props
}: ControllerProps<TFieldValues, TName>) => {
return (
<FormFieldContext.Provider value={{ name: props.name }}>
<Controller {...props} />
</FormFieldContext.Provider>
)
}
const useFormField = () => {
const fieldContext = React.useContext(FormFieldContext)
const itemContext = React.useContext(FormItemContext)
const { getFieldState } = useFormContext()
const formState = useFormState({ name: fieldContext.name })
const fieldState = getFieldState(fieldContext.name, formState)
if (!fieldContext) {
throw new Error('useFormField should be used within <FormField>')
}
const { id } = itemContext
return {
id,
name: fieldContext.name,
formItemId: `${id}-form-item`,
formDescriptionId: `${id}-form-item-description`,
formMessageId: `${id}-form-item-message`,
...fieldState,
}
}
type FormItemContextValue = {
id: string
}
const FormItemContext = React.createContext<FormItemContextValue>(
{} as FormItemContextValue,
)
function FormItem({ className, ...props }: React.ComponentProps<'div'>) {
const id = React.useId()
return (
<FormItemContext.Provider value={{ id }}>
<div
data-slot="form-item"
className={cn('grid gap-2', className)}
{...props}
/>
</FormItemContext.Provider>
)
}
function FormLabel({
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
const { error, formItemId } = useFormField()
return (
<Label
data-slot="form-label"
data-error={!!error}
className={cn('data-[error=true]:text-destructive', className)}
htmlFor={formItemId}
{...props}
/>
)
}
function FormControl({ ...props }: React.ComponentProps<typeof Slot>) {
const { error, formItemId, formDescriptionId, formMessageId } = useFormField()
return (
<Slot
data-slot="form-control"
id={formItemId}
aria-describedby={
!error
? `${formDescriptionId}`
: `${formDescriptionId} ${formMessageId}`
}
aria-invalid={!!error}
{...props}
/>
)
}
function FormDescription({ className, ...props }: React.ComponentProps<'p'>) {
const { formDescriptionId } = useFormField()
return (
<p
data-slot="form-description"
id={formDescriptionId}
className={cn('text-muted-foreground text-sm', className)}
{...props}
/>
)
}
function FormMessage({ className, ...props }: React.ComponentProps<'p'>) {
const { error, formMessageId } = useFormField()
const body = error ? String(error?.message ?? '') : props.children
if (!body) {
return null
}
return (
<p
data-slot="form-message"
id={formMessageId}
className={cn('text-destructive text-sm', className)}
{...props}
>
{body}
</p>
)
}
export {
useFormField,
Form,
FormItem,
FormLabel,
FormControl,
FormDescription,
FormMessage,
FormField,
}

View File

@ -0,0 +1,44 @@
'use client'
import * as React from 'react'
import * as HoverCardPrimitive from '@radix-ui/react-hover-card'
import { cn } from '@/lib/utils'
function HoverCard({
...props
}: React.ComponentProps<typeof HoverCardPrimitive.Root>) {
return <HoverCardPrimitive.Root data-slot="hover-card" {...props} />
}
function HoverCardTrigger({
...props
}: React.ComponentProps<typeof HoverCardPrimitive.Trigger>) {
return (
<HoverCardPrimitive.Trigger data-slot="hover-card-trigger" {...props} />
)
}
function HoverCardContent({
className,
align = 'center',
sideOffset = 4,
...props
}: React.ComponentProps<typeof HoverCardPrimitive.Content>) {
return (
<HoverCardPrimitive.Portal data-slot="hover-card-portal">
<HoverCardPrimitive.Content
data-slot="hover-card-content"
align={align}
sideOffset={sideOffset}
className={cn(
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-64 origin-(--radix-hover-card-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden',
className,
)}
{...props}
/>
</HoverCardPrimitive.Portal>
)
}
export { HoverCard, HoverCardTrigger, HoverCardContent }

View File

@ -0,0 +1,68 @@
"use client"
import * as React from "react"
import { OTPInput, OTPInputContext } from "input-otp"
import { Minus } from "lucide-react"
import { cn } from "@/lib/utils"
function InputOTP({
className,
containerClassName,
...props
}: React.ComponentProps<typeof OTPInput> & {
containerClassName?: string
}) {
return (
<OTPInput
data-slot="input-otp"
containerClassName={cn("flex items-center gap-2 has-disabled:opacity-50", containerClassName)}
className={cn("disabled:cursor-not-allowed", className)}
{...props}
/>
)
}
function InputOTPGroup({ className, ...props }: React.ComponentProps<"div">) {
return <div data-slot="input-otp-group" className={cn("flex items-center", className)} {...props} />
}
function InputOTPSlot({
index,
className,
...props
}: React.ComponentProps<"div"> & {
index: number
}) {
const inputOTPContext = React.useContext(OTPInputContext)
const { char, hasFakeCaret, isActive } = inputOTPContext?.slots[index] ?? {}
return (
<div
data-slot="input-otp-slot"
data-active={isActive}
className={cn(
"data-[active=true]:border-ring data-[active=true]:ring-ring/50 data-[active=true]:aria-invalid:ring-destructive/20 dark:data-[active=true]:aria-invalid:ring-destructive/40 aria-invalid:border-destructive data-[active=true]:aria-invalid:border-destructive dark:bg-input/30 border-input relative flex h-9 w-9 items-center justify-center border-y border-r text-sm shadow-xs transition-all outline-none first:rounded-l-md first:border-l last:rounded-r-md data-[active=true]:z-10 data-[active=true]:ring-[3px]",
className,
)}
{...props}
>
{char}
{hasFakeCaret && (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
<div className="animate-caret-blink bg-foreground h-4 w-px duration-1000" />
</div>
)}
</div>
)
}
function InputOTPSeparator({ ...props }: React.ComponentProps<"div">) {
return (
<div data-slot="input-otp-separator" role="separator" {...props}>
<Minus />
</div>
)
}
export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator }

21
components/ui/input.tsx Normal file
View File

@ -0,0 +1,21 @@
import * as React from 'react'
import { cn } from '@/lib/utils'
function Input({ className, type, ...props }: React.ComponentProps<'input'>) {
return (
<input
type={type}
data-slot="input"
className={cn(
'file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
'focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]',
'aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive',
className,
)}
{...props}
/>
)
}
export { Input }

24
components/ui/label.tsx Normal file
View File

@ -0,0 +1,24 @@
'use client'
import * as React from 'react'
import * as LabelPrimitive from '@radix-ui/react-label'
import { cn } from '@/lib/utils'
function Label({
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
return (
<LabelPrimitive.Root
data-slot="label"
className={cn(
'flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50',
className,
)}
{...props}
/>
)
}
export { Label }

236
components/ui/menubar.tsx Normal file
View File

@ -0,0 +1,236 @@
"use client"
import type * as React from "react"
import * as MenubarPrimitive from "@radix-ui/react-menubar"
import { Check, ChevronRight, Circle } from "lucide-react"
import { cn } from "@/lib/utils"
function Menubar({ className, ...props }: React.ComponentProps<typeof MenubarPrimitive.Root>) {
return (
<MenubarPrimitive.Root
data-slot="menubar"
className={cn("bg-background flex h-9 items-center gap-1 rounded-md border p-1 shadow-xs", className)}
{...props}
/>
)
}
function MenubarMenu({ ...props }: React.ComponentProps<typeof MenubarPrimitive.Menu>) {
return <MenubarPrimitive.Menu data-slot="menubar-menu" {...props} />
}
function MenubarGroup({ ...props }: React.ComponentProps<typeof MenubarPrimitive.Group>) {
return <MenubarPrimitive.Group data-slot="menubar-group" {...props} />
}
function MenubarPortal({ ...props }: React.ComponentProps<typeof MenubarPrimitive.Portal>) {
return <MenubarPrimitive.Portal data-slot="menubar-portal" {...props} />
}
function MenubarRadioGroup({ ...props }: React.ComponentProps<typeof MenubarPrimitive.RadioGroup>) {
return <MenubarPrimitive.RadioGroup data-slot="menubar-radio-group" {...props} />
}
function MenubarTrigger({ className, ...props }: React.ComponentProps<typeof MenubarPrimitive.Trigger>) {
return (
<MenubarPrimitive.Trigger
data-slot="menubar-trigger"
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex items-center rounded-sm px-2 py-1 text-sm font-medium outline-hidden select-none",
className,
)}
{...props}
/>
)
}
function MenubarContent({
className,
align = "start",
alignOffset = -4,
sideOffset = 8,
...props
}: React.ComponentProps<typeof MenubarPrimitive.Content>) {
return (
<MenubarPortal>
<MenubarPrimitive.Content
data-slot="menubar-content"
align={align}
alignOffset={alignOffset}
sideOffset={sideOffset}
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[12rem] origin-(--radix-menubar-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-md",
className,
)}
{...props}
/>
</MenubarPortal>
)
}
function MenubarItem({
className,
inset,
variant = "default",
...props
}: React.ComponentProps<typeof MenubarPrimitive.Item> & {
inset?: boolean
variant?: "default" | "destructive"
}) {
return (
<MenubarPrimitive.Item
data-slot="menubar-item"
data-inset={inset}
data-variant={variant}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
/>
)
}
function MenubarCheckboxItem({
className,
children,
checked,
...props
}: React.ComponentProps<typeof MenubarPrimitive.CheckboxItem>) {
return (
<MenubarPrimitive.CheckboxItem
data-slot="menubar-checkbox-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-xs py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
checked={checked}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<MenubarPrimitive.ItemIndicator>
<Check className="size-4" />
</MenubarPrimitive.ItemIndicator>
</span>
{children}
</MenubarPrimitive.CheckboxItem>
)
}
function MenubarRadioItem({ className, children, ...props }: React.ComponentProps<typeof MenubarPrimitive.RadioItem>) {
return (
<MenubarPrimitive.RadioItem
data-slot="menubar-radio-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-xs py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<MenubarPrimitive.ItemIndicator>
<Circle className="size-2 fill-current" />
</MenubarPrimitive.ItemIndicator>
</span>
{children}
</MenubarPrimitive.RadioItem>
)
}
function MenubarLabel({
className,
inset,
...props
}: React.ComponentProps<typeof MenubarPrimitive.Label> & {
inset?: boolean
}) {
return (
<MenubarPrimitive.Label
data-slot="menubar-label"
data-inset={inset}
className={cn("px-2 py-1.5 text-sm font-medium data-[inset]:pl-8", className)}
{...props}
/>
)
}
function MenubarSeparator({ className, ...props }: React.ComponentProps<typeof MenubarPrimitive.Separator>) {
return (
<MenubarPrimitive.Separator
data-slot="menubar-separator"
className={cn("bg-border -mx-1 my-1 h-px", className)}
{...props}
/>
)
}
function MenubarShortcut({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
data-slot="menubar-shortcut"
className={cn("text-muted-foreground ml-auto text-xs tracking-widest", className)}
{...props}
/>
)
}
function MenubarSub({ ...props }: React.ComponentProps<typeof MenubarPrimitive.Sub>) {
return <MenubarPrimitive.Sub data-slot="menubar-sub" {...props} />
}
function MenubarSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof MenubarPrimitive.SubTrigger> & {
inset?: boolean
}) {
return (
<MenubarPrimitive.SubTrigger
data-slot="menubar-sub-trigger"
data-inset={inset}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-none select-none data-[inset]:pl-8",
className,
)}
{...props}
>
{children}
<ChevronRight className="ml-auto h-4 w-4" />
</MenubarPrimitive.SubTrigger>
)
}
function MenubarSubContent({ className, ...props }: React.ComponentProps<typeof MenubarPrimitive.SubContent>) {
return (
<MenubarPrimitive.SubContent
data-slot="menubar-sub-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-menubar-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
className,
)}
{...props}
/>
)
}
export {
Menubar,
MenubarPortal,
MenubarMenu,
MenubarTrigger,
MenubarContent,
MenubarGroup,
MenubarSeparator,
MenubarLabel,
MenubarItem,
MenubarShortcut,
MenubarCheckboxItem,
MenubarRadioGroup,
MenubarRadioItem,
MenubarSub,
MenubarSubTrigger,
MenubarSubContent,
}

View File

@ -0,0 +1,142 @@
import type * as React from "react"
import * as NavigationMenuPrimitive from "@radix-ui/react-navigation-menu"
import { cva } from "class-variance-authority"
import { ChevronDown } from "lucide-react"
import { cn } from "@/lib/utils"
function NavigationMenu({
className,
children,
viewport = true,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Root> & {
viewport?: boolean
}) {
return (
<NavigationMenuPrimitive.Root
data-slot="navigation-menu"
data-viewport={viewport}
className={cn("group/navigation-menu relative flex max-w-max flex-1 items-center justify-center", className)}
{...props}
>
{children}
{viewport && <NavigationMenuViewport />}
</NavigationMenuPrimitive.Root>
)
}
function NavigationMenuList({ className, ...props }: React.ComponentProps<typeof NavigationMenuPrimitive.List>) {
return (
<NavigationMenuPrimitive.List
data-slot="navigation-menu-list"
className={cn("group flex flex-1 list-none items-center justify-center gap-1", className)}
{...props}
/>
)
}
function NavigationMenuItem({ className, ...props }: React.ComponentProps<typeof NavigationMenuPrimitive.Item>) {
return (
<NavigationMenuPrimitive.Item data-slot="navigation-menu-item" className={cn("relative", className)} {...props} />
)
}
const navigationMenuTriggerStyle = cva(
"group inline-flex h-9 w-max items-center justify-center rounded-md bg-background px-4 py-2 text-sm font-medium hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground disabled:pointer-events-none disabled:opacity-50 data-[state=open]:hover:bg-accent data-[state=open]:text-accent-foreground data-[state=open]:focus:bg-accent data-[state=open]:bg-accent/50 focus-visible:ring-ring/50 outline-none transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1",
)
function NavigationMenuTrigger({
className,
children,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Trigger>) {
return (
<NavigationMenuPrimitive.Trigger
data-slot="navigation-menu-trigger"
className={cn(navigationMenuTriggerStyle(), "group", className)}
{...props}
>
{children}{" "}
<ChevronDown
className="relative top-[1px] ml-1 size-3 transition duration-300 group-data-[state=open]:rotate-180"
aria-hidden="true"
/>
</NavigationMenuPrimitive.Trigger>
)
}
function NavigationMenuContent({ className, ...props }: React.ComponentProps<typeof NavigationMenuPrimitive.Content>) {
return (
<NavigationMenuPrimitive.Content
data-slot="navigation-menu-content"
className={cn(
"data-[motion^=from-]:animate-in data-[motion^=to-]:animate-out data-[motion^=from-]:fade-in data-[motion^=to-]:fade-out data-[motion=from-end]:slide-in-from-right-52 data-[motion=from-start]:slide-in-from-left-52 data-[motion=to-end]:slide-out-to-right-52 data-[motion=to-start]:slide-out-to-left-52 top-0 left-0 w-full p-2 pr-2.5 md:absolute md:w-auto",
"group-data-[viewport=false]/navigation-menu:bg-popover group-data-[viewport=false]/navigation-menu:text-popover-foreground group-data-[viewport=false]/navigation-menu:data-[state=open]:animate-in group-data-[viewport=false]/navigation-menu:data-[state=closed]:animate-out group-data-[viewport=false]/navigation-menu:data-[state=closed]:zoom-out-95 group-data-[viewport=false]/navigation-menu:data-[state=open]:zoom-in-95 group-data-[viewport=false]/navigation-menu:data-[state=open]:fade-in-0 group-data-[viewport=false]/navigation-menu:data-[state=closed]:fade-out-0 group-data-[viewport=false]/navigation-menu:top-full group-data-[viewport=false]/navigation-menu:mt-1.5 group-data-[viewport=false]/navigation-menu:overflow-hidden group-data-[viewport=false]/navigation-menu:rounded-md group-data-[viewport=false]/navigation-menu:border group-data-[viewport=false]/navigation-menu:shadow group-data-[viewport=false]/navigation-menu:duration-200 **:data-[slot=navigation-menu-link]:focus:ring-0 **:data-[slot=navigation-menu-link]:focus:outline-none",
className,
)}
{...props}
/>
)
}
function NavigationMenuViewport({
className,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Viewport>) {
return (
<div className={cn("absolute top-full left-0 isolate z-50 flex justify-center")}>
<NavigationMenuPrimitive.Viewport
data-slot="navigation-menu-viewport"
className={cn(
"origin-top-center bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-90 relative mt-1.5 h-[var(--radix-navigation-menu-viewport-height)] w-full overflow-hidden rounded-md border shadow md:w-[var(--radix-navigation-menu-viewport-width)]",
className,
)}
{...props}
/>
</div>
)
}
function NavigationMenuLink({ className, ...props }: React.ComponentProps<typeof NavigationMenuPrimitive.Link>) {
return (
<NavigationMenuPrimitive.Link
data-slot="navigation-menu-link"
className={cn(
"data-[active=true]:focus:bg-accent data-[active=true]:hover:bg-accent data-[active=true]:bg-accent/50 data-[active=true]:text-accent-foreground hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground focus-visible:ring-ring/50 [&_svg:not([class*='text-'])]:text-muted-foreground flex flex-col gap-1 rounded-sm p-2 text-sm transition-all outline-none focus-visible:ring-[3px] focus-visible:outline-1 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
/>
)
}
function NavigationMenuIndicator({
className,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Indicator>) {
return (
<NavigationMenuPrimitive.Indicator
data-slot="navigation-menu-indicator"
className={cn(
"data-[state=visible]:animate-in data-[state=hidden]:animate-out data-[state=hidden]:fade-out data-[state=visible]:fade-in top-full z-[1] flex h-1.5 items-end justify-center overflow-hidden",
className,
)}
{...props}
>
<div className="bg-border relative top-[60%] h-2 w-2 rotate-45 rounded-tl-sm shadow-md" />
</NavigationMenuPrimitive.Indicator>
)
}
export {
NavigationMenu,
NavigationMenuList,
NavigationMenuItem,
NavigationMenuContent,
NavigationMenuTrigger,
NavigationMenuLink,
NavigationMenuIndicator,
NavigationMenuViewport,
navigationMenuTriggerStyle,
}

View File

@ -0,0 +1,100 @@
import type * as React from "react"
import { ChevronLeft, ChevronRight, MoreHorizontal } from "lucide-react"
import { cn } from "@/lib/utils"
import { type Button, buttonVariants } from "@/components/ui/button"
function Pagination({ className, ...props }: React.ComponentProps<"nav">) {
return (
<nav
role="navigation"
aria-label="pagination"
data-slot="pagination"
className={cn("mx-auto flex w-full justify-center", className)}
{...props}
/>
)
}
function PaginationContent({ className, ...props }: React.ComponentProps<"ul">) {
return <ul data-slot="pagination-content" className={cn("flex flex-row items-center gap-1", className)} {...props} />
}
function PaginationItem({ ...props }: React.ComponentProps<"li">) {
return <li data-slot="pagination-item" {...props} />
}
type PaginationLinkProps = {
isActive?: boolean
} & Pick<React.ComponentProps<typeof Button>, "size"> &
React.ComponentProps<"a">
function PaginationLink({ className, isActive, size = "icon", ...props }: PaginationLinkProps) {
return (
<a
aria-current={isActive ? "page" : undefined}
data-slot="pagination-link"
data-active={isActive}
className={cn(
buttonVariants({
variant: isActive ? "outline" : "ghost",
size,
}),
className,
)}
{...props}
/>
)
}
function PaginationPrevious({ className, ...props }: React.ComponentProps<typeof PaginationLink>) {
return (
<PaginationLink
aria-label="Go to previous page"
size="default"
className={cn("gap-1 px-2.5 sm:pl-2.5", className)}
{...props}
>
<ChevronLeft />
<span className="hidden sm:block">Previous</span>
</PaginationLink>
)
}
function PaginationNext({ className, ...props }: React.ComponentProps<typeof PaginationLink>) {
return (
<PaginationLink
aria-label="Go to next page"
size="default"
className={cn("gap-1 px-2.5 sm:pr-2.5", className)}
{...props}
>
<span className="hidden sm:block">Next</span>
<ChevronRight />
</PaginationLink>
)
}
function PaginationEllipsis({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
aria-hidden
data-slot="pagination-ellipsis"
className={cn("flex size-9 items-center justify-center", className)}
{...props}
>
<MoreHorizontal className="size-4" />
<span className="sr-only">More pages</span>
</span>
)
}
export {
Pagination,
PaginationContent,
PaginationLink,
PaginationItem,
PaginationPrevious,
PaginationNext,
PaginationEllipsis,
}

48
components/ui/popover.tsx Normal file
View File

@ -0,0 +1,48 @@
'use client'
import * as React from 'react'
import * as PopoverPrimitive from '@radix-ui/react-popover'
import { cn } from '@/lib/utils'
function Popover({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Root>) {
return <PopoverPrimitive.Root data-slot="popover" {...props} />
}
function PopoverTrigger({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />
}
function PopoverContent({
className,
align = 'center',
sideOffset = 4,
...props
}: React.ComponentProps<typeof PopoverPrimitive.Content>) {
return (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
data-slot="popover-content"
align={align}
sideOffset={sideOffset}
className={cn(
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden',
className,
)}
{...props}
/>
</PopoverPrimitive.Portal>
)
}
function PopoverAnchor({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />
}
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }

View File

@ -0,0 +1,31 @@
'use client'
import * as React from 'react'
import * as ProgressPrimitive from '@radix-ui/react-progress'
import { cn } from '@/lib/utils'
function Progress({
className,
value,
...props
}: React.ComponentProps<typeof ProgressPrimitive.Root>) {
return (
<ProgressPrimitive.Root
data-slot="progress"
className={cn(
'bg-primary/20 relative h-2 w-full overflow-hidden rounded-full',
className,
)}
{...props}
>
<ProgressPrimitive.Indicator
data-slot="progress-indicator"
className="bg-primary h-full w-full flex-1 transition-all"
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
/>
</ProgressPrimitive.Root>
)
}
export { Progress }

View File

@ -0,0 +1,33 @@
"use client"
import type * as React from "react"
import * as RadioGroupPrimitive from "@radix-ui/react-radio-group"
import { Circle } from "lucide-react"
import { cn } from "@/lib/utils"
function RadioGroup({ className, ...props }: React.ComponentProps<typeof RadioGroupPrimitive.Root>) {
return <RadioGroupPrimitive.Root data-slot="radio-group" className={cn("grid gap-3", className)} {...props} />
}
function RadioGroupItem({ className, ...props }: React.ComponentProps<typeof RadioGroupPrimitive.Item>) {
return (
<RadioGroupPrimitive.Item
data-slot="radio-group-item"
className={cn(
"border-input text-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 aspect-square size-4 shrink-0 rounded-full border shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
{...props}
>
<RadioGroupPrimitive.Indicator
data-slot="radio-group-indicator"
className="relative flex items-center justify-center"
>
<Circle className="fill-primary absolute top-1/2 left-1/2 size-2 -translate-x-1/2 -translate-y-1/2" />
</RadioGroupPrimitive.Indicator>
</RadioGroupPrimitive.Item>
)
}
export { RadioGroup, RadioGroupItem }

View File

@ -0,0 +1,48 @@
"use client"
import type * as React from "react"
import { GripVertical } from "lucide-react"
import * as ResizablePrimitive from "react-resizable-panels"
import { cn } from "@/lib/utils"
function ResizablePanelGroup({ className, ...props }: React.ComponentProps<typeof ResizablePrimitive.PanelGroup>) {
return (
<ResizablePrimitive.PanelGroup
data-slot="resizable-panel-group"
className={cn("flex h-full w-full data-[panel-group-direction=vertical]:flex-col", className)}
{...props}
/>
)
}
function ResizablePanel({ ...props }: React.ComponentProps<typeof ResizablePrimitive.Panel>) {
return <ResizablePrimitive.Panel data-slot="resizable-panel" {...props} />
}
function ResizableHandle({
withHandle,
className,
...props
}: React.ComponentProps<typeof ResizablePrimitive.PanelResizeHandle> & {
withHandle?: boolean
}) {
return (
<ResizablePrimitive.PanelResizeHandle
data-slot="resizable-handle"
className={cn(
"bg-border focus-visible:ring-ring relative flex w-px items-center justify-center after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:ring-1 focus-visible:ring-offset-1 focus-visible:outline-hidden data-[panel-group-direction=vertical]:h-px data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-1 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:translate-x-0 data-[panel-group-direction=vertical]:after:-translate-y-1/2 [&[data-panel-group-direction=vertical]>div]:rotate-90",
className,
)}
{...props}
>
{withHandle && (
<div className="bg-border z-10 flex h-4 w-3 items-center justify-center rounded-xs border">
<GripVertical className="size-2.5" />
</div>
)}
</ResizablePrimitive.PanelResizeHandle>
)
}
export { ResizablePanelGroup, ResizablePanel, ResizableHandle }

View File

@ -0,0 +1,58 @@
'use client'
import * as React from 'react'
import * as ScrollAreaPrimitive from '@radix-ui/react-scroll-area'
import { cn } from '@/lib/utils'
function ScrollArea({
className,
children,
...props
}: React.ComponentProps<typeof ScrollAreaPrimitive.Root>) {
return (
<ScrollAreaPrimitive.Root
data-slot="scroll-area"
className={cn('relative', className)}
{...props}
>
<ScrollAreaPrimitive.Viewport
data-slot="scroll-area-viewport"
className="focus-visible:ring-ring/50 size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:outline-1"
>
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
)
}
function ScrollBar({
className,
orientation = 'vertical',
...props
}: React.ComponentProps<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) {
return (
<ScrollAreaPrimitive.ScrollAreaScrollbar
data-slot="scroll-area-scrollbar"
orientation={orientation}
className={cn(
'flex touch-none p-px transition-colors select-none',
orientation === 'vertical' &&
'h-full w-2.5 border-l border-l-transparent',
orientation === 'horizontal' &&
'h-2.5 flex-col border-t border-t-transparent',
className,
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb
data-slot="scroll-area-thumb"
className="bg-border relative flex-1 rounded-full"
/>
</ScrollAreaPrimitive.ScrollAreaScrollbar>
)
}
export { ScrollArea, ScrollBar }

160
components/ui/select.tsx Normal file
View File

@ -0,0 +1,160 @@
"use client"
import type * as React from "react"
import * as SelectPrimitive from "@radix-ui/react-select"
import { Check, ChevronDown, ChevronUp } from "lucide-react"
import { cn } from "@/lib/utils"
function Select({ ...props }: React.ComponentProps<typeof SelectPrimitive.Root>) {
return <SelectPrimitive.Root data-slot="select" {...props} />
}
function SelectGroup({ ...props }: React.ComponentProps<typeof SelectPrimitive.Group>) {
return <SelectPrimitive.Group data-slot="select-group" {...props} />
}
function SelectValue({ ...props }: React.ComponentProps<typeof SelectPrimitive.Value>) {
return <SelectPrimitive.Value data-slot="select-value" {...props} />
}
function SelectTrigger({
className,
size = "default",
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
size?: "sm" | "default"
}) {
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
data-size={size}
className={cn(
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDown className="size-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
)
}
function SelectContent({
className,
children,
position = "popper",
...props
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
data-slot="select-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className,
)}
position={position}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1",
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
)
}
function SelectLabel({ className, ...props }: React.ComponentProps<typeof SelectPrimitive.Label>) {
return (
<SelectPrimitive.Label
data-slot="select-label"
className={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
{...props}
/>
)
}
function SelectItem({ className, children, ...props }: React.ComponentProps<typeof SelectPrimitive.Item>) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className,
)}
{...props}
>
<span className="absolute right-2 flex size-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<Check className="size-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
)
}
function SelectSeparator({ className, ...props }: React.ComponentProps<typeof SelectPrimitive.Separator>) {
return (
<SelectPrimitive.Separator
data-slot="select-separator"
className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
{...props}
/>
)
}
function SelectScrollUpButton({ className, ...props }: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
return (
<SelectPrimitive.ScrollUpButton
data-slot="select-scroll-up-button"
className={cn("flex cursor-default items-center justify-center py-1", className)}
{...props}
>
<ChevronUp className="size-4" />
</SelectPrimitive.ScrollUpButton>
)
}
function SelectScrollDownButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
return (
<SelectPrimitive.ScrollDownButton
data-slot="select-scroll-down-button"
className={cn("flex cursor-default items-center justify-center py-1", className)}
{...props}
>
<ChevronDown className="size-4" />
</SelectPrimitive.ScrollDownButton>
)
}
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
}

View File

@ -0,0 +1,28 @@
'use client'
import * as React from 'react'
import * as SeparatorPrimitive from '@radix-ui/react-separator'
import { cn } from '@/lib/utils'
function Separator({
className,
orientation = 'horizontal',
decorative = true,
...props
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
return (
<SeparatorPrimitive.Root
data-slot="separator"
decorative={decorative}
orientation={orientation}
className={cn(
'bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px',
className,
)}
{...props}
/>
)
}
export { Separator }

103
components/ui/sheet.tsx Normal file
View File

@ -0,0 +1,103 @@
"use client"
import type * as React from "react"
import * as SheetPrimitive from "@radix-ui/react-dialog"
import { X } from "lucide-react"
import { cn } from "@/lib/utils"
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
return <SheetPrimitive.Root data-slot="sheet" {...props} />
}
function SheetTrigger({ ...props }: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />
}
function SheetClose({ ...props }: React.ComponentProps<typeof SheetPrimitive.Close>) {
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />
}
function SheetPortal({ ...props }: React.ComponentProps<typeof SheetPrimitive.Portal>) {
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />
}
function SheetOverlay({ className, ...props }: React.ComponentProps<typeof SheetPrimitive.Overlay>) {
return (
<SheetPrimitive.Overlay
data-slot="sheet-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className,
)}
{...props}
/>
)
}
function SheetContent({
className,
children,
side = "right",
...props
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
side?: "top" | "right" | "bottom" | "left"
}) {
return (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Content
data-slot="sheet-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
side === "right" &&
"data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm",
side === "left" &&
"data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm",
side === "top" &&
"data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b",
side === "bottom" &&
"data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t",
className,
)}
{...props}
>
{children}
<SheetPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-secondary absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none">
<X className="size-4" />
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
</SheetPrimitive.Content>
</SheetPortal>
)
}
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
return <div data-slot="sheet-header" className={cn("flex flex-col gap-1.5 p-4", className)} {...props} />
}
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
return <div data-slot="sheet-footer" className={cn("mt-auto flex flex-col gap-2 p-4", className)} {...props} />
}
function SheetTitle({ className, ...props }: React.ComponentProps<typeof SheetPrimitive.Title>) {
return (
<SheetPrimitive.Title
data-slot="sheet-title"
className={cn("text-foreground font-semibold", className)}
{...props}
/>
)
}
function SheetDescription({ className, ...props }: React.ComponentProps<typeof SheetPrimitive.Description>) {
return (
<SheetPrimitive.Description
data-slot="sheet-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
export { Sheet, SheetTrigger, SheetClose, SheetContent, SheetHeader, SheetFooter, SheetTitle, SheetDescription }

677
components/ui/sidebar.tsx Normal file
View File

@ -0,0 +1,677 @@
"use client"
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { PanelLeft } from "lucide-react"
import { useIsMobile } from "@/hooks/use-mobile"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Separator } from "@/components/ui/separator"
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "@/components/ui/sheet"
import { Skeleton } from "@/components/ui/skeleton"
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"
const SIDEBAR_COOKIE_NAME = "sidebar_state"
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7
const SIDEBAR_WIDTH = "16rem"
const SIDEBAR_WIDTH_MOBILE = "18rem"
const SIDEBAR_WIDTH_ICON = "3rem"
const SIDEBAR_KEYBOARD_SHORTCUT = "b"
type SidebarContextProps = {
state: "expanded" | "collapsed"
open: boolean
setOpen: (open: boolean) => void
openMobile: boolean
setOpenMobile: (open: boolean) => void
isMobile: boolean
toggleSidebar: () => void
}
const SidebarContext = React.createContext<SidebarContextProps | null>(null)
function useSidebar() {
const context = React.useContext(SidebarContext)
if (!context) {
throw new Error("useSidebar must be used within a SidebarProvider.")
}
return context
}
function SidebarProvider({
defaultOpen = true,
open: openProp,
onOpenChange: setOpenProp,
className,
style,
children,
...props
}: React.ComponentProps<"div"> & {
defaultOpen?: boolean
open?: boolean
onOpenChange?: (open: boolean) => void
}) {
const isMobile = useIsMobile()
const [openMobile, setOpenMobile] = React.useState(false)
// This is the internal state of the sidebar.
// We use openProp and setOpenProp for control from outside the component.
const [_open, _setOpen] = React.useState(defaultOpen)
const open = openProp ?? _open
const setOpen = React.useCallback(
(value: boolean | ((value: boolean) => boolean)) => {
const openState = typeof value === "function" ? value(open) : value
if (setOpenProp) {
setOpenProp(openState)
} else {
_setOpen(openState)
}
// This sets the cookie to keep the sidebar state.
document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`
},
[setOpenProp, open],
)
// Helper to toggle the sidebar.
const toggleSidebar = React.useCallback(() => {
return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open)
}, [isMobile, setOpen, setOpenMobile])
// Adds a keyboard shortcut to toggle the sidebar.
React.useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === SIDEBAR_KEYBOARD_SHORTCUT && (event.metaKey || event.ctrlKey)) {
event.preventDefault()
toggleSidebar()
}
}
window.addEventListener("keydown", handleKeyDown)
return () => window.removeEventListener("keydown", handleKeyDown)
}, [toggleSidebar])
// We add a state so that we can do data-state="expanded" or "collapsed".
// This makes it easier to style the sidebar with Tailwind classes.
const state = open ? "expanded" : "collapsed"
const contextValue = React.useMemo<SidebarContextProps>(
() => ({
state,
open,
setOpen,
isMobile,
openMobile,
setOpenMobile,
toggleSidebar,
}),
[state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar],
)
return (
<SidebarContext.Provider value={contextValue}>
<TooltipProvider delayDuration={0}>
<div
data-slot="sidebar-wrapper"
style={
{
"--sidebar-width": SIDEBAR_WIDTH,
"--sidebar-width-icon": SIDEBAR_WIDTH_ICON,
...style,
} as React.CSSProperties
}
className={cn("group/sidebar-wrapper has-data-[variant=inset]:bg-sidebar flex min-h-svh w-full", className)}
{...props}
>
{children}
</div>
</TooltipProvider>
</SidebarContext.Provider>
)
}
function Sidebar({
side = "left",
variant = "sidebar",
collapsible = "offcanvas",
className,
children,
...props
}: React.ComponentProps<"div"> & {
side?: "left" | "right"
variant?: "sidebar" | "floating" | "inset"
collapsible?: "offcanvas" | "icon" | "none"
}) {
const { isMobile, state, openMobile, setOpenMobile } = useSidebar()
if (collapsible === "none") {
return (
<div
data-slot="sidebar"
className={cn("bg-sidebar text-sidebar-foreground flex h-full w-(--sidebar-width) flex-col", className)}
{...props}
>
{children}
</div>
)
}
if (isMobile) {
return (
<Sheet open={openMobile} onOpenChange={setOpenMobile} {...props}>
<SheetContent
data-sidebar="sidebar"
data-slot="sidebar"
data-mobile="true"
className="bg-sidebar text-sidebar-foreground w-(--sidebar-width) p-0 [&>button]:hidden"
style={
{
"--sidebar-width": SIDEBAR_WIDTH_MOBILE,
} as React.CSSProperties
}
side={side}
>
<SheetHeader className="sr-only">
<SheetTitle>Sidebar</SheetTitle>
<SheetDescription>Displays the mobile sidebar.</SheetDescription>
</SheetHeader>
<div className="flex h-full w-full flex-col">{children}</div>
</SheetContent>
</Sheet>
)
}
return (
<div
className="group peer text-sidebar-foreground hidden md:block"
data-state={state}
data-collapsible={state === "collapsed" ? collapsible : ""}
data-variant={variant}
data-side={side}
data-slot="sidebar"
>
{/* This is what handles the sidebar gap on desktop */}
<div
data-slot="sidebar-gap"
className={cn(
"relative w-(--sidebar-width) bg-transparent transition-[width] duration-200 ease-linear",
"group-data-[collapsible=offcanvas]:w-0",
"group-data-[side=right]:rotate-180",
variant === "floating" || variant === "inset"
? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4)))]"
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon)",
)}
/>
<div
data-slot="sidebar-container"
className={cn(
"fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear md:flex",
side === "left"
? "left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]"
: "right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]",
// Adjust the padding for floating and inset variants.
variant === "floating" || variant === "inset"
? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]"
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon) group-data-[side=left]:border-r group-data-[side=right]:border-l",
className,
)}
{...props}
>
<div
data-sidebar="sidebar"
data-slot="sidebar-inner"
className="bg-sidebar group-data-[variant=floating]:border-sidebar-border flex h-full w-full flex-col group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:shadow-sm"
>
{children}
</div>
</div>
</div>
)
}
function SidebarTrigger({ className, onClick, ...props }: React.ComponentProps<typeof Button>) {
const { toggleSidebar } = useSidebar()
return (
<Button
data-sidebar="trigger"
data-slot="sidebar-trigger"
variant="ghost"
size="icon"
className={cn("size-7", className)}
onClick={(event) => {
onClick?.(event)
toggleSidebar()
}}
{...props}
>
<PanelLeft />
<span className="sr-only">Toggle Sidebar</span>
</Button>
)
}
function SidebarRail({ className, ...props }: React.ComponentProps<"button">) {
const { toggleSidebar } = useSidebar()
return (
<button
data-sidebar="rail"
data-slot="sidebar-rail"
aria-label="Toggle Sidebar"
tabIndex={-1}
onClick={toggleSidebar}
title="Toggle Sidebar"
className={cn(
"hover:after:bg-sidebar-border absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] sm:flex",
"in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize",
"[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize",
"hover:group-data-[collapsible=offcanvas]:bg-sidebar group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full",
"[[data-side=left][data-collapsible=offcanvas]_&]:-right-2",
"[[data-side=right][data-collapsible=offcanvas]_&]:-left-2",
className,
)}
{...props}
/>
)
}
function SidebarInset({ className, ...props }: React.ComponentProps<"main">) {
return (
<main
data-slot="sidebar-inset"
className={cn(
"bg-background relative flex w-full flex-1 flex-col",
"md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2",
className,
)}
{...props}
/>
)
}
function SidebarInput({ className, ...props }: React.ComponentProps<typeof Input>) {
return (
<Input
data-slot="sidebar-input"
data-sidebar="input"
className={cn("bg-background h-8 w-full shadow-none", className)}
{...props}
/>
)
}
function SidebarHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-header"
data-sidebar="header"
className={cn("flex flex-col gap-2 p-2", className)}
{...props}
/>
)
}
function SidebarFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-footer"
data-sidebar="footer"
className={cn("flex flex-col gap-2 p-2", className)}
{...props}
/>
)
}
function SidebarSeparator({ className, ...props }: React.ComponentProps<typeof Separator>) {
return (
<Separator
data-slot="sidebar-separator"
data-sidebar="separator"
className={cn("bg-sidebar-border mx-2 w-auto", className)}
{...props}
/>
)
}
function SidebarContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-content"
data-sidebar="content"
className={cn(
"flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden",
className,
)}
{...props}
/>
)
}
function SidebarGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-group"
data-sidebar="group"
className={cn("relative flex w-full min-w-0 flex-col p-2", className)}
{...props}
/>
)
}
function SidebarGroupLabel({
className,
asChild = false,
...props
}: React.ComponentProps<"div"> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "div"
return (
<Comp
data-slot="sidebar-group-label"
data-sidebar="group-label"
className={cn(
"text-sidebar-foreground/70 ring-sidebar-ring flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium outline-hidden transition-[margin,opacity] duration-200 ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
"group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0",
className,
)}
{...props}
/>
)
}
function SidebarGroupAction({
className,
asChild = false,
...props
}: React.ComponentProps<"button"> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "button"
return (
<Comp
data-slot="sidebar-group-action"
data-sidebar="group-action"
className={cn(
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground absolute top-3.5 right-3 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
// Increases the hit area of the button on mobile.
"after:absolute after:-inset-2 md:after:hidden",
"group-data-[collapsible=icon]:hidden",
className,
)}
{...props}
/>
)
}
function SidebarGroupContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-group-content"
data-sidebar="group-content"
className={cn("w-full text-sm", className)}
{...props}
/>
)
}
function SidebarMenu({ className, ...props }: React.ComponentProps<"ul">) {
return (
<ul
data-slot="sidebar-menu"
data-sidebar="menu"
className={cn("flex w-full min-w-0 flex-col gap-1", className)}
{...props}
/>
)
}
function SidebarMenuItem({ className, ...props }: React.ComponentProps<"li">) {
return (
<li
data-slot="sidebar-menu-item"
data-sidebar="menu-item"
className={cn("group/menu-item relative", className)}
{...props}
/>
)
}
const sidebarMenuButtonVariants = cva(
"peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-hidden ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-data-[sidebar=menu-action]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
{
variants: {
variant: {
default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
outline:
"bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]",
},
size: {
default: "h-8 text-sm",
sm: "h-7 text-xs",
lg: "h-12 text-sm group-data-[collapsible=icon]:p-0!",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
)
function SidebarMenuButton({
asChild = false,
isActive = false,
variant = "default",
size = "default",
tooltip,
className,
...props
}: React.ComponentProps<"button"> & {
asChild?: boolean
isActive?: boolean
tooltip?: string | React.ComponentProps<typeof TooltipContent>
} & VariantProps<typeof sidebarMenuButtonVariants>) {
const Comp = asChild ? Slot : "button"
const { isMobile, state } = useSidebar()
const button = (
<Comp
data-slot="sidebar-menu-button"
data-sidebar="menu-button"
data-size={size}
data-active={isActive}
className={cn(sidebarMenuButtonVariants({ variant, size }), className)}
{...props}
/>
)
if (!tooltip) {
return button
}
if (typeof tooltip === "string") {
tooltip = {
children: tooltip,
}
}
return (
<Tooltip>
<TooltipTrigger asChild>{button}</TooltipTrigger>
<TooltipContent side="right" align="center" hidden={state !== "collapsed" || isMobile} {...tooltip} />
</Tooltip>
)
}
function SidebarMenuAction({
className,
asChild = false,
showOnHover = false,
...props
}: React.ComponentProps<"button"> & {
asChild?: boolean
showOnHover?: boolean
}) {
const Comp = asChild ? Slot : "button"
return (
<Comp
data-slot="sidebar-menu-action"
data-sidebar="menu-action"
className={cn(
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground peer-hover/menu-button:text-sidebar-accent-foreground absolute top-1.5 right-1 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
// Increases the hit area of the button on mobile.
"after:absolute after:-inset-2 md:after:hidden",
"peer-data-[size=sm]/menu-button:top-1",
"peer-data-[size=default]/menu-button:top-1.5",
"peer-data-[size=lg]/menu-button:top-2.5",
"group-data-[collapsible=icon]:hidden",
showOnHover &&
"peer-data-[active=true]/menu-button:text-sidebar-accent-foreground group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 md:opacity-0",
className,
)}
{...props}
/>
)
}
function SidebarMenuBadge({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-menu-badge"
data-sidebar="menu-badge"
className={cn(
"text-sidebar-foreground pointer-events-none absolute right-1 flex h-5 min-w-5 items-center justify-center rounded-md px-1 text-xs font-medium tabular-nums select-none",
"peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[active=true]/menu-button:text-sidebar-accent-foreground",
"peer-data-[size=sm]/menu-button:top-1",
"peer-data-[size=default]/menu-button:top-1.5",
"peer-data-[size=lg]/menu-button:top-2.5",
"group-data-[collapsible=icon]:hidden",
className,
)}
{...props}
/>
)
}
function SidebarMenuSkeleton({
className,
showIcon = false,
...props
}: React.ComponentProps<"div"> & {
showIcon?: boolean
}) {
// Random width between 50 to 90%.
const width = React.useMemo(() => {
return `${Math.floor(Math.random() * 40) + 50}%`
}, [])
return (
<div
data-slot="sidebar-menu-skeleton"
data-sidebar="menu-skeleton"
className={cn("flex h-8 items-center gap-2 rounded-md px-2", className)}
{...props}
>
{showIcon && <Skeleton className="size-4 rounded-md" data-sidebar="menu-skeleton-icon" />}
<Skeleton
className="h-4 max-w-(--skeleton-width) flex-1"
data-sidebar="menu-skeleton-text"
style={
{
"--skeleton-width": width,
} as React.CSSProperties
}
/>
</div>
)
}
function SidebarMenuSub({ className, ...props }: React.ComponentProps<"ul">) {
return (
<ul
data-slot="sidebar-menu-sub"
data-sidebar="menu-sub"
className={cn(
"border-sidebar-border mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l px-2.5 py-0.5",
"group-data-[collapsible=icon]:hidden",
className,
)}
{...props}
/>
)
}
function SidebarMenuSubItem({ className, ...props }: React.ComponentProps<"li">) {
return (
<li
data-slot="sidebar-menu-sub-item"
data-sidebar="menu-sub-item"
className={cn("group/menu-sub-item relative", className)}
{...props}
/>
)
}
function SidebarMenuSubButton({
asChild = false,
size = "md",
isActive = false,
className,
...props
}: React.ComponentProps<"a"> & {
asChild?: boolean
size?: "sm" | "md"
isActive?: boolean
}) {
const Comp = asChild ? Slot : "a"
return (
<Comp
data-slot="sidebar-menu-sub-button"
data-sidebar="menu-sub-button"
data-size={size}
data-active={isActive}
className={cn(
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground active:bg-sidebar-accent active:text-sidebar-accent-foreground [&>svg]:text-sidebar-accent-foreground flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 outline-hidden focus-visible:ring-2 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
"data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground",
size === "sm" && "text-xs",
size === "md" && "text-sm",
"group-data-[collapsible=icon]:hidden",
className,
)}
{...props}
/>
)
}
export {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupAction,
SidebarGroupContent,
SidebarGroupLabel,
SidebarHeader,
SidebarInput,
SidebarInset,
SidebarMenu,
SidebarMenuAction,
SidebarMenuBadge,
SidebarMenuButton,
SidebarMenuItem,
SidebarMenuSkeleton,
SidebarMenuSub,
SidebarMenuSubButton,
SidebarMenuSubItem,
SidebarProvider,
SidebarRail,
SidebarSeparator,
SidebarTrigger,
useSidebar,
}

View File

@ -0,0 +1,13 @@
import { cn } from '@/lib/utils'
function Skeleton({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="skeleton"
className={cn('bg-accent animate-pulse rounded-md', className)}
{...props}
/>
)
}
export { Skeleton }

63
components/ui/slider.tsx Normal file
View File

@ -0,0 +1,63 @@
'use client'
import * as React from 'react'
import * as SliderPrimitive from '@radix-ui/react-slider'
import { cn } from '@/lib/utils'
function Slider({
className,
defaultValue,
value,
min = 0,
max = 100,
...props
}: React.ComponentProps<typeof SliderPrimitive.Root>) {
const _values = React.useMemo(
() =>
Array.isArray(value)
? value
: Array.isArray(defaultValue)
? defaultValue
: [min, max],
[value, defaultValue, min, max],
)
return (
<SliderPrimitive.Root
data-slot="slider"
defaultValue={defaultValue}
value={value}
min={min}
max={max}
className={cn(
'relative flex w-full touch-none items-center select-none data-[disabled]:opacity-50 data-[orientation=vertical]:h-full data-[orientation=vertical]:min-h-44 data-[orientation=vertical]:w-auto data-[orientation=vertical]:flex-col',
className,
)}
{...props}
>
<SliderPrimitive.Track
data-slot="slider-track"
className={cn(
'bg-muted relative grow overflow-hidden rounded-full data-[orientation=horizontal]:h-1.5 data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-1.5',
)}
>
<SliderPrimitive.Range
data-slot="slider-range"
className={cn(
'bg-primary absolute data-[orientation=horizontal]:h-full data-[orientation=vertical]:w-full',
)}
/>
</SliderPrimitive.Track>
{Array.from({ length: _values.length }, (_, index) => (
<SliderPrimitive.Thumb
data-slot="slider-thumb"
key={index}
className="border-primary bg-background ring-ring/50 block size-4 shrink-0 rounded-full border shadow-sm transition-[color,box-shadow] hover:ring-4 focus-visible:ring-4 focus-visible:outline-hidden disabled:pointer-events-none disabled:opacity-50"
/>
))}
</SliderPrimitive.Root>
)
}
export { Slider }

25
components/ui/sonner.tsx Normal file
View File

@ -0,0 +1,25 @@
'use client'
import { useTheme } from 'next-themes'
import { Toaster as Sonner, ToasterProps } from 'sonner'
const Toaster = ({ ...props }: ToasterProps) => {
const { theme = 'system' } = useTheme()
return (
<Sonner
theme={theme as ToasterProps['theme']}
className="toaster group"
style={
{
'--normal-bg': 'var(--popover)',
'--normal-text': 'var(--popover-foreground)',
'--normal-border': 'var(--border)',
} as React.CSSProperties
}
{...props}
/>
)
}
export { Toaster }

31
components/ui/switch.tsx Normal file
View File

@ -0,0 +1,31 @@
'use client'
import * as React from 'react'
import * as SwitchPrimitive from '@radix-ui/react-switch'
import { cn } from '@/lib/utils'
function Switch({
className,
...props
}: React.ComponentProps<typeof SwitchPrimitive.Root>) {
return (
<SwitchPrimitive.Root
data-slot="switch"
className={cn(
'peer data-[state=checked]:bg-primary data-[state=unchecked]:bg-input focus-visible:border-ring focus-visible:ring-ring/50 dark:data-[state=unchecked]:bg-input/80 inline-flex h-[1.15rem] w-8 shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50',
className,
)}
{...props}
>
<SwitchPrimitive.Thumb
data-slot="switch-thumb"
className={cn(
'bg-background dark:data-[state=unchecked]:bg-foreground dark:data-[state=checked]:bg-primary-foreground pointer-events-none block size-4 rounded-full ring-0 transition-transform data-[state=checked]:translate-x-[calc(100%-2px)] data-[state=unchecked]:translate-x-0',
)}
/>
</SwitchPrimitive.Root>
)
}
export { Switch }

116
components/ui/table.tsx Normal file
View File

@ -0,0 +1,116 @@
'use client'
import * as React from 'react'
import { cn } from '@/lib/utils'
function Table({ className, ...props }: React.ComponentProps<'table'>) {
return (
<div
data-slot="table-container"
className="relative w-full overflow-x-auto"
>
<table
data-slot="table"
className={cn('w-full caption-bottom text-sm', className)}
{...props}
/>
</div>
)
}
function TableHeader({ className, ...props }: React.ComponentProps<'thead'>) {
return (
<thead
data-slot="table-header"
className={cn('[&_tr]:border-b', className)}
{...props}
/>
)
}
function TableBody({ className, ...props }: React.ComponentProps<'tbody'>) {
return (
<tbody
data-slot="table-body"
className={cn('[&_tr:last-child]:border-0', className)}
{...props}
/>
)
}
function TableFooter({ className, ...props }: React.ComponentProps<'tfoot'>) {
return (
<tfoot
data-slot="table-footer"
className={cn(
'bg-muted/50 border-t font-medium [&>tr]:last:border-b-0',
className,
)}
{...props}
/>
)
}
function TableRow({ className, ...props }: React.ComponentProps<'tr'>) {
return (
<tr
data-slot="table-row"
className={cn(
'hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors',
className,
)}
{...props}
/>
)
}
function TableHead({ className, ...props }: React.ComponentProps<'th'>) {
return (
<th
data-slot="table-head"
className={cn(
'text-foreground h-10 px-2 text-left align-middle font-medium whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]',
className,
)}
{...props}
/>
)
}
function TableCell({ className, ...props }: React.ComponentProps<'td'>) {
return (
<td
data-slot="table-cell"
className={cn(
'p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]',
className,
)}
{...props}
/>
)
}
function TableCaption({
className,
...props
}: React.ComponentProps<'caption'>) {
return (
<caption
data-slot="table-caption"
className={cn('text-muted-foreground mt-4 text-sm', className)}
{...props}
/>
)
}
export {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
}

66
components/ui/tabs.tsx Normal file
View File

@ -0,0 +1,66 @@
'use client'
import * as React from 'react'
import * as TabsPrimitive from '@radix-ui/react-tabs'
import { cn } from '@/lib/utils'
function Tabs({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Root>) {
return (
<TabsPrimitive.Root
data-slot="tabs"
className={cn('flex flex-col gap-2', className)}
{...props}
/>
)
}
function TabsList({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.List>) {
return (
<TabsPrimitive.List
data-slot="tabs-list"
className={cn(
'bg-muted text-muted-foreground inline-flex h-9 w-fit items-center justify-center rounded-lg p-[3px]',
className,
)}
{...props}
/>
)
}
function TabsTrigger({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
return (
<TabsPrimitive.Trigger
data-slot="tabs-trigger"
className={cn(
"data-[state=active]:bg-background dark:data-[state=active]:text-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 text-foreground dark:text-muted-foreground inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:shadow-sm [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
/>
)
}
function TabsContent({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Content>) {
return (
<TabsPrimitive.Content
data-slot="tabs-content"
className={cn('flex-1 outline-none', className)}
{...props}
/>
)
}
export { Tabs, TabsList, TabsTrigger, TabsContent }

View File

@ -0,0 +1,18 @@
import * as React from 'react'
import { cn } from '@/lib/utils'
function Textarea({ className, ...props }: React.ComponentProps<'textarea'>) {
return (
<textarea
data-slot="textarea"
className={cn(
'border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
className,
)}
{...props}
/>
)
}
export { Textarea }

129
components/ui/toast.tsx Normal file
View File

@ -0,0 +1,129 @@
'use client'
import * as React from 'react'
import * as ToastPrimitives from '@radix-ui/react-toast'
import { cva, type VariantProps } from 'class-variance-authority'
import { X } from 'lucide-react'
import { cn } from '@/lib/utils'
const ToastProvider = ToastPrimitives.Provider
const ToastViewport = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Viewport>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Viewport>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Viewport
ref={ref}
className={cn(
'fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]',
className,
)}
{...props}
/>
))
ToastViewport.displayName = ToastPrimitives.Viewport.displayName
const toastVariants = cva(
'group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border p-6 pr-8 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full',
{
variants: {
variant: {
default: 'border bg-background text-foreground',
destructive:
'destructive group border-destructive bg-destructive text-destructive-foreground',
},
},
defaultVariants: {
variant: 'default',
},
},
)
const Toast = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> &
VariantProps<typeof toastVariants>
>(({ className, variant, ...props }, ref) => {
return (
<ToastPrimitives.Root
ref={ref}
className={cn(toastVariants({ variant }), className)}
{...props}
/>
)
})
Toast.displayName = ToastPrimitives.Root.displayName
const ToastAction = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Action>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Action>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Action
ref={ref}
className={cn(
'inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-transparent px-3 text-sm font-medium ring-offset-background transition-colors hover:bg-secondary focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 group-[.destructive]:border-muted/40 group-[.destructive]:hover:border-destructive/30 group-[.destructive]:hover:bg-destructive group-[.destructive]:hover:text-destructive-foreground group-[.destructive]:focus:ring-destructive',
className,
)}
{...props}
/>
))
ToastAction.displayName = ToastPrimitives.Action.displayName
const ToastClose = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Close>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Close>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Close
ref={ref}
className={cn(
'absolute right-2 top-2 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-none focus:ring-2 group-hover:opacity-100 group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50 group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600',
className,
)}
toast-close=""
{...props}
>
<X className="h-4 w-4" />
</ToastPrimitives.Close>
))
ToastClose.displayName = ToastPrimitives.Close.displayName
const ToastTitle = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Title>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Title>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Title
ref={ref}
className={cn('text-sm font-semibold', className)}
{...props}
/>
))
ToastTitle.displayName = ToastPrimitives.Title.displayName
const ToastDescription = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Description>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Description>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Description
ref={ref}
className={cn('text-sm opacity-90', className)}
{...props}
/>
))
ToastDescription.displayName = ToastPrimitives.Description.displayName
type ToastProps = React.ComponentPropsWithoutRef<typeof Toast>
type ToastActionElement = React.ReactElement<typeof ToastAction>
export {
type ToastProps,
type ToastActionElement,
ToastProvider,
ToastViewport,
Toast,
ToastTitle,
ToastDescription,
ToastClose,
ToastAction,
}

35
components/ui/toaster.tsx Normal file
View File

@ -0,0 +1,35 @@
'use client'
import { useToast } from '@/hooks/use-toast'
import {
Toast,
ToastClose,
ToastDescription,
ToastProvider,
ToastTitle,
ToastViewport,
} from '@/components/ui/toast'
export function Toaster() {
const { toasts } = useToast()
return (
<ToastProvider>
{toasts.map(function ({ id, title, description, action, ...props }) {
return (
<Toast key={id} {...props}>
<div className="grid gap-1">
{title && <ToastTitle>{title}</ToastTitle>}
{description && (
<ToastDescription>{description}</ToastDescription>
)}
</div>
{action}
<ToastClose />
</Toast>
)
})}
<ToastViewport />
</ToastProvider>
)
}

View File

@ -0,0 +1,73 @@
'use client'
import * as React from 'react'
import * as ToggleGroupPrimitive from '@radix-ui/react-toggle-group'
import { type VariantProps } from 'class-variance-authority'
import { cn } from '@/lib/utils'
import { toggleVariants } from '@/components/ui/toggle'
const ToggleGroupContext = React.createContext<
VariantProps<typeof toggleVariants>
>({
size: 'default',
variant: 'default',
})
function ToggleGroup({
className,
variant,
size,
children,
...props
}: React.ComponentProps<typeof ToggleGroupPrimitive.Root> &
VariantProps<typeof toggleVariants>) {
return (
<ToggleGroupPrimitive.Root
data-slot="toggle-group"
data-variant={variant}
data-size={size}
className={cn(
'group/toggle-group flex w-fit items-center rounded-md data-[variant=outline]:shadow-xs',
className,
)}
{...props}
>
<ToggleGroupContext.Provider value={{ variant, size }}>
{children}
</ToggleGroupContext.Provider>
</ToggleGroupPrimitive.Root>
)
}
function ToggleGroupItem({
className,
children,
variant,
size,
...props
}: React.ComponentProps<typeof ToggleGroupPrimitive.Item> &
VariantProps<typeof toggleVariants>) {
const context = React.useContext(ToggleGroupContext)
return (
<ToggleGroupPrimitive.Item
data-slot="toggle-group-item"
data-variant={context.variant || variant}
data-size={context.size || size}
className={cn(
toggleVariants({
variant: context.variant || variant,
size: context.size || size,
}),
'min-w-0 flex-1 shrink-0 rounded-none shadow-none first:rounded-l-md last:rounded-r-md focus:z-10 focus-visible:z-10 data-[variant=outline]:border-l-0 data-[variant=outline]:first:border-l',
className,
)}
{...props}
>
{children}
</ToggleGroupPrimitive.Item>
)
}
export { ToggleGroup, ToggleGroupItem }

47
components/ui/toggle.tsx Normal file
View File

@ -0,0 +1,47 @@
'use client'
import * as React from 'react'
import * as TogglePrimitive from '@radix-ui/react-toggle'
import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from '@/lib/utils'
const toggleVariants = cva(
"inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium hover:bg-muted hover:text-muted-foreground disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:shrink-0 focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] outline-none transition-[color,box-shadow] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive whitespace-nowrap",
{
variants: {
variant: {
default: 'bg-transparent',
outline:
'border border-input bg-transparent shadow-xs hover:bg-accent hover:text-accent-foreground',
},
size: {
default: 'h-9 px-2 min-w-9',
sm: 'h-8 px-1.5 min-w-8',
lg: 'h-10 px-2.5 min-w-10',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
},
)
function Toggle({
className,
variant,
size,
...props
}: React.ComponentProps<typeof TogglePrimitive.Root> &
VariantProps<typeof toggleVariants>) {
return (
<TogglePrimitive.Root
data-slot="toggle"
className={cn(toggleVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Toggle, toggleVariants }

61
components/ui/tooltip.tsx Normal file
View File

@ -0,0 +1,61 @@
'use client'
import * as React from 'react'
import * as TooltipPrimitive from '@radix-ui/react-tooltip'
import { cn } from '@/lib/utils'
function TooltipProvider({
delayDuration = 0,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
return (
<TooltipPrimitive.Provider
data-slot="tooltip-provider"
delayDuration={delayDuration}
{...props}
/>
)
}
function Tooltip({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
return (
<TooltipProvider>
<TooltipPrimitive.Root data-slot="tooltip" {...props} />
</TooltipProvider>
)
}
function TooltipTrigger({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
}
function TooltipContent({
className,
sideOffset = 0,
children,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
return (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
data-slot="tooltip-content"
sideOffset={sideOffset}
className={cn(
'bg-primary text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance',
className,
)}
{...props}
>
{children}
<TooltipPrimitive.Arrow className="bg-primary fill-primary z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]" />
</TooltipPrimitive.Content>
</TooltipPrimitive.Portal>
)
}
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }

View File

@ -0,0 +1,19 @@
import * as React from 'react'
const MOBILE_BREAKPOINT = 768
export function useIsMobile() {
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined)
React.useEffect(() => {
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`)
const onChange = () => {
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
}
mql.addEventListener('change', onChange)
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
return () => mql.removeEventListener('change', onChange)
}, [])
return !!isMobile
}

191
components/ui/use-toast.ts Normal file
View File

@ -0,0 +1,191 @@
'use client'
// Inspired by react-hot-toast library
import * as React from 'react'
import type { ToastActionElement, ToastProps } from '@/components/ui/toast'
const TOAST_LIMIT = 1
const TOAST_REMOVE_DELAY = 1000000
type ToasterToast = ToastProps & {
id: string
title?: React.ReactNode
description?: React.ReactNode
action?: ToastActionElement
}
const actionTypes = {
ADD_TOAST: 'ADD_TOAST',
UPDATE_TOAST: 'UPDATE_TOAST',
DISMISS_TOAST: 'DISMISS_TOAST',
REMOVE_TOAST: 'REMOVE_TOAST',
} as const
let count = 0
function genId() {
count = (count + 1) % Number.MAX_SAFE_INTEGER
return count.toString()
}
type ActionType = typeof actionTypes
type Action =
| {
type: ActionType['ADD_TOAST']
toast: ToasterToast
}
| {
type: ActionType['UPDATE_TOAST']
toast: Partial<ToasterToast>
}
| {
type: ActionType['DISMISS_TOAST']
toastId?: ToasterToast['id']
}
| {
type: ActionType['REMOVE_TOAST']
toastId?: ToasterToast['id']
}
interface State {
toasts: ToasterToast[]
}
const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>()
const addToRemoveQueue = (toastId: string) => {
if (toastTimeouts.has(toastId)) {
return
}
const timeout = setTimeout(() => {
toastTimeouts.delete(toastId)
dispatch({
type: 'REMOVE_TOAST',
toastId: toastId,
})
}, TOAST_REMOVE_DELAY)
toastTimeouts.set(toastId, timeout)
}
export const reducer = (state: State, action: Action): State => {
switch (action.type) {
case 'ADD_TOAST':
return {
...state,
toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
}
case 'UPDATE_TOAST':
return {
...state,
toasts: state.toasts.map((t) =>
t.id === action.toast.id ? { ...t, ...action.toast } : t,
),
}
case 'DISMISS_TOAST': {
const { toastId } = action
// ! Side effects ! - This could be extracted into a dismissToast() action,
// but I'll keep it here for simplicity
if (toastId) {
addToRemoveQueue(toastId)
} else {
state.toasts.forEach((toast) => {
addToRemoveQueue(toast.id)
})
}
return {
...state,
toasts: state.toasts.map((t) =>
t.id === toastId || toastId === undefined
? {
...t,
open: false,
}
: t,
),
}
}
case 'REMOVE_TOAST':
if (action.toastId === undefined) {
return {
...state,
toasts: [],
}
}
return {
...state,
toasts: state.toasts.filter((t) => t.id !== action.toastId),
}
}
}
const listeners: Array<(state: State) => void> = []
let memoryState: State = { toasts: [] }
function dispatch(action: Action) {
memoryState = reducer(memoryState, action)
listeners.forEach((listener) => {
listener(memoryState)
})
}
type Toast = Omit<ToasterToast, 'id'>
function toast({ ...props }: Toast) {
const id = genId()
const update = (props: ToasterToast) =>
dispatch({
type: 'UPDATE_TOAST',
toast: { ...props, id },
})
const dismiss = () => dispatch({ type: 'DISMISS_TOAST', toastId: id })
dispatch({
type: 'ADD_TOAST',
toast: {
...props,
id,
open: true,
onOpenChange: (open) => {
if (!open) dismiss()
},
},
})
return {
id: id,
dismiss,
update,
}
}
function useToast() {
const [state, setState] = React.useState<State>(memoryState)
React.useEffect(() => {
listeners.push(setState)
return () => {
const index = listeners.indexOf(setState)
if (index > -1) {
listeners.splice(index, 1)
}
}
}, [state])
return {
...state,
toast,
dismiss: (toastId?: string) => dispatch({ type: 'DISMISS_TOAST', toastId }),
}
}
export { useToast, toast }

70
copy-artist-images.sh Executable file
View File

@ -0,0 +1,70 @@
#!/bin/bash
# Copy artist portraits
cp "united-tattoo/temp/img/christylumbergportrait1.avif" "united-tattoo/public/artists/christy-lumberg-portrait.jpg" 2>/dev/null
cp "united-tattoo/temp/artist-pages/Angel Andrade/2 - Pictures & Bio/IMG_4856-.jpg" "united-tattoo/public/artists/angel-andrade-portrait.jpg" 2>/dev/null
cp "united-tattoo/temp/artist-pages/Donovan Lankford/2 - Pictures & Bio/DL (SQUARE).jpg" "united-tattoo/public/artists/donovan-lankford-portrait.jpg" 2>/dev/null
cp "united-tattoo/temp/artist-pages/Heather Santistevan/2 - Pictures & Bio/Photoleap_12_12_2024_10_33_15_WCJy6.jpg" "united-tattoo/public/artists/heather-santistevan-portrait.jpg" 2>/dev/null
cp "united-tattoo/temp/artist-pages/John Lapides/2 - Pictures & Bio/IMG_9058.jpg" "united-tattoo/public/artists/john-lapides-portrait.jpg" 2>/dev/null
cp "united-tattoo/temp/artist-pages/Steven _Sole_ Cedre/2 - Pictures & Bio/sole.jpg" "united-tattoo/public/artists/sole-cedre-portrait.jpg" 2>/dev/null
# Create placeholder portraits for artists without images yet
cp "united-tattoo/public/placeholder-user.jpg" "united-tattoo/public/artists/amari-rodriguez-portrait.jpg" 2>/dev/null
cp "united-tattoo/public/placeholder-user.jpg" "united-tattoo/public/artists/ej-segoviano-portrait.jpg" 2>/dev/null
cp "united-tattoo/public/placeholder-user.jpg" "united-tattoo/public/artists/pako-martinez-portrait.jpg" 2>/dev/null
# Copy some tattoo work samples from Angel Andrade
cp "united-tattoo/temp/artist-pages/Angel Andrade/Screenshot_20241219_155220_Instagram.jpg" "united-tattoo/public/artists/angel-andrade-work-1.jpg" 2>/dev/null
cp "united-tattoo/temp/artist-pages/Angel Andrade/Screenshot_20241219_155515_Instagram.jpg" "united-tattoo/public/artists/angel-andrade-work-2.jpg" 2>/dev/null
cp "united-tattoo/temp/artist-pages/Angel Andrade/Screenshot_20241219_155729_Instagram.jpg" "united-tattoo/public/artists/angel-andrade-work-3.jpg" 2>/dev/null
cp "united-tattoo/temp/artist-pages/Angel Andrade/Screenshot_20241219_155746_Instagram.jpg" "united-tattoo/public/artists/angel-andrade-work-4.jpg" 2>/dev/null
# Copy Donovan's work
cp "united-tattoo/temp/artist-pages/Donovan Lankford/3 - Tattoo Portfolio/Screenshot_20241217_150344_Instagram.jpg" "united-tattoo/public/artists/donovan-lankford-work-1.jpg" 2>/dev/null
cp "united-tattoo/temp/artist-pages/Donovan Lankford/3 - Tattoo Portfolio/Screenshot_20241217_150550_Instagram.jpg" "united-tattoo/public/artists/donovan-lankford-work-2.jpg" 2>/dev/null
cp "united-tattoo/temp/artist-pages/Donovan Lankford/3 - Tattoo Portfolio/Screenshot_20241217_150817_Instagram.jpg" "united-tattoo/public/artists/donovan-lankford-work-3.jpg" 2>/dev/null
cp "united-tattoo/temp/artist-pages/Donovan Lankford/3 - Tattoo Portfolio/Screenshot_20241217_151116_Instagram.jpg" "united-tattoo/public/artists/donovan-lankford-work-4.jpg" 2>/dev/null
# Copy Heather's work
cp "united-tattoo/temp/artist-pages/Heather Santistevan/3 - Tattoo Portfolio/heather1.jpg" "united-tattoo/public/artists/heather-santistevan-work-1.jpg" 2>/dev/null
cp "united-tattoo/temp/artist-pages/Heather Santistevan/3 - Tattoo Portfolio/heather2.jpg" "united-tattoo/public/artists/heather-santistevan-work-2.jpg" 2>/dev/null
cp "united-tattoo/temp/artist-pages/Heather Santistevan/3 - Tattoo Portfolio/heather3.jpg" "united-tattoo/public/artists/heather-santistevan-work-3.jpg" 2>/dev/null
cp "united-tattoo/temp/artist-pages/Heather Santistevan/3 - Tattoo Portfolio/heather4.jpg" "united-tattoo/public/artists/heather-santistevan-work-4.jpg" 2>/dev/null
# Copy John's work
cp "united-tattoo/temp/artist-pages/John Lapides/3 - Tattoo Portfolio/IMG_9070.jpg" "united-tattoo/public/artists/john-lapides-work-1.jpg" 2>/dev/null
cp "united-tattoo/temp/artist-pages/John Lapides/3 - Tattoo Portfolio/IMG_9073.jpg" "united-tattoo/public/artists/john-lapides-work-2.jpg" 2>/dev/null
cp "united-tattoo/temp/artist-pages/John Lapides/3 - Tattoo Portfolio/IMG_9074.jpg" "united-tattoo/public/artists/john-lapides-work-3.jpg" 2>/dev/null
# Copy Sole's work
cp "united-tattoo/temp/artist-pages/Steven _Sole_ Cedre/3 - Tattoo Portfolio/solo1.jpg" "united-tattoo/public/artists/sole-cedre-work-1.jpg" 2>/dev/null
cp "united-tattoo/temp/artist-pages/Steven _Sole_ Cedre/3 - Tattoo Portfolio/solo2.jpg" "united-tattoo/public/artists/sole-cedre-work-2.jpg" 2>/dev/null
cp "united-tattoo/temp/artist-pages/Steven _Sole_ Cedre/3 - Tattoo Portfolio/solo3.jpg" "united-tattoo/public/artists/sole-cedre-work-3.jpg" 2>/dev/null
cp "united-tattoo/temp/artist-pages/Steven _Sole_ Cedre/3 - Tattoo Portfolio/solo4.jpg" "united-tattoo/public/artists/sole-cedre-work-4.jpg" 2>/dev/null
# Use existing placeholder tattoo images for artists without portfolio yet
cp "united-tattoo/public/traditional-neo-traditional-tattoo-artwork.jpg" "united-tattoo/public/artists/christy-lumberg-work-1.jpg" 2>/dev/null
cp "united-tattoo/public/black-and-grey-portrait-tattoo-masterpiece.jpg" "united-tattoo/public/artists/christy-lumberg-work-2.jpg" 2>/dev/null
cp "united-tattoo/public/colorful-traditional-bird-tattoo.jpg" "united-tattoo/public/artists/christy-lumberg-work-3.jpg" 2>/dev/null
cp "united-tattoo/public/neo-traditional-wolf-tattoo-design.jpg" "united-tattoo/public/artists/christy-lumberg-work-4.jpg" 2>/dev/null
cp "united-tattoo/public/american-traditional-anchor-tattoo.jpg" "united-tattoo/public/artists/amari-rodriguez-work-1.jpg" 2>/dev/null
cp "united-tattoo/public/traditional-rose-tattoo-with-bold-colors.jpg" "united-tattoo/public/artists/amari-rodriguez-work-2.jpg" 2>/dev/null
cp "united-tattoo/public/fine-line-botanical-tattoo-elegant.jpg" "united-tattoo/public/artists/amari-rodriguez-work-3.jpg" 2>/dev/null
cp "united-tattoo/public/black-and-grey-portrait-tattoo-masterpiece.jpg" "united-tattoo/public/artists/ej-segoviano-work-1.jpg" 2>/dev/null
cp "united-tattoo/public/photorealistic-portrait-tattoo-black-and-grey.jpg" "united-tattoo/public/artists/ej-segoviano-work-2.jpg" 2>/dev/null
cp "united-tattoo/public/realistic-portrait-tattoo-artwork.jpg" "united-tattoo/public/artists/ej-segoviano-work-3.jpg" 2>/dev/null
cp "united-tattoo/public/traditional-neo-traditional-tattoo-artwork.jpg" "united-tattoo/public/artists/pako-martinez-work-1.jpg" 2>/dev/null
cp "united-tattoo/public/american-traditional-anchor-tattoo.jpg" "united-tattoo/public/artists/pako-martinez-work-2.jpg" 2>/dev/null
cp "united-tattoo/public/traditional-rose-tattoo-with-bold-colors.jpg" "united-tattoo/public/artists/pako-martinez-work-3.jpg" 2>/dev/null
# Copy logos
cp "united-tattoo/temp/logos/UNITED WEBSITE LOGO.jpg" "united-tattoo/public/united-logo-website.jpg" 2>/dev/null
cp "united-tattoo/temp/logos/LETTERING LOGO.png" "united-tattoo/public/united-logo-lettering.png" 2>/dev/null
# Copy studio image
cp "united-tattoo/temp/img/united-tattoo-studio-colorado-springs.jpg" "united-tattoo/public/united-studio-main.jpg" 2>/dev/null
echo "Artist images copied successfully!"

301
data/artists.ts Normal file
View File

@ -0,0 +1,301 @@
export interface Artist {
id: number
slug: string
name: string
title: string
specialty: string
faceImage: string
workImages: string[]
bio: string
experience: string
rating: number
reviews: number
availability: string
styles: string[]
description1: {
text: string
details: string[]
}
description2?: {
text: string
details: string[]
}
description3?: {
text: string
details: string[]
}
instagram?: string
facebook?: string
twitter?: string
}
export const artists: Artist[] = [
{
id: 1,
slug: "christy-lumberg",
name: "Christy Lumberg",
title: "The Ink Mama",
specialty: "Expert Cover-Up & Illustrative Specialist",
faceImage: "/artists/christy-lumberg-portrait.jpg",
workImages: [
"/artists/christy-lumberg-work-1.jpg",
"/artists/christy-lumberg-work-2.jpg",
"/artists/christy-lumberg-work-3.jpg",
"/artists/christy-lumberg-work-4.jpg"
],
bio: "With over 22 years of experience, Christy Lumberg is a powerhouse in the tattoo industry, known for her exceptional cover-ups, tattoo makeovers, and bold illustrative designs.",
experience: "22+ years",
rating: 5.0,
reviews: 245,
availability: "Available",
styles: ["Cover-ups", "Illustrative", "Black & Grey", "Color Work", "Tattoo Makeovers"],
description1: {
text: "Meet Christy Lumberg - The Ink Mama of United Tattoo",
details: [
"With over 22 years of experience, Christy Lumberg is a powerhouse in the tattoo industry, known for her exceptional cover-ups, tattoo makeovers, and bold illustrative designs.",
"Whether you're looking to transform old ink, refresh a faded piece, or bring a brand-new vision to life, Christy's precision and artistry deliver next-level results."
]
},
description2: {
text: "CEO & Trusted Artist",
details: [
"As the CEO of United Tattoo, based in Fountain and Colorado Springs, she has cultivated a space where artistry, creativity, and expertise thrive.",
"Clients travel from all over to sit in her chair—because when it comes to experience, Christy is the name you trust."
]
},
description3: {
text: "Specialties & Portfolio",
details: [
"✔ Cover-Up Specialist Turning past ink into stunning new pieces.",
"✔ Tattoo Makeovers Revitalizing and enhancing faded tattoos.",
"✔ Illustrative Style From bold black-and-grey to vibrant, intricate designs.",
"✔ Trusted Artist in Fountain & Colorado Springs A leader in the local tattoo scene.",
"Before & After cover-ups and transformations.",
"Illustrative masterpieces in full color and black and grey."
]
},
instagram: "https://www.instagram.com/inkmama719",
facebook: "",
twitter: ""
},
{
id: 2,
slug: "angel-andrade",
name: "Angel Andrade",
title: "",
specialty: "Precision in the details",
faceImage: "/artists/angel-andrade-portrait.jpg",
workImages: [
"/artists/angel-andrade-work-1.jpg",
"/artists/angel-andrade-work-2.jpg",
"/artists/angel-andrade-work-3.jpg",
"/artists/angel-andrade-work-4.jpg"
],
bio: "From lifelike micro designs to clean, modern aesthetics, Angel's tattoos are proof that big impact comes in small packages.",
experience: "5 years",
rating: 4.8,
reviews: 89,
availability: "Available",
styles: ["Fine Line", "Micro Realism", "Black & Grey", "Minimalist", "Geometric"],
description1: {
text: "Precision in the details",
details: [
"From lifelike micro designs to clean, modern aesthetics, Angel's tattoos are proof that big impact comes in small packages.",
"Angel specializes in fine line work and micro realism, creating intricate designs that showcase exceptional attention to detail."
]
}
},
{
id: 3,
slug: "amari-rodriguez",
name: "Amari Rodriguez",
title: "",
specialty: "Apprentice Artist",
faceImage: "/artists/amari-rodriguez-portrait.jpg",
workImages: [
"/artists/amari-rodriguez-work-1.jpg",
"/artists/amari-rodriguez-work-2.jpg",
"/artists/amari-rodriguez-work-3.jpg"
],
bio: "Passionate apprentice artist bringing fresh creativity and dedication to every piece.",
experience: "Apprentice",
rating: 4.5,
reviews: 12,
availability: "Available",
styles: ["Traditional", "Color Work", "Black & Grey", "Fine Line"],
description1: {
text: "Rising Talent",
details: [
"Amari is our talented apprentice, training under the guidance of Christy Lumberg.",
"Bringing fresh perspectives and passionate dedication to the art of tattooing."
]
}
},
{
id: 4,
slug: "donovan-lankford",
name: "Donovan Lankford",
title: "",
specialty: "Boldly Illustrated",
faceImage: "/artists/donovan-lankford-portrait.jpg",
workImages: [
"/artists/donovan-lankford-work-1.jpg",
"/artists/donovan-lankford-work-2.jpg",
"/artists/donovan-lankford-work-3.jpg",
"/artists/donovan-lankford-work-4.jpg"
],
bio: "Donovan's artistry seamlessly merges bold and intricate illustrative details, infusing each tattoo with unparalleled passion and creativity.",
experience: "8 years",
rating: 4.9,
reviews: 167,
availability: "Available",
styles: ["Anime", "Illustrative", "Black & Grey", "Dotwork", "Neo-Traditional"],
description1: {
text: "Boldly Illustrated",
details: [
"Donovan's artistry seamlessly merges bold and intricate illustrative details, infusing each tattoo with unparalleled passion and creativity.",
"From anime-inspired designs to striking black and grey illustrative work and meticulous dotwork, his versatility brings every vision to life."
]
}
},
{
id: 5,
slug: "efrain-ej-segoviano",
name: "Efrain 'EJ' Segoviano",
title: "",
specialty: "Evolving Boldly",
faceImage: "/artists/ej-segoviano-portrait.jpg",
workImages: [
"/artists/ej-segoviano-work-1.jpg",
"/artists/ej-segoviano-work-2.jpg",
"/artists/ej-segoviano-work-3.jpg"
],
bio: "EJ is a self-taught tattoo artist redefining creativity with fresh perspectives and undeniable skill.",
experience: "6 years",
rating: 4.7,
reviews: 93,
availability: "Available",
styles: ["Black & Grey", "High Contrast", "Realism", "Illustrative"],
description1: {
text: "Evolving Boldly",
details: [
"EJ is a self-taught tattoo artist redefining creativity with fresh perspectives and undeniable skill.",
"A rising star in the industry, his high-contrast black and grey designs showcase a bold, evolving artistry that leaves a lasting impression."
]
}
},
{
id: 6,
slug: "heather-santistevan",
name: "Heather Santistevan",
title: "",
specialty: "Art in Motion",
faceImage: "/artists/heather-santistevan-portrait.jpg",
workImages: [
"/artists/heather-santistevan-work-1.jpg",
"/artists/heather-santistevan-work-2.jpg",
"/artists/heather-santistevan-work-3.jpg",
"/artists/heather-santistevan-work-4.jpg"
],
bio: "With a creative journey spanning since 2012, Heather brings unmatched artistry to the tattoo world.",
experience: "12+ years",
rating: 4.8,
reviews: 178,
availability: "Limited slots",
styles: ["Watercolor", "Embroidery Style", "Patchwork", "Illustrative", "Color Work"],
description1: {
text: "Art in Motion",
details: [
"With a creative journey spanning since 2012, Heather Santistevan brings unmatched artistry to the tattoo world.",
"Specializing in vibrant watercolor designs and intricate embroidery-style patchwork, her work turns skin into stunning, wearable art."
]
}
},
{
id: 7,
slug: "john-lapides",
name: "John Lapides",
title: "",
specialty: "Sharp and Crisp",
faceImage: "/artists/john-lapides-portrait.jpg",
workImages: [
"/artists/john-lapides-work-1.jpg",
"/artists/john-lapides-work-2.jpg",
"/artists/john-lapides-work-3.jpg"
],
bio: "John's artistic arsenal is as sharp as his tattoos, specializing in fine line, blackwork, geometric patterns, and neo-traditional styles.",
experience: "10 years",
rating: 4.9,
reviews: 142,
availability: "Available",
styles: ["Fine Line", "Blackwork", "Geometric", "Neo-Traditional", "Dotwork"],
description1: {
text: "Sharp and Crisp",
details: [
"John's artistic arsenal is as sharp as his tattoos, specializing in fine line, blackwork, geometric patterns, and neo-traditional styles.",
"Each piece reflects his crisp precision and passion for pushing the boundaries of tattoo artistry."
]
}
},
{
id: 8,
slug: "pako-martinez",
name: "Pako Martinez",
title: "",
specialty: "Traditional Artistry",
faceImage: "/artists/pako-martinez-portrait.jpg",
workImages: [
"/artists/pako-martinez-work-1.jpg",
"/artists/pako-martinez-work-2.jpg",
"/artists/pako-martinez-work-3.jpg"
],
bio: "Master of traditional tattoo artistry bringing bold lines and vibrant colors to life.",
experience: "7 years",
rating: 4.6,
reviews: 98,
availability: "Available",
styles: ["Traditional", "American Traditional", "Neo-Traditional", "Color Work"],
description1: {
text: "Traditional Master",
details: [
"Pako brings traditional tattoo artistry to life with bold lines and vibrant colors.",
"Specializing in American traditional and neo-traditional styles."
]
}
},
{
id: 9,
slug: "steven-sole-cedre",
name: "Steven 'Sole' Cedre",
title: "It has to have soul, Sole!",
specialty: "Gritty Realism & Comic Art",
faceImage: "/artists/sole-cedre-portrait.jpg",
workImages: [
"/artists/sole-cedre-work-1.jpg",
"/artists/sole-cedre-work-2.jpg",
"/artists/sole-cedre-work-3.jpg",
"/artists/sole-cedre-work-4.jpg"
],
bio: "Embark on an epic journey with Steven 'Sole' Cedre, a creative force with over three decades of electrifying artistry.",
experience: "30+ years",
rating: 5.0,
reviews: 287,
availability: "Limited slots",
styles: ["Realism", "Comic Book", "Black & Grey", "Portraits", "Illustrative"],
description1: {
text: "It has to have soul, Sole!",
details: [
"Embark on an epic journey with Steven 'Sole' Cedre, a creative force with over three decades of electrifying artistry.",
"Fusing gritty realism with bold, comic book-inspired designs, Sole's tattoos are a dynamic celebration of storytelling and imagination."
]
}
}
]
export const getArtistById = (id: number): Artist | undefined => {
return artists.find(artist => artist.id === id)
}
export const getArtistBySlug = (slug: string): Artist | undefined => {
return artists.find(artist => artist.slug === slug)
}

19
hooks/use-mobile.ts Normal file
View File

@ -0,0 +1,19 @@
import * as React from 'react'
const MOBILE_BREAKPOINT = 768
export function useIsMobile() {
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined)
React.useEffect(() => {
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`)
const onChange = () => {
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
}
mql.addEventListener('change', onChange)
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
return () => mql.removeEventListener('change', onChange)
}, [])
return !!isMobile
}

191
hooks/use-toast.ts Normal file
View File

@ -0,0 +1,191 @@
'use client'
// Inspired by react-hot-toast library
import * as React from 'react'
import type { ToastActionElement, ToastProps } from '@/components/ui/toast'
const TOAST_LIMIT = 1
const TOAST_REMOVE_DELAY = 1000000
type ToasterToast = ToastProps & {
id: string
title?: React.ReactNode
description?: React.ReactNode
action?: ToastActionElement
}
const actionTypes = {
ADD_TOAST: 'ADD_TOAST',
UPDATE_TOAST: 'UPDATE_TOAST',
DISMISS_TOAST: 'DISMISS_TOAST',
REMOVE_TOAST: 'REMOVE_TOAST',
} as const
let count = 0
function genId() {
count = (count + 1) % Number.MAX_SAFE_INTEGER
return count.toString()
}
type ActionType = typeof actionTypes
type Action =
| {
type: ActionType['ADD_TOAST']
toast: ToasterToast
}
| {
type: ActionType['UPDATE_TOAST']
toast: Partial<ToasterToast>
}
| {
type: ActionType['DISMISS_TOAST']
toastId?: ToasterToast['id']
}
| {
type: ActionType['REMOVE_TOAST']
toastId?: ToasterToast['id']
}
interface State {
toasts: ToasterToast[]
}
const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>()
const addToRemoveQueue = (toastId: string) => {
if (toastTimeouts.has(toastId)) {
return
}
const timeout = setTimeout(() => {
toastTimeouts.delete(toastId)
dispatch({
type: 'REMOVE_TOAST',
toastId: toastId,
})
}, TOAST_REMOVE_DELAY)
toastTimeouts.set(toastId, timeout)
}
export const reducer = (state: State, action: Action): State => {
switch (action.type) {
case 'ADD_TOAST':
return {
...state,
toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
}
case 'UPDATE_TOAST':
return {
...state,
toasts: state.toasts.map((t) =>
t.id === action.toast.id ? { ...t, ...action.toast } : t,
),
}
case 'DISMISS_TOAST': {
const { toastId } = action
// ! Side effects ! - This could be extracted into a dismissToast() action,
// but I'll keep it here for simplicity
if (toastId) {
addToRemoveQueue(toastId)
} else {
state.toasts.forEach((toast) => {
addToRemoveQueue(toast.id)
})
}
return {
...state,
toasts: state.toasts.map((t) =>
t.id === toastId || toastId === undefined
? {
...t,
open: false,
}
: t,
),
}
}
case 'REMOVE_TOAST':
if (action.toastId === undefined) {
return {
...state,
toasts: [],
}
}
return {
...state,
toasts: state.toasts.filter((t) => t.id !== action.toastId),
}
}
}
const listeners: Array<(state: State) => void> = []
let memoryState: State = { toasts: [] }
function dispatch(action: Action) {
memoryState = reducer(memoryState, action)
listeners.forEach((listener) => {
listener(memoryState)
})
}
type Toast = Omit<ToasterToast, 'id'>
function toast({ ...props }: Toast) {
const id = genId()
const update = (props: ToasterToast) =>
dispatch({
type: 'UPDATE_TOAST',
toast: { ...props, id },
})
const dismiss = () => dispatch({ type: 'DISMISS_TOAST', toastId: id })
dispatch({
type: 'ADD_TOAST',
toast: {
...props,
id,
open: true,
onOpenChange: (open) => {
if (!open) dismiss()
},
},
})
return {
id: id,
dismiss,
update,
}
}
function useToast() {
const [state, setState] = React.useState<State>(memoryState)
React.useEffect(() => {
listeners.push(setState)
return () => {
const index = listeners.indexOf(setState)
if (index > -1) {
listeners.splice(index, 1)
}
}
}, [state])
return {
...state,
toast,
dismiss: (toastId?: string) => dispatch({ type: 'DISMISS_TOAST', toastId }),
}
}
export { useToast, toast }

6
lib/utils.ts Normal file
View File

@ -0,0 +1,6 @@
import { clsx, type ClassValue } from 'clsx'
import { twMerge } from 'tailwind-merge'
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}

View File

@ -1,4 +1,15 @@
/** @type {import('next').NextConfig} */
const nextConfig = {};
const nextConfig = {
eslint: {
ignoreDuringBuilds: true,
},
typescript: {
ignoreBuildErrors: true,
},
images: {
unoptimized: true,
},
output: "standalone",
}
export default nextConfig;
export default nextConfig

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