Mobile-first web app for a car wash business called AutoSplash, built with Next.js and deployed on Vercel. The app simulates a 5-screen mobile app and must be fully functional from any device via a public URL.
| Layer | Choice |
|---|---|
| Framework | Next.js 14 (App Router) |
| Styling | Tailwind CSS |
| Animations | Framer Motion |
| Data | Local JSON (/data/services.json) |
| Audio | HTML5 <audio> + Web Audio API fallback |
| Deployment | Vercel |
| Token | Hex | Use |
|---|---|---|
primary |
#1E3A5F |
Backgrounds, headers |
primary-light |
#2A4F7C |
Card surfaces, nav bar |
accent |
#38BDF8 |
CTAs, highlights, active states |
accent-dark |
#0284C7 |
Pressed/hover state on accent |
white |
#FFFFFF |
Text on dark bg, card bg |
gray-soft |
#F0F4F8 |
Screen background, list bg |
text-muted |
#94A3B8 |
Labels, secondary text |
success |
#4ADE80 |
Confirmation feedback |
Use Inter (Google Fonts) — clean, modern, legible on small screens.
| Role | Size | Weight | Notes |
|---|---|---|---|
| App title (Portada) | text-5xl |
800 | Letter-spacing tight |
| Screen heading | text-2xl |
700 | |
| Card title | text-lg |
600 | |
| Body / description | text-sm |
400 | Leading relaxed |
| Label / badge | text-xs |
500 | Uppercase tracking |
- Base spacing unit:
4px(Tailwind default) - Card border radius:
rounded-2xl(16px) - Button border radius:
rounded-full - Phone frame:
rounded-[2.5rem](40px — realistic bezel feel)
/* Card lift */
box-shadow: 0 4px 16px rgba(30, 58, 95, 0.12);
/* Floating button */
box-shadow: 0 8px 24px rgba(56, 189, 248, 0.35);
/* Phone frame shell */
box-shadow: 0 24px 64px rgba(0, 0, 0, 0.35);The app lives inside a centered phone-frame on desktop. On real mobile it fills the viewport.
┌─────────────────────────────────────────┐ ← viewport (desktop)
│ ┌──────────────┐ │
│ │ status bar │ │
│ │ │ │
│ │ CONTENT │ │
│ │ │ │
│ │ nav / back │ │
│ └──────────────┘ │
│ max-w: 430px │
└─────────────────────────────────────────┘
// app/layout.tsx — root wrapper
<div className="min-h-screen bg-gray-100 flex items-center justify-center">
<div className="w-full max-w-[430px] min-h-screen bg-white shadow-2xl overflow-hidden relative">
{children}
</div>
</div>Goal: impress in 3 seconds, then auto-advance.
Layout:
┌────────────────────────┐
│ [water drop anim] │ ← Framer Motion SVG or particle system
│ │
│ AutoSplash │ ← text-5xl font-extrabold text-white
│ Tu auto, siempre │
│ reluciente │ ← text-lg text-sky-300 italic
│ │
│ ● ○ ○ ○ ○ │ ← dot indicator (optional, shows screen progress)
└────────────────────────┘
Background: bg-gradient-to-b from-[#0F2744] via-[#1E3A5F] to-[#2A4F7C]
Animation ideas (pick one or combine):
- Falling water drops — 6–8 SVG circle elements, staggered
ykeyframes,opacityfade in/out, looping. - Shine sweep — a diagonal white gradient overlay that slides across the title text (
background-clip: text). - Ripple rings — concentric circles expanding from center, like a drop hitting water,
scale0→2 withopacity1→0. - Car silhouette — SVG car outline with animated sparkle dots that light up sequentially.
Recommended implementation (ripple rings):
{[0, 0.4, 0.8].map((delay, i) => (
<motion.div
key={i}
className="absolute rounded-full border-2 border-sky-400/40"
style={{ width: 80, height: 80 }}
animate={{ scale: [1, 3], opacity: [0.6, 0] }}
transition={{ duration: 2, delay, repeat: Infinity, ease: 'easeOut' }}
/>
))}Auto-advance: useEffect(() => { const t = setTimeout(() => navigate('menu'), 3000); return () => clearTimeout(t); }, [])
Goal: clear navigation hub. Three large, finger-friendly buttons.
Layout:
┌────────────────────────┐
│ 💧 AutoSplash ··· │ ← header bar, primary bg
├────────────────────────┤
│ │
│ Tu lavadero de │
│ confianza 🚿 │ ← welcome subtext
│ │
│ ┌──────────────────┐ │
│ │ 🔊 Escucha promo │ │ ← accent bg button
│ └──────────────────┘ │
│ ┌──────────────────┐ │
│ │ 🚗 Ver servicios │ │ ← outlined or white bg button
│ └──────────────────┘ │
│ ┌──────────────────┐ │
│ │ 📞 Contáctanos │ │ ← outlined button
│ └──────────────────┘ │
│ │
└────────────────────────┘
Button hierarchy:
- Button 1 (audio): full
accentfill — the hero CTA - Buttons 2 & 3:
whitefill withprimaryborder and text — secondary
Audio handling:
const audioRef = useRef<HTMLAudioElement>(null);
const playPromo = () => {
if (audioRef.current) {
audioRef.current.play().catch(() => playFallbackBeep());
} else {
playFallbackBeep();
}
};
// Web Audio API fallback if promo.mp3 is missing
const playFallbackBeep = () => {
const ctx = new AudioContext();
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.connect(gain);
gain.connect(ctx.destination);
osc.frequency.value = 440;
gain.gain.setValueAtTime(0.3, ctx.currentTime);
gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 1.2);
osc.start();
osc.stop(ctx.currentTime + 1.2);
};Entrance animation: buttons stagger in from bottom (y: 30 → 0, opacity: 0 → 1), each 100ms apart.
Goal: quickly scannable list; one tap to see full details.
Layout:
┌────────────────────────┐
│ ← AutoSplash │ ← back button + screen title
├────────────────────────┤
│ Nuestros servicios │ ← text-xl font-bold
│ │
│ ┌──────────────────┐ │
│ │ 🧽 Lavado básico │ │ ← service card
│ │ (coche) │ │
│ │ $160 │ │ ← price badge (accent color)
│ └──────────────────┘ │
│ ┌──────────────────┐ │
│ │ ... │ │
│ └──────────────────┘ │
│ (scrollable) │
└────────────────────────┘
ServiceCard design:
- White card on
gray-softbackground - Left side: service icon (emoji or SVG) + name
- Right side: price pill with
accentbackground rounded-2xl, subtle shadow,active:scale-95press feedback- Entrance: cards stagger in (
y: 20 → 0) with 50ms delay each
Service icons by category:
| Category | Icon |
|---|---|
| Lavado básico | 🚿 |
| Lavado completo | ✨ |
| Encerado | 🏅 |
Goal: full info at a glance; feel like a receipt or product page.
Layout:
┌────────────────────────┐
│ ← Detalle │
├────────────────────────┤
│ [large hero section] │ ← service name + color band
│ │
│ ┌──────────────────┐ │
│ │ Servicio │ │
│ │ Lavado completo │ │ ← detail row card
│ └──────────────────┘ │
│ ┌──────────────────┐ │
│ │ Precio $250 │ │
│ └──────────────────┘ │
│ ┌──────────────────┐ │
│ │ Descripción │ │
│ │ Lavado ext + ... │ │
│ └──────────────────┘ │
│ ┌──────────────────┐ │
│ │ Duración 45 min │ │
│ └──────────────────┘ │
│ │
│ [ Reservar ahora ] │ ← decorative CTA (non-functional, links to Contacto)
└────────────────────────┘
Detail row component:
<div className="bg-white rounded-2xl p-4 flex justify-between items-start shadow-sm">
<span className="text-xs uppercase tracking-widest text-slate-400 font-medium">{label}</span>
<span className="text-sm font-semibold text-primary text-right max-w-[60%]">{value}</span>
</div>Hero band: bg-gradient-to-r from-[#1E3A5F] to-[#38BDF8] with white service name centered.
Goal: instant access to contact info; feel warm and local.
Layout:
┌────────────────────────┐
│ ← Contacto │
├────────────────────────┤
│ │
│ 📍 Dónde estamos │
│ ┌──────────────────┐ │
│ │ Av. Siempre │ │
│ │ Limpia 101, CDMX │ │
│ └──────────────────┘ │
│ │
│ 📞 Teléfono │
│ ┌──────────────────┐ │
│ │ 55-1234-5678 │ │ ← tap to call: href="tel:5512345678"
│ └──────────────────┘ │
│ │
│ 🕐 Horario │
│ ┌──────────────────┐ │
│ │ Lun–Dom │ │
│ │ 8:00 – 20:00 │ │
│ └──────────────────┘ │
│ │
│ [ 💬 WhatsApp ] │ ← decorative, href="https://wa.me/525512345678"
└────────────────────────┘
All screen changes use a shared AnimatePresence + slide pattern:
// Entering: slide in from right
// Exiting: slide out to left
<motion.div
key={currentScreen}
initial={{ x: '100%', opacity: 0 }}
animate={{ x: 0, opacity: 1 }}
exit={{ x: '-100%', opacity: 0 }}
transition={{ type: 'spring', stiffness: 300, damping: 30 }}
>Back navigation reverses direction (x: '-100%' enter, x: '100%' exit).
autosplash/
├── app/
│ ├── layout.tsx # Root layout + Inter font + phone frame shell
│ ├── page.tsx # All screen state + navigation logic
│ └── globals.css # Tailwind base + custom keyframe animations
├── components/
│ ├── screens/
│ │ ├── Portada.tsx
│ │ ├── Menu.tsx
│ │ ├── Servicios.tsx
│ │ ├── Detalle.tsx
│ │ └── Contacto.tsx
│ └── ui/
│ ├── Button.tsx # Rounded button, primary/secondary variants
│ ├── BackButton.tsx # ← arrow + label
│ └── ServiceCard.tsx # Card with name, icon, price badge
├── data/
│ └── services.json
├── public/
│ └── audio/
│ └── promo.mp3
├── types/
│ └── service.ts # Service interface
├── next.config.js
├── tailwind.config.ts
├── tsconfig.json
├── package.json
└── CLAUDE.md
[
{
"nombre": "Lavado básico (coche)",
"precio": "$160",
"descripcion": "Lavado exterior con agua a presión y secado manual.",
"duracion": "20 min"
},
{
"nombre": "Lavado básico (camioneta)",
"precio": "$190",
"descripcion": "Lavado exterior con agua a presión y secado manual.",
"duracion": "25 min"
},
{
"nombre": "Lavado completo (coche)",
"precio": "$250",
"descripcion": "Lavado exterior + aspirado interior + limpieza de vidrios.",
"duracion": "45 min"
},
{
"nombre": "Lavado completo (camioneta)",
"precio": "$300",
"descripcion": "Lavado exterior + aspirado interior + limpieza de vidrios.",
"duracion": "60 min"
},
{
"nombre": "Encerado (coche)",
"precio": "$380",
"descripcion": "Lavado completo + aplicación de cera protectora a mano.",
"duracion": "90 min"
},
{
"nombre": "Encerado (camioneta)",
"precio": "$450",
"descripcion": "Lavado completo + aplicación de cera protectora a mano.",
"duracion": "2 hrs"
}
]Import:
import services from '@/data/services.json';Type:
// types/service.ts
export interface Service {
nombre: string;
precio: string;
descripcion: string;
duracion: string;
}type Screen = 'portada' | 'menu' | 'servicios' | 'detalle' | 'contacto';
type Direction = 'forward' | 'back';
const [currentScreen, setCurrentScreen] = useState<Screen>('portada');
const [selectedService, setSelectedService] = useState<Service | null>(null);
const [navDirection, setNavDirection] = useState<Direction>('forward');
const navigate = (screen: Screen, direction: Direction = 'forward') => {
setNavDirection(direction);
setCurrentScreen(screen);
};- No localhost deps — works from any device via Vercel URL.
- No backend, no env vars — JSON is the entire data layer.
- Audio —
useRef<HTMLAudioElement>+ Web Audio API beep fallback. - Framer Motion only — no other animation libraries.
- No external UI kits — Tailwind + custom components only.
- TypeScript throughout.
- Touch-first — all tap targets min
44pxtall (WCAG 2.5.5).
/** @type {import('next').NextConfig} */
const nextConfig = {};
module.exports = nextConfig;import type { Config } from 'tailwindcss';
const config: Config = {
content: [
'./app/**/*.{ts,tsx}',
'./components/**/*.{ts,tsx}',
],
theme: {
extend: {
colors: {
primary: '#1E3A5F',
'primary-light': '#2A4F7C',
accent: '#38BDF8',
'accent-dark': '#0284C7',
'gray-soft': '#F0F4F8',
},
fontFamily: {
sans: ['var(--font-inter)', 'sans-serif'],
},
},
},
plugins: [],
};
export default config;{
"compilerOptions": {
"target": "es2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [{ "name": "next" }],
"paths": { "@/*": ["./*"] }
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}{
"name": "autosplash",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint"
},
"dependencies": {
"framer-motion": "^11.0.0",
"next": "14.2.3",
"react": "^18",
"react-dom": "^18"
},
"devDependencies": {
"@types/node": "^20",
"@types/react": "^18",
"@types/react-dom": "^18",
"autoprefixer": "^10",
"eslint": "^8",
"eslint-config-next": "14.2.3",
"postcss": "^8",
"tailwindcss": "^3",
"typescript": "^5"
}
}Next.js 14 App Router renders server-side by default. Every file that uses useState, useEffect, useRef, framer-motion, or AudioContext must have "use client" as its very first line. Missing this causes a build error like:
Error: useState only works in Client Components. Add "use client" to the top of the file.
Affected files: app/page.tsx, all components/screens/*.tsx, all components/ui/*.tsx.
Put AnimatePresence in app/page.tsx (which is already "use client"). Do not put it in layout.tsx.
Required for import services from '@/data/services.json' to work without a type error.
Load it in app/layout.tsx and apply via CSS variable:
import { Inter } from 'next/font/google';
const inter = Inter({ subsets: ['latin'], variable: '--font-inter' });
export default function RootLayout({ children }) {
return (
<html lang="es" className={inter.variable}>
<body>{children}</body>
</html>
);
}The audio button must silently fall back to the Web Audio API beep. Never throw or show an error UI if the file is missing. The .catch() handler on .play() covers this.
Create public/audio/.gitkeep so the directory exists. Next.js serves /audio/promo.mp3 from public/audio/promo.mp3 — if the directory is missing the path still works, but create it for correctness.
The default eslint-config-next is strict. Avoid any types — use the Service interface everywhere. Name all components explicitly (not anonymous arrow functions as default exports).
Without mode="wait", the exiting and entering screens overlap. Use:
<AnimatePresence mode="wait">Claude Code must run these steps in order and not stop until every step passes cleanly:
1. npm install
2. npm run lint ← fix ALL warnings and errors before moving on
3. npm run build ← fix ALL errors; repeat lint → build until clean
Do not skip lint. A passing build with lint errors is not done.
Fix-loop rule: if npm run build fails, read the full error output, fix the root cause in the source files, then re-run npm run build. Do not move on until it exits with code 0.
Common build errors and how to fix them:
| Error | Fix |
|---|---|
useState only works in Client Components |
Add "use client" to the top of the file |
Cannot find module '@/data/services.json' |
Verify resolveJsonModule: true in tsconfig |
Type 'null' is not assignable to type 'Service' |
Guard with if (!selectedService) return null in Detalle |
Framer Motion: AnimatePresence requires... |
Add mode="wait" to AnimatePresence |
ESLint: 'X' is defined but never used |
Remove the unused import or variable |
'window' is not defined |
Wrap AudioContext usage in typeof window !== 'undefined' check or inside a click handler |
npm install # Install all deps
npm run lint # ESLint — must pass with 0 errors
npm run build # Production build — must exit 0
npm run dev # Local dev server (http://localhost:3000)
vercel --prod # Deploy (run manually after build passes)Claude Code cannot do these steps for you:
-
Verify the dev server looks right
npm run dev
Open
http://localhost:3000in a browser. Check:- Portada animation plays and auto-advances after ~3 s
- All three Menu buttons work
- Each service card navigates to its own detail (not the same one every time)
- Back buttons return to the correct screen
- Tap the audio button — you should hear either the promo or the fallback beep
-
Test on a real mobile device While
npm run devis running, open your phone's browser and navigate tohttp://<your-local-IP>:3000. Confirm touch targets feel right and animations are smooth. -
Log in to Vercel (one-time)
vercel login
Choose "Continue with GitHub" (or your preferred method). This only needs to be done once per machine.
-
Deploy
vercel --prod
Vercel will print a production URL like
https://autosplash-xxxx.vercel.app. Copy it. -
Test the production URL from your phone Open the Vercel URL on a mobile browser (not localhost). This is the URL you submit. Confirm:
- Page loads (no 404, no blank screen)
- Animation plays on Screen 1
- Audio button works (iOS Safari may require a user gesture — the button tap counts)
- Navigating to a service and back works correctly
-
iOS audio note iOS Safari blocks audio until a user interaction. The audio button is a user interaction, so it is fine. Do not try to play audio on
useEffector on page load — it will be silently blocked.
- Public Vercel URL works from any device (not just localhost)
- Screen 1: visible looping animation; auto-advances after 3 s
- Screen 2: audio button plays sound (promo or beep fallback); buttons 2 & 3 navigate
- Screen 3: full service list from
services.json; tapping any card navigates to its detail - Screen 4: shows ≥ 4 fields; works for every item clicked, not just one hardcoded example
- Screen 5: phone number, address, and hours all visible
- All screen transitions are animated (slide in/out)
-
npm run lintpasses with 0 errors -
npm run buildexits with code 0