Compare commits

..

No commits in common. "2fed5d42162b3b33b6c70ee36517f6fec07bb9fb" and "7757d80add30b2c8e2f549cce767163e58e394c6" have entirely different histories.

112 changed files with 175 additions and 1350 deletions

View File

@ -1,22 +0,0 @@
import { NextRequest } from 'next/server'
import { getDB } from '@/lib/db'
export async function GET(_req: NextRequest, { params }: { params: { artistId: string } }) {
try {
const db = getDB()
const result = await db.prepare(`
SELECT * FROM flash_items
WHERE artist_id = ? AND is_available = 1
ORDER BY order_index ASC, created_at DESC
`).bind(params.artistId).all()
return new Response(JSON.stringify({ items: result.results }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
})
} catch (err: any) {
return new Response(JSON.stringify({ error: err?.message || 'Failed to fetch flash items' }), { status: 500 })
}
}

View File

@ -1,24 +0,0 @@
import { NextRequest } from 'next/server'
import { getDB } from '@/lib/db'
export async function GET(_req: NextRequest, { params }: { params: { id: string } }) {
try {
const db = getDB()
const result = await db.prepare(`
SELECT * FROM flash_items WHERE id = ?
`).bind(params.id).first()
if (!result) {
return new Response(JSON.stringify({ error: 'Not found' }), { status: 404 })
}
return new Response(JSON.stringify({ item: result }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
})
} catch (err: any) {
return new Response(JSON.stringify({ error: err?.message || 'Failed to fetch flash item' }), { status: 500 })
}
}

View File

@ -3,13 +3,10 @@
import { useState, useEffect, useRef, useCallback } from "react"
import Image from "next/image"
import { Button } from "@/components/ui/button"
import { Badge } from "@/components/ui/badge"
import Link from "next/link"
import { Instagram, ExternalLink, Loader2 } from "lucide-react"
import { ArrowLeft, Instagram, ExternalLink, Loader2, DollarSign } from "lucide-react"
import { useArtist } from "@/hooks/use-artist-data"
import { useIsMobile } from "@/hooks/use-mobile"
import { type CarouselApi, Carousel, CarouselContent, CarouselItem, CarouselPrevious, CarouselNext } from "@/components/ui/carousel"
import { useFlash } from "@/hooks/use-flash"
// Removed mobile filter scroll area
interface ArtistPortfolioProps {
artistId: string
@ -19,110 +16,30 @@ export function ArtistPortfolio({ artistId }: ArtistPortfolioProps) {
const [selectedCategory, setSelectedCategory] = useState("All")
const [selectedImage, setSelectedImage] = useState<string | null>(null)
const [scrollY, setScrollY] = useState(0)
const isMobile = useIsMobile()
// carousel indicator state (mobile)
const [carouselApi, setCarouselApi] = useState<CarouselApi | null>(null)
const [carouselCount, setCarouselCount] = useState(0)
const [carouselCurrent, setCarouselCurrent] = useState(0)
const [showSwipeHint, setShowSwipeHint] = useState(true)
const [showFullBio, setShowFullBio] = useState(false)
const [flashApi, setFlashApi] = useState<CarouselApi | null>(null)
// Fetch artist data from API
const { data: artist, isLoading, error } = useArtist(artistId)
const { data: flashItems = [] } = useFlash(artist?.id)
// keep a reference to the last focused thumbnail so we can return focus on modal close
const lastFocusedRef = useRef<HTMLElement | null>(null)
const closeButtonRef = useRef<HTMLButtonElement | null>(null)
const touchStartX = useRef<number | null>(null)
useEffect(() => {
// Enable parallax only on desktop to avoid jank on mobile
if (isMobile) return
const handleScroll = () => setScrollY(window.scrollY)
window.addEventListener("scroll", handleScroll)
return () => window.removeEventListener("scroll", handleScroll)
}, [isMobile])
// Fade swipe hint after a short delay
useEffect(() => {
const t = setTimeout(() => setShowSwipeHint(false), 2500)
return () => clearTimeout(t)
}, [])
// Preserve scroll position when modal opens/closes
useEffect(() => {
if (!selectedImage) return
const y = window.scrollY
const { body } = document
body.style.position = "fixed"
body.style.top = `-${y}px`
body.style.left = "0"
body.style.right = "0"
return () => {
const top = body.style.top
body.style.position = ""
body.style.top = ""
body.style.left = ""
body.style.right = ""
const restoreY = Math.abs(parseInt(top || "0", 10))
window.scrollTo(0, restoreY)
}
}, [selectedImage])
// Carousel indicators state wiring
useEffect(() => {
if (!carouselApi) return
setCarouselCount(carouselApi.scrollSnapList().length)
setCarouselCurrent(carouselApi.selectedScrollSnap())
const onSelect = () => setCarouselCurrent(carouselApi.selectedScrollSnap())
carouselApi.on("select", onSelect)
return () => {
carouselApi.off("select", onSelect)
}
}, [carouselApi])
// Flash carousel scale effect based on position (desktop emphasis)
useEffect(() => {
if (!flashApi) return
const updateScales = () => {
const root = flashApi.rootNode() as HTMLElement | null
const slides = flashApi.slideNodes() as HTMLElement[]
if (!root || !slides?.length) return
const rect = root.getBoundingClientRect()
const centerX = rect.left + rect.width / 2
slides.forEach((slide) => {
const sRect = slide.getBoundingClientRect()
const sCenter = sRect.left + sRect.width / 2
const dist = Math.abs(sCenter - centerX)
const norm = Math.min(dist / (rect.width / 2), 1) // 0 at center, 1 at edge
const scale = 0.92 + (1 - norm) * 0.08 // 0.92 at edge → 1.0 center
slide.style.transition = 'transform 200ms ease'
slide.style.transform = `scale(${scale})`
})
}
updateScales()
flashApi.on('scroll', updateScales)
flashApi.on('reInit', updateScales)
return () => {
flashApi.off('scroll', updateScales)
flashApi.off('reInit', updateScales)
}
}, [flashApi])
// Derived lists (safe when `artist` is undefined during initial renders)
const portfolioImages = artist?.portfolioImages || []
// Exclude profile/non-public images from the displayed gallery
const galleryImages = portfolioImages.filter((img) => img.isPublic !== false && !img.tags.includes('profile'))
// Get unique categories from tags (use gallery images only)
const allTags = galleryImages.flatMap(img => img.tags)
// Get unique categories from tags
const allTags = portfolioImages.flatMap(img => img.tags)
const categories = ["All", ...Array.from(new Set(allTags))]
const filteredPortfolio = selectedCategory === "All"
? galleryImages
: galleryImages.filter(img => img.tags.includes(selectedCategory))
? portfolioImages
: portfolioImages.filter(img => img.tags.includes(selectedCategory))
// keyboard navigation for modal (kept as hooks so they run in same order every render)
const goToIndex = useCallback(
@ -215,14 +132,25 @@ export function ArtistPortfolio({ artistId }: ArtistPortfolioProps) {
const profileImage = portfolioImages.find(img => img.tags.includes('profile'))?.url ||
portfolioImages[0]?.url ||
"/placeholder.svg"
const bioText = artist.bio || ""
return (
<div className="min-h-screen bg-black text-white">
{/* Removed Back to Artists button per request */}
{/* 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 (Desktop only) */}
<section className="relative h-screen overflow-hidden -mt-20 hidden md:block">
{/* 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">
@ -234,7 +162,14 @@ export function ArtistPortfolio({ artistId }: ArtistPortfolioProps) {
className="object-cover"
/>
<div className="absolute inset-0 bg-gradient-to-r from-transparent to-black/50" />
{/* Availability badge removed */}
<div className="absolute top-28 left-8">
<Badge
variant={artist.isActive ? "default" : "secondary"}
className="bg-white/20 backdrop-blur-sm text-white border-white/30"
>
{artist.isActive ? "Available" : "Unavailable"}
</Badge>
</div>
</div>
</div>
@ -246,6 +181,7 @@ export function ArtistPortfolio({ artistId }: ArtistPortfolioProps) {
<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.specialties.join(", ")}</p>
</div>
<p className="text-gray-300 mb-8 leading-relaxed text-lg max-w-lg">{artist.bio}</p>
@ -264,9 +200,24 @@ export function ArtistPortfolio({ artistId }: ArtistPortfolioProps) {
</a>
</div>
)}
{artist.hourlyRate && (
<div className="flex items-center space-x-3">
<DollarSign className="w-5 h-5 text-gray-400" />
<span className="text-gray-300">Starting at ${artist.hourlyRate}/hr</span>
</div>
)}
</div>
{/* Specialties and pricing hidden on desktop per request */}
<div className="mb-8">
<h3 className="font-semibold mb-4 text-lg">Specializes in:</h3>
<div className="flex flex-wrap gap-2">
{artist.specialties.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">
@ -291,42 +242,8 @@ export function ArtistPortfolio({ artistId }: ArtistPortfolioProps) {
</div>
</section>
{/* Hero Section - Mobile stacked */}
<section className="md:hidden -mt-16">
<div className="relative w-full h-[55vh]">
<Image
src={profileImage}
alt={artist.name}
fill
sizes="100vw"
className="object-cover"
/>
<div className="absolute inset-0 bg-gradient-to-t from-black/70 via-black/20 to-transparent" />
</div>
<div className="px-6 py-8">
<h1 className="font-playfair text-4xl font-bold mb-2 text-balance">{artist.name}</h1>
<p className="text-white/80 mb-4 text-base">{artist.specialties.join(", ")}</p>
<p className="text-white/80 leading-relaxed mb-2 text-[17px]">
{showFullBio ? bioText : bioText.slice(0, 180)}{bioText.length > 180 && !showFullBio ? "…" : ""}
</p>
{bioText.length > 180 && (
<button onClick={() => setShowFullBio((v) => !v)} className="text-white/70 text-sm underline">
{showFullBio ? "Show less" : "Read more"}
</button>
)}
<div className="flex flex-col sm:flex-row gap-3">
<Button asChild size="lg" className="bg-white text-black hover:bg-gray-100 !text-black hover:!text-black">
<Link href={`/book?artist=${artist.slug}`}>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>
</section>
{/* Portfolio Section with Split Screen Layout (Desktop only) */}
<section className="relative bg-black hidden md:block">
{/* 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">
@ -441,97 +358,6 @@ export function ArtistPortfolio({ artistId }: ArtistPortfolioProps) {
</div>
</section>
{/* Mobile Portfolio: Carousel + Filters (simplified) */}
<section className="md:hidden bg-black">
{/* Removed mobile category filters for simplicity */}
{/* Carousel only */}
<div className="px-2 pb-10">
{filteredPortfolio.length === 0 ? (
<div className="flex items-center justify-center h-64">
<p className="text-gray-400">No portfolio images available</p>
</div>
) : (
<div className="relative" aria-label="Portfolio carousel">
<Carousel opts={{ align: "start", loop: true }} className="w-full" setApi={setCarouselApi}>
<CarouselContent>
{filteredPortfolio.map((item) => (
<CarouselItem key={item.id} className="basis-full">
<div className="w-full h-[70vh] relative">
<Image
src={item.url || "/placeholder.svg"}
alt={item.caption || `${artist.name} portfolio image`}
fill
sizes="100vw"
className="object-contain bg-black"
/>
</div>
</CarouselItem>)
)}
</CarouselContent>
</Carousel>
<div className="pointer-events-none absolute top-2 right-3 rounded-full bg-white/10 backdrop-blur px-2 py-1 text-xs text-white">
{filteredPortfolio.length} pieces
</div>
{/* Swipe hint */}
{showSwipeHint && (
<div className="pointer-events-none absolute bottom-2 left-1/2 -translate-x-1/2 rounded-full bg-white/10 backdrop-blur px-3 py-1 text-xs text-white">
Swipe left or right
</div>
)}
{/* Dots indicators */}
<div className="mt-3 flex items-center justify-center gap-2" role="tablist" aria-label="Carousel indicators">
{Array.from({ length: carouselCount }).map((_, i) => (
<button
key={i}
onClick={() => carouselApi?.scrollTo(i)}
aria-current={carouselCurrent === i}
aria-label={`Go to slide ${i + 1}`}
className={`h-2 w-2 rounded-full ${carouselCurrent === i ? "bg-white" : "bg-white/40"}`}
/>
))}
</div>
</div>
)}
</div>
</section>
{/* Available Flash (carousel) */}
{flashItems && flashItems.length > 0 && (
<section className="bg-black border-t border-white/10 py-10">
<div className="px-4 md:px-0 md:max-w-none md:w-screen">
<h3 className="font-playfair text-3xl md:text-4xl font-bold mb-6">Available Flash</h3>
<div className="relative">
<Carousel opts={{ align: "start", loop: true, skipSnaps: false, dragFree: true }} className="w-full relative" setApi={setFlashApi}>
<CarouselContent>
{flashItems.map((item) => (
<CarouselItem key={item.id} className="basis-full md:basis-1/2 lg:basis-1/3">
<div className="relative w-full aspect-[4/5] bg-black rounded-md overflow-hidden">
<Image src={item.url} alt={item.title || `${artist?.name} flash`} fill sizes="(max-width:768px) 100vw, 33vw" className="object-cover" />
</div>
<div className="flex items-center justify-end mt-3">
<Button asChild size="sm" className="bg-white text-black hover:bg-gray-100 !text-black">
<Link href={`/book?artist=${artist?.slug}&flashId=${item.id}`}>Book this</Link>
</Button>
</div>
</CarouselItem>
))}
</CarouselContent>
{/* Minimal nav controls */}
<CarouselPrevious className="left-4 top-1/2 -translate-y-1/2 size-12 md:size-14 z-10 bg-black/85 text-white shadow-xl border border-white/30 hover:bg-black rounded-full" aria-label="Previous flash" />
<CarouselNext className="right-4 top-1/2 -translate-y-1/2 size-12 md:size-14 z-10 bg-black/85 text-white shadow-xl border border-white/30 hover:bg-black rounded-full" aria-label="Next flash" />
</Carousel>
{/* Edge fade gradients (desktop) */}
<div className="pointer-events-none hidden md:block absolute inset-y-0 left-0 w-24 bg-gradient-to-r from-black to-transparent" />
<div className="pointer-events-none hidden md:block absolute inset-y-0 right-0 w-24 bg-gradient-to-l from-black to-transparent" />
</div>
{showSwipeHint && (
<div className="pointer-events-none mt-3 text-center text-xs text-white/70">Swipe or use </div>
)}
</div>
</section>
)}
{/* Contact Section */}
<section className="relative py-32 bg-black border-t border-white/10">
<div className="container mx-auto px-8 text-center">
@ -559,7 +385,22 @@ export function ArtistPortfolio({ artistId }: ArtistPortfolioProps) {
</Button>
</div>
{/* Desktop stats removed per request */}
<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.specialties.length}+</div>
<div className="text-gray-400">Specialties</div>
</div>
<div>
<div className="text-3xl font-bold mb-2">{portfolioImages.length}</div>
<div className="text-gray-400">Portfolio Pieces</div>
</div>
<div>
<div className="text-3xl font-bold mb-2">{artist.hourlyRate ? `$${artist.hourlyRate}` : "Contact"}</div>
<div className="text-gray-400">Starting Rate</div>
</div>
</div>
</div>
</div>
</div>
</section>
@ -576,24 +417,6 @@ export function ArtistPortfolio({ artistId }: ArtistPortfolioProps) {
<div
className="relative max-w-6xl max-h-[90vh] w-full flex items-center justify-center"
onClick={(e) => e.stopPropagation()}
onTouchStart={(e) => {
touchStartX.current = e.touches[0].clientX
}}
onTouchEnd={(e) => {
if (touchStartX.current == null) return
const dx = e.changedTouches[0].clientX - touchStartX.current
const threshold = 40
if (Math.abs(dx) > threshold) {
if (dx < 0) {
const next = (currentIndex + 1) % filteredPortfolio.length
goToIndex(next)
} else {
const prev = (currentIndex - 1 + filteredPortfolio.length) % filteredPortfolio.length
goToIndex(prev)
}
}
touchStartX.current = null
}}
>
{/* Prev */}
<button

View File

@ -106,14 +106,29 @@ export function ArtistsSection() {
style={transitionDelay ? { transitionDelay } : undefined}
>
<div className={`relative w-full ${aspectFor(i)} overflow-hidden rounded-md border border-white/10 bg-black`}>
{/* Imagery: use only the artist portrait */}
{/* Imagery */}
<div className="absolute inset-0 artist-image">
<img
src={artist.faceImage || "/placeholder.svg"}
alt={`${artist.name} portrait`}
src={artist.workImages?.[0] || "/placeholder.svg"}
alt={`${artist.name} tattoo work`}
className="w-full h-full object-cover"
loading="lazy"
/>
<div className="absolute inset-0 bg-black/30"></div>
{/* Portrait with feathered mask */}
<div className="absolute left-0 top-0 w-3/5 h-full pointer-events-none">
<img
src={artist.faceImage || "/placeholder.svg"}
alt={`${artist.name} portrait`}
className="w-full h-full object-cover"
style={{
maskImage: "linear-gradient(to right, black 0%, black 70%, transparent 100%)",
WebkitMaskImage: "linear-gradient(to right, black 0%, black 70%, transparent 100%)",
}}
loading="lazy"
/>
</div>
</div>
{/* Softer hover wash (replaces heavy overlay) */}

View File

@ -1,9 +1,6 @@
"use client"
import type React from "react"
import { useSearchParams } from "next/navigation"
import { useEffect } from "react"
import { fetchFlashItem } from "@/hooks/use-flash"
import { useState, useMemo } from "react"
import { Button } from "@/components/ui/button"
@ -36,8 +33,6 @@ interface BookingFormProps {
}
export function BookingForm({ artistId }: BookingFormProps) {
const search = useSearchParams()
const flashIdParam = search?.get('flashId') || undefined
const [step, setStep] = useState(1)
const [selectedDate, setSelectedDate] = useState<Date>()
@ -73,25 +68,11 @@ export function BookingForm({ artistId }: BookingFormProps) {
depositAmount: 100,
agreeToTerms: false,
agreeToDeposit: false,
flashId: flashIdParam || "",
})
const selectedArtist = artists?.find((a) => a.slug === formData.artistId)
const selectedSize = tattooSizes.find((size) => size.size === formData.tattooSize)
const bookingEnabled = useFeatureFlag("BOOKING_ENABLED")
// Prefill from flash piece if provided
useEffect(() => {
const load = async () => {
if (!flashIdParam) return
const item = await fetchFlashItem(flashIdParam)
if (!item) return
setFormData((prev) => ({
...prev,
tattooDescription: [item.title, item.description].filter(Boolean).join(' - '),
}))
}
load()
}, [flashIdParam])
// Calculate appointment start and end times for availability checking
const { appointmentStart, appointmentEnd } = useMemo(() => {

View File

@ -108,7 +108,7 @@ export const artists: Artist[] = [
{
id: 3,
slug: "amari-rodriguez",
name: "Amari Kyss",
name: "Amari Rodriguez",
title: "",
specialty: "Apprentice Artist",
faceImage: "/artists/amari-rodriguez-portrait.jpg",

View File

@ -1,31 +0,0 @@
import { useQuery } from '@tanstack/react-query'
import type { FlashItem } from '@/types/database'
export const flashKeys = {
listByArtist: (artistId: string) => ['flash', 'list', artistId] as const,
item: (id: string) => ['flash', 'item', id] as const,
}
export function useFlash(artistId: string | undefined) {
return useQuery({
queryKey: flashKeys.listByArtist(artistId || ''),
queryFn: async () => {
if (!artistId) return [] as FlashItem[]
const res = await fetch(`/api/flash/${artistId}`)
if (!res.ok) throw new Error('Failed to fetch flash')
const data = await res.json()
return (data.items || []) as FlashItem[]
},
enabled: !!artistId,
staleTime: 1000 * 60 * 5,
})
}
export async function fetchFlashItem(id: string): Promise<FlashItem | null> {
const res = await fetch(`/api/flash/item/${id}`)
if (!res.ok) return null
const data = await res.json()
return (data.item || null) as FlashItem | null
}

View File

@ -7,8 +7,7 @@ import type {
UpdateArtistInput,
CreateAppointmentInput,
UpdateSiteSettingsInput,
AppointmentFilters,
FlashItem
AppointmentFilters
} from '@/types/database'
// Type for Cloudflare D1 database binding
@ -164,20 +163,6 @@ export async function getArtistWithPortfolio(id: string, env?: any): Promise<imp
ORDER BY order_index ASC, created_at DESC
`).bind(id).all();
// Fetch flash items (public only) - tolerate missing table in older DBs
let flashRows: any[] = []
try {
const flashResult = await db.prepare(`
SELECT * FROM flash_items
WHERE artist_id = ? AND is_available = 1
ORDER BY order_index ASC, created_at DESC
`).bind(id).all();
flashRows = flashResult.results as any[]
} catch (_err) {
// Table may not exist yet; treat as empty
flashRows = []
}
const artist = artistResult as any;
return {
@ -200,20 +185,6 @@ export async function getArtistWithPortfolio(id: string, env?: any): Promise<imp
isPublic: Boolean(img.is_public),
createdAt: new Date(img.created_at)
})),
// Attach as non-breaking field (not in Artist type but useful to callers)
flashItems: flashRows.map(row => ({
id: row.id,
artistId: row.artist_id,
url: row.url,
title: row.title || undefined,
description: row.description || undefined,
price: row.price ?? undefined,
sizeHint: row.size_hint || undefined,
tags: row.tags ? JSON.parse(row.tags) : undefined,
orderIndex: row.order_index || 0,
isAvailable: Boolean(row.is_available),
createdAt: new Date(row.created_at)
})) as FlashItem[],
availability: [],
createdAt: new Date(artist.created_at),
updatedAt: new Date(artist.updated_at),

View File

@ -7,13 +7,6 @@ export default withAuth(
const token = req.nextauth.token
const { pathname } = req.nextUrl
// Permanent redirect for renamed artist slug
if (pathname === "/artists/amari-rodriguez") {
const url = new URL("/artists/amari-kyss", req.url)
const res = NextResponse.redirect(url, 308)
return res
}
// Allow token-based bypass for admin migrate endpoint (non-interactive deployments)
const migrateToken = process.env.MIGRATE_TOKEN
const headerToken = req.headers.get("x-migrate-token")

745
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -128,10 +128,8 @@
"@vitest/coverage-v8": "^3.2.4",
"eslint": "^8.57.0",
"eslint-config-next": "14.2.16",
"heic-convert": "^2.1.0",
"jsdom": "^27.0.0",
"postcss": "^8.5",
"sharp": "^0.34.4",
"tailwindcss": "^4.1.9",
"tw-animate-css": "1.3.3",
"typescript": "^5",

View File

@ -1,138 +0,0 @@
# Tattoo Artist Portfolio Questionnaire
## Basic Information
**Artist Name/Alias: Amari Kyss**
**Contact Email: grimmtatt@gmail.com**
**Instagram Handle:@grimmtatt**
**Other Social Media/Website:** <https://grimmtatts.glossgenius.com/>
## Background
**How did you get started in tattooing? In my Mothers House**
**Who were your mentors or influences? Christy Lumberg**
**In 2-3 paragraphs, describe your artistic philosophy and what makes your work unique:**
i think what sets me apart isnt just how i tattoo its how i care. i dont want this to feel like a
transaction, like youre ordering a tattoo the way youd order a meal. this isnt fast, or
disposable, or something to rush through. i want every person who sits in my chair to feel like
theyre seen, like their story matters, and like the art we make together is something sacred
even if its small. i know i didnt invent traditional tattooing, and im not pretending to be the
first person to lead with kindness. what i am is genuine. consistent. thoughtful. i approach this
work with deep respect for the history of it, for the people who wear it, and for the trust that
comes with putting something permanent on someones body. id do this for free if the world
let me. because to me, tattooing isnt just a job for me its an exchange of energy, of care, of time. and
i think that intention lives in every piece i put out.
**What do you want potential clients to know about you and your work?**
id want them to know it feels like hanging out with someone they could actually be friends with
outside of the tattoo. like it was easy, comforting, and they didnt have to be anything but
themselves. no pressure to be confident or outgoing or have the perfect idea or body just come
as you are, and thats more than enough. i really try to create a space where people feel safe
and accepted. your body is welcome here. your story is welcome here. i want it to feel like
youre just spending time with someone who sees you, hears you, and wants you to leave
feeling a little more at home in yourself.
**What are your goals for your tattoo career in the next few years?**
**slang insane ink**
## Artistic Style & Specialties
**What tattoo styles do you specialize in?** (Check all that apply)
- \[ x\] Traditional/American Traditional
- \[x \] Neo-Traditional
- \[ \] Realism (Black & Grey)
- \[ \] Realism (Color)
- \[x \] Japanese/Irezumi
- \[x \] Blackwork
- \[x \] Fine Line
- \[ \] Geometric
- \[ \] Watercolor
- \[ \] Tribal
- \[ \] Portrait
- \[ x\] Lettering/Script
- \[ \] Illustrative
- \[x \] Dotwork
- \[ \] Biomechanical
- \[x \] Cover-ups
- \[ \] Other: \________________\_
**What are your top 3 favorite styles to tattoo?**
1. American and Japanese Traditional
2. Floral Black and Grey
3. Color Work
**What types of designs do you most enjoy creating?**
**Anything American Traditional**
**Are there any styles or subjects you prefer NOT to tattoo?**
**Realism**
## Portfolio Pieces
**Please list 5-10 of your best tattoos that represent your work:**
[https://portal.united-tattoos.com/index.php/f/17904](https://portal.united-tattoos.com/index.php/f/17904 (preview))
## Process & Approach
**Describe your consultation process with clients:**
**Talking about the design seeing the space they want it and then going over availability, price ranges and the scheduling with a deposit**
**How do you approach custom design work?**
**with love**
## Availability & Pricing
**Current booking status:**
- \[ x\] Currently booking
- \[ \] Waitlist
- \[ \] By appointment only
- \[x \] Walk-ins welcome
**Typical booking lead time:**
**idk what this means**
**Average session length:**
**depends on the tattoo**
**Hourly rate or price range:**
**I price by piece outside of day sessions**
**Minimum charge:**
**0**
**Do you require a deposit?** If yes, how much? yes depending on how much the tattoo is no more than $100 though

Binary file not shown.

Before

Width:  |  Height:  |  Size: 516 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 373 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 517 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 540 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 426 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 564 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 978 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 421 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 552 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 497 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 515 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 405 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 203 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 394 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 372 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 533 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 635 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 541 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 467 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 779 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 333 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 559 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 583 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 538 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 344 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 287 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 166 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 391 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 172 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 176 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 155 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 171 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 739 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 391 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 173 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 183 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 156 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 161 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 139 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 562 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 282 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 196 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 164 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 142 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 476 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 382 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 450 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 166 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 165 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 268 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 498 KiB

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