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:
140
apps/landing/src/components/publico/EncabezadoDesarrollo.tsx
Normal file
140
apps/landing/src/components/publico/EncabezadoDesarrollo.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
45
apps/landing/src/components/publico/EsqueletoDesarrollo.tsx
Normal file
45
apps/landing/src/components/publico/EsqueletoDesarrollo.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
70
apps/landing/src/components/publico/EstadosDesarrollo.tsx
Normal file
70
apps/landing/src/components/publico/EstadosDesarrollo.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
36
apps/landing/src/components/publico/FranjaDatos.tsx
Normal file
36
apps/landing/src/components/publico/FranjaDatos.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
81
apps/landing/src/components/publico/PieContacto.tsx
Normal file
81
apps/landing/src/components/publico/PieContacto.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
69
apps/landing/src/components/publico/SeccionDescripcion.tsx
Normal file
69
apps/landing/src/components/publico/SeccionDescripcion.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
39
apps/landing/src/components/publico/SeccionModelos.tsx
Normal file
39
apps/landing/src/components/publico/SeccionModelos.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
318
apps/landing/src/components/publico/TablaDisponibilidad.tsx
Normal file
318
apps/landing/src/components/publico/TablaDisponibilidad.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
83
apps/landing/src/components/publico/TarjetaModelo.tsx
Normal file
83
apps/landing/src/components/publico/TarjetaModelo.tsx
Normal 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">→</span>
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
75
apps/landing/src/components/publico/contacto.ts
Normal file
75
apps/landing/src/components/publico/contacto.ts
Normal 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
|
||||
}
|
||||
91
apps/landing/src/components/publico/formato.ts
Normal file
91
apps/landing/src/components/publico/formato.ts
Normal 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()}…`
|
||||
}
|
||||
Reference in New Issue
Block a user