# Mercado Minero — Autenticación + Publicación (Fase 2) — 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:** Permitir registro/login con email+contraseña y, ya autenticado, publicar un activo minero mediante un asistente de 7 pasos que guarda en la base y lo muestra en el catálogo.

**Architecture:** Auth propia con `crypto` de Node (scrypt) + sesiones en BD (tabla `Session`) + cookie httpOnly + `middleware.ts` protegiendo `/dashboard/*`. Server Actions para registro/login/logout/publicar. Asistente como componente cliente que acumula estado y al final llama a un server action `createListingAction`.

**Tech Stack:** Next.js 16 (App Router, Server Actions, middleware), React 19 (`useActionState`), Prisma 6 (SQLite), `crypto` nativo, Vitest.

**Decisión de diseño clave:** las funciones testeables (`hashPassword`, `verifyPassword`, `createSession`, `getSessionUser`, `deleteSession`) viven en `src/lib/auth.ts` y **no** importan `next/headers`. La función que lee la cookie (`getCurrentUser`) vive aparte en `src/lib/current-user.ts`. Así Vitest no intenta cargar `next/headers`.

---

## Estructura de archivos (nuevos / modificados)

```
prisma/schema.prisma                     # +User, +Session, +campos de Listing
src/lib/constants.ts                     # +OFFER_TYPES, SELLER_TYPES, CONTACT_PREFERENCES, MINERALIZATION_TYPES, WORKS_OPTIONS
src/lib/auth.ts                          # hash + sesiones (testeable)            [TDD]
src/lib/auth.test.ts
src/lib/current-user.ts                  # getCurrentUser() (lee cookie)
src/lib/publish.ts                       # buildListingInput() (puro)             [TDD]
src/lib/publish.test.ts
src/lib/listings.ts                      # MOD: filtra status=PUBLISHED
src/app/(auth)/actions.ts                # 'use server' registerAction/loginAction/logoutAction
src/app/(auth)/login/page.tsx
src/app/(auth)/registro/page.tsx
src/components/auth/AuthForm.tsx         # form cliente reutilizable (useActionState)
src/app/dashboard/actions.ts             # 'use server' createListingAction
src/app/dashboard/page.tsx               # inicio protegido
src/app/dashboard/publicar-activo/page.tsx
src/components/dashboard/PublishWizard.tsx   # cliente, estado de 7 pasos
src/components/dashboard/WizardFields.tsx    # inputs reutilizables (Text/Select/Textarea/Checkbox/ChipGroup)
src/components/dashboard/wizardContent.ts    # textos de la columna lateral + plantilla "Generar con IA"
src/components/Header.tsx                 # MOD: server component consciente de sesión
src/middleware.ts                         # protege /dashboard/*
```

---

## Task 1: Esquema (User, Session, extensión de Listing) + constantes

**Files:**
- Modify: `prisma/schema.prisma`, `src/lib/constants.ts`

- [ ] **Step 1: Añadir modelos y campos en `prisma/schema.prisma`**

Añadir al final (modelos nuevos) y modificar `Listing`. El bloque `Listing` completo queda así (reemplazar el existente):
```prisma
model Listing {
  id              String   @id @default(cuid())
  slug            String   @unique
  title           String
  description     String
  category        String
  assetType       String?
  stage           String?
  country         String
  region          String?
  city            String?
  lat             Float?
  lng             Float?
  priceUsd        Int?
  surfaceHa       Float?
  concessionCount Int?
  verified        Boolean  @default(false)
  imageSeed       String?
  publishedAt     DateTime @default(now())

  status   String  @default("PUBLISHED") // DRAFT | PUBLISHED
  owner    User?   @relation(fields: [ownerId], references: [id])
  ownerId  String?

  offerType          String?
  headline           String?
  sellReason         String?
  sellerCompany      String?
  sellerWebsite      String?
  sellerType         String?
  mineralizationType String?
  works              String?
  waterAccess        String?
  energyAccess       String?
  esgEnvStatus       String?
  esgClosurePlan     String?
  esgCommunity       String?
  esgLiabilities     String?
  contactName        String?
  contactRole        String?
  contactEmail       String?
  contactPhone       String?
  contactPreference  String?
  hasAgent           Boolean @default(false)

  commodities ListingCommodity[]
  dealTypes   ListingDealType[]

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

model User {
  id           String    @id @default(cuid())
  email        String    @unique
  passwordHash String
  name         String
  company      String?
  role         String    @default("SELLER")
  createdAt    DateTime  @default(now())
  sessions     Session[]
  listings     Listing[]
}

model Session {
  id        String   @id @default(cuid())
  token     String   @unique
  user      User     @relation(fields: [userId], references: [id], onDelete: Cascade)
  userId    String
  expiresAt DateTime
  createdAt DateTime @default(now())

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

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

Run:
```bash
npx prisma migrate dev --name add_auth_and_listing_fields
```
Expected: aplica la migración y regenera el cliente. (SQLite rellena las filas existentes con los defaults; `status` queda `PUBLISHED`.)

- [ ] **Step 3: Re-sembrar (las semillas siguen como PUBLISHED, sin dueño)**

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

- [ ] **Step 4: Añadir constantes en `src/lib/constants.ts`**

Agregar al final del archivo:
```typescript
export const OFFER_TYPES = ['VENTA_TOTAL', 'VENTA_PARCIAL'] as const
export type OfferType = (typeof OFFER_TYPES)[number]
export const OFFER_TYPE_LABELS: Record<OfferType, string> = {
  VENTA_TOTAL: 'Venta total',
  VENTA_PARCIAL: 'Venta parcial',
}

export const SELLER_TYPES = ['PERSONA_NATURAL', 'PERSONA_JURIDICA', 'ESTADO'] as const
export type SellerType = (typeof SELLER_TYPES)[number]
export const SELLER_TYPE_LABELS: Record<SellerType, string> = {
  PERSONA_NATURAL: 'Persona natural',
  PERSONA_JURIDICA: 'Persona jurídica',
  ESTADO: 'El Estado',
}

export const CONTACT_PREFERENCES = ['EMAIL', 'TELEFONO', 'WHATSAPP'] as const
export type ContactPreference = (typeof CONTACT_PREFERENCES)[number]
export const CONTACT_PREFERENCE_LABELS: Record<ContactPreference, string> = {
  EMAIL: 'Email',
  TELEFONO: 'Teléfono',
  WHATSAPP: 'WhatsApp',
}

export const MINERALIZATION_TYPES = [
  'Veta', 'Diseminado', 'Manto', 'Pórfido', 'Skarn', 'IOCG', 'Epitermal', 'Placer', 'Otro',
] as const

export const WORKS_OPTIONS = [
  'Reconocimiento en terreno', 'Muestreo de superficie', 'Análisis de laboratorio',
  'Mapeo geológico', 'Geofísica', 'Geoquímica', 'Sondajes', 'Estimación de recursos',
  'Informe técnico', 'Producción histórica / actual',
] as const
```

- [ ] **Step 5: Commit**

```bash
git add -A
git commit -m "feat: modelos User/Session, extensión de Listing y constantes de publicación"
```

---

## Task 2: Capa de auth (hash + sesiones, TDD)

**Files:**
- Create: `src/lib/auth.ts`, `src/lib/auth.test.ts`

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

`src/lib/auth.test.ts`:
```typescript
import { describe, it, expect } from 'vitest'
import { hashPassword, verifyPassword, createSession, getSessionUser, deleteSession } from './auth'
import { prisma } from './prisma'

describe('password hashing', () => {
  it('acepta la contraseña correcta y rechaza la incorrecta', () => {
    const stored = hashPassword('SuperSecreta123')
    expect(stored).toContain(':')
    expect(verifyPassword('SuperSecreta123', stored)).toBe(true)
    expect(verifyPassword('otra', stored)).toBe(false)
  })
  it('rechaza un formato inválido sin lanzar', () => {
    expect(verifyPassword('x', 'no-tiene-formato')).toBe(false)
  })
})

describe('sesiones', () => {
  it('crea una sesión y devuelve el usuario; la borra después', async () => {
    const user = await prisma.user.create({
      data: { email: `t${Date.now()}@test.cl`, passwordHash: hashPassword('x12345678'), name: 'Test' },
    })
    const token = await createSession(user.id)
    const found = await getSessionUser(token)
    expect(found?.id).toBe(user.id)
    await deleteSession(token)
    expect(await getSessionUser(token)).toBeNull()
    await prisma.user.delete({ where: { id: user.id } })
  })
})
```

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

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

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

```typescript
import { scryptSync, randomBytes, timingSafeEqual } from 'node:crypto'
import { prisma } from './prisma'

const SESSION_DAYS = 30

export function hashPassword(plain: string): string {
  const salt = randomBytes(16).toString('hex')
  const hash = scryptSync(plain, salt, 64).toString('hex')
  return `${salt}:${hash}`
}

export function verifyPassword(plain: string, stored: string): boolean {
  const [salt, hash] = stored.split(':')
  if (!salt || !hash) return false
  const expected = Buffer.from(hash, 'hex')
  const actual = scryptSync(plain, salt, 64)
  return expected.length === actual.length && timingSafeEqual(expected, actual)
}

export async function createSession(userId: string): Promise<string> {
  const token = randomBytes(32).toString('hex')
  const expiresAt = new Date(Date.now() + SESSION_DAYS * 24 * 60 * 60 * 1000)
  await prisma.session.create({ data: { token, userId, expiresAt } })
  return token
}

export async function getSessionUser(token: string) {
  const session = await prisma.session.findUnique({ where: { token }, include: { user: true } })
  if (!session || session.expiresAt < new Date()) return null
  return session.user
}

export async function deleteSession(token: string): Promise<void> {
  await prisma.session.deleteMany({ where: { token } })
}
```

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

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

- [ ] **Step 5: Commit**

```bash
git add -A
git commit -m "feat: capa de auth (scrypt + sesiones en BD) con TDD"
```

---

## Task 3: getCurrentUser (lee cookie)

**Files:**
- Create: `src/lib/session-cookie.ts`, `src/lib/current-user.ts`

> El nombre de la cookie vive en su propio módulo SIN dependencias (`session-cookie.ts`) para que tanto `current-user.ts` (usa `next/headers`) como `middleware.ts` (runtime edge, NO puede importar `next/headers`) lo compartan sin arrastrar dependencias incompatibles.

- [ ] **Step 1: Crear `src/lib/session-cookie.ts`**

```typescript
export const SESSION_COOKIE = 'mm_session'
```

- [ ] **Step 2: Implementar `src/lib/current-user.ts`**

```typescript
import { cookies } from 'next/headers'
import { getSessionUser } from './auth'
import { SESSION_COOKIE } from './session-cookie'

export async function getCurrentUser() {
  const store = await cookies()
  const token = store.get(SESSION_COOKIE)?.value
  if (!token) return null
  return getSessionUser(token)
}
```

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

Run: `npx tsc --noEmit`
Expected: sin errores.

- [ ] **Step 3: Commit**

```bash
git add -A
git commit -m "feat: getCurrentUser() lee la cookie de sesión"
```

---

## Task 4: Server actions de auth

**Files:**
- Create: `src/app/(auth)/actions.ts`

- [ ] **Step 1: Implementar `src/app/(auth)/actions.ts`**

```typescript
'use server'

import { cookies } from 'next/headers'
import { redirect } from 'next/navigation'
import { prisma } from '@/lib/prisma'
import { hashPassword, verifyPassword, createSession, deleteSession } from '@/lib/auth'
import { SESSION_COOKIE } from '@/lib/session-cookie'

type FormState = { error?: string }

async function setSessionCookie(userId: string) {
  const token = await createSession(userId)
  const store = await cookies()
  store.set(SESSION_COOKIE, token, {
    httpOnly: true,
    sameSite: 'lax',
    secure: process.env.NODE_ENV === 'production',
    path: '/',
    maxAge: 60 * 60 * 24 * 30,
  })
}

export async function registerAction(_prev: FormState, formData: FormData): Promise<FormState> {
  const name = String(formData.get('name') ?? '').trim()
  const email = String(formData.get('email') ?? '').trim().toLowerCase()
  const company = String(formData.get('company') ?? '').trim() || null
  const password = String(formData.get('password') ?? '')

  if (!name || !email || password.length < 8) {
    return { error: 'Completa nombre, email y una contraseña de al menos 8 caracteres.' }
  }
  const exists = await prisma.user.findUnique({ where: { email } })
  if (exists) return { error: 'Ya existe una cuenta con ese email.' }

  const user = await prisma.user.create({
    data: { name, email, company, passwordHash: hashPassword(password) },
  })
  await setSessionCookie(user.id)
  redirect('/dashboard')
}

export async function loginAction(_prev: FormState, formData: FormData): Promise<FormState> {
  const email = String(formData.get('email') ?? '').trim().toLowerCase()
  const password = String(formData.get('password') ?? '')
  const next = String(formData.get('next') ?? '/dashboard') || '/dashboard'

  const user = await prisma.user.findUnique({ where: { email } })
  if (!user || !verifyPassword(password, user.passwordHash)) {
    return { error: 'Email o contraseña incorrectos.' }
  }
  await setSessionCookie(user.id)
  redirect(next.startsWith('/') ? next : '/dashboard')
}

export async function logoutAction(): Promise<void> {
  const store = await cookies()
  const token = store.get(SESSION_COOKIE)?.value
  if (token) await deleteSession(token)
  store.delete(SESSION_COOKIE)
  redirect('/')
}
```

- [ ] **Step 2: Verificar tipos**

Run: `npx tsc --noEmit`
Expected: sin errores.

- [ ] **Step 3: Commit**

```bash
git add -A
git commit -m "feat: server actions de registro/login/logout"
```

---

## Task 5: Páginas de login y registro

**Files:**
- Create: `src/components/auth/AuthForm.tsx`, `src/app/(auth)/login/page.tsx`, `src/app/(auth)/registro/page.tsx`

- [ ] **Step 1: `src/components/auth/AuthForm.tsx` (cliente, reutilizable)**

```tsx
'use client'

import { useActionState } from 'react'
import Link from 'next/link'
import { Container } from '@/components/ui/Container'

type FormState = { error?: string }
type Action = (prev: FormState, fd: FormData) => Promise<FormState>

export function AuthForm({
  mode,
  action,
  next,
}: {
  mode: 'login' | 'register'
  action: Action
  next?: string
}) {
  const [state, formAction, pending] = useActionState(action, {})
  const isRegister = mode === 'register'

  return (
    <Container className="max-w-md py-20">
      <h1 className="text-2xl font-bold text-white">{isRegister ? 'Crea tu cuenta' : 'Inicia sesión'}</h1>
      <p className="mt-1 text-sm text-slatey">
        {isRegister ? 'Para publicar activos en Mercado Minero.' : 'Accede a tu panel de Mercado Minero.'}
      </p>
      <form action={formAction} className="mt-6 flex flex-col gap-4">
        {next && <input type="hidden" name="next" value={next} />}
        {isRegister && (
          <>
            <Input name="name" label="Nombre" required />
            <Input name="company" label="Empresa (opcional)" />
          </>
        )}
        <Input name="email" type="email" label="Email" required />
        <Input name="password" type="password" label="Contraseña" required />
        {state.error && <p className="text-sm text-red-400">{state.error}</p>}
        <button
          type="submit"
          disabled={pending}
          className="rounded-md bg-amber-brand px-4 py-2.5 text-sm font-semibold text-night disabled:opacity-60"
        >
          {pending ? 'Procesando…' : isRegister ? 'Crear cuenta' : 'Entrar'}
        </button>
      </form>
      <p className="mt-6 text-sm text-slatey">
        {isRegister ? (
          <>
            ¿Ya tienes cuenta?{' '}
            <Link href="/login" className="text-amber-brand hover:underline">
              Inicia sesión
            </Link>
          </>
        ) : (
          <>
            ¿No tienes cuenta?{' '}
            <Link href="/registro" className="text-amber-brand hover:underline">
              Regístrate
            </Link>
          </>
        )}
      </p>
    </Container>
  )
}

function Input({
  name,
  label,
  type = 'text',
  required = false,
}: {
  name: string
  label: string
  type?: string
  required?: boolean
}) {
  return (
    <label className="flex flex-col gap-1 text-sm text-slatey">
      {label}
      <input
        name={name}
        type={type}
        required={required}
        className="rounded-md border border-white/10 bg-night-700 px-3 py-2 text-white outline-none focus:border-amber-brand/60"
      />
    </label>
  )
}
```

- [ ] **Step 2: `src/app/(auth)/login/page.tsx`**

```tsx
import { AuthForm } from '@/components/auth/AuthForm'
import { loginAction } from '../actions'

export default async function LoginPage({
  searchParams,
}: {
  searchParams: Promise<{ next?: string }>
}) {
  const { next } = await searchParams
  return <AuthForm mode="login" action={loginAction} next={next} />
}
```

- [ ] **Step 3: `src/app/(auth)/registro/page.tsx`**

```tsx
import { AuthForm } from '@/components/auth/AuthForm'
import { registerAction } from '../actions'

export default function RegisterPage() {
  return <AuthForm mode="register" action={registerAction} />
}
```

- [ ] **Step 4: Commit**

```bash
git add -A
git commit -m "feat: páginas y formulario de login/registro"
```

---

## Task 6: Middleware, dashboard de inicio y Header con sesión

**Files:**
- Create: `src/middleware.ts`, `src/app/dashboard/page.tsx`
- Modify: `src/components/Header.tsx`

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

```typescript
import { NextResponse, type NextRequest } from 'next/server'
import { SESSION_COOKIE } from '@/lib/session-cookie'

export function middleware(request: NextRequest) {
  const token = request.cookies.get(SESSION_COOKIE)?.value
  if (!token) {
    const url = new URL('/login', request.url)
    url.searchParams.set('next', request.nextUrl.pathname)
    return NextResponse.redirect(url)
  }
  return NextResponse.next()
}

export const config = { matcher: ['/dashboard/:path*'] }
```

> `SESSION_COOKIE` se importa desde `@/lib/session-cookie` (módulo sin dependencias, creado en Task 3) — el middleware corre en runtime edge y NO puede importar `next/headers`.

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

```tsx
import Link from 'next/link'
import { redirect } from 'next/navigation'
import { getCurrentUser } from '@/lib/current-user'
import { Container } from '@/components/ui/Container'

export default async function DashboardPage() {
  const user = await getCurrentUser()
  if (!user) redirect('/login?next=/dashboard')

  return (
    <Container className="py-12">
      <p className="kicker">Panel</p>
      <h1 className="text-2xl font-bold text-white">Hola, {user.name}</h1>
      <p className="mt-1 text-slatey">Gestiona tus publicaciones en Mercado Minero.</p>
      <div className="mt-8 grid gap-4 sm:grid-cols-2">
        <Link
          href="/dashboard/publicar-activo"
          className="rounded-xl border-t-2 border-amber-brand bg-night-700 p-6 transition-colors hover:bg-night-600"
        >
          <div className="text-2xl">⛰</div>
          <h2 className="mt-3 font-semibold text-white">Publicar activo minero</h2>
          <p className="mt-2 text-sm text-slatey">Concesiones y proyectos de exploración. Conecta con compradores e inversionistas.</p>
          <p className="mt-4 text-sm font-semibold text-amber-brand">Comenzar →</p>
        </Link>
      </div>
    </Container>
  )
}
```

- [ ] **Step 3: Reemplazar `src/components/Header.tsx` (consciente de sesión)**

```tsx
import Link from 'next/link'
import { Container } from './ui/Container'
import { getCurrentUser } from '@/lib/current-user'
import { logoutAction } from '@/app/(auth)/actions'

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

export async function Header() {
  const user = await getCurrentUser()
  return (
    <header className="sticky top-0 z-40 border-b border-white/5 bg-night/80 backdrop-blur">
      <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 items-center gap-6 text-sm text-slatey md:flex">
            {nav.map((n) => (
              <Link key={n.href} href={n.href} className="transition-colors hover:text-white">
                {n.label}
              </Link>
            ))}
          </nav>
        </div>
        <div className="flex items-center gap-4">
          {user ? (
            <>
              <Link
                href="/dashboard/publicar-activo"
                className="hidden rounded-md border border-amber-brand/60 px-3 py-1.5 text-sm text-amber-brand sm:inline"
              >
                Publicar
              </Link>
              <Link href="/dashboard" className="text-sm text-white hover:text-amber-brand">
                {user.name.split(' ')[0]}
              </Link>
              <form action={logoutAction}>
                <button type="submit" className="text-xs text-slatey hover:text-white">
                  Salir
                </button>
              </form>
            </>
          ) : (
            <>
              <Link href="/login" className="text-sm text-slatey hover:text-white">
                Iniciar sesión
              </Link>
              <Link
                href="/registro"
                className="rounded-md border border-amber-brand/60 px-3 py-1.5 text-sm text-amber-brand"
              >
                Publicar
              </Link>
            </>
          )}
        </div>
      </Container>
    </header>
  )
}
```

- [ ] **Step 4: Verificar tipos y commit**

Run: `npx tsc --noEmit`
Expected: sin errores.
```bash
git add -A
git commit -m "feat: middleware de protección, dashboard de inicio y Header con sesión"
```

---

## Task 7: Validación/armado del activo (`lib/publish.ts`, TDD)

**Files:**
- Create: `src/lib/publish.ts`, `src/lib/publish.test.ts`

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

`src/lib/publish.test.ts`:
```typescript
import { describe, it, expect } from 'vitest'
import { buildListingInput, type WizardForm } from './publish'

const base: WizardForm = {
  title: 'Proyecto Demo Cu en Atacama',
  stage: 'EXPLORACION_BASICA',
  assetType: 'CONCESION_MINERA',
  offerType: 'VENTA_TOTAL',
  commodities: ['CU', 'AU'],
  primaryCommodity: 'CU',
  country: 'CL',
  region: 'Atacama',
  city: 'Copiapó',
  surfaceHa: '120',
  concessionCount: '3',
  dealTypes: ['VENTA_100'],
  priceUsd: '1500000',
  headline: '',
  sellReason: 'Reenfoque de portafolio',
  sellerCompany: 'Demo Mining SpA',
  sellerWebsite: '',
  sellerType: 'PERSONA_JURIDICA',
  mineralizationType: 'Veta',
  works: ['Sondajes', 'Mapeo geológico'],
  waterAccess: 'Disponible en cuenca',
  energyAccess: 'Línea MT a 5 km',
  esgEnvStatus: '',
  esgClosurePlan: '',
  esgCommunity: '',
  esgLiabilities: '',
  description: 'Proyecto de cobre con vetas reconocidas y potencial en profundidad.',
  contactName: 'Ana Pérez',
  contactRole: 'Gerente',
  contactEmail: 'ana@demo.cl',
  contactPhone: '+56 9 1234 5678',
  contactPreference: 'EMAIL',
  hasAgent: false,
}

describe('buildListingInput', () => {
  it('arma un input válido con relaciones y slug', () => {
    const r = buildListingInput(base)
    expect(r.ok).toBe(true)
    if (!r.ok) return
    expect(r.data.slug).toBe('proyecto-demo-cu-en-atacama')
    expect(r.data.category).toBe('ACTIVO')
    expect(r.data.priceUsd).toBe(1_500_000)
    expect(r.data.surfaceHa).toBe(120)
    expect(r.data.commodities.create).toEqual([
      { commodity: 'CU', isPrimary: true },
      { commodity: 'AU', isPrimary: false },
    ])
    expect(r.data.dealTypes.create).toEqual([{ dealType: 'VENTA_100' }])
    expect(r.data.status).toBe('PUBLISHED')
  })

  it('rechaza si falta título, país, commodity o estructura', () => {
    expect(buildListingInput({ ...base, title: '' }).ok).toBe(false)
    expect(buildListingInput({ ...base, country: '' }).ok).toBe(false)
    expect(buildListingInput({ ...base, commodities: [] }).ok).toBe(false)
    expect(buildListingInput({ ...base, dealTypes: [] }).ok).toBe(false)
  })

  it('descarta precio/superficie no numéricos en lugar de fallar', () => {
    const r = buildListingInput({ ...base, priceUsd: 'abc', surfaceHa: '' })
    expect(r.ok).toBe(true)
    if (!r.ok) return
    expect(r.data.priceUsd).toBeNull()
    expect(r.data.surfaceHa).toBeNull()
  })
})
```

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

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

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

```typescript
export interface WizardForm {
  title: string
  stage: string
  assetType: string
  offerType: string
  commodities: string[]
  primaryCommodity: string
  country: string
  region: string
  city: string
  surfaceHa: string
  concessionCount: string
  dealTypes: string[]
  priceUsd: string
  headline: string
  sellReason: string
  sellerCompany: string
  sellerWebsite: string
  sellerType: string
  mineralizationType: string
  works: string[]
  waterAccess: string
  energyAccess: string
  esgEnvStatus: string
  esgClosurePlan: string
  esgCommunity: string
  esgLiabilities: string
  description: string
  contactName: string
  contactRole: string
  contactEmail: string
  contactPhone: string
  contactPreference: string
  hasAgent: boolean
}

export function slugify(text: string): string {
  return text
    .toLowerCase()
    .normalize('NFD')
    .replace(/[\u0300-\u036f]/g, '')
    .replace(/[^a-z0-9]+/g, '-')
    .replace(/^-+|-+$/g, '')
    .slice(0, 80)
}

function numOrNull(s: string): number | null {
  if (!s || !s.trim()) return null
  const n = Number(s)
  return Number.isFinite(n) ? n : null
}

type BuildResult =
  | { ok: true; data: ReturnType<typeof toData> }
  | { ok: false; errors: string[] }

function toData(form: WizardForm) {
  const commodities = form.commodities.map((c) => ({
    commodity: c,
    isPrimary: c === form.primaryCommodity,
  }))
  return {
    slug: slugify(form.title),
    title: form.title.trim(),
    description: form.description.trim() || form.title.trim(),
    category: 'ACTIVO',
    assetType: form.assetType || null,
    stage: form.stage || null,
    country: form.country.trim(),
    region: form.region.trim() || null,
    city: form.city.trim() || null,
    priceUsd: numOrNull(form.priceUsd),
    surfaceHa: numOrNull(form.surfaceHa),
    concessionCount: numOrNull(form.concessionCount),
    imageSeed: slugify(form.title) || 'activo',
    status: 'PUBLISHED',
    offerType: form.offerType || null,
    headline: form.headline.trim() || null,
    sellReason: form.sellReason.trim() || null,
    sellerCompany: form.sellerCompany.trim() || null,
    sellerWebsite: form.sellerWebsite.trim() || null,
    sellerType: form.sellerType || null,
    mineralizationType: form.mineralizationType || null,
    works: form.works.join(', ') || null,
    waterAccess: form.waterAccess.trim() || null,
    energyAccess: form.energyAccess.trim() || null,
    esgEnvStatus: form.esgEnvStatus.trim() || null,
    esgClosurePlan: form.esgClosurePlan.trim() || null,
    esgCommunity: form.esgCommunity.trim() || null,
    esgLiabilities: form.esgLiabilities.trim() || null,
    contactName: form.contactName.trim() || null,
    contactRole: form.contactRole.trim() || null,
    contactEmail: form.contactEmail.trim() || null,
    contactPhone: form.contactPhone.trim() || null,
    contactPreference: form.contactPreference || null,
    hasAgent: form.hasAgent,
    commodities: { create: commodities },
    dealTypes: { create: form.dealTypes.map((d) => ({ dealType: d })) },
  }
}

export function buildListingInput(form: WizardForm): BuildResult {
  const errors: string[] = []
  if (!form.title.trim()) errors.push('El nombre del activo es obligatorio.')
  if (!form.country.trim()) errors.push('El país es obligatorio.')
  if (form.commodities.length === 0) errors.push('Selecciona al menos un mineral.')
  if (form.dealTypes.length === 0) errors.push('Selecciona al menos una estructura de negocio.')
  if (errors.length > 0) return { ok: false, errors }
  return { ok: true, data: toData(form) }
}
```

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

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

- [ ] **Step 5: Commit**

```bash
git add -A
git commit -m "feat: buildListingInput valida y arma el activo desde el asistente (TDD)"
```

---

## Task 8: createListingAction + filtro de estado en el catálogo

**Files:**
- Create: `src/app/dashboard/actions.ts`
- Modify: `src/lib/listings.ts`

- [ ] **Step 1: Filtrar por estado en `src/lib/listings.ts`**

En `buildWhere`, añadir como primera línea del cuerpo (después de `const where ... = {}`):
```typescript
  where.status = 'PUBLISHED'
```
(El resto de la función queda igual.)

- [ ] **Step 2: Verificar que los tests de listings siguen pasando**

Run: `npm test -- listings`
Expected: PASS (las 13 semillas son PUBLISHED).

- [ ] **Step 3: Implementar `src/app/dashboard/actions.ts`**

```typescript
'use server'

import { randomBytes } from 'node:crypto'
import { redirect } from 'next/navigation'
import { prisma } from '@/lib/prisma'
import { getCurrentUser } from '@/lib/current-user'
import { buildListingInput, type WizardForm } from '@/lib/publish'

export type PublishResult = { ok: false; errors: string[] }

export async function createListingAction(form: WizardForm): Promise<PublishResult> {
  const user = await getCurrentUser()
  if (!user) redirect('/login?next=/dashboard/publicar-activo')

  const built = buildListingInput(form)
  if (!built.ok) return { ok: false, errors: built.errors }

  // Garantizar slug único
  let slug = built.data.slug || 'activo'
  if (await prisma.listing.findUnique({ where: { slug } })) {
    slug = `${slug}-${randomBytes(3).toString('hex')}`
  }

  await prisma.listing.create({
    data: { ...built.data, slug, ownerId: user.id },
  })
  redirect(`/marketplace/${slug}`)
}
```

- [ ] **Step 4: Commit**

```bash
git add -A
git commit -m "feat: createListingAction publica el activo; catálogo filtra status=PUBLISHED"
```

---

## Task 9: Asistente de publicación (7 pasos)

**Files:**
- Create: `src/components/dashboard/WizardFields.tsx`, `src/components/dashboard/wizardContent.ts`, `src/components/dashboard/PublishWizard.tsx`, `src/app/dashboard/publicar-activo/page.tsx`

- [ ] **Step 1: `src/components/dashboard/WizardFields.tsx` (inputs reutilizables, cliente)**

```tsx
'use client'

export function Text({
  label, value, onChange, placeholder, type = 'text',
}: { label: string; value: string; onChange: (v: string) => void; placeholder?: string; type?: string }) {
  return (
    <label className="flex flex-col gap-1 text-sm text-slatey">
      {label}
      <input
        type={type}
        value={value}
        placeholder={placeholder}
        onChange={(e) => onChange(e.target.value)}
        className="rounded-md border border-white/10 bg-night-600 px-3 py-2 text-white outline-none focus:border-amber-brand/60"
      />
    </label>
  )
}

export function Area({
  label, value, onChange, placeholder,
}: { label: string; value: string; onChange: (v: string) => void; placeholder?: string }) {
  return (
    <label className="flex flex-col gap-1 text-sm text-slatey">
      {label}
      <textarea
        rows={4}
        value={value}
        placeholder={placeholder}
        onChange={(e) => onChange(e.target.value)}
        className="rounded-md border border-white/10 bg-night-600 px-3 py-2 text-white outline-none focus:border-amber-brand/60"
      />
    </label>
  )
}

export function Select({
  label, value, onChange, options, includeEmpty = true,
}: {
  label: string; value: string; onChange: (v: string) => void
  options: { value: string; label: string }[]; includeEmpty?: boolean
}) {
  return (
    <label className="flex flex-col gap-1 text-sm text-slatey">
      {label}
      <select
        value={value}
        onChange={(e) => onChange(e.target.value)}
        className="rounded-md border border-white/10 bg-night-600 px-3 py-2 text-white"
      >
        {includeEmpty && <option value="">Selecciona…</option>}
        {options.map((o) => (
          <option key={o.value} value={o.value}>{o.label}</option>
        ))}
      </select>
    </label>
  )
}

export function ChipGroup({
  label, options, selected, onToggle,
}: {
  label: string; options: { value: string; label: string }[]
  selected: string[]; onToggle: (v: string) => void
}) {
  return (
    <div className="flex flex-col gap-2 text-sm text-slatey">
      {label}
      <div className="flex flex-wrap gap-2">
        {options.map((o) => {
          const on = selected.includes(o.value)
          return (
            <button
              key={o.value}
              type="button"
              onClick={() => onToggle(o.value)}
              className={`rounded-full border px-3 py-1 text-xs transition-colors ${
                on ? 'border-amber-brand bg-amber-brand/15 text-amber-brand' : 'border-white/10 text-slatey hover:text-white'
              }`}
            >
              {o.label}
            </button>
          )
        })}
      </div>
    </div>
  )
}
```

- [ ] **Step 2: `src/components/dashboard/wizardContent.ts` (textos laterales + plantilla IA)**

```typescript
import type { WizardForm } from '@/lib/publish'

export const STEP_TITLES = [
  'Identificación', 'Ubicación', 'Negocio', 'Ficha técnica', 'Narrativa', 'Contacto', 'Revisión',
]

export const WHY_PUBLISH = [
  ['Alcance global', 'Conecta con compradores e inversionistas de la región y el mundo.'],
  ['Avisos mejorados', 'Presentación profesional para due diligence.'],
  ['Especialistas en pertenencias', 'Diseñado para la venta de activos mineros.'],
  ['Tráfico calificado', 'Compradores serios, no curiosos.'],
]

export const WHAT_NEXT = [
  'Nuestro equipo revisa tu aviso (hasta 24 h).',
  'Generamos un resumen profesional para compradores.',
  'Se publica y conecta con compradores e inversionistas.',
  'Recibes consultas directas por correo electrónico.',
]

// Generador por plantillas (sin IA real / sin red).
export function generarHeadline(form: WizardForm): string {
  const mineral = form.primaryCommodity || form.commodities[0] || 'mineral'
  const lugar = [form.city, form.region].filter(Boolean).join(', ')
  const sup = form.surfaceHa ? `${form.surfaceHa} ha de ` : ''
  return `${sup}${mineral} en ${lugar || form.country} — ${form.mineralizationType || 'proyecto'} en etapa de ${form.stage ? form.stage.toLowerCase().replace(/_/g, ' ') : 'exploración'}.`
}

export function generarResumen(form: WizardForm): string {
  const trabajos = form.works.length ? ` Trabajos realizados: ${form.works.join(', ').toLowerCase()}.` : ''
  const trato = form.dealTypes.length ? ` Disponible para ${form.dealTypes.join(', ')}.` : ''
  return `${form.title || 'Proyecto minero'} ubicado en ${[form.city, form.region, form.country].filter(Boolean).join(', ')}.${trabajos}${trato} ${form.sellReason ? `Motivo: ${form.sellReason}.` : ''}`.trim()
}
```

- [ ] **Step 3: `src/components/dashboard/PublishWizard.tsx` (cliente)**

```tsx
'use client'

import { useState } from 'react'
import { Text, Area, Select, ChipGroup } from './WizardFields'
import { STEP_TITLES, WHY_PUBLISH, WHAT_NEXT, generarHeadline, generarResumen } from './wizardContent'
import { createListingAction } from '@/app/dashboard/actions'
import type { WizardForm } from '@/lib/publish'
import {
  ASSET_TYPES, STAGES, STAGE_LABELS, DEAL_TYPES, DEAL_TYPE_LABELS, COMMODITIES, COMMODITY_LABELS,
  COUNTRY_LABELS, OFFER_TYPES, OFFER_TYPE_LABELS, SELLER_TYPES, SELLER_TYPE_LABELS,
  CONTACT_PREFERENCES, CONTACT_PREFERENCE_LABELS, MINERALIZATION_TYPES, WORKS_OPTIONS,
} from '@/lib/constants'

const EMPTY: WizardForm = {
  title: '', stage: '', assetType: '', offerType: '', commodities: [], primaryCommodity: '',
  country: 'CL', region: '', city: '', surfaceHa: '', concessionCount: '', dealTypes: [], priceUsd: '',
  headline: '', sellReason: '', sellerCompany: '', sellerWebsite: '', sellerType: '',
  mineralizationType: '', works: [], waterAccess: '', energyAccess: '',
  esgEnvStatus: '', esgClosurePlan: '', esgCommunity: '', esgLiabilities: '',
  description: '', contactName: '', contactRole: '', contactEmail: '', contactPhone: '',
  contactPreference: '', hasAgent: false,
}

const opt = (values: readonly string[], labels?: Record<string, string>) =>
  values.map((v) => ({ value: v, label: labels?.[v] ?? v }))

export function PublishWizard() {
  const [step, setStep] = useState(0)
  const [form, setForm] = useState<WizardForm>(EMPTY)
  const [errors, setErrors] = useState<string[]>([])
  const [submitting, setSubmitting] = useState(false)

  const set = <K extends keyof WizardForm>(k: K, v: WizardForm[K]) => setForm((f) => ({ ...f, [k]: v }))
  const toggle = (k: 'commodities' | 'works' | 'dealTypes', v: string) =>
    setForm((f) => ({ ...f, [k]: f[k].includes(v) ? f[k].filter((x) => x !== v) : [...f[k], v] }))

  async function publish() {
    setSubmitting(true)
    setErrors([])
    const res = await createListingAction(form)
    // Si createListingAction redirige, este código no se alcanza.
    if (res && !res.ok) {
      setErrors(res.errors)
      setSubmitting(false)
      setStep(0)
    }
  }

  return (
    <div className="grid gap-8 lg:grid-cols-[1fr_320px]">
      <div>
        <p className="kicker">Paso {step + 1} de 7</p>
        <h1 className="text-2xl font-bold text-white">Publicar activo minero</h1>
        <ol className="mt-4 flex flex-wrap gap-2 text-xs">
          {STEP_TITLES.map((t, i) => (
            <li
              key={t}
              className={`rounded px-2 py-1 ${i === step ? 'bg-amber-brand text-night' : i < step ? 'bg-amber-brand/20 text-amber-brand' : 'bg-white/5 text-slatey'}`}
            >
              {i + 1}. {t}
            </li>
          ))}
        </ol>

        <div className="mt-6 flex flex-col gap-4 rounded-xl border border-white/5 bg-night-700 p-6">
          {step === 0 && (
            <>
              <h2 className="font-semibold text-white">Identificación del activo</h2>
              <Text label="Nombre del proyecto *" value={form.title} onChange={(v) => set('title', v)} placeholder="Ej: Proyecto Cu-Au en Canela" />
              <Select label="Etapa del proyecto" value={form.stage} onChange={(v) => set('stage', v)} options={opt(STAGES, STAGE_LABELS)} />
              <Select label="Tipo de activo" value={form.assetType} onChange={(v) => set('assetType', v)} options={opt(ASSET_TYPES, { TERRENO_PROYECTO: 'Terreno / Proyecto', CONCESION_MINERA: 'Concesión minera', RELAVE_RIPIO_ESCORIA: 'Relave / Ripio / Escoria', INFRAESTRUCTURA_PLANTA: 'Infraestructura / Planta' })} />
              <Select label="Tipo de oferta" value={form.offerType} onChange={(v) => set('offerType', v)} options={opt(OFFER_TYPES, OFFER_TYPE_LABELS)} />
              <ChipGroup label="Mineralización (selecciona los minerales) *" options={opt(COMMODITIES, COMMODITY_LABELS)} selected={form.commodities} onToggle={(v) => toggle('commodities', v)} />
              {form.commodities.length > 0 && (
                <Select label="Mineral principal" value={form.primaryCommodity} onChange={(v) => set('primaryCommodity', v)} options={opt(form.commodities, COMMODITY_LABELS)} />
              )}
            </>
          )}

          {step === 1 && (
            <>
              <h2 className="font-semibold text-white">Ubicación y título minero</h2>
              <Select label="País *" value={form.country} onChange={(v) => set('country', v)} options={Object.entries(COUNTRY_LABELS).map(([value, label]) => ({ value, label }))} includeEmpty={false} />
              <Text label="Región / Provincia" value={form.region} onChange={(v) => set('region', v)} />
              <Text label="Comuna / Ciudad" value={form.city} onChange={(v) => set('city', v)} />
              <Text label="Hectáreas totales" type="number" value={form.surfaceHa} onChange={(v) => set('surfaceHa', v)} />
              <Text label="Cantidad de concesiones" type="number" value={form.concessionCount} onChange={(v) => set('concessionCount', v)} />
            </>
          )}

          {step === 2 && (
            <>
              <h2 className="font-semibold text-white">Estructura del negocio</h2>
              <ChipGroup label="Estructura de la oferta *" options={opt(DEAL_TYPES, DEAL_TYPE_LABELS)} selected={form.dealTypes} onToggle={(v) => toggle('dealTypes', v)} />
              <Text label="Precio solicitado (USD)" type="number" value={form.priceUsd} onChange={(v) => set('priceUsd', v)} />
              <Area label="Motivo de la venta o asociación" value={form.sellReason} onChange={(v) => set('sellReason', v)} />
              <Text label="Razón social / Nombre del titular" value={form.sellerCompany} onChange={(v) => set('sellerCompany', v)} />
              <Text label="Sitio web" value={form.sellerWebsite} onChange={(v) => set('sellerWebsite', v)} />
              <Select label="Tipo de vendedor" value={form.sellerType} onChange={(v) => set('sellerType', v)} options={opt(SELLER_TYPES, SELLER_TYPE_LABELS)} />
            </>
          )}

          {step === 3 && (
            <>
              <h2 className="font-semibold text-white">Ficha técnica</h2>
              <Select label="Tipo de mineralización" value={form.mineralizationType} onChange={(v) => set('mineralizationType', v)} options={opt(MINERALIZATION_TYPES)} />
              <ChipGroup label="Trabajos realizados" options={opt(WORKS_OPTIONS)} selected={form.works} onToggle={(v) => toggle('works', v)} />
              <Text label="Acceso a agua" value={form.waterAccess} onChange={(v) => set('waterAccess', v)} />
              <Text label="Acceso a energía" value={form.energyAccess} onChange={(v) => set('energyAccess', v)} />
            </>
          )}

          {step === 4 && (
            <>
              <h2 className="font-semibold text-white">Narrativa y media</h2>
              <div className="flex flex-col gap-1">
                <Text label="Headline técnico" value={form.headline} onChange={(v) => set('headline', v)} />
                <button type="button" onClick={() => set('headline', generarHeadline(form))} className="self-start text-xs font-semibold text-amber-brand">✨ Generar con IA</button>
              </div>
              <div className="flex flex-col gap-1">
                <Area label="Resumen / descripción del proyecto" value={form.description} onChange={(v) => set('description', v)} />
                <button type="button" onClick={() => set('description', generarResumen(form))} className="self-start text-xs font-semibold text-amber-brand">✨ Generar con IA</button>
              </div>
              <h3 className="mt-2 text-sm font-semibold text-white">ESG (opcional)</h3>
              <Text label="Estado del estudio de impacto ambiental" value={form.esgEnvStatus} onChange={(v) => set('esgEnvStatus', v)} />
              <Text label="Plan de cierre" value={form.esgClosurePlan} onChange={(v) => set('esgClosurePlan', v)} />
              <Area label="Relación comunitaria" value={form.esgCommunity} onChange={(v) => set('esgCommunity', v)} />
              <Area label="Pasivos heredados" value={form.esgLiabilities} onChange={(v) => set('esgLiabilities', v)} />
              <p className="rounded-md border border-dashed border-white/15 p-4 text-xs text-slatey">Portada, galería de fotos y documentos: la carga de archivos llegará en una próxima fase. Por ahora la portada se genera automáticamente.</p>
            </>
          )}

          {step === 5 && (
            <>
              <h2 className="font-semibold text-white">Contacto y verificación</h2>
              <Text label="Nombre de contacto" value={form.contactName} onChange={(v) => set('contactName', v)} />
              <Text label="Rol / cargo" value={form.contactRole} onChange={(v) => set('contactRole', v)} />
              <Text label="Correo electrónico" type="email" value={form.contactEmail} onChange={(v) => set('contactEmail', v)} />
              <Text label="Teléfono" value={form.contactPhone} onChange={(v) => set('contactPhone', v)} />
              <Select label="Formato preferido de contacto" value={form.contactPreference} onChange={(v) => set('contactPreference', v)} options={opt(CONTACT_PREFERENCES, CONTACT_PREFERENCE_LABELS)} />
            </>
          )}

          {step === 6 && (
            <>
              <h2 className="font-semibold text-white">Revisión</h2>
              <p className="text-sm text-slatey">Revisa los datos clave antes de publicar.</p>
              <ul className="space-y-1 text-sm text-white">
                <li><span className="text-slatey">Nombre:</span> {form.title || '—'}</li>
                <li><span className="text-slatey">Ubicación:</span> {[form.city, form.region, COUNTRY_LABELS[form.country] ?? form.country].filter(Boolean).join(', ') || '—'}</li>
                <li><span className="text-slatey">Minerales:</span> {form.commodities.join(', ') || '—'}</li>
                <li><span className="text-slatey">Estructura:</span> {form.dealTypes.join(', ') || '—'}</li>
                <li><span className="text-slatey">Precio:</span> {form.priceUsd ? `USD ${form.priceUsd}` : 'A consultar'}</li>
              </ul>
              {errors.length > 0 && (
                <ul className="mt-2 list-inside list-disc text-sm text-red-400">
                  {errors.map((e) => <li key={e}>{e}</li>)}
                </ul>
              )}
            </>
          )}
        </div>

        <div className="mt-6 flex items-center justify-between">
          <button
            type="button"
            onClick={() => setStep((s) => Math.max(0, s - 1))}
            disabled={step === 0}
            className="rounded-md border border-white/15 px-4 py-2 text-sm text-white disabled:opacity-40"
          >
            ← Atrás
          </button>
          {step < 6 ? (
            <button type="button" onClick={() => setStep((s) => Math.min(6, s + 1))} className="rounded-md bg-amber-brand px-5 py-2 text-sm font-semibold text-night">
              Siguiente →
            </button>
          ) : (
            <button type="button" onClick={publish} disabled={submitting} className="rounded-md bg-amber-brand px-5 py-2 text-sm font-semibold text-night disabled:opacity-60">
              {submitting ? 'Publicando…' : 'Publicar'}
            </button>
          )}
        </div>
      </div>

      <aside className="flex flex-col gap-6 rounded-xl border border-white/5 bg-night-800 p-6 text-sm">
        <div>
          <p className="kicker mb-2">¿Por qué publicar?</p>
          <ul className="space-y-3">
            {WHY_PUBLISH.map(([t, d]) => (
              <li key={t}>
                <p className="font-semibold text-white">{t}</p>
                <p className="text-slatey">{d}</p>
              </li>
            ))}
          </ul>
        </div>
        <div>
          <p className="kicker mb-2">¿Qué sucede después?</p>
          <ol className="list-inside list-decimal space-y-1 text-slatey">
            {WHAT_NEXT.map((t) => <li key={t}>{t}</li>)}
          </ol>
        </div>
      </aside>
    </div>
  )
}
```

- [ ] **Step 4: `src/app/dashboard/publicar-activo/page.tsx`**

```tsx
import { redirect } from 'next/navigation'
import { getCurrentUser } from '@/lib/current-user'
import { Container } from '@/components/ui/Container'
import { PublishWizard } from '@/components/dashboard/PublishWizard'

export default async function PublicarActivoPage() {
  const user = await getCurrentUser()
  if (!user) redirect('/login?next=/dashboard/publicar-activo')
  return (
    <Container className="py-10">
      <PublishWizard />
    </Container>
  )
}
```

- [ ] **Step 5: Verificar tipos**

Run: `npx tsc --noEmit`
Expected: sin errores.

- [ ] **Step 6: Commit**

```bash
git add -A
git commit -m "feat: asistente de publicación de 7 pasos"
```

---

## Task 10: Verificación final + smoke end-to-end

**Files:** ninguno.

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

Run: `npm test`
Expected: PASS (filters, listings, auth, publish).

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

Run: `npm run build`
Expected: build exitoso. Rutas nuevas presentes: `/login`, `/registro`, `/dashboard`, `/dashboard/publicar-activo`.

- [ ] **Step 3: Humo end-to-end con un usuario real (script)**

Run:
```bash
npm run dev > /tmp/mm_dev2.log 2>&1 &
sleep 8
# Registro (sigue redirección, guarda cookies)
curl -s -c /tmp/mm_cookies.txt -b /tmp/mm_cookies.txt -L -o /dev/null -w "registro: %{http_code}\n" \
  --data-urlencode "name=Tester" --data-urlencode "email=tester@demo.cl" --data-urlencode "password=clave12345" \
  http://localhost:3000/registro
# /dashboard con cookie debe dar 200
echo "dashboard (con sesión): $(curl -s -b /tmp/mm_cookies.txt -o /dev/null -w '%{http_code}' http://localhost:3000/dashboard)"
# /dashboard sin cookie debe redirigir a /login (307/308)
echo "dashboard (sin sesión): $(curl -s -o /dev/null -w '%{http_code}' http://localhost:3000/dashboard)"
pkill -f "next dev" 2>/dev/null; pkill -f "next-server" 2>/dev/null
```
Expected: `registro` termina en 200 (tras redirección a /dashboard); `dashboard (con sesión)` = 200; `dashboard (sin sesión)` = 307 o 308 (redirección a /login).

> Nota: el registro vía `curl` sobre un server action puede requerir el encabezado correcto; si el flujo por `curl` resulta frágil, verificar el registro/login/publicación manualmente en el navegador (registrar, completar el asistente y confirmar que el activo aparece en `/marketplace`).

- [ ] **Step 4: Verificación visual (navegador)**

Levantar `npm run dev`, ir a `/registro`, crear cuenta, completar el asistente y confirmar que el nuevo activo aparece en `/marketplace` y su detalle es accesible. Confirmar que el Header muestra el nombre y "Salir".

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

```bash
git add -A
git commit -m "chore: verificación Fase 2 — auth + publicación funcionando" --allow-empty
```

---

## Resumen de cobertura del spec

| Requisito del spec | Tarea(s) |
|---|---|
| Modelos User/Session + extensión Listing | 1 |
| Constantes nuevas | 1 |
| Hash + sesiones (lib/auth, TDD) | 2 |
| getCurrentUser (cookie) | 3 |
| Server actions registro/login/logout | 4 |
| Páginas login/registro | 5 |
| Middleware protección /dashboard/* | 6 |
| Dashboard de inicio | 6 |
| Header consciente de sesión | 6 |
| buildListingInput (lib/publish, TDD) | 7 |
| createListingAction + catálogo status=PUBLISHED | 8 |
| Asistente 7 pasos + "Generar con IA" (plantilla) | 9 |
| Pruebas + build + smoke | 10 |
