# Mercado Minero — Catálogo del Marketplace (Fase 1) — Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Construir desde cero una app Next.js que corra en `http://localhost:3000` con la home de Mercado Minero y un catálogo de oportunidades mineras filtrable, leído desde una base de datos SQLite con datos semilla.

**Architecture:** Next.js 15 App Router con React Server Components. Prisma + SQLite como base de datos (esquema portable a PostgreSQL). Una capa de datos pura (`lib/`) testeada con Vitest que traduce filtros de la URL a consultas Prisma. UI con Tailwind CSS en tema oscuro + acento ámbar, fiel a las capturas del sitio real.

**Tech Stack:** Next.js 15, TypeScript, React Server Components, Tailwind CSS, Prisma ORM, SQLite, Vitest.

**Nota sobre SQLite/Prisma:** SQLite no soporta `enum` ni listas escalares en Prisma. Los valores tipo-enum (categoría, etapa, tipo de activo) se guardan como `String` y se validan con constantes/uniones de TypeScript en `lib/constants.ts`. Commodities y tipos de trato van en tablas de relación. El precio se guarda como `Int` (USD enteros).

---

## Estructura de archivos

```
package.json, tsconfig.json, next.config.ts, tailwind.config.ts, postcss.config.mjs, vitest.config.ts, .gitignore, .env
prisma/
  schema.prisma          # modelos Listing, ListingCommodity, ListingDealType
  seed.ts                # ~13 oportunidades semilla
src/
  lib/
    constants.ts         # uniones/labels de category, stage, assetType, dealType, commodity
    types.ts             # ListingFilter, tipos derivados
    prisma.ts            # singleton PrismaClient
    filters.ts           # parseSearchParams(searchParams) -> ListingFilter   [TDD]
    filters.test.ts
    listings.ts          # getListings(filter), getListingBySlug(slug), getMarketStats()  [TDD]
    listings.test.ts
    format.ts            # formatUsd, formatHa, commodityLabel...
  app/
    layout.tsx           # html/body, fuentes, Header, Footer
    globals.css          # Tailwind + variables de tema
    page.tsx             # Home
    marketplace/
      page.tsx           # Catálogo (lee searchParams)
      loading.tsx
      [slug]/
        page.tsx         # Detalle
        not-found.tsx
      productos/page.tsx # Próximamente
      servicios/page.tsx # Próximamente
    inversionistas/page.tsx   # Próximamente
    como-funciona/page.tsx    # Próximamente
  components/
    Header.tsx, Footer.tsx
    home/ Hero.tsx, StatsBand.tsx, MarketsSection.tsx, TwoSidesSection.tsx, CtaBand.tsx
    catalog/ FilterBar.tsx, ListingGrid.tsx, ListingCard.tsx, MarketStats.tsx, EmptyState.tsx
    ui/ CommodityBadge.tsx, StageBadge.tsx, DealTypeBadge.tsx, PriceTag.tsx, VerifiedBadge.tsx, ComingSoon.tsx, Container.tsx
```

---

## Task 0: Scaffold del proyecto Next.js

**Files:**
- Create: todo el esqueleto del proyecto vía `create-next-app`.

- [ ] **Step 1: Crear la app Next.js en el directorio actual**

Run (desde la raíz del proyecto "Portal Minero"):
```bash
npx create-next-app@latest . --ts --tailwind --eslint --app --src-dir --import-alias "@/*" --no-turbopack --use-npm
```
Responder "Yes" si pregunta por continuar en un directorio no vacío (ya existe `docs/` y `Capturas/`).
Expected: crea `package.json`, `src/app/`, `tailwind.config.ts`, etc.

- [ ] **Step 2: Verificar que arranca**

Run:
```bash
npm run dev &
sleep 6
curl -s -o /dev/null -w "%{http_code}" http://localhost:3000
kill %1
```
Expected: `200`

- [ ] **Step 3: Mover `Capturas/` fuera del árbol servido y añadir a .gitignore**

Las capturas son material de referencia, no parte del sitio. Añadir a `.gitignore`:
```
# Material de referencia (no versionar binarios pesados)
/Capturas/
# Base de datos local
/prisma/dev.db
/prisma/dev.db-journal
```

- [ ] **Step 4: Commit**

```bash
git add -A
git commit -m "chore: scaffold Next.js + Tailwind + TypeScript"
```

---

## Task 1: Dependencias (Prisma + Vitest)

**Files:**
- Modify: `package.json` (scripts)
- Create: `vitest.config.ts`, `.env`

- [ ] **Step 1: Instalar dependencias**

Run:
```bash
npm install -D prisma vitest @types/node tsx
npm install @prisma/client
```

- [ ] **Step 2: Inicializar Prisma con SQLite**

Run:
```bash
npx prisma init --datasource-provider sqlite
```
Esto crea `prisma/schema.prisma` y añade `DATABASE_URL` a `.env`. Asegurar en `.env`:
```
DATABASE_URL="file:./dev.db"
```

- [ ] **Step 3: Crear `vitest.config.ts`**

```typescript
import { defineConfig } from 'vitest/config'
import path from 'node:path'

export default defineConfig({
  test: {
    environment: 'node',
    include: ['src/**/*.test.ts'],
  },
  resolve: {
    alias: { '@': path.resolve(__dirname, './src') },
  },
})
```

- [ ] **Step 4: Añadir scripts a `package.json`**

En `"scripts"` agregar:
```json
"db:seed": "tsx prisma/seed.ts",
"test": "vitest run",
"test:watch": "vitest"
```

- [ ] **Step 5: Commit**

```bash
git add -A
git commit -m "chore: añadir Prisma (SQLite) y Vitest"
```

---

## Task 2: Esquema de base de datos

**Files:**
- Modify: `prisma/schema.prisma`

- [ ] **Step 1: Escribir el esquema**

Reemplazar el contenido de `prisma/schema.prisma` por:
```prisma
generator client {
  provider = "prisma-client-js"
}

datasource db {
  provider = "sqlite"
  url      = env("DATABASE_URL")
}

model Listing {
  id              String   @id @default(cuid())
  slug            String   @unique
  title           String
  description     String
  category        String   // ACTIVO | PRODUCTO | SERVICIO
  assetType       String?  // TERRENO_PROYECTO | CONCESION_MINERA | RELAVE_RIPIO_ESCORIA | INFRAESTRUCTURA_PLANTA
  stage           String?  // PROSPECCION | EXPLORACION_BASICA | EXPLORACION_AVANZADA | PRE_DESARROLLO | PRODUCCION
  country         String
  region          String?
  city            String?
  lat             Float?
  lng             Float?
  priceUsd        Int?
  surfaceHa       Float?
  concessionCount Int?
  verified        Boolean  @default(false)
  imageSeed       String?  // semilla para el thumbnail generado
  publishedAt     DateTime @default(now())

  commodities ListingCommodity[]
  dealTypes   ListingDealType[]

  @@index([category])
  @@index([country])
}

model ListingCommodity {
  id        String  @id @default(cuid())
  listing   Listing @relation(fields: [listingId], references: [id], onDelete: Cascade)
  listingId String
  commodity String  // AU | AG | CU | PB | ZN | MN | V | LI | FE | CO | NI | SN | W | MO | U | COAL | OTRO
  isPrimary Boolean @default(false)

  @@index([commodity])
}

model ListingDealType {
  id        String  @id @default(cuid())
  listing   Listing @relation(fields: [listingId], references: [id], onDelete: Cascade)
  listingId String
  dealType  String  // VENTA_100 | VENTA_PARCIAL | EARN_IN | FARM_IN | ROYALTY | OPCION_COMPRA | OFF_TAKE | SPIN_OUT | ARRIENDO | PARTICIPACION_ACCIONARIA | ACUERDO_TRIBUTO | ALIANZA_ESTRATEGICA | ADQUISICION

  @@index([dealType])
}
```

- [ ] **Step 2: Crear la migración**

Run:
```bash
npx prisma migrate dev --name init
```
Expected: crea `prisma/migrations/.../migration.sql` y genera el cliente Prisma.

- [ ] **Step 3: Commit**

```bash
git add -A
git commit -m "feat: esquema Prisma (Listing, commodities, dealTypes)"
```

---

## Task 3: Constantes, tipos y cliente Prisma

**Files:**
- Create: `src/lib/constants.ts`, `src/lib/types.ts`, `src/lib/prisma.ts`, `src/lib/format.ts`

- [ ] **Step 1: `src/lib/constants.ts`**

```typescript
export const CATEGORIES = ['ACTIVO', 'PRODUCTO', 'SERVICIO'] as const
export type Category = (typeof CATEGORIES)[number]

export const ASSET_TYPES = [
  'TERRENO_PROYECTO', 'CONCESION_MINERA', 'RELAVE_RIPIO_ESCORIA', 'INFRAESTRUCTURA_PLANTA',
] as const
export type AssetType = (typeof ASSET_TYPES)[number]

export const STAGES = [
  'PROSPECCION', 'EXPLORACION_BASICA', 'EXPLORACION_AVANZADA', 'PRE_DESARROLLO', 'PRODUCCION',
] as const
export type Stage = (typeof STAGES)[number]

export const DEAL_TYPES = [
  'ADQUISICION', 'VENTA_100', 'VENTA_PARCIAL', 'EARN_IN', 'FARM_IN', 'ROYALTY',
  'OPCION_COMPRA', 'OFF_TAKE', 'SPIN_OUT', 'ARRIENDO', 'PARTICIPACION_ACCIONARIA',
  'ACUERDO_TRIBUTO', 'ALIANZA_ESTRATEGICA',
] as const
export type DealType = (typeof DEAL_TYPES)[number]

export const COMMODITIES = [
  'AU', 'AG', 'CU', 'PB', 'ZN', 'MN', 'V', 'LI', 'FE', 'CO', 'NI', 'SN', 'W', 'MO', 'U', 'COAL', 'OTRO',
] as const
export type Commodity = (typeof COMMODITIES)[number]

export const STAGE_LABELS: Record<Stage, string> = {
  PROSPECCION: 'Prospección',
  EXPLORACION_BASICA: 'Exploración básica',
  EXPLORACION_AVANZADA: 'Exploración avanzada',
  PRE_DESARROLLO: 'Pre-desarrollo minero',
  PRODUCCION: 'Producción',
}

export const DEAL_TYPE_LABELS: Record<DealType, string> = {
  ADQUISICION: 'Adquisición',
  VENTA_100: 'Venta 100%',
  VENTA_PARCIAL: 'Venta parcial',
  EARN_IN: 'Earn-in',
  FARM_IN: 'Farm-in',
  ROYALTY: 'Royalty',
  OPCION_COMPRA: 'Opción de compra',
  OFF_TAKE: 'Off-take',
  SPIN_OUT: 'Spin-out',
  ARRIENDO: 'Arriendo',
  PARTICIPACION_ACCIONARIA: 'Participación accionaria',
  ACUERDO_TRIBUTO: 'Acuerdo de tributo',
  ALIANZA_ESTRATEGICA: 'Alianza estratégica',
}

export const COMMODITY_LABELS: Record<Commodity, string> = {
  AU: 'Oro', AG: 'Plata', CU: 'Cobre', PB: 'Plomo', ZN: 'Zinc', MN: 'Manganeso',
  V: 'Vanadio', LI: 'Litio', FE: 'Hierro', CO: 'Cobalto', NI: 'Níquel', SN: 'Estaño',
  W: 'Tungsteno', MO: 'Molibdeno', U: 'Uranio', COAL: 'Carbón', OTRO: 'Otro',
}

// Símbolo químico mostrado en los badges (Au, Ag, Cu...)
export const COMMODITY_SYMBOLS: Record<Commodity, string> = {
  AU: 'Au', AG: 'Ag', CU: 'Cu', PB: 'Pb', ZN: 'Zn', MN: 'Mn', V: 'V', LI: 'Li',
  FE: 'Fe', CO: 'Co', NI: 'Ni', SN: 'Sn', W: 'W', MO: 'Mo', U: 'U', COAL: 'C', OTRO: '·',
}

export const COUNTRY_LABELS: Record<string, string> = {
  CL: 'Chile', AR: 'Argentina', PE: 'Perú', BO: 'Bolivia', MX: 'México', BR: 'Brasil', CO: 'Colombia',
}

export const SORT_OPTIONS = ['recientes', 'precio_asc', 'precio_desc'] as const
export type SortOption = (typeof SORT_OPTIONS)[number]
```

- [ ] **Step 2: `src/lib/types.ts`**

```typescript
import type { Commodity, DealType, SortOption } from './constants'

export interface ListingFilter {
  category?: string
  dealType?: DealType
  commodity?: Commodity
  country?: string
  priceMin?: number
  priceMax?: number
  surfaceMin?: number
  surfaceMax?: number
  verifiedOnly: boolean
  sort: SortOption
}
```

- [ ] **Step 3: `src/lib/prisma.ts`**

```typescript
import { PrismaClient } from '@prisma/client'

const globalForPrisma = globalThis as unknown as { prisma?: PrismaClient }

export const prisma = globalForPrisma.prisma ?? new PrismaClient()

if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma
```

- [ ] **Step 4: `src/lib/format.ts`**

```typescript
export function formatUsd(value: number | null | undefined): string {
  if (value == null) return 'Precio a consultar'
  if (value >= 1_000_000) {
    const m = value / 1_000_000
    return `USD ${m % 1 === 0 ? m.toFixed(0) : m.toFixed(1)}M`
  }
  if (value >= 1_000) return `USD ${(value / 1_000).toFixed(0)}K`
  return `USD ${value.toLocaleString('es-CL')}`
}

export function formatHa(value: number | null | undefined): string {
  if (value == null) return '—'
  return `${value.toLocaleString('es-CL')} ha`
}
```

- [ ] **Step 5: Commit**

```bash
git add -A
git commit -m "feat: constantes, tipos, cliente Prisma y formateadores"
```

---

## Task 4: Parseo de filtros (TDD)

**Files:**
- Create: `src/lib/filters.ts`
- Test: `src/lib/filters.test.ts`

- [ ] **Step 1: Escribir el test que falla**

`src/lib/filters.test.ts`:
```typescript
import { describe, it, expect } from 'vitest'
import { parseSearchParams } from './filters'

describe('parseSearchParams', () => {
  it('devuelve valores por defecto sin params', () => {
    const f = parseSearchParams({})
    expect(f.verifiedOnly).toBe(false)
    expect(f.sort).toBe('recientes')
    expect(f.commodity).toBeUndefined()
  })

  it('parsea commodity y país válidos', () => {
    const f = parseSearchParams({ commodity: 'CU', pais: 'CL' })
    expect(f.commodity).toBe('CU')
    expect(f.country).toBe('CL')
  })

  it('ignora commodity inválido', () => {
    const f = parseSearchParams({ commodity: 'XYZ' })
    expect(f.commodity).toBeUndefined()
  })

  it('parsea rangos numéricos de precio y descarta basura', () => {
    const f = parseSearchParams({ precioMin: '1000000', precioMax: 'abc' })
    expect(f.priceMin).toBe(1_000_000)
    expect(f.priceMax).toBeUndefined()
  })

  it('parsea verificados=1 como true', () => {
    expect(parseSearchParams({ verificados: '1' }).verifiedOnly).toBe(true)
    expect(parseSearchParams({ verificados: 'true' }).verifiedOnly).toBe(true)
    expect(parseSearchParams({ verificados: '0' }).verifiedOnly).toBe(false)
  })

  it('acepta orden válido y cae a recientes si es inválido', () => {
    expect(parseSearchParams({ orden: 'precio_asc' }).sort).toBe('precio_asc')
    expect(parseSearchParams({ orden: 'zzz' }).sort).toBe('recientes')
  })
})
```

- [ ] **Step 2: Ejecutar el test y verificar que falla**

Run: `npm test -- filters`
Expected: FAIL ("Cannot find module './filters'" o similar).

- [ ] **Step 3: Implementar `src/lib/filters.ts`**

```typescript
import { COMMODITIES, DEAL_TYPES, SORT_OPTIONS, type Commodity, type DealType, type SortOption } from './constants'
import type { ListingFilter } from './types'

type Params = Record<string, string | string[] | undefined>

function str(params: Params, key: string): string | undefined {
  const v = params[key]
  const s = Array.isArray(v) ? v[0] : v
  return s && s.length > 0 ? s : undefined
}

function num(params: Params, key: string): number | undefined {
  const s = str(params, key)
  if (s === undefined) return undefined
  const n = Number(s)
  return Number.isFinite(n) ? n : undefined
}

export function parseSearchParams(params: Params): ListingFilter {
  const commodityRaw = str(params, 'commodity')
  const dealRaw = str(params, 'negocio')
  const sortRaw = str(params, 'orden')
  const verificados = str(params, 'verificados')

  return {
    category: str(params, 'tipo'),
    commodity: COMMODITIES.includes(commodityRaw as Commodity) ? (commodityRaw as Commodity) : undefined,
    dealType: DEAL_TYPES.includes(dealRaw as DealType) ? (dealRaw as DealType) : undefined,
    country: str(params, 'pais'),
    priceMin: num(params, 'precioMin'),
    priceMax: num(params, 'precioMax'),
    surfaceMin: num(params, 'superficieMin'),
    surfaceMax: num(params, 'superficieMax'),
    verifiedOnly: verificados === '1' || verificados === 'true',
    sort: SORT_OPTIONS.includes(sortRaw as SortOption) ? (sortRaw as SortOption) : 'recientes',
  }
}
```

- [ ] **Step 4: Ejecutar el test y verificar que pasa**

Run: `npm test -- filters`
Expected: PASS (6 tests).

- [ ] **Step 5: Commit**

```bash
git add -A
git commit -m "feat: parseo y validación de filtros del catálogo (TDD)"
```

---

## Task 5: Datos semilla

**Files:**
- Create: `prisma/seed.ts`

- [ ] **Step 1: Escribir el seed**

`prisma/seed.ts` (las 13 oportunidades, todas explícitas):
```typescript
import { PrismaClient } from '@prisma/client'

const prisma = new PrismaClient()

type Seed = {
  slug: string; title: string; description: string; category: string
  assetType?: string; stage?: string; country: string; region?: string; city?: string
  lat?: number; lng?: number; priceUsd?: number; surfaceHa?: number; concessionCount?: number
  verified: boolean; imageSeed: string; commodities: { c: string; primary?: boolean }[]; deals: string[]
}

const listings: Seed[] = [
  {
    slug: 'cerro-del-viento-deseado-santa-cruz',
    title: 'Proyecto Cerro del Viento — Ag–Au epitermal en Macizo del Deseado, Santa Cruz',
    description: 'Proyecto epitermal de plata y oro en el prolífico Macizo del Deseado. Vetas de baja sulfuración con anomalías superficiales y sondajes históricos. Oportunidad de exploración avanzada con infraestructura cercana.',
    category: 'ACTIVO', assetType: 'CONCESION_MINERA', stage: 'EXPLORACION_AVANZADA',
    country: 'AR', region: 'Santa Cruz', city: 'Puerto San Julián', lat: -49.31, lng: -67.73,
    priceUsd: 2_400_000, surfaceHa: 4800, concessionCount: 6, verified: true, imageSeed: 'cerro-viento',
    commodities: [{ c: 'AG', primary: true }, { c: 'AU' }], deals: ['VENTA_100', 'EARN_IN'],
  },
  {
    slug: 'veta-esperanza-combarbala-coquimbo',
    title: 'Proyecto Veta Esperanza — Cu–Au IOCG en Combarbalá, Coquimbo',
    description: 'Sistema IOCG de cobre-oro con veta principal expuesta y continuidad en profundidad. Muestreos con leyes atractivas de CuT. Apto para venta total o joint venture.',
    category: 'ACTIVO', assetType: 'CONCESION_MINERA', stage: 'EXPLORACION_AVANZADA',
    country: 'CL', region: 'Coquimbo', city: 'Combarbalá', lat: -31.18, lng: -71.0,
    priceUsd: 1_800_000, surfaceHa: 100, concessionCount: 2, verified: true, imageSeed: 'veta-esperanza',
    commodities: [{ c: 'CU', primary: true }, { c: 'AU' }], deals: ['VENTA_100', 'EARN_IN'],
  },
  {
    slug: 'la-laura-vicuna-coquimbo',
    title: 'Proyecto La Laura — Oro, Cobre y Plata en Vicuña, Región de Coquimbo',
    description: 'Propiedad polimetálica en el valle de Elqui con presencia de oro, cobre y plata. Acceso por camino y cercanía a servicios. Etapa de exploración básica con potencial de crecimiento.',
    category: 'ACTIVO', assetType: 'CONCESION_MINERA', stage: 'EXPLORACION_BASICA',
    country: 'CL', region: 'Coquimbo', city: 'Vicuña', lat: -30.03, lng: -70.71,
    priceUsd: 1_300_000, surfaceHa: 300, concessionCount: 3, verified: false, imageSeed: 'la-laura',
    commodities: [{ c: 'AU', primary: true }, { c: 'CU' }, { c: 'AG' }], deals: ['VENTA_100'],
  },
  {
    slug: 'cobre-subterraneo-180ha-desarrollo-avanzado',
    title: 'Proyecto subterráneo de cobre con 180 ha de pertenencias y desarrollo avanzado',
    description: 'Mina subterránea de cobre con labores de desarrollo avanzadas y 180 hectáreas de pertenencias. Lista para reactivación productiva con plan minero definido.',
    category: 'ACTIVO', assetType: 'CONCESION_MINERA', stage: 'PRE_DESARROLLO',
    country: 'CL', region: 'Coquimbo', city: 'Río Hurtado', lat: -30.29, lng: -70.71,
    priceUsd: 3_200_000, surfaceHa: 180, concessionCount: 4, verified: true, imageSeed: 'cobre-subterraneo',
    commodities: [{ c: 'CU', primary: true }], deals: ['VENTA_100', 'PARTICIPACION_ACCIONARIA'],
  },
  {
    slug: 'concesion-cu-au-canela-90ha',
    title: 'Concesión de Explotación Cu–Au en Canela (Choapa) — 90 ha',
    description: 'Concesión de explotación de cobre y oro en Canela, provincia de Choapa. 90 hectáreas con vetas reconocidas y buena accesibilidad.',
    category: 'ACTIVO', assetType: 'CONCESION_MINERA', stage: 'EXPLORACION_AVANZADA',
    country: 'CL', region: 'Coquimbo', city: 'Canela', lat: -31.39, lng: -71.45,
    priceUsd: 950_000, surfaceHa: 90, concessionCount: 1, verified: false, imageSeed: 'canela-90',
    commodities: [{ c: 'CU', primary: true }, { c: 'AU' }], deals: ['VENTA_100'],
  },
  {
    slug: 'cu-au-canela-100ha-veta-principal',
    title: 'Proyecto Cu–Au en Canela (Choapa) — 100 ha, veta principal +600 m y muestreos hasta 8% CuT',
    description: 'Proyecto de cobre-oro en Canela con veta principal de más de 600 m de corrida y muestreos de hasta 8% CuT. 100 hectáreas con fuerte potencial.',
    category: 'ACTIVO', assetType: 'CONCESION_MINERA', stage: 'EXPLORACION_AVANZADA',
    country: 'CL', region: 'Coquimbo', city: 'Canela', lat: -31.4, lng: -71.46,
    priceUsd: 1_500_000, surfaceHa: 100, concessionCount: 2, verified: true, imageSeed: 'canela-100',
    commodities: [{ c: 'CU', primary: true }, { c: 'AU' }], deals: ['VENTA_100', 'EARN_IN'],
  },
  {
    slug: 'cu-au-iocg-canela-baja',
    title: 'Proyecto Cu–Au IOCG Canela Baja (Coquimbo) — veta de alta ley con potencial en profundidad',
    description: 'Sistema IOCG en Canela Baja con veta de alta ley y continuidad esperada en profundidad. Oportunidad para socio explorador.',
    category: 'ACTIVO', assetType: 'CONCESION_MINERA', stage: 'EXPLORACION_BASICA',
    country: 'CL', region: 'Coquimbo', city: 'Canela', lat: -31.42, lng: -71.48,
    priceUsd: 1_100_000, surfaceHa: 120, concessionCount: 2, verified: false, imageSeed: 'canela-baja',
    commodities: [{ c: 'CU', primary: true }, { c: 'AU' }], deals: ['EARN_IN', 'OPCION_COMPRA'],
  },
  {
    slug: 'loma-del-medio-canela-100ha',
    title: 'IV Región de Coquimbo (Choapa), Canela — sector Loma del Medio, 100 ha, mineralización Cu-Au-Ag',
    description: 'Sector Loma del Medio en Canela: 100 ha con mineralización Cu-Au-Ag, muestreos con CuT >1.40%, Au +1.49 g/t y Ag +27.32 g/t, y plan minero vigente hasta 5.500 t/mes.',
    category: 'ACTIVO', assetType: 'CONCESION_MINERA', stage: 'PRE_DESARROLLO',
    country: 'CL', region: 'Coquimbo', city: 'Canela', lat: -31.41, lng: -71.47,
    priceUsd: 2_900_000, surfaceHa: 100, concessionCount: 3, verified: true, imageSeed: 'loma-medio',
    commodities: [{ c: 'CU', primary: true }, { c: 'AU' }, { c: 'AG' }], deals: ['VENTA_100', 'PARTICIPACION_ACCIONARIA'],
  },
  {
    slug: 'cobre-alta-ley-petorca-817ha',
    title: 'Cobre de alta ley en Chile Central (Petorca) — 817 ha, anomalías hasta 4.4% CuT',
    description: 'Propiedad de 817 hectáreas en Petorca, Chile Central, con anomalías de cobre de hasta 4.4% CuT. Gran superficie para exploración sistemática.',
    category: 'ACTIVO', assetType: 'CONCESION_MINERA', stage: 'EXPLORACION_BASICA',
    country: 'CL', region: 'Valparaíso', city: 'Petorca', lat: -32.25, lng: -70.93,
    priceUsd: 4_200_000, surfaceHa: 817, concessionCount: 8, verified: false, imageSeed: 'petorca-817',
    commodities: [{ c: 'CU', primary: true }], deals: ['VENTA_100', 'EARN_IN'],
  },
  {
    slug: 'litio-salar-norte-prospecto',
    title: 'Prospecto de litio en salar, Norte de Chile — pertenencias para exploración',
    description: 'Pertenencias en zona de salar del norte de Chile con potencial de litio en salmuera. Etapa de prospección, ideal para socio con capacidad de exploración.',
    category: 'ACTIVO', assetType: 'TERRENO_PROYECTO', stage: 'PROSPECCION',
    country: 'CL', region: 'Antofagasta', city: 'Calama', lat: -22.46, lng: -68.93,
    priceUsd: 5_500_000, surfaceHa: 2500, concessionCount: 12, verified: false, imageSeed: 'litio-salar',
    commodities: [{ c: 'LI', primary: true }], deals: ['EARN_IN', 'PARTICIPACION_ACCIONARIA'],
  },
  {
    slug: 'oro-epitermal-jujuy',
    title: 'Proyecto de oro epitermal en Jujuy, Argentina — exploración con sondajes históricos',
    description: 'Proyecto epitermal de oro en la Puna de Jujuy con sondajes históricos y anomalías geoquímicas. Oportunidad de farm-in para avanzar el modelo.',
    category: 'ACTIVO', assetType: 'CONCESION_MINERA', stage: 'EXPLORACION_AVANZADA',
    country: 'AR', region: 'Jujuy', city: 'Susques', lat: -23.4, lng: -66.37,
    priceUsd: 3_800_000, surfaceHa: 3200, concessionCount: 5, verified: true, imageSeed: 'oro-jujuy',
    commodities: [{ c: 'AU', primary: true }, { c: 'AG' }], deals: ['FARM_IN', 'VENTA_100'],
  },
  {
    slug: 'planta-procesamiento-flotacion-coquimbo',
    title: 'Planta de procesamiento por flotación en Coquimbo — infraestructura lista',
    description: 'Planta de flotación con infraestructura instalada y permisos vigentes en la Región de Coquimbo. Disponible para venta o arriendo a productores de la zona.',
    category: 'ACTIVO', assetType: 'INFRAESTRUCTURA_PLANTA', stage: 'PRODUCCION',
    country: 'CL', region: 'Coquimbo', city: 'Ovalle', lat: -30.6, lng: -71.2,
    priceUsd: 6_500_000, surfaceHa: 5, verified: true, imageSeed: 'planta-flotacion',
    commodities: [{ c: 'CU', primary: true }, { c: 'AU' }], deals: ['VENTA_100', 'ARRIENDO'],
  },
  {
    slug: 'relave-reprocesamiento-atacama',
    title: 'Relave para reprocesamiento en Atacama — recuperación de Cu y Au',
    description: 'Depósito de relaves con contenido recuperable de cobre y oro en la Región de Atacama. Oportunidad de reprocesamiento con tecnología moderna.',
    category: 'ACTIVO', assetType: 'RELAVE_RIPIO_ESCORIA', stage: 'PRE_DESARROLLO',
    country: 'CL', region: 'Atacama', city: 'Copiapó', lat: -27.37, lng: -70.33,
    priceUsd: 2_100_000, surfaceHa: 40, verified: false, imageSeed: 'relave-atacama',
    commodities: [{ c: 'CU', primary: true }, { c: 'AU' }], deals: ['VENTA_100', 'OFF_TAKE'],
  },
]

async function main() {
  await prisma.listingDealType.deleteMany()
  await prisma.listingCommodity.deleteMany()
  await prisma.listing.deleteMany()

  for (const l of listings) {
    await prisma.listing.create({
      data: {
        slug: l.slug, title: l.title, description: l.description, category: l.category,
        assetType: l.assetType, stage: l.stage, country: l.country, region: l.region, city: l.city,
        lat: l.lat, lng: l.lng, priceUsd: l.priceUsd, surfaceHa: l.surfaceHa,
        concessionCount: l.concessionCount, verified: l.verified, imageSeed: l.imageSeed,
        commodities: { create: l.commodities.map((c) => ({ commodity: c.c, isPrimary: !!c.primary })) },
        dealTypes: { create: l.deals.map((d) => ({ dealType: d })) },
      },
    })
  }
  const count = await prisma.listing.count()
  console.log(`Seed completado: ${count} oportunidades.`)
}

main().catch((e) => { console.error(e); process.exit(1) }).finally(() => prisma.$disconnect())
```

- [ ] **Step 2: Ejecutar el seed**

Run:
```bash
npm run db:seed
```
Expected: `Seed completado: 13 oportunidades.`

- [ ] **Step 3: Commit**

```bash
git add -A
git commit -m "feat: datos semilla del catálogo (13 oportunidades)"
```

---

## Task 6: Capa de datos `listings.ts` (TDD)

**Files:**
- Create: `src/lib/listings.ts`
- Test: `src/lib/listings.test.ts`

> El test usa la base SQLite ya sembrada (Task 5). Asume que `npm run db:seed` se ejecutó.

- [ ] **Step 1: Escribir el test que falla**

`src/lib/listings.test.ts`:
```typescript
import { describe, it, expect } from 'vitest'
import { getListings, getListingBySlug, getMarketStats } from './listings'

describe('getListings', () => {
  it('devuelve todas las oportunidades sin filtros', async () => {
    const r = await getListings({ verifiedOnly: false, sort: 'recientes' })
    expect(r.length).toBeGreaterThanOrEqual(13)
  })

  it('filtra por país', async () => {
    const r = await getListings({ verifiedOnly: false, sort: 'recientes', country: 'AR' })
    expect(r.length).toBeGreaterThan(0)
    expect(r.every((l) => l.country === 'AR')).toBe(true)
  })

  it('filtra por commodity (relación)', async () => {
    const r = await getListings({ verifiedOnly: false, sort: 'recientes', commodity: 'LI' })
    expect(r.length).toBeGreaterThan(0)
    expect(r.every((l) => l.commodities.some((c) => c.commodity === 'LI'))).toBe(true)
  })

  it('filtra solo verificados', async () => {
    const r = await getListings({ verifiedOnly: true, sort: 'recientes' })
    expect(r.every((l) => l.verified)).toBe(true)
  })

  it('respeta rango de precio', async () => {
    const r = await getListings({ verifiedOnly: false, sort: 'recientes', priceMin: 4_000_000 })
    expect(r.every((l) => (l.priceUsd ?? 0) >= 4_000_000)).toBe(true)
  })

  it('ordena por precio ascendente', async () => {
    const r = await getListings({ verifiedOnly: false, sort: 'precio_asc' })
    const prices = r.map((l) => l.priceUsd ?? 0)
    const sorted = [...prices].sort((a, b) => a - b)
    expect(prices).toEqual(sorted)
  })
})

describe('getListingBySlug', () => {
  it('devuelve la oportunidad por slug', async () => {
    const l = await getListingBySlug('cerro-del-viento-deseado-santa-cruz')
    expect(l?.title).toContain('Cerro del Viento')
  })
  it('devuelve null si no existe', async () => {
    expect(await getListingBySlug('no-existe')).toBeNull()
  })
})

describe('getMarketStats', () => {
  it('devuelve total y suma de precios', async () => {
    const s = await getMarketStats()
    expect(s.count).toBeGreaterThanOrEqual(13)
    expect(s.totalUsd).toBeGreaterThan(0)
  })
})
```

- [ ] **Step 2: Ejecutar el test y verificar que falla**

Run: `npm test -- listings`
Expected: FAIL ("Cannot find module './listings'").

- [ ] **Step 3: Implementar `src/lib/listings.ts`**

```typescript
import { prisma } from './prisma'
import type { ListingFilter } from './types'
import type { Prisma } from '@prisma/client'

function buildWhere(filter: ListingFilter): Prisma.ListingWhereInput {
  const where: Prisma.ListingWhereInput = {}
  if (filter.category) where.category = filter.category
  if (filter.country) where.country = filter.country
  if (filter.verifiedOnly) where.verified = true
  if (filter.commodity) where.commodities = { some: { commodity: filter.commodity } }
  if (filter.dealType) where.dealTypes = { some: { dealType: filter.dealType } }
  if (filter.priceMin != null || filter.priceMax != null) {
    where.priceUsd = {}
    if (filter.priceMin != null) where.priceUsd.gte = filter.priceMin
    if (filter.priceMax != null) where.priceUsd.lte = filter.priceMax
  }
  if (filter.surfaceMin != null || filter.surfaceMax != null) {
    where.surfaceHa = {}
    if (filter.surfaceMin != null) where.surfaceHa.gte = filter.surfaceMin
    if (filter.surfaceMax != null) where.surfaceHa.lte = filter.surfaceMax
  }
  return where
}

function buildOrderBy(sort: ListingFilter['sort']): Prisma.ListingOrderByWithRelationInput {
  if (sort === 'precio_asc') return { priceUsd: 'asc' }
  if (sort === 'precio_desc') return { priceUsd: 'desc' }
  return { publishedAt: 'desc' }
}

export async function getListings(filter: ListingFilter) {
  return prisma.listing.findMany({
    where: buildWhere(filter),
    orderBy: buildOrderBy(filter.sort),
    include: { commodities: true, dealTypes: true },
  })
}

export async function getListingBySlug(slug: string) {
  return prisma.listing.findUnique({
    where: { slug },
    include: { commodities: true, dealTypes: true },
  })
}

export async function getMarketStats() {
  const [count, agg, countries] = await Promise.all([
    prisma.listing.count(),
    prisma.listing.aggregate({ _sum: { priceUsd: true } }),
    prisma.listing.findMany({ select: { country: true }, distinct: ['country'] }),
  ])
  return { count, totalUsd: agg._sum.priceUsd ?? 0, countryCount: countries.length }
}

export type ListingWithRelations = Awaited<ReturnType<typeof getListings>>[number]
```

- [ ] **Step 4: Ejecutar el test y verificar que pasa**

Run: `npm test -- listings`
Expected: PASS.

- [ ] **Step 5: Commit**

```bash
git add -A
git commit -m "feat: capa de datos getListings/getListingBySlug/getMarketStats (TDD)"
```

---

## Task 7: Tema y layout base

**Files:**
- Modify: `src/app/globals.css`, `tailwind.config.ts`, `src/app/layout.tsx`
- Create: `src/components/ui/Container.tsx`

- [ ] **Step 1: Detectar la versión de Tailwind**

Run: `npm ls tailwindcss | head -n 3`
- Si dice `tailwindcss@4.x` → usar **Step 2A** (config en CSS) y BORRAR cualquier `tailwind.config.ts` si existe.
- Si dice `tailwindcss@3.x` → usar **Step 2B** (`tailwind.config.ts`).

Los tokens de marca usados en todo el proyecto son: `night`, `night-800`, `night-700`, `night-600` (fondos), `amber-brand` (acento), `slatey` (texto secundario) y `max-w-content`.

- [ ] **Step 2A (Tailwind v4): `src/app/globals.css`**

Reemplazar TODO el contenido de `src/app/globals.css` por:
```css
@import "tailwindcss";

@theme {
  --color-night: #0b1220;
  --color-night-800: #0f1828;
  --color-night-700: #131c2b;
  --color-night-600: #1b2538;
  --color-amber-brand: #f59e0b;
  --color-slatey: #94a3b8;
  --max-width-content: 1200px;
}

:root { color-scheme: dark; }

body {
  background-color: #0b1220;
  color: #e5edf7;
  -webkit-font-smoothing: antialiased;
}

.kicker {
  text-transform: uppercase;
  letter-spacing: 0.18em;
  font-size: 0.72rem;
  color: #f59e0b;
  font-weight: 600;
}
```
(En v4 no se usa `tailwind.config.ts`; los tokens de `@theme` generan utilidades como `bg-night-700`, `text-amber-brand`, `max-w-content`.)

- [ ] **Step 2B (Tailwind v3): `tailwind.config.ts` + `globals.css`**

`tailwind.config.ts`:
```typescript
import type { Config } from 'tailwindcss'

export default {
  content: ['./src/**/*.{ts,tsx,mdx}'],
  theme: {
    extend: {
      colors: {
        night: { DEFAULT: '#0b1220', 800: '#0f1828', 700: '#131c2b', 600: '#1b2538' },
        amber: { brand: '#f59e0b' },
        slatey: '#94a3b8',
      },
      maxWidth: { content: '1200px' },
    },
  },
  plugins: [],
} satisfies Config
```

`src/app/globals.css`:
```css
@tailwind base;
@tailwind components;
@tailwind utilities;

:root { color-scheme: dark; }

body {
  background-color: #0b1220;
  color: #e5edf7;
  -webkit-font-smoothing: antialiased;
}

.kicker {
  text-transform: uppercase;
  letter-spacing: 0.18em;
  font-size: 0.72rem;
  color: #f59e0b;
  font-weight: 600;
}
```

- [ ] **Step 3: `src/components/ui/Container.tsx`**

```tsx
export function Container({ children, className = '' }: { children: React.ReactNode; className?: string }) {
  return <div className={`mx-auto w-full max-w-content px-5 ${className}`}>{children}</div>
}
```

- [ ] **Step 4: `src/app/layout.tsx`**

```tsx
import type { Metadata } from 'next'
import './globals.css'
import { Header } from '@/components/Header'
import { Footer } from '@/components/Footer'

export const metadata: Metadata = {
  title: 'Mercado Minero — Donde se transa la minería de Latinoamérica',
  description: 'Activos, productos minerales y servicios en una sola plataforma. Conectamos la oferta minera de la región con compradores e inversionistas de todo el mundo.',
}

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="es">
      <body className="min-h-screen flex flex-col">
        <Header />
        <main className="flex-1">{children}</main>
        <Footer />
      </body>
    </html>
  )
}
```

- [ ] **Step 5: Commit**

```bash
git add -A
git commit -m "feat: tema oscuro, paleta de marca y layout base"
```

---

## Task 8: Header y Footer

**Files:**
- Create: `src/components/Header.tsx`, `src/components/Footer.tsx`

- [ ] **Step 1: `src/components/Header.tsx`**

```tsx
import Link from 'next/link'
import { Container } from './ui/Container'

const nav = [
  { href: '/marketplace', label: 'Marketplace' },
  { href: '/inversionistas', label: 'Inversionistas' },
  { href: '/como-funciona', label: 'Cómo funciona' },
]

export function Header() {
  return (
    <header className="border-b border-white/5 bg-night/80 backdrop-blur sticky top-0 z-40">
      <Container className="flex h-14 items-center justify-between">
        <div className="flex items-center gap-8">
          <Link href="/" className="flex items-center gap-2 font-bold tracking-wide text-amber-brand">
            <span aria-hidden>⛰</span> MERCADO MINERO
          </Link>
          <nav className="hidden md:flex items-center gap-6 text-sm text-slatey">
            {nav.map((n) => (
              <Link key={n.href} href={n.href} className="hover:text-white transition-colors">{n.label}</Link>
            ))}
          </nav>
        </div>
        <div className="flex items-center gap-4">
          <span className="hidden sm:inline rounded-md border border-amber-brand/60 px-3 py-1.5 text-sm text-amber-brand">Publicar</span>
          <span className="text-xs text-slatey">ES</span>
          <span className="flex h-8 w-8 items-center justify-center rounded-full border border-amber-brand/60 text-xs text-amber-brand">MM</span>
        </div>
      </Container>
    </header>
  )
}
```

- [ ] **Step 2: `src/components/Footer.tsx`**

```tsx
import Link from 'next/link'
import { Container } from './ui/Container'

export function Footer() {
  return (
    <footer className="border-t border-white/5 mt-20">
      <Container className="py-10">
        <div className="flex flex-col gap-6 md:flex-row md:items-center md:justify-between">
          <div>
            <p className="kicker mb-1">Suscríbete para recibir avisos</p>
            <p className="text-sm text-slatey max-w-md">Activos nuevos por email de nuevas oportunidades y noticias de Mercado Minero.</p>
          </div>
          <form className="flex gap-2" aria-label="Suscripción">
            <input type="email" placeholder="Email" className="rounded-md bg-night-700 border border-white/10 px-3 py-2 text-sm outline-none focus:border-amber-brand/60" />
            <button type="button" className="rounded-md bg-amber-brand px-4 py-2 text-sm font-semibold text-night">Suscríbete</button>
          </form>
        </div>
        <div className="mt-8 flex flex-col gap-2 border-t border-white/5 pt-6 text-xs text-slatey md:flex-row md:items-center md:justify-between">
          <span className="text-amber-brand font-semibold">⛰ MERCADO MINERO</span>
          <span>© 2026 Mercado Minero</span>
          <div className="flex gap-4">
            <Link href="/" className="hover:text-white">Inicio</Link>
            <Link href="/marketplace" className="hover:text-white">Explorar</Link>
            <span>Términos, Condiciones y Disclaimer</span>
          </div>
        </div>
      </Container>
    </footer>
  )
}
```

- [ ] **Step 3: Verificar build de tipos**

Run: `npx tsc --noEmit`
Expected: sin errores (las páginas referenciadas se crean en tareas siguientes; si `tsc` se queja de rutas inexistentes no aplica — Next resuelve rutas en runtime, no en tsc).

- [ ] **Step 4: Commit**

```bash
git add -A
git commit -m "feat: Header y Footer"
```

---

## Task 9: Átomos de UI (badges, precio, thumbnail)

**Files:**
- Create: `src/components/ui/CommodityBadge.tsx`, `StageBadge.tsx`, `DealTypeBadge.tsx`, `PriceTag.tsx`, `VerifiedBadge.tsx`, `Thumb.tsx`

- [ ] **Step 1: `CommodityBadge.tsx`**

```tsx
import { COMMODITY_SYMBOLS, COMMODITY_LABELS, type Commodity } from '@/lib/constants'

export function CommodityBadge({ code }: { code: string }) {
  const c = code as Commodity
  return (
    <span title={COMMODITY_LABELS[c] ?? code} className="inline-flex h-6 min-w-6 items-center justify-center rounded-full bg-amber-brand/15 px-2 text-xs font-semibold text-amber-brand">
      {COMMODITY_SYMBOLS[c] ?? code}
    </span>
  )
}
```

- [ ] **Step 2: `StageBadge.tsx`**

```tsx
import { STAGE_LABELS, type Stage } from '@/lib/constants'

export function StageBadge({ stage }: { stage: string | null }) {
  if (!stage) return null
  const label = STAGE_LABELS[stage as Stage] ?? stage
  return <span className="rounded bg-white/5 px-2 py-0.5 text-[11px] uppercase tracking-wide text-slatey">{label}</span>
}
```

- [ ] **Step 3: `DealTypeBadge.tsx`**

```tsx
import { DEAL_TYPE_LABELS, type DealType } from '@/lib/constants'

export function DealTypeBadge({ deal }: { deal: string }) {
  const label = DEAL_TYPE_LABELS[deal as DealType] ?? deal
  return <span className="rounded bg-amber-brand/90 px-2 py-0.5 text-[11px] font-semibold uppercase text-night">{label}</span>
}
```

- [ ] **Step 4: `PriceTag.tsx`**

```tsx
import { formatUsd } from '@/lib/format'

export function PriceTag({ value }: { value: number | null }) {
  return <span className="text-lg font-bold text-amber-brand">{formatUsd(value)}</span>
}
```

- [ ] **Step 5: `VerifiedBadge.tsx`**

```tsx
export function VerifiedBadge({ verified }: { verified: boolean }) {
  if (!verified) return <span className="text-[11px] text-slatey">Sin verificar</span>
  return <span className="inline-flex items-center gap-1 text-[11px] text-emerald-400">✓ Verificado</span>
}
```

- [ ] **Step 6: `Thumb.tsx` (placeholder de imagen determinista)**

```tsx
// Genera un degradado determinista a partir de la semilla; sin dependencias de red.
function hash(s: string): number {
  let h = 0
  for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) >>> 0
  return h
}

export function Thumb({ seed, className = '' }: { seed: string; className?: string }) {
  const h = hash(seed)
  const hue = h % 360
  const hue2 = (hue + 40) % 360
  return (
    <div
      className={`relative overflow-hidden ${className}`}
      style={{ background: `linear-gradient(135deg, hsl(${hue} 45% 28%), hsl(${hue2} 40% 16%))` }}
      aria-hidden
    >
      <div className="absolute inset-0 opacity-30" style={{ backgroundImage: 'radial-gradient(circle at 30% 20%, rgba(255,255,255,.25), transparent 40%)' }} />
    </div>
  )
}
```

- [ ] **Step 7: Commit**

```bash
git add -A
git commit -m "feat: átomos de UI (badges, precio, thumbnail)"
```

---

## Task 10: Tarjeta y grilla del catálogo

**Files:**
- Create: `src/components/catalog/ListingCard.tsx`, `ListingGrid.tsx`, `EmptyState.tsx`, `MarketStats.tsx`

- [ ] **Step 1: `ListingCard.tsx`**

```tsx
import Link from 'next/link'
import type { ListingWithRelations } from '@/lib/listings'
import { COUNTRY_LABELS } from '@/lib/constants'
import { formatHa } from '@/lib/format'
import { Thumb } from '@/components/ui/Thumb'
import { CommodityBadge } from '@/components/ui/CommodityBadge'
import { StageBadge } from '@/components/ui/StageBadge'
import { DealTypeBadge } from '@/components/ui/DealTypeBadge'
import { PriceTag } from '@/components/ui/PriceTag'
import { VerifiedBadge } from '@/components/ui/VerifiedBadge'

export function ListingCard({ listing }: { listing: ListingWithRelations }) {
  const place = [listing.city, COUNTRY_LABELS[listing.country] ?? listing.country].filter(Boolean).join(', ')
  return (
    <Link href={`/marketplace/${listing.slug}`} className="group flex flex-col overflow-hidden rounded-xl border border-white/5 bg-night-700 transition-colors hover:border-amber-brand/40">
      <div className="relative h-40">
        <Thumb seed={listing.imageSeed ?? listing.slug} className="h-full w-full" />
        {listing.dealTypes[0] && (
          <div className="absolute left-3 top-3"><DealTypeBadge deal={listing.dealTypes[0].dealType} /></div>
        )}
      </div>
      <div className="flex flex-1 flex-col gap-3 p-4">
        <h3 className="line-clamp-2 text-sm font-semibold text-white">{listing.title}</h3>
        <p className="text-xs text-slatey">📍 {place} {listing.surfaceHa ? `· ${formatHa(listing.surfaceHa)}` : ''}</p>
        <div className="flex flex-wrap items-center gap-1.5">
          {listing.commodities.map((c) => <CommodityBadge key={c.id} code={c.commodity} />)}
          <StageBadge stage={listing.stage} />
        </div>
        <div className="mt-auto flex items-center justify-between pt-2">
          <PriceTag value={listing.priceUsd} />
          <VerifiedBadge verified={listing.verified} />
        </div>
        <span className="rounded-md bg-amber-brand/10 py-2 text-center text-sm font-semibold text-amber-brand group-hover:bg-amber-brand/20">Ver oportunidad</span>
      </div>
    </Link>
  )
}
```

- [ ] **Step 2: `EmptyState.tsx`**

```tsx
import Link from 'next/link'

export function EmptyState() {
  return (
    <div className="col-span-full flex flex-col items-center gap-3 rounded-xl border border-white/5 bg-night-700 py-16 text-center">
      <p className="text-lg font-semibold">No hay oportunidades con esos filtros</p>
      <p className="text-sm text-slatey">Prueba ampliando el rango de precio o quitando filtros.</p>
      <Link href="/marketplace" className="rounded-md bg-amber-brand px-4 py-2 text-sm font-semibold text-night">Limpiar filtros</Link>
    </div>
  )
}
```

- [ ] **Step 3: `ListingGrid.tsx`**

```tsx
import type { ListingWithRelations } from '@/lib/listings'
import { ListingCard } from './ListingCard'
import { EmptyState } from './EmptyState'

export function ListingGrid({ listings }: { listings: ListingWithRelations[] }) {
  return (
    <div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
      {listings.length === 0 ? <EmptyState /> : listings.map((l) => <ListingCard key={l.id} listing={l} />)}
    </div>
  )
}
```

- [ ] **Step 4: `MarketStats.tsx`**

```tsx
import { formatUsd } from '@/lib/format'

export function MarketStats({ count, totalUsd }: { count: number; totalUsd: number }) {
  return (
    <div className="flex items-center gap-6 text-sm">
      <div><span className="text-xl font-bold text-white">{count}</span> <span className="text-slatey">oportunidades</span></div>
      <div><span className="text-xl font-bold text-amber-brand">{formatUsd(totalUsd)}</span> <span className="text-slatey">en cartera</span></div>
    </div>
  )
}
```

- [ ] **Step 5: Commit**

```bash
git add -A
git commit -m "feat: tarjeta, grilla, estado vacío y stats del catálogo"
```

---

## Task 11: Barra de filtros

**Files:**
- Create: `src/components/catalog/FilterBar.tsx`

> Server Component con un `<form method="get">`: al enviar, los filtros van a la URL como query params y la página se re-renderiza en el servidor. Sin estado de cliente.

- [ ] **Step 1: `FilterBar.tsx`**

```tsx
import { COMMODITIES, COMMODITY_LABELS, DEAL_TYPES, DEAL_TYPE_LABELS, COUNTRY_LABELS, SORT_OPTIONS } from '@/lib/constants'
import type { ListingFilter } from '@/lib/types'

const SORT_LABELS: Record<string, string> = {
  recientes: 'Más recientes', precio_asc: 'Precio: menor a mayor', precio_desc: 'Precio: mayor a menor',
}

export function FilterBar({ filter }: { filter: ListingFilter }) {
  return (
    <form method="get" className="flex flex-wrap items-end gap-3 rounded-xl border border-white/5 bg-night-700 p-4">
      <Field label="Commodity" name="commodity" value={filter.commodity}
        options={COMMODITIES.map((c) => ({ value: c, label: COMMODITY_LABELS[c] }))} />
      <Field label="Negocio" name="negocio" value={filter.dealType}
        options={DEAL_TYPES.map((d) => ({ value: d, label: DEAL_TYPE_LABELS[d] }))} />
      <Field label="País" name="pais" value={filter.country}
        options={Object.entries(COUNTRY_LABELS).map(([value, label]) => ({ value, label }))} />
      <NumberField label="Precio mín (USD)" name="precioMin" value={filter.priceMin} />
      <NumberField label="Precio máx (USD)" name="precioMax" value={filter.priceMax} />
      <Field label="Orden" name="orden" value={filter.sort} includeEmpty={false}
        options={SORT_OPTIONS.map((s) => ({ value: s, label: SORT_LABELS[s] }))} />
      <label className="flex items-center gap-2 text-sm text-slatey">
        <input type="checkbox" name="verificados" value="1" defaultChecked={filter.verifiedOnly} /> Solo verificados
      </label>
      <button type="submit" className="rounded-md bg-amber-brand px-4 py-2 text-sm font-semibold text-night">Filtrar</button>
      <a href="/marketplace" className="text-sm text-slatey hover:text-white">Limpiar</a>
    </form>
  )
}

function Field({ label, name, value, options, includeEmpty = true }: {
  label: string; name: string; value?: string; options: { value: string; label: string }[]; includeEmpty?: boolean
}) {
  return (
    <label className="flex flex-col gap-1 text-xs text-slatey">
      {label}
      <select name={name} defaultValue={value ?? ''} className="rounded-md bg-night-600 border border-white/10 px-2 py-1.5 text-sm text-white">
        {includeEmpty && <option value="">Todos</option>}
        {options.map((o) => <option key={o.value} value={o.value}>{o.label}</option>)}
      </select>
    </label>
  )
}

function NumberField({ label, name, value }: { label: string; name: string; value?: number }) {
  return (
    <label className="flex flex-col gap-1 text-xs text-slatey">
      {label}
      <input type="number" name={name} defaultValue={value ?? ''} className="w-32 rounded-md bg-night-600 border border-white/10 px-2 py-1.5 text-sm text-white" />
    </label>
  )
}
```

- [ ] **Step 2: Commit**

```bash
git add -A
git commit -m "feat: barra de filtros (form GET, sin estado de cliente)"
```

---

## Task 12: Página del catálogo

**Files:**
- Create: `src/app/marketplace/page.tsx`, `src/app/marketplace/loading.tsx`

- [ ] **Step 1: `src/app/marketplace/page.tsx`**

```tsx
import { parseSearchParams } from '@/lib/filters'
import { getListings, getMarketStats } from '@/lib/listings'
import { Container } from '@/components/ui/Container'
import { FilterBar } from '@/components/catalog/FilterBar'
import { ListingGrid } from '@/components/catalog/ListingGrid'
import { MarketStats } from '@/components/catalog/MarketStats'

export const dynamic = 'force-dynamic'

export default async function MarketplacePage({
  searchParams,
}: {
  searchParams: Promise<Record<string, string | string[] | undefined>>
}) {
  const params = await searchParams
  const filter = parseSearchParams(params)
  const [listings, stats] = await Promise.all([getListings(filter), getMarketStats()])

  return (
    <Container className="py-8">
      <div className="mb-6 flex flex-col gap-4">
        <div className="flex flex-wrap items-center justify-between gap-4">
          <div>
            <p className="kicker">Marketplace minero</p>
            <h1 className="text-2xl font-bold text-white">Oportunidades mineras</h1>
          </div>
          <MarketStats count={stats.count} totalUsd={stats.totalUsd} />
        </div>
        <FilterBar filter={filter} />
        <p className="text-sm text-slatey">{listings.length} resultado{listings.length === 1 ? '' : 's'}</p>
      </div>
      <ListingGrid listings={listings} />
    </Container>
  )
}
```

- [ ] **Step 2: `src/app/marketplace/loading.tsx`**

```tsx
import { Container } from '@/components/ui/Container'

export default function Loading() {
  return (
    <Container className="py-8">
      <div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
        {Array.from({ length: 6 }).map((_, i) => (
          <div key={i} className="h-72 animate-pulse rounded-xl bg-night-700" />
        ))}
      </div>
    </Container>
  )
}
```

- [ ] **Step 3: Verificar en el navegador (servidor de dev)**

Run:
```bash
npm run dev &
sleep 6
curl -s "http://localhost:3000/marketplace" | grep -c "Ver oportunidad"
curl -s "http://localhost:3000/marketplace?commodity=LI" | grep -o "litio\|Litio" | head -1
kill %1
```
Expected: el primer comando devuelve un número ≥ 13; el filtro por litio devuelve resultados.

- [ ] **Step 4: Commit**

```bash
git add -A
git commit -m "feat: página del catálogo con filtros y stats"
```

---

## Task 13: Página de detalle

**Files:**
- Create: `src/app/marketplace/[slug]/page.tsx`, `src/app/marketplace/[slug]/not-found.tsx`

- [ ] **Step 1: `src/app/marketplace/[slug]/page.tsx`**

```tsx
import { notFound } from 'next/navigation'
import { getListingBySlug } from '@/lib/listings'
import { Container } from '@/components/ui/Container'
import { Thumb } from '@/components/ui/Thumb'
import { CommodityBadge } from '@/components/ui/CommodityBadge'
import { StageBadge } from '@/components/ui/StageBadge'
import { DealTypeBadge } from '@/components/ui/DealTypeBadge'
import { PriceTag } from '@/components/ui/PriceTag'
import { VerifiedBadge } from '@/components/ui/VerifiedBadge'
import { COUNTRY_LABELS } from '@/lib/constants'
import { formatHa } from '@/lib/format'

export default async function ListingPage({ params }: { params: Promise<{ slug: string }> }) {
  const { slug } = await params
  const listing = await getListingBySlug(slug)
  if (!listing) notFound()

  const place = [listing.city, listing.region, COUNTRY_LABELS[listing.country] ?? listing.country].filter(Boolean).join(', ')

  return (
    <Container className="py-8">
      <a href="/marketplace" className="text-sm text-slatey hover:text-white">← Volver al marketplace</a>
      <div className="mt-4 grid gap-8 lg:grid-cols-3">
        <div className="lg:col-span-2">
          <Thumb seed={listing.imageSeed ?? listing.slug} className="h-64 w-full rounded-xl" />
          <h1 className="mt-6 text-2xl font-bold text-white">{listing.title}</h1>
          <p className="mt-1 text-sm text-slatey">📍 {place}</p>
          <div className="mt-4 flex flex-wrap gap-2">
            {listing.dealTypes.map((d) => <DealTypeBadge key={d.id} deal={d.dealType} />)}
            <StageBadge stage={listing.stage} />
          </div>
          <div className="mt-3 flex flex-wrap gap-2">
            {listing.commodities.map((c) => <CommodityBadge key={c.id} code={c.commodity} />)}
          </div>
          <p className="mt-6 leading-relaxed text-slate-200">{listing.description}</p>
        </div>
        <aside className="h-fit rounded-xl border border-white/5 bg-night-700 p-5">
          <PriceTag value={listing.priceUsd} />
          <dl className="mt-4 space-y-2 text-sm">
            <Row label="Superficie" value={formatHa(listing.surfaceHa)} />
            <Row label="Concesiones" value={listing.concessionCount?.toString() ?? '—'} />
            <Row label="País" value={COUNTRY_LABELS[listing.country] ?? listing.country} />
          </dl>
          <div className="mt-4"><VerifiedBadge verified={listing.verified} /></div>
          <button type="button" className="mt-5 w-full rounded-md bg-amber-brand py-2.5 text-sm font-semibold text-night">Contactar al vendedor</button>
        </aside>
      </div>
    </Container>
  )
}

function Row({ label, value }: { label: string; value: string }) {
  return (
    <div className="flex justify-between border-b border-white/5 pb-2">
      <dt className="text-slatey">{label}</dt><dd className="text-white">{value}</dd>
    </div>
  )
}
```

- [ ] **Step 2: `src/app/marketplace/[slug]/not-found.tsx`**

```tsx
import { Container } from '@/components/ui/Container'

export default function NotFound() {
  return (
    <Container className="py-24 text-center">
      <h1 className="text-2xl font-bold">Oportunidad no encontrada</h1>
      <p className="mt-2 text-slatey">La publicación que buscas no existe o fue retirada.</p>
      <a href="/marketplace" className="mt-6 inline-block rounded-md bg-amber-brand px-4 py-2 text-sm font-semibold text-night">Ver marketplace</a>
    </Container>
  )
}
```

- [ ] **Step 3: Verificar detalle**

Run:
```bash
npm run dev &
sleep 6
curl -s -o /dev/null -w "%{http_code}\n" "http://localhost:3000/marketplace/cerro-del-viento-deseado-santa-cruz"
curl -s -o /dev/null -w "%{http_code}\n" "http://localhost:3000/marketplace/no-existe"
kill %1
```
Expected: `200` para el slug válido, `404` para el inexistente.

- [ ] **Step 4: Commit**

```bash
git add -A
git commit -m "feat: página de detalle de oportunidad + 404"
```

---

## Task 14: Home

**Files:**
- Create: `src/components/home/Hero.tsx`, `StatsBand.tsx`, `MarketsSection.tsx`, `TwoSidesSection.tsx`, `CtaBand.tsx`
- Create/Modify: `src/app/page.tsx`

- [ ] **Step 1: `Hero.tsx`**

```tsx
import Link from 'next/link'
import { Container } from '@/components/ui/Container'

export function Hero() {
  return (
    <section className="py-20 text-center">
      <Container>
        <p className="kicker">El marketplace minero de Latinoamérica</p>
        <h1 className="mx-auto mt-4 max-w-3xl text-4xl font-bold leading-tight text-white sm:text-5xl">
          Donde se transa la minería de <span className="text-amber-brand">Latinoamérica</span>
        </h1>
        <p className="mx-auto mt-5 max-w-xl text-slatey">
          Activos, productos minerales y servicios en una sola plataforma. Conectamos la oferta minera de la región con compradores e inversionistas de todo el mundo.
        </p>
        <div className="mt-8 flex justify-center gap-3">
          <Link href="/marketplace" className="rounded-md bg-amber-brand px-5 py-2.5 text-sm font-semibold text-night">Explorar el marketplace</Link>
          <a href="#suscribe" className="rounded-md border border-white/15 px-5 py-2.5 text-sm text-white">Suscríbete para recibir avisos</a>
        </div>
      </Container>
    </section>
  )
}
```

- [ ] **Step 2: `StatsBand.tsx`**

```tsx
import { Container } from '@/components/ui/Container'

const stats = [
  { value: '74%', caption: 'del M&A minero global va a LatAm' },
  { value: '~US$3B', caption: 'en exploración LatAm 2025' },
  { value: 'US$239B', caption: 'inversión proyectada al 2033' },
]

export function StatsBand() {
  return (
    <section className="border-y border-white/5 bg-night-800 py-12">
      <Container className="grid grid-cols-1 gap-8 text-center sm:grid-cols-3">
        {stats.map((s) => (
          <div key={s.value}>
            <p className="text-3xl font-bold text-white">{s.value}</p>
            <p className="mt-1 text-sm text-slatey">{s.caption}</p>
          </div>
        ))}
      </Container>
    </section>
  )
}
```

- [ ] **Step 3: `MarketsSection.tsx`**

```tsx
import Link from 'next/link'
import { Container } from '@/components/ui/Container'

const markets = [
  { icon: '⛰', title: 'Activos mineros', desc: 'Concesiones y proyectos de exploración: venta, JV, earn-in, royalty y más.', href: '/marketplace', cta: 'Explorar activos' },
  { icon: '◆', title: 'Productos minerales', desc: 'Concentrados, doré, cátodos. Venta directa, ofertas y suministro recurrente.', href: '/marketplace/productos', cta: 'Explorar productos' },
  { icon: '🔧', title: 'Servicios', desc: 'Perforación, laboratorio y logística para operaciones mineras.', href: '/marketplace/servicios', cta: 'Próximamente', soon: true },
]

export function MarketsSection() {
  return (
    <section className="py-16">
      <Container>
        <p className="kicker">Qué se transa</p>
        <h2 className="mt-2 text-2xl font-bold text-white">Tres mercados, una plataforma</h2>
        <div className="mt-8 grid gap-4 md:grid-cols-3">
          {markets.map((m) => (
            <Link key={m.title} href={m.href} className="rounded-xl border-t-2 border-amber-brand bg-night-700 p-6 transition-colors hover:bg-night-600">
              <div className="text-2xl">{m.icon}</div>
              <h3 className="mt-3 font-semibold text-white">{m.title}</h3>
              <p className="mt-2 text-sm text-slatey">{m.desc}</p>
              <p className={`mt-4 text-sm font-semibold ${m.soon ? 'text-slatey' : 'text-amber-brand'}`}>{m.cta} →</p>
            </Link>
          ))}
        </div>
      </Container>
    </section>
  )
}
```

- [ ] **Step 4: `TwoSidesSection.tsx`**

```tsx
import { Container } from '@/components/ui/Container'

export function TwoSidesSection() {
  return (
    <section className="py-16">
      <Container>
        <p className="kicker">Para quién es</p>
        <h2 className="mt-2 text-2xl font-bold text-white">Dos lados de un mismo mercado</h2>
        <div className="mt-8 grid gap-4 md:grid-cols-2">
          <div className="rounded-xl bg-night-700 p-6">
            <p className="kicker">Oferta · Latinoamérica</p>
            <h3 className="mt-2 font-semibold text-white">Vendedores</h3>
            <p className="mt-2 text-sm text-slatey">Titulares mineros, productores y agentes. Publica tu activo o producto y llega a compradores calificados, sin intermediarios.</p>
          </div>
          <div className="rounded-xl bg-night-700 p-6">
            <p className="kicker">Demanda · Global</p>
            <h3 className="mt-2 font-semibold text-white">Compradores e inversionistas</h3>
            <p className="mt-2 text-sm text-slatey">Inversionistas, smelters, traders y fundiciones. Accede al deal flow minero de la región con información estructurada.</p>
          </div>
        </div>
      </Container>
    </section>
  )
}
```

- [ ] **Step 5: `CtaBand.tsx`**

```tsx
import Link from 'next/link'
import { Container } from '@/components/ui/Container'

export function CtaBand() {
  return (
    <section className="py-20 text-center">
      <Container>
        <h2 className="mx-auto max-w-2xl text-3xl font-bold text-white">El deal flow minero de Latinoamérica, en un solo lugar</h2>
        <p className="mt-3 text-slatey">Explora oportunidades. Empieza hoy.</p>
        <Link href="/marketplace" className="mt-6 inline-block rounded-md bg-amber-brand px-6 py-3 text-sm font-semibold text-night">Explorar el marketplace</Link>
      </Container>
    </section>
  )
}
```

- [ ] **Step 6: `src/app/page.tsx`**

```tsx
import { Hero } from '@/components/home/Hero'
import { StatsBand } from '@/components/home/StatsBand'
import { MarketsSection } from '@/components/home/MarketsSection'
import { TwoSidesSection } from '@/components/home/TwoSidesSection'
import { CtaBand } from '@/components/home/CtaBand'

export default function HomePage() {
  return (
    <>
      <Hero />
      <StatsBand />
      <MarketsSection />
      <TwoSidesSection />
      <CtaBand />
    </>
  )
}
```

- [ ] **Step 7: Verificar home**

Run:
```bash
npm run dev &
sleep 6
curl -s "http://localhost:3000/" | grep -o "Latinoamérica" | head -1
kill %1
```
Expected: imprime `Latinoamérica`.

- [ ] **Step 8: Commit**

```bash
git add -A
git commit -m "feat: home (hero, stats, mercados, dos lados, CTA)"
```

---

## Task 15: Páginas "Próximamente"

**Files:**
- Create: `src/components/ui/ComingSoon.tsx`, `src/app/inversionistas/page.tsx`, `src/app/como-funciona/page.tsx`, `src/app/marketplace/productos/page.tsx`, `src/app/marketplace/servicios/page.tsx`

- [ ] **Step 1: `ComingSoon.tsx`**

```tsx
import { Container } from './Container'

export function ComingSoon({ title, description }: { title: string; description: string }) {
  return (
    <Container className="py-24 text-center">
      <div className="text-3xl" aria-hidden>⛰</div>
      <h1 className="mt-4 text-2xl font-bold text-white">{title}</h1>
      <p className="mx-auto mt-2 max-w-md text-slatey">{description}</p>
    </Container>
  )
}
```

- [ ] **Step 2: Las cuatro páginas**

`src/app/inversionistas/page.tsx`:
```tsx
import { ComingSoon } from '@/components/ui/ComingSoon'
export default function Page() {
  return <ComingSoon title="Próximamente" description="Inversionistas — propuesta dedicada para inversores. Pendiente de implementación." />
}
```

`src/app/como-funciona/page.tsx`:
```tsx
import { ComingSoon } from '@/components/ui/ComingSoon'
export default function Page() {
  return <ComingSoon title="Próximamente" description="Cómo funciona Mercado Minero — guía para vendedores, compradores y socios. Pendiente de implementación." />
}
```

`src/app/marketplace/productos/page.tsx`:
```tsx
import { ComingSoon } from '@/components/ui/ComingSoon'
export default function Page() {
  return <ComingSoon title="Próximamente: productos minerales" description="Estamos curando los primeros lotes y contratos de suministro. Vuelve pronto." />
}
```

`src/app/marketplace/servicios/page.tsx`:
```tsx
import { ComingSoon } from '@/components/ui/ComingSoon'
export default function Page() {
  return <ComingSoon title="Próximamente" description="Servicios — proveedores y asesores. Pendiente de implementación." />
}
```

- [ ] **Step 3: Commit**

```bash
git add -A
git commit -m "feat: páginas Próximamente (inversionistas, cómo funciona, productos, servicios)"
```

---

## Task 16: Verificación final

**Files:** ninguno (verificación).

- [ ] **Step 1: Tests unitarios completos**

Run: `npm test`
Expected: todos los tests (filters + listings) PASS.

- [ ] **Step 2: Build de producción**

Run: `npm run build`
Expected: build exitoso sin errores de tipos.

- [ ] **Step 3: Humo end-to-end**

Run:
```bash
npm run dev &
sleep 6
for url in "/" "/marketplace" "/marketplace?commodity=CU&pais=CL" "/marketplace/cerro-del-viento-deseado-santa-cruz" "/inversionistas" "/como-funciona" "/marketplace/productos" "/marketplace/servicios"; do
  code=$(curl -s -o /dev/null -w "%{http_code}" "http://localhost:3000$url")
  echo "$code  $url"
done
kill %1
```
Expected: todas las rutas devuelven `200`.

- [ ] **Step 4: Captura visual (opcional, recomendado)**

Levantar `npm run dev` y revisar `http://localhost:3000` y `http://localhost:3000/marketplace` en el navegador. Confirmar tema oscuro + acento ámbar, tarjetas con precio, filtros funcionando.

- [ ] **Step 5: Commit final**

```bash
git add -A
git commit -m "chore: verificación final Fase 1 — catálogo funcionando en localhost"
```

---

## Resumen de cobertura del spec

| Requisito del spec | Tarea(s) |
|---|---|
| Stack Next.js + Prisma + SQLite + Tailwind + Vitest | 0, 1 |
| Modelo de datos (Listing + commodities + dealTypes) | 2, 3 |
| Identidad visual (tema oscuro + ámbar) | 7, 8, 9 |
| Datos semilla (~13 oportunidades) | 5 |
| Parseo de filtros | 4 |
| Capa de datos (getListings/BySlug/Stats) | 6 |
| Home (hero, stats, mercados, dos lados, CTA) | 14 |
| Catálogo con filtros y orden | 10, 11, 12 |
| Detalle por slug + 404 | 13 |
| Páginas "Próximamente" | 15 |
| Manejo de errores (vacío, 404, loading) | 10, 12, 13 |
| Pruebas (TDD) | 4, 6, 16 |
| Arranque en localhost | 16 |
