feat(desarrollos): modulo de alta de desarrollos con inventario, reglas del cotizador por desarrollo y micrositio publico

Modulo nuevo (solo admin) para dar de alta desarrollos sin tocar codigo:
- CRUD de la ficha, modelos e inventario (alta manual y generador por rango)
- Reglas del cotizador por desarrollo, editables y persistidas en la BD
- El cotizador consume las reglas desde /desarrollos/reglas-cotizador y cae a
  las constantes con aviso cuando un desarrollo aun no tiene reglas guardadas
- Micrositio publico en /d/:clave (sin sesion) con ficha, modelos y
  disponibilidad; jamas expone datos de clientes ni cotizaciones

Integracion:
- La landing monta el modulo en el grid (clave 'desarrollos', solo admin) y el
  micrositio fuera del login; el rol manager NO accede (matriz y API con 403)
- La tabla de roles de Configuracion ahora lista todos los modulos
- Se alinea la FK de ReglaCotizadorDesarrollo con la BD viva (Desarrollo.id)

Verificado: 708 pruebas del API, 115 del cotizador y 89 de la landing en verde;
12/12 cotizaciones identicas antes y despues del cambio de reglas; prueba en
vivo del API con alta, modelos, inventario, reglas, micrositio y baja (30/30).
This commit is contained in:
2026-09-21 09:52:47 +00:00
parent 2fc80e7f9e
commit d6548999bf
68 changed files with 10016 additions and 66 deletions

View File

@@ -0,0 +1,140 @@
import { useState } from 'react'
import { ArrowRight, CalendarDays, MapPin, MessageCircle } from 'lucide-react'
import type { EstadoDesarrollo, TipoDesarrollo } from '../../services/publico'
import { etiquetaEstado, mesAnio, normalizarTexto } from './formato'
interface Props {
nombre: string
zona?: string | null
direccion?: string | null
estado: EstadoDesarrollo
tipo: TipoDesarrollo
fechaEntregaProgramada?: string | null
imagenUrl?: string | null
logoUrl?: string | null
/** URL de WhatsApp ya armada (ver components/publico/contacto.ts) */
urlWhatsApp: string
onVerDisponibilidad: () => void
}
const ETIQUETA_TIPO: Record<TipoDesarrollo, string> = {
VERTICAL: 'Desarrollo vertical',
HORIZONTAL: 'Desarrollo horizontal',
}
const CLASE_ESTADO: Record<EstadoDesarrollo, string> = {
PREVENTA: 'border-white/30 bg-[#F59E0B]/20 text-[#FDE68A]',
ACTIVO: 'border-white/30 bg-[#10B981]/20 text-[#A7F3D0]',
CERRADO: 'border-white/25 bg-white/10 text-white/80',
}
/** Encabezado tipo hero: imagen del desarrollo, identidad, estado y CTA principal */
export function EncabezadoDesarrollo({
nombre,
zona,
direccion,
estado,
tipo,
fechaEntregaProgramada,
imagenUrl,
logoUrl,
urlWhatsApp,
onVerDisponibilidad,
}: Props) {
const [imagenRota, setImagenRota] = useState(false)
const mostrarImagen = Boolean(imagenUrl) && !imagenRota
const entrega = mesAnio(fechaEntregaProgramada)
const ubicacion = [zona, direccion].filter((v) => v && normalizarTexto(v)).join(' · ')
return (
<header className="relative isolate overflow-hidden bg-[#0B1220] text-white">
{mostrarImagen ? (
<>
<img
src={imagenUrl as string}
alt={`Vista de ${nombre}`}
onError={() => setImagenRota(true)}
className="absolute inset-0 h-full w-full object-cover object-center"
/>
<div
aria-hidden="true"
className="absolute inset-0 bg-gradient-to-t from-[#0B1220] via-[#0B1220]/85 to-[#0B1220]/45"
/>
<div
aria-hidden="true"
className="absolute inset-0 bg-gradient-to-r from-[#0B1220]/85 via-[#0B1220]/40 to-transparent"
/>
</>
) : (
<>
<div
aria-hidden="true"
className="absolute inset-0 bg-gradient-to-br from-[#0F172A] via-[#1E3A8A] to-[#2563EB]"
/>
<div
aria-hidden="true"
className="absolute inset-0 opacity-40 [background-image:radial-gradient(circle_at_20%_20%,rgba(255,255,255,0.18),transparent_55%),radial-gradient(circle_at_80%_0%,rgba(255,255,255,0.12),transparent_45%)]"
/>
</>
)}
<div className="relative mx-auto flex min-h-[540px] w-full max-w-6xl flex-col justify-end px-4 pb-14 pt-16 sm:px-6 sm:pb-20 sm:pt-24 lg:min-h-[620px] lg:px-8">
{logoUrl ? (
<img
src={logoUrl}
alt={`Logotipo de ${nombre}`}
className="mb-6 h-10 w-auto max-w-[180px] object-contain object-left sm:h-12"
/>
) : null}
<div className="flex flex-wrap items-center gap-2">
<span
className={`inline-flex items-center rounded-full border px-3 py-1 text-xs font-semibold tracking-wide uppercase ${CLASE_ESTADO[estado]}`}
>
{etiquetaEstado(estado)}
</span>
{entrega ? (
<span className="inline-flex items-center gap-1.5 rounded-full border border-white/25 bg-white/10 px-3 py-1 text-xs font-medium text-white/90 backdrop-blur-sm">
<CalendarDays aria-hidden="true" className="h-3.5 w-3.5" />
Entrega: {entrega}
</span>
) : null}
<span className="inline-flex items-center rounded-full border border-white/20 bg-white/5 px-3 py-1 text-xs font-medium text-white/75">
{ETIQUETA_TIPO[tipo]}
</span>
</div>
<h1 className="mt-5 max-w-3xl text-3xl leading-tight font-semibold tracking-tight text-white sm:text-4xl lg:text-5xl">
{nombre}
</h1>
{ubicacion ? (
<p className="mt-4 flex items-center gap-2 text-sm text-white/80 sm:text-base">
<MapPin aria-hidden="true" className="h-4 w-4 shrink-0 text-accent" />
<span>{ubicacion}</span>
</p>
) : null}
<div className="mt-8 flex flex-col gap-3 sm:flex-row sm:items-center">
<a
href={urlWhatsApp}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center justify-center gap-2 rounded-xl bg-[#25D366] px-6 py-3.5 text-sm font-semibold text-[#06281A] shadow-lg shadow-black/20 transition-colors duration-150 ease-out hover:bg-[#1FBE5A]"
>
<MessageCircle aria-hidden="true" className="h-4 w-4" />
Escríbenos por WhatsApp
</a>
<button
type="button"
onClick={onVerDisponibilidad}
className="inline-flex items-center justify-center gap-2 rounded-xl border border-white/35 bg-white/10 px-6 py-3.5 text-sm font-semibold text-white backdrop-blur-sm transition-colors duration-150 ease-out hover:bg-white/20"
>
Ver disponibilidad
<ArrowRight aria-hidden="true" className="h-4 w-4" />
</button>
</div>
</div>
</header>
)
}

View File

@@ -0,0 +1,45 @@
import Skeleton from '../Skeleton'
/** Estado de carga del micrositio: mismos bloques que la página real */
export function EsqueletoDesarrollo() {
return (
<div aria-busy="true" aria-live="polite" role="status">
<span className="sr-only">Cargando información del desarrollo…</span>
<div className="bg-[#0B1220]">
<div className="mx-auto flex min-h-[420px] w-full max-w-6xl flex-col justify-end px-4 pb-16 sm:px-6 lg:px-8">
<Skeleton className="h-6 w-28 bg-white/10" />
<Skeleton className="mt-6 h-10 w-3/4 max-w-xl bg-white/10" />
<Skeleton className="mt-4 h-4 w-1/2 max-w-sm bg-white/10" />
<Skeleton className="mt-8 h-12 w-full max-w-md bg-white/10" />
</div>
</div>
<div className="mx-auto w-full max-w-6xl px-4 sm:px-6 lg:px-8">
<div className="-mt-10 grid grid-cols-2 gap-px overflow-hidden rounded-2xl border border-[#E5E7EB] bg-[#E5E7EB] sm:-mt-12 lg:grid-cols-4 dark:border-[#1E293B] dark:bg-[#1E293B]">
{[0, 1, 2, 3].map((i) => (
<div key={i} className="bg-white p-5 sm:p-6 dark:bg-[#0F172A]">
<Skeleton className="h-3 w-24" />
<Skeleton className="mt-3 h-7 w-16" />
</div>
))}
</div>
<div className="grid gap-10 py-16 lg:grid-cols-2">
<div className="space-y-4">
<Skeleton className="h-8 w-52" />
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-11/12" />
<Skeleton className="h-4 w-4/5" />
</div>
<Skeleton className="h-48 w-full rounded-2xl" />
</div>
<div className="grid gap-5 pb-16 sm:grid-cols-2 lg:grid-cols-3">
{[0, 1, 2].map((i) => (
<Skeleton key={i} className="h-56 w-full rounded-2xl" />
))}
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,70 @@
import { AlertTriangle, Compass, RefreshCw } from 'lucide-react'
interface Props {
urlWhatsApp: string
}
/** Pantalla elegante para enlaces inválidos o desarrollos que ya no están publicados */
export function DesarrolloNoEncontrado({ urlWhatsApp }: Props) {
return (
<main className="flex min-h-screen flex-col items-center justify-center bg-[#0B1220] px-6 py-20 text-center text-white">
<span
aria-hidden="true"
className="flex h-14 w-14 items-center justify-center rounded-2xl border border-white/15 bg-white/5"
>
<Compass className="h-6 w-6 text-accent" />
</span>
<h1 className="mt-6 text-3xl font-semibold tracking-tight sm:text-4xl">
Desarrollo no encontrado
</h1>
<p className="mt-4 max-w-md text-sm leading-relaxed text-white/70">
El enlace que abriste no corresponde a un desarrollo publicado. Puede que la clave haya
cambiado o que el proyecto ya no esté disponible en línea.
</p>
<a
href={urlWhatsApp}
target="_blank"
rel="noopener noreferrer"
className="mt-8 inline-flex items-center justify-center rounded-xl bg-[#25D366] px-6 py-3.5 text-sm font-semibold text-[#06281A] shadow-lg shadow-black/30 transition-colors duration-150 ease-out hover:bg-[#1FBE5A]"
>
Preguntar por WhatsApp
</a>
<p className="mt-6 text-xs text-white/50">
Si recibiste este enlace de un asesor, pídele que lo verifique.
</p>
</main>
)
}
interface PropsError {
mensaje: string
onReintentar: () => void
}
/** Error de red o del servicio público: se puede reintentar sin recargar */
export function EstadoErrorDesarrollo({ mensaje, onReintentar }: PropsError) {
return (
<main className="flex min-h-screen flex-col items-center justify-center bg-[#F8FAFC] px-6 py-20 text-center dark:bg-[#0F172A]">
<span
aria-hidden="true"
className="flex h-14 w-14 items-center justify-center rounded-2xl border border-[#E5E7EB] bg-white shadow-sm dark:border-[#1E293B] dark:bg-[#1E293B]"
>
<AlertTriangle className="h-6 w-6 text-amber-500" />
</span>
<h1 className="mt-6 text-2xl font-semibold tracking-tight text-[#111827] sm:text-3xl dark:text-white">
No pudimos cargar el desarrollo
</h1>
<p className="mt-4 max-w-md text-sm leading-relaxed text-[#64748B] dark:text-[#94A3B8]">
{mensaje}
</p>
<button
type="button"
onClick={onReintentar}
className="mt-8 inline-flex items-center justify-center gap-2 rounded-xl bg-accent px-6 py-3.5 text-sm font-semibold text-white shadow-sm transition-colors duration-150 ease-out hover:bg-accent-hover"
>
<RefreshCw aria-hidden="true" className="h-4 w-4" />
Reintentar
</button>
</main>
)
}

View File

@@ -0,0 +1,36 @@
interface Props {
unidades: number
modelos: number
entrega: string
zona: string
}
/** Franja de datos clave con números grandes (se monta sobre el hero) */
export function FranjaDatos({ unidades, modelos, entrega, zona }: Props) {
const datos = [
{ etiqueta: 'Unidades disponibles', valor: String(unidades) },
{ etiqueta: 'Modelos', valor: String(modelos) },
{ etiqueta: 'Entrega estimada', valor: entrega },
{ etiqueta: 'Zona', valor: zona },
]
return (
<section
aria-label="Datos clave del desarrollo"
className="mx-auto w-full max-w-6xl px-4 sm:px-6 lg:px-8"
>
<dl className="-mt-10 grid grid-cols-2 gap-px overflow-hidden rounded-2xl border border-[#E5E7EB] bg-[#E5E7EB] shadow-lg shadow-black/5 sm:-mt-12 lg:grid-cols-4 dark:border-[#1E293B] dark:bg-[#1E293B] dark:shadow-black/20">
{datos.map((dato) => (
<div key={dato.etiqueta} className="bg-white p-5 sm:p-6 dark:bg-[#0F172A]">
<dt className="text-xs font-medium tracking-wide text-[#64748B] uppercase dark:text-[#94A3B8]">
{dato.etiqueta}
</dt>
<dd className="mt-2 text-2xl font-semibold tracking-tight text-[#111827] sm:text-3xl dark:text-white">
{dato.valor}
</dd>
</div>
))}
</dl>
</section>
)
}

View File

@@ -0,0 +1,81 @@
import { Mail, MessageCircle } from 'lucide-react'
import { telefonoLegible } from './contacto'
interface Props {
nombreDesarrollo: string
urlWhatsApp: string
urlCorreo: string
telefono: string
correo: string
}
/** Pie con los canales de contacto y el aviso de precios sujetos a cambio */
export function PieContacto({
nombreDesarrollo,
urlWhatsApp,
urlCorreo,
telefono,
correo,
}: Props) {
return (
<footer className="bg-[#0B1220] text-white/80">
<div className="mx-auto w-full max-w-6xl px-4 py-14 sm:px-6 sm:py-16 lg:px-8">
<div className="grid gap-10 lg:grid-cols-[1.4fr_1fr]">
<div>
<p className="text-xs font-semibold tracking-[0.18em] text-accent uppercase">
Agenda tu visita
</p>
<h2 className="mt-3 text-2xl font-semibold tracking-tight text-white sm:text-3xl">
Conoce {nombreDesarrollo} en persona
</h2>
<p className="mt-4 max-w-xl text-sm leading-relaxed text-white/70">
Nuestro equipo de ventas te acompaña en todo el proceso: disponibilidad vigente,
formas de pago, créditos y entrega.
</p>
<div className="mt-6 flex flex-col gap-3 sm:flex-row">
<a
href={urlWhatsApp}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center justify-center gap-2 rounded-xl bg-[#25D366] px-6 py-3.5 text-sm font-semibold text-[#06281A] shadow-lg shadow-black/30 transition-colors duration-150 ease-out hover:bg-[#1FBE5A]"
>
<MessageCircle aria-hidden="true" className="h-4 w-4" />
WhatsApp {telefonoLegible(telefono)}
</a>
<a
href={urlCorreo}
className="inline-flex items-center justify-center gap-2 rounded-xl border border-white/25 bg-white/5 px-6 py-3.5 text-sm font-semibold text-white transition-colors duration-150 ease-out hover:bg-white/15"
>
<Mail aria-hidden="true" className="h-4 w-4" />
Escribir un correo
</a>
</div>
<p className="mt-4 text-sm text-white/60">{correo}</p>
</div>
<div>
<h3 className="text-sm font-semibold text-white">Antes de decidir</h3>
<ul className="mt-4 space-y-3 text-sm leading-relaxed text-white/70">
<li>
Precios de lista en pesos mexicanos, sujetos a cambio sin previo aviso y sin
apartado.
</li>
<li>
La disponibilidad mostrada corresponde al inventario publicado al día de hoy y puede
cambiar.
</li>
<li>Imágenes y renders son ilustrativos y no forman parte del contrato.</li>
</ul>
</div>
</div>
<div className="mt-12 border-t border-white/10 pt-6 text-xs text-white/50">
<p>
© {new Date().getFullYear()} FRASA Desarrollos. Todos los derechos reservados.
{nombreDesarrollo ? ` · ${nombreDesarrollo}` : ''}
</p>
</div>
</div>
</footer>
)
}

View File

@@ -0,0 +1,69 @@
import { Check, Sparkles } from 'lucide-react'
import { separarAmenidades } from './formato'
interface Props {
descripcion?: string | null
amenidades?: string | null
}
/** Descripción del desarrollo a dos columnas + amenidades como chips con palomita */
export function SeccionDescripcion({ descripcion, amenidades }: Props) {
const parrafos = (descripcion ?? '')
.split(/\n+/)
.map((p) => p.trim())
.filter(Boolean)
const listaAmenidades = separarAmenidades(amenidades)
if (parrafos.length === 0 && listaAmenidades.length === 0) return null
return (
<section
aria-labelledby="titulo-descripcion"
className="mx-auto w-full max-w-6xl px-4 py-16 sm:px-6 sm:py-20 lg:px-8"
>
<div className="grid gap-10 lg:grid-cols-2 lg:gap-14">
<div>
<p className="text-xs font-semibold tracking-[0.18em] text-accent uppercase">El desarrollo</p>
<h2
id="titulo-descripcion"
className="mt-3 text-2xl font-semibold tracking-tight text-[#111827] sm:text-3xl dark:text-white"
>
Sobre el desarrollo
</h2>
<div className="mt-5 space-y-4 text-base leading-relaxed text-[#4B5563] dark:text-[#CBD5E1]">
{parrafos.length > 0 ? (
parrafos.map((parrafo, indice) => <p key={indice}>{parrafo}</p>)
) : (
<p>
Escríbenos para conocer a detalle las características, acabados y formas de pago de
este desarrollo.
</p>
)}
</div>
</div>
{listaAmenidades.length > 0 ? (
<div className="rounded-2xl border border-[#E5E7EB] bg-white p-6 shadow-sm sm:p-8 dark:border-[#1E293B] dark:bg-[#1E293B]">
<h3 className="flex items-center gap-2 text-base font-semibold text-[#111827] dark:text-white">
<Sparkles aria-hidden="true" className="h-4 w-4 text-accent" />
Amenidades
</h3>
<ul className="mt-5 grid gap-3 sm:grid-cols-2">
{listaAmenidades.map((amenidad) => (
<li key={amenidad} className="flex items-start gap-2.5 text-sm text-[#374151] dark:text-[#CBD5E1]">
<span
aria-hidden="true"
className="mt-0.5 flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-accent/10 text-accent dark:bg-accent/20"
>
<Check className="h-3.5 w-3.5" strokeWidth={3} />
</span>
<span>{amenidad}</span>
</li>
))}
</ul>
</div>
) : null}
</div>
</section>
)
}

View File

@@ -0,0 +1,39 @@
import type { ModeloPublico } from '../../services/publico'
import { TarjetaModelo } from './TarjetaModelo'
interface Props {
modelos: ModeloPublico[]
onVerUnidades: () => void
}
/** Modelos del desarrollo en tarjetas (1 → 2 → 3 columnas) */
export function SeccionModelos({ modelos, onVerUnidades }: Props) {
if (modelos.length === 0) return null
return (
<section
aria-labelledby="titulo-modelos"
className="bg-white py-16 sm:py-20 dark:bg-[#0B1220]"
>
<div className="mx-auto w-full max-w-6xl px-4 sm:px-6 lg:px-8">
<p className="text-xs font-semibold tracking-[0.18em] text-accent uppercase">Tipologías</p>
<h2
id="titulo-modelos"
className="mt-3 text-2xl font-semibold tracking-tight text-[#111827] sm:text-3xl dark:text-white"
>
Modelos disponibles
</h2>
<p className="mt-3 max-w-2xl text-base leading-relaxed text-[#64748B] dark:text-[#94A3B8]">
Compara las tipologías y elige la que mejor se adapte a ti. Los precios son de lista y
pueden variar según ubicación, nivel y acabados.
</p>
<ul className="mt-8 grid gap-5 sm:grid-cols-2 lg:grid-cols-3">
{modelos.map((modelo) => (
<TarjetaModelo key={modelo.nombre} modelo={modelo} onVerUnidades={onVerUnidades} />
))}
</ul>
</div>
</section>
)
}

View File

@@ -0,0 +1,318 @@
import { useEffect, useMemo, useState } from 'react'
import { ArrowUpDown, MessageCircle, Search, SlidersHorizontal } from 'lucide-react'
import type { UnidadPublica } from '../../services/publico'
import { moneda, monedaOTexto, normalizarTexto, superficie } from './formato'
interface Props {
unidades: UnidadPublica[]
urlWhatsApp: string
}
/** Unidades visibles antes de cada "Mostrar más" (hay desarrollos con 90+ unidades) */
const POR_PAGINA = 12
interface Rango {
id: string
desde: number
hasta: number
etiqueta: string
}
/** Tres rangos de precio derivados del inventario publicado */
function rangosDePrecio(unidades: UnidadPublica[]): Rango[] {
const precios = unidades.map((u) => u.precioLista).filter((p) => Number.isFinite(p) && p > 0)
if (precios.length === 0) return []
const minimo = Math.min(...precios)
const maximo = Math.max(...precios)
if (maximo <= minimo) return []
const paso = (maximo - minimo) / 3
return [0, 1, 2].map((i) => {
const desde = minimo + paso * i
const hasta = i === 2 ? maximo : minimo + paso * (i + 1)
return {
id: `r${i}`,
desde,
hasta,
etiqueta:
i === 0
? `Hasta ${moneda(hasta)}`
: i === 2
? `Más de ${moneda(desde)}`
: `${moneda(desde)} – ${moneda(hasta)}`,
}
})
}
const claseControl =
'h-10 w-full rounded-xl border border-[#D1D5DB] bg-white px-3 text-sm text-[#111827] transition-colors duration-150 focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/20 dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#E2E8F0]'
/** Inventario disponible: filtrable por modelo, rango de precio y código; ordenable por precio */
export function TablaDisponibilidad({ unidades, urlWhatsApp }: Props) {
const [busqueda, setBusqueda] = useState('')
const [modelo, setModelo] = useState('')
const [rango, setRango] = useState('')
const [ascendente, setAscendente] = useState(true)
const [limite, setLimite] = useState(POR_PAGINA)
const modelos = useMemo(
() => Array.from(new Set(unidades.map((u) => u.modelo))).sort((a, b) => a.localeCompare(b, 'es')),
[unidades],
)
const rangos = useMemo(() => rangosDePrecio(unidades), [unidades])
const filtradas = useMemo(() => {
const texto = normalizarTexto(busqueda)
const activo = rangos.find((r) => r.id === rango)
return unidades
.filter((u) => (modelo ? u.modelo === modelo : true))
.filter((u) => {
if (!activo) return true
if (activo.id === 'r0') return u.precioLista <= activo.hasta
if (activo.id === 'r2') return u.precioLista > activo.desde
return u.precioLista > activo.desde && u.precioLista <= activo.hasta
})
.filter((u) => (texto ? normalizarTexto(u.codigo).includes(texto) : true))
.sort((a, b) => {
const diferencia = a.precioLista - b.precioLista
const orden = diferencia !== 0 ? diferencia : a.codigo.localeCompare(b.codigo, 'es')
return ascendente ? orden : -orden
})
}, [unidades, busqueda, modelo, rango, rangos, ascendente])
// Al cambiar cualquier filtro u orden se vuelve a la primera tanda de resultados
useEffect(() => {
setLimite(POR_PAGINA)
}, [busqueda, modelo, rango, ascendente])
const visibles = filtradas.slice(0, limite)
const restantes = filtradas.length - visibles.length
const hayFiltros = Boolean(busqueda || modelo || rango)
const limpiarFiltros = () => {
setBusqueda('')
setModelo('')
setRango('')
}
return (
<section
id="disponibilidad"
aria-labelledby="titulo-disponibilidad"
className="scroll-mt-6 bg-[#F8FAFC] py-16 sm:py-20 dark:bg-[#0F172A]"
>
<div className="mx-auto w-full max-w-6xl px-4 sm:px-6 lg:px-8">
<p className="text-xs font-semibold tracking-[0.18em] text-accent uppercase">Inventario</p>
<h2
id="titulo-disponibilidad"
className="mt-3 text-2xl font-semibold tracking-tight text-[#111827] sm:text-3xl dark:text-white"
>
Disponibilidad
</h2>
<p className="mt-3 max-w-2xl text-base leading-relaxed text-[#64748B] dark:text-[#94A3B8]">
Consulta las unidades libres al día de hoy, filtra por modelo o precio y ordena por precio.
</p>
{unidades.length === 0 ? (
<p className="mt-8 rounded-2xl border border-[#E5E7EB] bg-white p-6 text-sm text-[#4B5563] shadow-sm dark:border-[#1E293B] dark:bg-[#1E293B] dark:text-[#CBD5E1]">
Por el momento no hay unidades publicadas en línea.{' '}
<a
href={urlWhatsApp}
target="_blank"
rel="noopener noreferrer"
className="font-medium text-accent hover:text-accent-hover"
>
Escríbenos por WhatsApp
</a>{' '}
y te compartimos el inventario actualizado.
</p>
) : (
<>
<div className="mt-8 flex flex-col gap-3 rounded-2xl border border-[#E5E7EB] bg-white p-4 shadow-sm sm:flex-row sm:flex-wrap sm:items-center dark:border-[#1E293B] dark:bg-[#1E293B]">
<div className="relative w-full sm:max-w-xs sm:flex-1">
<Search
aria-hidden="true"
className="pointer-events-none absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 text-[#94A3B8]"
/>
<input
type="search"
value={busqueda}
onChange={(e) => setBusqueda(e.target.value)}
placeholder="Buscar por código (ej. A-101)"
aria-label="Buscar por código"
className={`${claseControl} pl-9`}
/>
</div>
<select
value={modelo}
onChange={(e) => setModelo(e.target.value)}
aria-label="Filtrar por modelo"
className={`${claseControl} sm:w-48`}
>
<option value="">Todos los modelos</option>
{modelos.map((nombre) => (
<option key={nombre} value={nombre}>
{nombre}
</option>
))}
</select>
{rangos.length > 0 ? (
<select
value={rango}
onChange={(e) => setRango(e.target.value)}
aria-label="Rango de precio"
className={`${claseControl} sm:w-52`}
>
<option value="">Todos los precios</option>
{rangos.map((r) => (
<option key={r.id} value={r.id}>
{r.etiqueta}
</option>
))}
</select>
) : null}
<button
type="button"
onClick={() => setAscendente((valor) => !valor)}
className="inline-flex h-10 items-center justify-center gap-2 rounded-xl border border-[#D1D5DB] bg-white px-4 text-sm font-medium text-[#374151] transition-colors duration-150 ease-out hover:border-accent hover:text-accent dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#CBD5E1]"
>
<ArrowUpDown aria-hidden="true" className="h-4 w-4" />
Precio: {ascendente ? 'menor a mayor' : 'mayor a menor'}
</button>
{hayFiltros ? (
<button
type="button"
onClick={limpiarFiltros}
className="h-10 text-sm font-medium text-[#64748B] transition-colors duration-150 hover:text-accent dark:text-[#94A3B8]"
>
Limpiar filtros
</button>
) : null}
</div>
<p className="mt-4 text-sm text-[#64748B] dark:text-[#94A3B8]" aria-live="polite">
{filtradas.length} {filtradas.length === 1 ? 'unidad' : 'unidades'}
{hayFiltros ? ` de ${unidades.length}` : ''}
</p>
{filtradas.length === 0 ? (
<div className="mt-4 rounded-2xl border border-[#E5E7EB] bg-white p-8 text-center shadow-sm dark:border-[#1E293B] dark:bg-[#1E293B]">
<SlidersHorizontal aria-hidden="true" className="mx-auto h-6 w-6 text-[#94A3B8]" />
<p className="mt-3 text-sm text-[#4B5563] dark:text-[#CBD5E1]">
No hay unidades que coincidan con tu búsqueda.
</p>
<button
type="button"
onClick={limpiarFiltros}
className="mt-4 text-sm font-medium text-accent transition-colors duration-150 hover:text-accent-hover"
>
Limpiar filtros
</button>
</div>
) : (
<div className="mt-4 overflow-hidden rounded-2xl border border-[#E5E7EB] bg-white shadow-sm dark:border-[#1E293B] dark:bg-[#1E293B]">
<table className="w-full border-collapse text-left text-sm">
<caption className="sr-only">Unidades disponibles con precio de lista</caption>
<thead className="hidden bg-[#F8FAFC] text-xs tracking-wide text-[#64748B] uppercase md:table-header-group dark:bg-[#0F172A] dark:text-[#94A3B8]">
<tr>
<th scope="col" className="px-5 py-3 font-semibold">Código</th>
<th scope="col" className="px-5 py-3 font-semibold">Modelo</th>
<th scope="col" className="px-5 py-3 font-semibold">Nivel</th>
<th scope="col" className="px-5 py-3 font-semibold">Superficie</th>
<th scope="col" className="px-5 py-3 text-right font-semibold">Precio de lista</th>
<th scope="col" className="px-5 py-3 text-right font-semibold">Con acabados</th>
</tr>
</thead>
<tbody>
{visibles.map((unidad) => (
<tr
key={unidad.codigo}
className="grid grid-cols-2 gap-x-4 gap-y-3 border-b border-[#E5E7EB] p-4 transition-colors duration-100 last:border-b-0 hover:bg-[#F8FAFC] md:table-row md:p-0 dark:border-[#334155] dark:hover:bg-[#0F172A]"
>
<td className="col-span-2 md:table-cell md:px-5 md:py-3.5">
<span className="block text-[11px] font-semibold tracking-wide text-[#94A3B8] uppercase md:hidden">
Código
</span>
<span
data-testid="codigo-unidad"
className="font-semibold text-[#111827] dark:text-white"
>
{unidad.codigo}
</span>
</td>
<td className="md:table-cell md:px-5 md:py-3.5">
<span className="block text-[11px] font-semibold tracking-wide text-[#94A3B8] uppercase md:hidden">
Modelo
</span>
<span className="text-[#374151] dark:text-[#CBD5E1]">{unidad.modelo}</span>
</td>
<td className="md:table-cell md:px-5 md:py-3.5">
<span className="block text-[11px] font-semibold tracking-wide text-[#94A3B8] uppercase md:hidden">
Nivel
</span>
<span className="text-[#374151] dark:text-[#CBD5E1]">{unidad.nivel ?? '—'}</span>
</td>
<td className="md:table-cell md:px-5 md:py-3.5">
<span className="block text-[11px] font-semibold tracking-wide text-[#94A3B8] uppercase md:hidden">
Superficie
</span>
<span className="text-[#374151] dark:text-[#CBD5E1]">
{superficie(unidad.supConstruccion) ?? '—'}
</span>
</td>
<td className="md:table-cell md:px-5 md:py-3.5 md:text-right">
<span className="block text-[11px] font-semibold tracking-wide text-[#94A3B8] uppercase md:hidden">
Precio de lista
</span>
<span className="font-medium text-[#111827] dark:text-white">
{monedaOTexto(unidad.precioLista)}
</span>
</td>
<td className="md:table-cell md:px-5 md:py-3.5 md:text-right">
<span className="block text-[11px] font-semibold tracking-wide text-[#94A3B8] uppercase md:hidden">
Con acabados
</span>
<span className="text-[#374151] dark:text-[#CBD5E1]">
{monedaOTexto(unidad.precioAcabados)}
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{restantes > 0 ? (
<div className="mt-6 flex justify-center">
<button
type="button"
onClick={() => setLimite((valor) => valor + POR_PAGINA)}
className="inline-flex items-center justify-center rounded-xl border border-[#D1D5DB] bg-white px-6 py-3 text-sm font-semibold text-[#374151] shadow-sm transition-colors duration-150 ease-out hover:border-accent hover:text-accent dark:border-[#334155] dark:bg-[#1E293B] dark:text-[#CBD5E1]"
>
Mostrar más unidades ({restantes} restantes)
</button>
</div>
) : null}
<p className="mt-8 flex flex-wrap items-center gap-2 text-sm text-[#64748B] dark:text-[#94A3B8]">
¿Buscas algo en específico?
<a
href={urlWhatsApp}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1.5 font-medium text-accent transition-colors duration-150 hover:text-accent-hover"
>
<MessageCircle aria-hidden="true" className="h-4 w-4" />
Pregunta por disponibilidad en tiempo real
</a>
</p>
</>
)}
</div>
</section>
)
}

View File

@@ -0,0 +1,83 @@
import { Bath, BedDouble, Car, Ruler } from 'lucide-react'
import type { ModeloPublico } from '../../services/publico'
import { moneda, superficie } from './formato'
interface Props {
modelo: ModeloPublico
onVerUnidades?: () => void
}
/** Tarjeta de un modelo con sus características, precio desde y disponibilidad */
export function TarjetaModelo({ modelo, onVerUnidades }: Props) {
const agotado = modelo.disponibles <= 0
const desde = moneda(modelo.precioDesde)
const sup = superficie(modelo.supConstruccion)
const caracteristicas = [
{ icono: BedDouble, etiqueta: 'Recámaras', valor: modelo.recamaras, sufijo: 'rec.' },
{ icono: Bath, etiqueta: 'Baños', valor: modelo.banos, sufijo: 'baños' },
{ icono: Car, etiqueta: 'Estacionamientos', valor: modelo.estacionamientos, sufijo: 'auto' },
].filter((c) => c.valor !== null && c.valor !== undefined)
return (
<li
className={`flex flex-col rounded-2xl border border-[#E5E7EB] bg-white p-6 shadow-sm transition-all duration-200 ease-out hover:shadow-md dark:border-[#1E293B] dark:bg-[#1E293B] ${
agotado ? 'opacity-60 hover:shadow-sm' : 'hover:border-accent/40'
}`}
>
<div className="flex items-start justify-between gap-3">
<h3 className="text-lg font-semibold tracking-tight text-[#111827] dark:text-white">
{modelo.nombre}
</h3>
{agotado ? (
<span className="shrink-0 rounded-full border border-[#E5E7EB] bg-[#F8FAFC] px-2.5 py-0.5 text-xs font-semibold text-[#64748B] dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#94A3B8]">
Agotado
</span>
) : (
<span className="shrink-0 rounded-full bg-emerald-50 px-2.5 py-0.5 text-xs font-semibold text-emerald-700 dark:bg-emerald-950 dark:text-emerald-300">
{modelo.disponibles} {modelo.disponibles === 1 ? 'disponible' : 'disponibles'}
</span>
)}
</div>
<dl className="mt-5 grid grid-cols-2 gap-3 text-sm text-[#4B5563] dark:text-[#CBD5E1]">
{caracteristicas.map(({ icono: Icono, etiqueta, valor, sufijo }) => (
<div key={etiqueta} className="flex items-center gap-2">
<Icono aria-hidden="true" className="h-4 w-4 shrink-0 text-accent" />
<dt className="sr-only">{etiqueta}</dt>
<dd>
{valor} {sufijo === 'auto' && valor === 1 ? 'auto' : sufijo}
</dd>
</div>
))}
{sup ? (
<div className="flex items-center gap-2">
<Ruler aria-hidden="true" className="h-4 w-4 shrink-0 text-accent" />
<dt className="sr-only">Superficie</dt>
<dd>{sup}</dd>
</div>
) : null}
</dl>
<div className="mt-6 flex items-end justify-between gap-3 border-t border-[#F1F5F9] pt-5 dark:border-[#334155]">
<div>
<p className="text-xs font-medium tracking-wide text-[#64748B] uppercase dark:text-[#94A3B8]">
Desde
</p>
<p className="mt-1 text-xl font-semibold tracking-tight text-[#111827] dark:text-white">
{desde ?? 'Precio a consultar'}
</p>
</div>
{onVerUnidades ? (
<button
type="button"
onClick={onVerUnidades}
className="text-sm font-medium text-accent transition-colors duration-150 ease-out hover:text-accent-hover"
>
Ver unidades <span aria-hidden="true">&rarr;</span>
</button>
) : null}
</div>
</li>
)
}

View File

@@ -0,0 +1,75 @@
// ─────────────────────────────────────────────────────────────────────
// CONTACTO DEL MICROSITIO PÚBLICO ⚠️ CAMBIAR AQUÍ
// Este es el único lugar donde se configura a dónde llegan los prospectos
// que entran al micrositio de un desarrollo (/d/:clave).
// ─────────────────────────────────────────────────────────────────────
/**
* WhatsApp del equipo de ventas que recibe los mensajes del micrositio.
* Formato: 10 dígitos (se agrega la lada de México +52 automáticamente) o el
* número completo con lada de país (p. ej. '5213312345678').
* 🔧 Cambiar por el número real de ventas antes de publicar.
*/
export const WHATSAPP_CONTACTO = '3312345678'
/**
* Correo de contacto mostrado en el pie. 🔧 Reemplazar por el buzón real
* (placeholder de ejemplo hasta que el cliente lo confirme).
*/
export const CORREO_CONTACTO = 'contacto@frasadesarrollos.mx'
/**
* Normaliza un teléfono a dígitos para enlaces wa.me/tel:. Si el número no trae
* lada internacional (10 dígitos), asume México (+52); si ya la trae, la respeta.
*/
export function telefonoParaEnlace(telefono: string): string {
const digitos = telefono.replace(/\D/g, '')
if (digitos.length === 10) return `52${digitos}`
return digitos
}
/** Mensaje prellenado del prospecto: "Hola, me interesa <desarrollo>…" */
export function mensajeInteres(nombreDesarrollo: string): string {
return `Hola, me interesa ${nombreDesarrollo}. ¿Me pueden enviar precios, disponibilidad y modelos?`
}
/** URL de WhatsApp con un mensaje libre prellenado */
export function urlWhatsAppConsulta(
mensaje: string,
telefono: string = WHATSAPP_CONTACTO,
): string {
const texto = encodeURIComponent(mensaje)
return `https://wa.me/${telefonoParaEnlace(telefono)}?text=${texto}`
}
/** URL de WhatsApp con el mensaje de interés prellenado */
export function urlWhatsAppDesarrollo(
nombreDesarrollo: string,
telefono: string = WHATSAPP_CONTACTO,
): string {
return urlWhatsAppConsulta(mensajeInteres(nombreDesarrollo), telefono)
}
/** Enlace mailto con el mismo mensaje de interés */
export function urlCorreoDesarrollo(
nombreDesarrollo: string,
correo: string = CORREO_CONTACTO,
): string {
const asunto = encodeURIComponent(`Información de ${nombreDesarrollo}`)
const cuerpo = encodeURIComponent(mensajeInteres(nombreDesarrollo))
return `mailto:${correo}?subject=${asunto}&body=${cuerpo}`
}
/** Teléfono en formato legible para mostrar: "33 1234 5678" */
export function telefonoLegible(telefono: string): string {
const digitos = telefonoPrefijoLocal(telefono)
if (digitos.length === 10) return `${digitos.slice(0, 2)} ${digitos.slice(2, 6)} ${digitos.slice(6)}`
return digitos
}
function telefonoPrefijoLocal(telefono: string): string {
const digitos = telefono.replace(/\D/g, '')
if (digitos.length === 12 && digitos.startsWith('52')) return digitos.slice(2)
if (digitos.length === 13 && digitos.startsWith('521')) return digitos.slice(3)
return digitos
}

View File

@@ -0,0 +1,91 @@
// Formateo compartido por el micrositio público (es-MX, moneda MXN).
const MESES = [
'Enero',
'Febrero',
'Marzo',
'Abril',
'Mayo',
'Junio',
'Julio',
'Agosto',
'Septiembre',
'Octubre',
'Noviembre',
'Diciembre',
]
const formatoMonedaMXN = new Intl.NumberFormat('es-MX', {
style: 'currency',
currency: 'MXN',
maximumFractionDigits: 0,
})
/** "$2,500,000" o null cuando no hay precio */
export function moneda(valor: number | null | undefined): string | null {
if (valor === null || valor === undefined || !Number.isFinite(valor)) return null
return formatoMonedaMXN.format(valor)
}
/** Igual que `moneda` pero con guión cuando falta el dato (para tablas) */
export function monedaOTexto(valor: number | null | undefined, reemplazo = '—'): string {
return moneda(valor) ?? reemplazo
}
/** Superficie en metros cuadrados: "85 m²" */
export function superficie(valor: number | null | undefined): string | null {
if (valor === null || valor === undefined || !Number.isFinite(valor)) return null
const numero = new Intl.NumberFormat('es-MX', { maximumFractionDigits: 2 }).format(valor)
return `${numero} m²`
}
/** "2026-09-30" o ISO completo → "Septiembre 2026" (null si no se puede leer) */
export function mesAnio(fecha: string | null | undefined): string | null {
if (!fecha) return null
const coincidencia = /^(\d{4})-(\d{2})/.exec(fecha.trim())
let anio: number
let mes: number
if (coincidencia) {
anio = Number(coincidencia[1])
mes = Number(coincidencia[2])
} else {
const interpretada = new Date(fecha)
if (Number.isNaN(interpretada.getTime())) return null
anio = interpretada.getFullYear()
mes = interpretada.getMonth() + 1
}
if (mes < 1 || mes > 12) return null
return `${MESES[mes - 1]} ${anio}`
}
/** "Preventa" | "Disponible" | "Cerrado" */
export function etiquetaEstado(estado: 'ACTIVO' | 'PREVENTA' | 'CERRADO'): string {
if (estado === 'PREVENTA') return 'Preventa'
if (estado === 'CERRADO') return 'Cerrado'
return 'Disponible'
}
/** Sin acentos y en minúsculas: búsquedas tolerantes ("Metropolitano" ≈ "metropólitano") */
export function normalizarTexto(texto: string): string {
return texto
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.toLowerCase()
.trim()
}
/** "Alberca, Gimnasio, , Coworking" → ['Alberca', 'Gimnasio', 'Coworking'] */
export function separarAmenidades(amenidades: string | null | undefined): string[] {
if (!amenidades) return []
return amenidades
.split(',')
.map((a) => a.trim())
.filter((a) => a.length > 0)
}
/** Texto plano para meta descripción: sin saltos de línea ni espacios repetidos */
export function resumenPlano(texto: string | null | undefined, maximo = 155): string {
const plano = (texto ?? '').replace(/\s+/g, ' ').trim()
if (plano.length <= maximo) return plano
return `${plano.slice(0, maximo - 1).trimEnd()}…`
}