---
name: web-coding
description: >
  Production-ready website coding skill for building fast, beautiful, mobile-first web projects with zero bugs. Use whenever the user wants to write code for a website, create components, set up a project, implement a feature, optimize performance, handle images, write CSS, or build any part of a web frontend or backend. Triggers: "write the code", "build the component", "implement this feature", "set up the project", "code the page", "напиши код", "создай компонент", "настрой проект", "реализуй", "код для сайта", "верстка", "фронтенд". Always reads the project PRD and design system before writing any code. Output is always clean, typed, production-ready code saved to the project folder.
---

# Web Coding Skill

This skill ensures every line of code written for a web project is clean, typed, performant, mobile-first, and free of common bugs. It establishes the tech stack, project architecture, and coding patterns that the entire codebase should follow.

---

## Step 0 — Read Before Coding

Before writing any code, always read:
- `02_PRD.md` — tech stack decisions, page list, integrations
- `03_Design-System.md` — design tokens, component inventory, breakpoints
- Any existing code files if the project is already started

Never invent a stack or design decision that contradicts the PRD. If something is unclear, note it as a comment in code (`// TODO: confirm with PRD`) rather than blocking.

---

## Step 1 — Tech Stack Decision

### The Standard Stack (use unless PRD says otherwise)

| Layer | Technology | Why |
|---|---|---|
| Framework | **Next.js 15 (App Router)** | Best-in-class SSR/SSG for SEO, React Server Components reduce JS bundle |
| Language | **TypeScript** | Catches bugs at compile time, not at 2am in production |
| Styling | **Tailwind CSS v4** | Utility-first, zero dead CSS, works perfectly with design tokens |
| CMS | **Sanity.io** | Flexible schema, live preview, great DX, free tier generous |
| Images | **Next.js `<Image />`** | Automatic WebP, responsive srcset, lazy loading — zero config |
| Icons | **Lucide React** | Consistent, tree-shakable, MIT licensed |
| Animation | **Framer Motion** | Production-quality animations, respects `prefers-reduced-motion` |
| Forms | **React Hook Form + Zod** | Performant, type-safe validation |
| Fonts | **next/font** | Self-hosted, no layout shift, subsetting automatic |
| Hosting | **Vercel** | Automatic preview deploys, edge CDN, built for Next.js |
| DNS/CDN | **Cloudflare** | Free CDN, DDoS, SSL, fastest DNS |

### When to deviate:
- **No budget / fast launch** → Replace Next.js + Sanity with Webflow. Still use this skill for custom code blocks.
- **E-commerce needed** → Add Shopify Storefront API or keep with Next.js + separate Stripe integration.
- **Very simple site (< 5 pages, no CMS)** → Plain Next.js with static JSON data files instead of Sanity.

---

## Step 2 — Project Architecture

### Folder Structure

```
project-root/
├── app/                          # Next.js App Router
│   ├── layout.tsx                # Root layout (fonts, metadata, providers)
│   ├── page.tsx                  # Homepage /
│   ├── services/
│   │   └── page.tsx              # /services
│   ├── gallery/
│   │   └── page.tsx              # /gallery
│   ├── team/
│   │   └── page.tsx              # /team
│   ├── about/
│   │   └── page.tsx              # /about
│   ├── branches/
│   │   └── page.tsx              # /branches
│   ├── booking/
│   │   └── page.tsx              # /booking
│   └── contact/
│       └── page.tsx              # /contact
│
├── components/
│   ├── atoms/                    # Button, Input, Badge, Icon, Avatar
│   ├── molecules/                # ServiceCard, TeamCard, ReviewCard, BeforeAfter
│   ├── organisms/                # Header, Footer, Hero, ServicesSection
│   └── ui/                       # shadcn/ui overrides if used
│
├── lib/
│   ├── sanity/
│   │   ├── client.ts             # Sanity client config
│   │   ├── queries.ts            # GROQ queries
│   │   └── types.ts              # TypeScript types from Sanity schema
│   ├── utils.ts                  # cn(), formatPrice(), etc.
│   └── constants.ts              # Site config, nav links, etc.
│
├── hooks/
│   ├── useScrollReveal.ts        # Intersection Observer hook
│   └── useMediaQuery.ts          # Breakpoint detection
│
├── styles/
│   └── globals.css               # CSS custom properties (design tokens) + Tailwind base
│
├── public/
│   ├── fonts/                    # Self-hosted if not using Google
│   └── og-image.jpg              # Open Graph fallback image
│
├── sanity/                       # Sanity Studio (co-located)
│   ├── schemas/
│   │   ├── service.ts
│   │   ├── master.ts
│   │   ├── galleryItem.ts
│   │   ├── review.ts
│   │   └── branch.ts
│   └── sanity.config.ts
│
├── next.config.ts
├── tailwind.config.ts
├── tsconfig.json
└── .env.local                    # NEVER commit this
```

### The mental model: Server vs Client

```
Server Components (default) → fetch data, render HTML, no JS sent to browser
Client Components ('use client') → interactive UI, event handlers, useState

Rule: Everything is a Server Component UNLESS it needs:
  - onClick, onChange, event handlers
  - useState, useEffect, useRef
  - Browser APIs (window, localStorage)
  - Third-party client-only libraries
```

---

## Step 3 — Global Setup Patterns

### globals.css — Design Tokens + Tailwind Base

```css
@import "tailwindcss";

/* ===== DESIGN TOKENS ===== */
:root {
  /* Colors */
  --color-primary:       #F4A7B9;
  --color-primary-dark:  #E08899;
  --color-primary-light: #FDF0F4;
  --color-accent:        #1A1A1A;
  --color-accent-hover:  #333333;
  --color-secondary:     #E8D5CB;

  --color-neutral-50:  #FAF7F5;
  --color-neutral-100: #F2EDE9;
  --color-neutral-200: #E5DDD8;
  --color-neutral-400: #B5A9A4;
  --color-neutral-700: #5C4F4A;
  --color-neutral-900: #2D2D2D;

  --color-white:   #FFFFFF;
  --color-success: #38A169;
  --color-error:   #E53E3E;

  /* Spacing */
  --space-1: 0.25rem; --space-2: 0.5rem;  --space-3: 0.75rem;
  --space-4: 1rem;    --space-6: 1.5rem;  --space-8: 2rem;
  --space-12: 3rem;   --space-16: 4rem;   --space-24: 6rem;

  /* Typography */
  --font-heading: 'Cormorant Garamond', Georgia, serif;
  --font-body:    'Inter', -apple-system, sans-serif;

  /* Radius */
  --radius-sm: 4px;  --radius-md: 8px;
  --radius-lg: 16px; --radius-xl: 24px; --radius-full: 9999px;

  /* Shadows */
  --shadow-sm: 0 1px 3px rgba(244, 167, 185, 0.08);
  --shadow-md: 0 4px 16px rgba(244, 167, 185, 0.12);
  --shadow-lg: 0 8px 32px rgba(244, 167, 185, 0.16);

  /* Transitions */
  --transition-fast:   150ms ease;
  --transition-base:   250ms ease;
  --transition-slow:   400ms ease-in-out;
  --transition-spring: 300ms cubic-bezier(0.34, 1.56, 0.64, 1);
}

/* ===== BASE STYLES ===== */
html { scroll-behavior: smooth; }

body {
  font-family: var(--font-body);
  color: var(--color-neutral-900);
  background-color: var(--color-neutral-50);
  -webkit-font-smoothing: antialiased;
}

h1, h2, h3, h4 { font-family: var(--font-heading); }

/* ===== FOCUS STATES (never remove) ===== */
:focus-visible {
  outline: 2px solid var(--color-primary-dark);
  outline-offset: 2px;
  border-radius: var(--radius-sm);
}

/* ===== REDUCED MOTION ===== */
@media (prefers-reduced-motion: reduce) {
  *, *::before, *::after {
    animation-duration: 0.01ms !important;
    transition-duration: 0.01ms !important;
  }
}
```

### tailwind.config.ts — Token Bridge

```typescript
import type { Config } from 'tailwindcss'

const config: Config = {
  content: ['./app/**/*.{ts,tsx}', './components/**/*.{ts,tsx}'],
  theme: {
    extend: {
      colors: {
        primary:   { DEFAULT: 'var(--color-primary)', dark: 'var(--color-primary-dark)', light: 'var(--color-primary-light)' },
        accent:    { DEFAULT: 'var(--color-accent)',  hover: 'var(--color-accent-hover)' },
        secondary: 'var(--color-secondary)',
        neutral:   {
          50: 'var(--color-neutral-50)',   100: 'var(--color-neutral-100)',
          200: 'var(--color-neutral-200)', 400: 'var(--color-neutral-400)',
          700: 'var(--color-neutral-700)', 900: 'var(--color-neutral-900)',
        },
      },
      fontFamily: {
        heading: 'var(--font-heading)',
        body:    'var(--font-body)',
      },
      borderRadius: {
        sm: 'var(--radius-sm)', md: 'var(--radius-md)',
        lg: 'var(--radius-lg)', xl: 'var(--radius-xl)',
      },
      boxShadow: {
        sm: 'var(--shadow-sm)', md: 'var(--shadow-md)', lg: 'var(--shadow-lg)',
      },
      transitionDuration: {
        fast: '150', base: '250', slow: '400',
      },
      screens: {
        sm: '480px', md: '768px', lg: '1024px', xl: '1280px', '2xl': '1536px',
      },
    },
  },
}
export default config
```

### lib/utils.ts

```typescript
import { clsx, type ClassValue } from 'clsx'
import { twMerge } from 'tailwind-merge'

// Merge Tailwind classes safely (no conflicts)
export function cn(...inputs: ClassValue[]) {
  return twMerge(clsx(inputs))
}

// Format price for display
export function formatPrice(price: number, currency = '₸'): string {
  return `от ${price.toLocaleString('ru-KZ')} ${currency}`
}

// Truncate text with ellipsis
export function truncate(text: string, maxLength: number): string {
  if (text.length <= maxLength) return text
  return text.slice(0, maxLength).trimEnd() + '…'
}
```

---

## Step 4 — Component Patterns

### The Component Template

Every component follows this exact structure. No exceptions.

```typescript
// components/molecules/ServiceCard.tsx
import Image from 'next/image'
import Link from 'next/link'
import { Clock } from 'lucide-react'
import { cn, formatPrice } from '@/lib/utils'

// 1. Type first
interface ServiceCardProps {
  title: string
  description: string
  price: number
  duration: number        // minutes
  imageUrl: string
  imageAlt: string
  category: string
  bookingUrl?: string
  className?: string
}

// 2. Component (Server Component by default — no 'use client')
export function ServiceCard({
  title,
  description,
  price,
  duration,
  imageUrl,
  imageAlt,
  category,
  bookingUrl = '/booking',
  className,
}: ServiceCardProps) {
  return (
    <article
      className={cn(
        'group flex flex-col overflow-hidden rounded-lg bg-white',
        'shadow-sm transition-all duration-base',
        'hover:-translate-y-1 hover:shadow-md',
        className
      )}
    >
      {/* Image — always use Next/Image, never <img> */}
      <div className="relative aspect-[4/3] overflow-hidden">
        <Image
          src={imageUrl}
          alt={imageAlt}
          fill
          sizes="(max-width: 768px) 100vw, (max-width: 1024px) 50vw, 33vw"
          className="object-cover transition-transform duration-slow group-hover:scale-105"
        />
        <span className="absolute left-3 top-3 rounded-full bg-white/90 px-3 py-1 text-xs font-medium text-neutral-700">
          {category}
        </span>
      </div>

      {/* Content */}
      <div className="flex flex-1 flex-col gap-3 p-5">
        <h3 className="font-heading text-xl font-semibold text-neutral-900">{title}</h3>
        <p className="flex-1 text-sm leading-relaxed text-neutral-700">{description}</p>

        {/* Meta row */}
        <div className="flex items-center gap-4 text-sm text-neutral-400">
          <span className="flex items-center gap-1">
            <Clock size={14} aria-hidden="true" />
            {duration} мин
          </span>
          <span className="font-medium text-neutral-900">{formatPrice(price)}</span>
        </div>

        {/* CTA */}
        <Link
          href={bookingUrl}
          className={cn(
            'mt-1 inline-flex items-center justify-center rounded-md',
            'bg-accent px-4 py-3 text-sm font-medium text-white',
            'transition-colors duration-fast hover:bg-accent-hover',
            'focus-visible:outline-2 focus-visible:outline-primary-dark',
            'min-h-[44px]' // Touch target
          )}
        >
          Записаться на {title.toLowerCase()}
        </Link>
      </div>
    </article>
  )
}
```

### Button Component (Atom — reusable)

```typescript
// components/atoms/Button.tsx
'use client' // Only if it needs onClick that can't be server-handled

import { type ButtonHTMLAttributes, forwardRef } from 'react'
import { cn } from '@/lib/utils'

interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
  variant?: 'primary' | 'secondary' | 'ghost'
  size?: 'sm' | 'md' | 'lg'
  isLoading?: boolean
}

export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
  ({ variant = 'primary', size = 'md', isLoading, className, children, disabled, ...props }, ref) => {
    return (
      <button
        ref={ref}
        disabled={disabled || isLoading}
        aria-busy={isLoading}
        className={cn(
          // Base
          'inline-flex items-center justify-center font-medium transition-all',
          'focus-visible:outline-2 focus-visible:outline-primary-dark focus-visible:outline-offset-2',
          'disabled:cursor-not-allowed disabled:opacity-50',
          'min-h-[44px] w-full md:w-auto', // Full-width mobile, auto desktop

          // Variants
          variant === 'primary' && [
            'rounded-md bg-accent text-white',
            'hover:bg-accent-hover active:scale-[0.98]',
          ],
          variant === 'secondary' && [
            'rounded-md border border-primary bg-transparent text-neutral-900',
            'hover:bg-primary-light',
          ],
          variant === 'ghost' && [
            'text-neutral-700 underline-offset-4 hover:text-neutral-900 hover:underline',
          ],

          // Sizes
          size === 'sm' && 'px-4 py-2 text-sm',
          size === 'md' && 'px-6 py-3 text-base',
          size === 'lg' && 'px-8 py-4 text-lg',

          className
        )}
        {...props}
      >
        {isLoading ? (
          <span className="flex items-center gap-2">
            <span className="h-4 w-4 animate-spin rounded-full border-2 border-white border-t-transparent" />
            Загрузка…
          </span>
        ) : children}
      </button>
    )
  }
)
Button.displayName = 'Button'
```

---

## Step 5 — Image Optimization (Critical)

### Rule: NEVER use `<img>`. ALWAYS use `next/image`.

```typescript
// ✅ CORRECT — responsive image
import Image from 'next/image'

<Image
  src={imageUrl}
  alt="Описательный alt-текст на русском"
  width={800}
  height={600}
  sizes="(max-width: 768px) 100vw, (max-width: 1024px) 50vw, 400px"
  className="object-cover"
  // loading="lazy" is default for below-fold
  // Use priority for hero/above-fold images only:
  priority // ← only for the FIRST visible image (LCP element)
/>

// ✅ CORRECT — fill parent container
<div className="relative aspect-[4/3]">
  <Image
    src={imageUrl}
    alt="..."
    fill
    sizes="(max-width: 768px) 100vw, 33vw"
    className="object-cover"
  />
</div>
```

### next.config.ts — Image Domains

```typescript
import type { NextConfig } from 'next'

const config: NextConfig = {
  images: {
    formats: ['image/avif', 'image/webp'], // Prefer AVIF, fallback WebP
    remotePatterns: [
      { protocol: 'https', hostname: 'cdn.sanity.io' },
      { protocol: 'https', hostname: 'res.cloudinary.com' },
    ],
    // Sizes for srcset generation
    deviceSizes: [375, 640, 750, 828, 1080, 1200, 1920],
    imageSizes: [16, 32, 48, 64, 96, 128, 256, 384],
  },
  // Enable React Compiler for automatic memoization (Next 15)
  experimental: { reactCompiler: true },
}

export default config
```

### Before/After Image Slider (Client Component)

```typescript
// components/molecules/BeforeAfterSlider.tsx
'use client'

import { useRef, useState, useCallback } from 'react'
import Image from 'next/image'
import { cn } from '@/lib/utils'

interface BeforeAfterSliderProps {
  before: { src: string; alt: string }
  after:  { src: string; alt: string }
  className?: string
}

export function BeforeAfterSlider({ before, after, className }: BeforeAfterSliderProps) {
  const [position, setPosition] = useState(50) // 0–100
  const containerRef = useRef<HTMLDivElement>(null)
  const isDragging = useRef(false)

  const updatePosition = useCallback((clientX: number) => {
    if (!containerRef.current) return
    const rect = containerRef.current.getBoundingClientRect()
    const pct  = Math.min(100, Math.max(0, ((clientX - rect.left) / rect.width) * 100))
    setPosition(pct)
  }, [])

  // Mouse events
  const onMouseMove = useCallback((e: React.MouseEvent) => {
    if (isDragging.current) updatePosition(e.clientX)
  }, [updatePosition])

  // Touch events
  const onTouchMove = useCallback((e: React.TouchEvent) => {
    updatePosition(e.touches[0].clientX)
  }, [updatePosition])

  return (
    <div
      ref={containerRef}
      className={cn('relative aspect-[4/3] cursor-col-resize overflow-hidden rounded-lg select-none', className)}
      onMouseDown={() => { isDragging.current = true }}
      onMouseUp={() => { isDragging.current = false }}
      onMouseLeave={() => { isDragging.current = false }}
      onMouseMove={onMouseMove}
      onTouchMove={onTouchMove}
      role="img"
      aria-label={`Сравнение: ${before.alt} и ${after.alt}`}
    >
      {/* After (bottom layer) */}
      <Image src={after.src} alt={after.alt} fill sizes="(max-width: 768px) 100vw, 50vw" className="object-cover" />

      {/* Before (clipped top layer) */}
      <div className="absolute inset-0 overflow-hidden" style={{ width: `${position}%` }}>
        <Image src={before.src} alt={before.alt} fill sizes="(max-width: 768px) 100vw, 50vw" className="object-cover" />
      </div>

      {/* Drag handle */}
      <div
        className="absolute top-0 h-full w-0.5 bg-white shadow-lg"
        style={{ left: `${position}%`, transform: 'translateX(-50%)' }}
      >
        <div
          className={cn(
            'absolute top-1/2 -translate-x-1/2 -translate-y-1/2',
            'flex h-11 w-11 items-center justify-center rounded-full bg-white shadow-md',
            'text-neutral-900 transition-transform duration-spring hover:scale-110'
          )}
          aria-hidden="true"
        >
          ↔
        </div>
      </div>

      {/* Labels */}
      <span className="absolute bottom-3 left-3 rounded-full bg-black/60 px-3 py-1 text-xs text-white">До</span>
      <span className="absolute bottom-3 right-3 rounded-full bg-black/60 px-3 py-1 text-xs text-white">После</span>
    </div>
  )
}
```

---

## Step 6 — Mobile-First CSS Patterns

### The Golden Rule

```
Write mobile styles first. Add desktop with min-width.
NEVER add `@media (max-width: ...)` to undo desktop styles.
```

```typescript
// ✅ CORRECT — Mobile first
<div className={cn(
  'grid grid-cols-1 gap-4',      // Mobile: 1 column
  'md:grid-cols-2',              // Tablet: 2 columns
  'lg:grid-cols-3',              // Desktop: 3 columns
)}>

// ❌ WRONG — Desktop first (creates cascading overrides)
<div className={cn(
  'grid grid-cols-3 gap-4',
  'max-md:grid-cols-2',
  'max-sm:grid-cols-1',
)}>
```

### Section Layout Pattern

```typescript
// components/organisms/Section.tsx
import { cn } from '@/lib/utils'
import { type ReactNode } from 'react'

interface SectionProps {
  children: ReactNode
  className?: string
  background?: 'white' | 'neutral' | 'secondary' | 'primary-light'
  id?: string
}

export function Section({ children, className, background = 'white', id }: SectionProps) {
  const bgClasses = {
    white:          'bg-white',
    neutral:        'bg-neutral-50',
    secondary:      'bg-secondary',
    'primary-light':'bg-primary-light',
  }

  return (
    <section
      id={id}
      className={cn(
        bgClasses[background],
        'py-12 md:py-20',      // 48px mobile / 80px desktop
        className
      )}
    >
      <div className="mx-auto max-w-[1280px] px-6 lg:px-12">
        {children}
      </div>
    </section>
  )
}
```

### Touch Target Rule

```typescript
// All clickable elements must meet 44×44px minimum
// In Tailwind: min-h-[44px] min-w-[44px]

// WhatsApp floating button
<a
  href="https://wa.me/77XXXXXXXXX"
  target="_blank"
  rel="noopener noreferrer"
  aria-label="Написать в WhatsApp"
  className={cn(
    'fixed bottom-6 right-6 z-50',
    'flex h-14 w-14 items-center justify-center rounded-full',
    'bg-[#25D366] text-white shadow-lg',
    'transition-transform duration-base hover:scale-110',
    'focus-visible:outline-2 focus-visible:outline-white',
  )}
>
  {/* WhatsApp SVG icon */}
</a>
```

---

## Step 7 — Performance Patterns

### Font Loading (Zero Layout Shift)

```typescript
// app/layout.tsx
import { Cormorant_Garamond, Inter } from 'next/font/google'

const cormorant = Cormorant_Garamond({
  subsets: ['latin', 'cyrillic'],
  weight: ['400', '500', '600', '700'],
  variable: '--font-heading',
  display: 'swap', // Shows fallback font while loading
})

const inter = Inter({
  subsets: ['latin', 'cyrillic'],
  variable: '--font-body',
  display: 'swap',
})

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="ru" className={`${cormorant.variable} ${inter.variable}`}>
      <body>{children}</body>
    </html>
  )
}
```

### Metadata (SEO — every page)

```typescript
// app/services/page.tsx
import type { Metadata } from 'next'

export const metadata: Metadata = {
  title: 'Услуги и цены — Pink Beauty | Маникюр, Педикюр, Наращивание в Астане',
  description: 'Полный прайс-лист Pink Beauty: маникюр от 3000₸, педикюр, наращивание ногтей. Прозрачные цены, профессиональные мастера. Онлайн-запись.',
  openGraph: {
    title: 'Услуги Pink Beauty — Астана',
    description: 'Маникюр, педикюр, наращивание. Цены от 3000₸.',
    images: [{ url: '/og-services.jpg', width: 1200, height: 630 }],
    locale: 'ru_KZ',
    type: 'website',
  },
}
```

### Schema.org (Local SEO)

```typescript
// components/organisms/SchemaMarkup.tsx
// Place in root layout for site-wide schema + individual pages for specific

export function SalonSchema() {
  const schema = {
    '@context': 'https://schema.org',
    '@type': 'BeautySalon',
    name: 'Pink Beauty',
    url: 'https://pinkbeauty.kz',
    telephone: '+77XXXXXXXXX',
    image: 'https://pinkbeauty.kz/og-image.jpg',
    priceRange: '₸₸',
    aggregateRating: {
      '@type': 'AggregateRating',
      ratingValue: '4.6',
      reviewCount: '662',
    },
    address: {
      '@type': 'PostalAddress',
      streetAddress: 'просп. Кабанбай батыра 58Б/5',
      addressLocality: 'Астана',
      addressCountry: 'KZ',
    },
    openingHoursSpecification: {
      '@type': 'OpeningHoursSpecification',
      dayOfWeek: ['Monday','Tuesday','Wednesday','Thursday','Friday','Saturday','Sunday'],
      opens: '09:00',
      closes: '21:00',
    },
  }

  return (
    <script
      type="application/ld+json"
      dangerouslySetInnerHTML={{ __html: JSON.stringify(schema) }}
    />
  )
}
```

### Scroll Reveal Hook (Performance-safe)

```typescript
// hooks/useScrollReveal.ts
'use client'

import { useEffect, useRef } from 'react'

export function useScrollReveal(threshold = 0.1) {
  const ref = useRef<HTMLDivElement>(null)

  useEffect(() => {
    const el = ref.current
    if (!el) return

    // Respect prefers-reduced-motion
    if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
      el.style.opacity = '1'
      return
    }

    el.style.opacity = '0'
    el.style.transform = 'translateY(20px)'
    el.style.transition = 'opacity 400ms ease-out, transform 400ms ease-out'

    const observer = new IntersectionObserver(
      ([entry]) => {
        if (entry.isIntersecting) {
          el.style.opacity = '1'
          el.style.transform = 'translateY(0)'
          observer.disconnect() // Run once
        }
      },
      { threshold }
    )

    observer.observe(el)
    return () => observer.disconnect()
  }, [threshold])

  return ref
}

// Usage:
// const ref = useScrollReveal()
// <div ref={ref}> ... </div>
```

### Stagger animation for grids

```typescript
// Apply delay via inline style to each card
{services.map((service, index) => (
  <div
    key={service.id}
    style={{ transitionDelay: `${index * 100}ms` }}
  >
    <ServiceCard {...service} />
  </div>
))}
```

---

## Step 8 — Bug Prevention Checklist

Run through this before committing any component or page.

### TypeScript
- [ ] Every prop has a type (no `any`)
- [ ] Optional props marked with `?` and have defaults
- [ ] API response types defined in `lib/sanity/types.ts`
- [ ] No `as unknown as SomeType` casts — fix the type properly

### Hydration (Next.js specific)
- [ ] No `Math.random()`, `Date.now()`, or `new Date()` in render without `suppressHydrationWarning`
- [ ] No browser-only APIs (`window`, `document`, `localStorage`) at module level — wrap in `useEffect`
- [ ] No conditional rendering based on `typeof window !== 'undefined'` in JSX — use a mounted state

```typescript
// ✅ CORRECT — handle window safely
'use client'
import { useState, useEffect } from 'react'

function ClientOnly({ children }: { children: React.ReactNode }) {
  const [mounted, setMounted] = useState(false)
  useEffect(() => setMounted(true), [])
  if (!mounted) return null
  return <>{children}</>
}
```

### Images
- [ ] Every `<Image>` has a descriptive `alt` attribute (in Russian)
- [ ] Decorative images have `alt=""`
- [ ] Hero/above-fold image has `priority` prop
- [ ] All other images have `loading="lazy"` (default, don't need to add)
- [ ] `sizes` prop matches the actual rendered size

### Accessibility
- [ ] One `<h1>` per page
- [ ] Heading hierarchy is sequential (h1→h2→h3, no gaps)
- [ ] All interactive elements have visible focus styles
- [ ] Icon-only buttons have `aria-label`
- [ ] Form inputs have associated `<label>` elements
- [ ] Color is not the only way information is conveyed (add text/icons too)

### Mobile
- [ ] Test on 375px viewport (iPhone SE — the smallest common size)
- [ ] All tap targets ≥ 44×44px
- [ ] No horizontal scroll on mobile (check `overflow-x` on body)
- [ ] Text doesn't overflow containers on small screens
- [ ] Touch events work on carousel/slider components

### Performance
- [ ] No `useEffect` that runs on every render (check deps array)
- [ ] Images not stretching beyond their displayed size
- [ ] No imported library > 50KB that could be replaced with a smaller one
- [ ] Lighthouse mobile score > 85 before shipping

### Common Next.js Mistakes to Avoid
```typescript
// ❌ WRONG — Link with <a> inside
import Link from 'next/link'
<Link href="/services"><a>Услуги</a></Link>

// ✅ CORRECT — Next.js 13+ syntax
<Link href="/services">Услуги</Link>

// ❌ WRONG — Using router.push for external links
router.push('https://wa.me/...')

// ✅ CORRECT
<a href="https://wa.me/..." target="_blank" rel="noopener noreferrer">

// ❌ WRONG — Fetching in useEffect for initial data
useEffect(() => { fetch('/api/services').then(...) }, [])

// ✅ CORRECT — Fetch in Server Component (no useEffect needed)
async function ServicesPage() {
  const services = await getServices() // Direct Sanity fetch
  return <ServiceGrid services={services} />
}
```

---

## Step 9 — Sanity CMS Integration

### Client Setup

```typescript
// lib/sanity/client.ts
import { createClient } from 'next-sanity'

export const client = createClient({
  projectId: process.env.NEXT_PUBLIC_SANITY_PROJECT_ID!,
  dataset:   process.env.NEXT_PUBLIC_SANITY_DATASET ?? 'production',
  apiVersion: '2024-01-01',
  useCdn: process.env.NODE_ENV === 'production', // CDN in prod, fresh in dev
})
```

### Type-safe GROQ Queries

```typescript
// lib/sanity/queries.ts
import { client } from './client'
import type { Service, Master, GalleryItem } from './types'

export async function getServices(): Promise<Service[]> {
  return client.fetch(`
    *[_type == "service"] | order(order asc) {
      _id, title, description, price, duration, category,
      "imageUrl": image.asset->url,
      "imageAlt": image.alt,
    }
  `)
}

export async function getMasters(): Promise<Master[]> {
  return client.fetch(`
    *[_type == "master"] | order(name asc) {
      _id, name, specialty, experience, instagram,
      "imageUrl": photo.asset->url,
      "imageAlt": photo.alt,
    }
  `)
}
```

### On-demand Revalidation (keep data fresh)

```typescript
// app/api/revalidate/route.ts
import { revalidatePath } from 'next/cache'
import { type NextRequest, NextResponse } from 'next/server'

export async function POST(req: NextRequest) {
  const secret = req.headers.get('x-revalidate-secret')
  if (secret !== process.env.REVALIDATE_SECRET) {
    return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
  }

  revalidatePath('/services')
  revalidatePath('/gallery')
  revalidatePath('/team')
  return NextResponse.json({ revalidated: true })
}
// Configure this webhook URL in Sanity dashboard → API → Webhooks
```

---

## Step 10 — Environment Variables

```bash
# .env.local (NEVER commit)
NEXT_PUBLIC_SANITY_PROJECT_ID=your_project_id
NEXT_PUBLIC_SANITY_DATASET=production
SANITY_API_TOKEN=your_write_token

REVALIDATE_SECRET=random_long_string_here

NEXT_PUBLIC_GA_ID=G-XXXXXXXXXX
NEXT_PUBLIC_YM_ID=XXXXXXXX

NEXT_PUBLIC_WHATSAPP_NUMBER=77XXXXXXXXX
NEXT_PUBLIC_SITE_URL=https://pinkbeauty.kz

# Yclients (if used)
NEXT_PUBLIC_YCLIENTS_COMPANY_ID=XXXXXX
YCLIENTS_API_TOKEN=your_api_token
```

```typescript
// lib/constants.ts — type-safe access to env vars
export const siteConfig = {
  name:        'Pink Beauty',
  url:         process.env.NEXT_PUBLIC_SITE_URL ?? 'https://pinkbeauty.kz',
  whatsapp:    process.env.NEXT_PUBLIC_WHATSAPP_NUMBER ?? '',
  gaId:        process.env.NEXT_PUBLIC_GA_ID ?? '',
  ymId:        process.env.NEXT_PUBLIC_YM_ID ?? '',
} as const
```

---

## Step 11 — Pre-Launch Quality Checklist

Run before every deploy to production.

### Performance
- [ ] Lighthouse mobile > 85 on all key pages
- [ ] LCP < 2.5s (check in Chrome DevTools → Performance)
- [ ] No images > 200KB (use `next/image` to enforce)
- [ ] Bundle analyzer: `ANALYZE=true npm run build` — no surprise large deps

### SEO
- [ ] Each page has unique `<title>` and `<meta name="description">`
- [ ] All pages in sitemap.xml (auto-generated via `next-sitemap`)
- [ ] Schema.org JSON-LD on homepage and service pages
- [ ] robots.txt allows crawling
- [ ] OG image (1200×630) for social sharing

### Cross-browser
- [ ] Chrome Android (latest)
- [ ] Safari iOS (latest) — test `position: sticky`, `100vh` (use `100svh`)
- [ ] Chrome Desktop
- [ ] Firefox Desktop

### Forms & Booking
- [ ] Form validation messages appear in Russian
- [ ] Phone number format: +7 (XXX) XXX-XX-XX
- [ ] Form submits correctly to Yclients / email
- [ ] Error state: network failure is handled gracefully
- [ ] Success state: clear confirmation shown

### Content
- [ ] No placeholder text ("Lorem ipsum") in production
- [ ] All images have non-empty alt text (in Russian)
- [ ] Phone numbers are real and clickable (`tel:`)
- [ ] WhatsApp link works: `https://wa.me/77XXXXXXXXX`
- [ ] 2GIS embed loads correctly

---

## Output Conventions

- All files in **TypeScript** (`.tsx` / `.ts`), never plain `.js`
- Component names: `PascalCase`
- Hooks: `useCamelCase`
- Utility functions: `camelCase`
- CSS classes: Tailwind utilities only (no custom class names unless absolutely necessary)
- File names: `kebab-case.tsx`
- Comments in Russian for business logic, English for technical patterns
- Every new file starts with a one-line comment stating its purpose
