Compare commits
7 Commits
df700bcbfb
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ff047cac2e | ||
|
|
d783ce56d4 | ||
|
|
ba7b10a778 | ||
|
|
b4e8f6d3a3 | ||
|
|
dbef9ef670 | ||
|
|
facf3cd79f | ||
|
|
5afda5af05 |
@@ -327,9 +327,15 @@ Somos los únicos en España que combinamos **captura instantánea del lead + ll
|
|||||||
- **Título del paso:** ¿Cómo prefieres contarnos tu reforma, [Nombre]?
|
- **Título del paso:** ¿Cómo prefieres contarnos tu reforma, [Nombre]?
|
||||||
- **Subtitle:** Tú eliges. Por cualquiera de las tres nos das lo que necesitamos para preparar tu render y tu presupuesto.
|
- **Subtitle:** Tú eliges. Por cualquiera de las tres nos das lo que necesitamos para preparar tu render y tu presupuesto.
|
||||||
|
|
||||||
|
- **Stepper de progreso (encima del título):**
|
||||||
|
- Paso 1 (completado): *Tus datos*
|
||||||
|
- Paso 2 (actual): *Tu reforma*
|
||||||
|
- Paso 3 (pendiente): *Render + presupuesto*
|
||||||
|
|
||||||
- **Tarjeta Llamada — título:** Que te llamemos
|
- **Tarjeta Llamada — título:** Que te llamemos
|
||||||
**Descripción:** Un asistente te llama y te hace unas preguntas rápidas. Lo más cómodo: no escribes nada.
|
**Descripción:** Un asistente te llama y te hace unas preguntas rápidas. Lo más cómodo: no escribes nada.
|
||||||
**CTA:** Quiero que me llamen
|
**CTA:** Quiero que me llamen
|
||||||
|
**Badge:** La más rápida
|
||||||
- **Tarjeta WhatsApp — título:** Por WhatsApp
|
- **Tarjeta WhatsApp — título:** Por WhatsApp
|
||||||
**Descripción:** Seguimos por chat a tu ritmo. Puedes mandar fotos y notas cuando quieras.
|
**Descripción:** Seguimos por chat a tu ritmo. Puedes mandar fotos y notas cuando quieras.
|
||||||
**CTA:** Seguir por WhatsApp
|
**CTA:** Seguir por WhatsApp
|
||||||
@@ -337,6 +343,19 @@ Somos los únicos en España que combinamos **captura instantánea del lead + ll
|
|||||||
**Descripción:** Tú lo cuentas zona por zona y subes las fotos. Recibes el presupuesto al instante.
|
**Descripción:** Tú lo cuentas zona por zona y subes las fotos. Recibes el presupuesto al instante.
|
||||||
**CTA:** Rellenar el formulario
|
**CTA:** Rellenar el formulario
|
||||||
|
|
||||||
|
#### Bloque "Qué pasa después" (debajo de las tarjetas del chooser)
|
||||||
|
|
||||||
|
> Recuerda al lead lo que va a recibir elija el canal que elija: personalización, render con
|
||||||
|
> imágenes en minutos, y visita gratuita posterior para el presupuesto definitivo.
|
||||||
|
|
||||||
|
- **Título:** Elijas lo que elijas, esto es lo que pasa después
|
||||||
|
- **Paso 1 — título:** Nos cuentas tu reforma a tu manera
|
||||||
|
**Body:** Fotos, medidas, gustos. Cuanto más nos cuentes, más se parecerá el resultado a lo que tienes en la cabeza.
|
||||||
|
- **Paso 2 — título:** Render + presupuesto en minutos
|
||||||
|
**Body:** Ves tu espacio ya reformado en imágenes, con un presupuesto orientativo desglosado partida a partida.
|
||||||
|
- **Paso 3 — título:** Visita gratuita para el presupuesto final
|
||||||
|
**Body:** Si te convence, acuerdas una visita con [Reformista]: ve la obra, la mide con detalle y te confirma el precio definitivo. Sin compromiso.
|
||||||
|
|
||||||
### Paso 2 (canal llamada)
|
### Paso 2 (canal llamada)
|
||||||
|
|
||||||
- **Título del paso:** Te llamamos cuando quieras
|
- **Título del paso:** Te llamamos cuando quieras
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
BIN
mvp/b2c/public/whatsapp.png
Normal file
BIN
mvp/b2c/public/whatsapp.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.8 MiB |
@@ -46,7 +46,7 @@ export default async function FunnelPage({ params }: { params: Promise<{ slug: s
|
|||||||
className={theme.heading === 'serif' ? 'theme-serif' : undefined}
|
className={theme.heading === 'serif' ? 'theme-serif' : undefined}
|
||||||
style={themeStyle(tenant.themePreset, tenant.themeColor)}
|
style={themeStyle(tenant.themePreset, tenant.themeColor)}
|
||||||
>
|
>
|
||||||
<TenantBrand nombreEmpresa={tenant.nombreEmpresa} logoUrl={tenant.logoUrl} showLogin />
|
<TenantBrand nombreEmpresa={tenant.nombreEmpresa} logoUrl={tenant.logoUrl} />
|
||||||
<main id="main-content">
|
<main id="main-content">
|
||||||
<Hero slug={tenant.slug} />
|
<Hero slug={tenant.slug} />
|
||||||
<ReformaSlider />
|
<ReformaSlider />
|
||||||
|
|||||||
@@ -58,6 +58,20 @@
|
|||||||
--transition-fast: 150ms ease;
|
--transition-fast: 150ms ease;
|
||||||
--transition-base: 250ms ease;
|
--transition-base: 250ms ease;
|
||||||
--transition-slow: 400ms ease;
|
--transition-slow: 400ms ease;
|
||||||
|
|
||||||
|
/* Animations (usar con motion-safe: para respetar prefers-reduced-motion) */
|
||||||
|
--animate-fade-up: fade-up 0.7s cubic-bezier(0.16, 1, 0.3, 1) both;
|
||||||
|
|
||||||
|
@keyframes fade-up {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(14px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
|
|||||||
@@ -1,82 +1,295 @@
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { notFound } from 'next/navigation';
|
import { notFound } from 'next/navigation';
|
||||||
import { getPublicLead } from '@/lib/funnel/public-queries';
|
import { getPublicLead } from '@/lib/funnel/public-queries';
|
||||||
|
import { resolveTheme, themeStyle } from '@/lib/funnel/themes';
|
||||||
import TenantBrand from '@/components/funnel/TenantBrand';
|
import TenantBrand from '@/components/funnel/TenantBrand';
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic';
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
|
const BRAND = 'var(--brand, #0a0a0a)';
|
||||||
|
const BRAND_CONTRAST = 'var(--brand-contrast, #ffffff)';
|
||||||
|
|
||||||
|
function IconLlamada() {
|
||||||
|
return (
|
||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||||
|
<path
|
||||||
|
d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7A2 2 0 0 1 22 16.92z"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function IconWhatsapp() {
|
||||||
|
return (
|
||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||||
|
<path
|
||||||
|
d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function IconFormulario() {
|
||||||
|
return (
|
||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||||
|
<path
|
||||||
|
d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M14 2v6h6M16 13H8M16 17H8M10 9H8"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const CANALES = [
|
const CANALES = [
|
||||||
{
|
{
|
||||||
slug: 'llamada',
|
slug: 'llamada',
|
||||||
icon: '📞',
|
icon: <IconLlamada />,
|
||||||
titulo: 'Que te llamemos',
|
titulo: 'Que te llamemos',
|
||||||
descripcion:
|
descripcion:
|
||||||
'Un asistente te llama y te hace unas preguntas rápidas. Lo más cómodo: no escribes nada.',
|
'Un asistente te llama y te hace unas preguntas rápidas. Lo más cómodo: no escribes nada.',
|
||||||
cta: 'Quiero que me llamen',
|
cta: 'Quiero que me llamen',
|
||||||
|
badge: 'La más rápida',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
slug: 'whatsapp',
|
slug: 'whatsapp',
|
||||||
icon: '💬',
|
icon: <IconWhatsapp />,
|
||||||
titulo: 'Por WhatsApp',
|
titulo: 'Por WhatsApp',
|
||||||
descripcion:
|
descripcion: 'Seguimos por chat a tu ritmo. Puedes mandar fotos y notas cuando quieras.',
|
||||||
'Seguimos por chat a tu ritmo. Puedes mandar fotos y notas cuando quieras.',
|
|
||||||
cta: 'Seguir por WhatsApp',
|
cta: 'Seguir por WhatsApp',
|
||||||
|
badge: null,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
slug: 'formulario',
|
slug: 'formulario',
|
||||||
icon: '📝',
|
icon: <IconFormulario />,
|
||||||
titulo: 'Rellenar un formulario',
|
titulo: 'Rellenar un formulario',
|
||||||
descripcion:
|
descripcion:
|
||||||
'Tú lo cuentas zona por zona y subes las fotos. Recibes el presupuesto al instante.',
|
'Tú lo cuentas zona por zona y subes las fotos. Recibes el presupuesto al instante.',
|
||||||
cta: 'Rellenar el formulario',
|
cta: 'Rellenar el formulario',
|
||||||
|
badge: null,
|
||||||
},
|
},
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
|
const PASOS_DESPUES = [
|
||||||
|
{
|
||||||
|
titulo: 'Nos cuentas tu reforma a tu manera',
|
||||||
|
body: 'Fotos, medidas, gustos. Cuanto más nos cuentes, más se parecerá el resultado a lo que tienes en la cabeza.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
titulo: 'Render + presupuesto en minutos',
|
||||||
|
body: 'Ves tu espacio ya reformado en imágenes, con un presupuesto orientativo desglosado partida a partida.',
|
||||||
|
},
|
||||||
|
// El tercer paso interpola el nombre del reformista; se monta en el componente.
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
function Stepper() {
|
||||||
|
const conectores = 'h-px flex-1 min-w-4';
|
||||||
|
return (
|
||||||
|
<ol className="flex items-center gap-2.5 sm:gap-3" aria-label="Progreso de tu solicitud">
|
||||||
|
<li className="flex items-center gap-2 shrink-0">
|
||||||
|
<span
|
||||||
|
className="w-6 h-6 rounded-full flex items-center justify-center shrink-0"
|
||||||
|
style={{ backgroundColor: BRAND, color: BRAND_CONTRAST }}
|
||||||
|
>
|
||||||
|
<svg width="12" height="12" viewBox="0 0 32 32" fill="none" aria-hidden="true">
|
||||||
|
<path
|
||||||
|
d="M6 16l7 7L26 9"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="4"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
<span className="hidden sm:inline text-xs font-semibold text-gray-600">Tus datos</span>
|
||||||
|
</li>
|
||||||
|
<li aria-hidden="true" className={conectores} style={{ backgroundColor: BRAND }} />
|
||||||
|
<li className="flex items-center gap-2 shrink-0" aria-current="step">
|
||||||
|
<span
|
||||||
|
className="w-6 h-6 rounded-full text-[11px] font-black flex items-center justify-center shrink-0"
|
||||||
|
style={{ backgroundColor: BRAND, color: BRAND_CONTRAST }}
|
||||||
|
>
|
||||||
|
2
|
||||||
|
</span>
|
||||||
|
<span className="text-xs font-bold text-black">Tu reforma</span>
|
||||||
|
</li>
|
||||||
|
<li aria-hidden="true" className={`${conectores} bg-gray-200`} />
|
||||||
|
<li className="flex items-center gap-2 shrink-0">
|
||||||
|
<span className="w-6 h-6 rounded-full bg-white border border-gray-300 text-[11px] font-bold text-gray-400 flex items-center justify-center shrink-0">
|
||||||
|
3
|
||||||
|
</span>
|
||||||
|
<span className="hidden sm:inline text-xs font-semibold text-gray-400">
|
||||||
|
Render + presupuesto
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default async function ChooserPage({ params }: { params: Promise<{ id: string }> }) {
|
export default async function ChooserPage({ params }: { params: Promise<{ id: string }> }) {
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
const data = await getPublicLead(id);
|
const data = await getPublicLead(id);
|
||||||
if (!data) notFound();
|
if (!data) notFound();
|
||||||
|
|
||||||
const { lead, tenant } = data;
|
const { lead, tenant } = data;
|
||||||
|
const theme = resolveTheme(tenant?.themePreset, tenant?.themeColor);
|
||||||
|
const nombrePila = lead.nombre.split(' ')[0];
|
||||||
|
const nombreReformista = tenant?.nombreEmpresa ?? 'el reformista';
|
||||||
|
|
||||||
|
const pasosDespues = [
|
||||||
|
...PASOS_DESPUES,
|
||||||
|
{
|
||||||
|
titulo: 'Visita gratuita para el presupuesto final',
|
||||||
|
body: `Si te convence, acuerdas una visita con ${nombreReformista}: ve la obra, la mide con detalle y te confirma el precio definitivo. Sin compromiso.`,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<div
|
||||||
|
className={theme.heading === 'serif' ? 'theme-serif' : undefined}
|
||||||
|
style={themeStyle(tenant?.themePreset, tenant?.themeColor)}
|
||||||
|
>
|
||||||
{tenant && <TenantBrand nombreEmpresa={tenant.nombreEmpresa} logoUrl={tenant.logoUrl} />}
|
{tenant && <TenantBrand nombreEmpresa={tenant.nombreEmpresa} logoUrl={tenant.logoUrl} />}
|
||||||
<div className="container py-10 max-w-2xl flex flex-col gap-6">
|
|
||||||
<div className="flex flex-col gap-2">
|
<div className="relative overflow-hidden">
|
||||||
|
{/* Halo sutil con el color de marca del reformista */}
|
||||||
|
<div
|
||||||
|
aria-hidden="true"
|
||||||
|
className="absolute inset-x-0 top-0 h-80 pointer-events-none"
|
||||||
|
style={{
|
||||||
|
background: `radial-gradient(60% 100% at 50% 0%, color-mix(in srgb, ${BRAND} 8%, transparent), transparent 70%)`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="container relative max-w-4xl py-10 md:py-14 flex flex-col gap-8 md:gap-10">
|
||||||
|
<div className="motion-safe:animate-fade-up">
|
||||||
|
<Stepper />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<header
|
||||||
|
className="flex flex-col gap-3 max-w-2xl motion-safe:animate-fade-up"
|
||||||
|
style={{ animationDelay: '80ms' }}
|
||||||
|
>
|
||||||
<span className="text-xs font-semibold uppercase tracking-widest text-gray-400">
|
<span className="text-xs font-semibold uppercase tracking-widest text-gray-400">
|
||||||
Elige cómo seguir
|
Elige cómo seguir
|
||||||
</span>
|
</span>
|
||||||
<h1 className="text-2xl font-black tracking-tight text-black">
|
<h1 className="text-3xl md:text-4xl font-black tracking-tight text-black leading-[1.1] text-balance">
|
||||||
¿Cómo prefieres contarnos tu reforma, {lead.nombre.split(' ')[0]}?
|
¿Cómo prefieres contarnos tu reforma, {nombrePila}?
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-sm text-gray-500 leading-relaxed">
|
<p className="text-sm md:text-base text-gray-500 leading-relaxed">
|
||||||
Tú eliges. Por cualquiera de las tres nos das lo que necesitamos para preparar tu render
|
Tú eliges. Por cualquiera de las tres nos das lo que necesitamos para preparar tu
|
||||||
y tu presupuesto.
|
render y tu presupuesto.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</header>
|
||||||
|
|
||||||
<div className="flex flex-col gap-3">
|
<div className="grid gap-3 md:grid-cols-3 md:gap-4">
|
||||||
{CANALES.map((c) => (
|
{CANALES.map((c, i) => (
|
||||||
<Link
|
<Link
|
||||||
key={c.slug}
|
key={c.slug}
|
||||||
href={`/solicitud/${id}/${c.slug}`}
|
href={`/solicitud/${id}/${c.slug}`}
|
||||||
className="group bg-white border border-gray-200 rounded-xl p-5 shadow-sm flex items-center gap-4 transition-all hover:border-black hover:shadow-md"
|
className="group relative flex items-start gap-4 md:flex-col md:gap-5 bg-white border border-gray-200 rounded-2xl p-5 md:p-6 shadow-sm transition-all duration-250 hover:-translate-y-0.5 hover:border-[color:var(--brand,#0a0a0a)] hover:shadow-[0_16px_40px_-12px_rgba(0,0,0,0.18)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--brand,#0a0a0a)] focus-visible:ring-offset-2 motion-safe:animate-fade-up"
|
||||||
|
style={{ animationDelay: `${160 + i * 80}ms` }}
|
||||||
|
>
|
||||||
|
{c.badge && (
|
||||||
|
<span
|
||||||
|
className="absolute top-0 right-5 -translate-y-1/2 px-2.5 py-1 rounded-full text-[10px] font-bold uppercase tracking-widest"
|
||||||
|
style={{ backgroundColor: BRAND, color: BRAND_CONTRAST }}
|
||||||
|
>
|
||||||
|
{c.badge}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span
|
||||||
|
className="w-12 h-12 rounded-xl flex items-center justify-center shrink-0 transition-transform duration-250 group-hover:scale-105"
|
||||||
|
style={{ backgroundColor: BRAND, color: BRAND_CONTRAST }}
|
||||||
|
aria-hidden="true"
|
||||||
>
|
>
|
||||||
<span className="text-3xl shrink-0" aria-hidden="true">
|
|
||||||
{c.icon}
|
{c.icon}
|
||||||
</span>
|
</span>
|
||||||
<div className="flex flex-col gap-0.5 min-w-0">
|
<span className="flex flex-col gap-1.5 min-w-0 flex-1">
|
||||||
<span className="text-base font-bold text-black">{c.titulo}</span>
|
<h2 className="text-lg font-black tracking-tight text-black leading-snug">
|
||||||
<span className="text-sm text-gray-500 leading-snug">{c.descripcion}</span>
|
{c.titulo}
|
||||||
<span className="text-sm font-semibold text-[color:var(--brand,#0a0a0a)] mt-1">
|
</h2>
|
||||||
{c.cta} →
|
<span className="text-sm text-gray-500 leading-relaxed">{c.descripcion}</span>
|
||||||
|
<span
|
||||||
|
className="flex items-center gap-1.5 text-sm font-bold mt-2 md:mt-auto md:pt-4"
|
||||||
|
style={{ color: BRAND }}
|
||||||
|
>
|
||||||
|
{c.cta}
|
||||||
|
<svg
|
||||||
|
width="15"
|
||||||
|
height="15"
|
||||||
|
viewBox="0 0 16 16"
|
||||||
|
fill="none"
|
||||||
|
aria-hidden="true"
|
||||||
|
className="transition-transform duration-250 group-hover:translate-x-1"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M2 8h12M10 4l4 4-4 4"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
|
||||||
</Link>
|
</Link>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<section
|
||||||
|
className="bg-white border border-gray-200 rounded-2xl p-6 md:p-8 shadow-sm motion-safe:animate-fade-up"
|
||||||
|
style={{ animationDelay: '420ms' }}
|
||||||
|
aria-labelledby="que-pasa-despues"
|
||||||
|
>
|
||||||
|
<h2
|
||||||
|
id="que-pasa-despues"
|
||||||
|
className="text-base md:text-lg font-black tracking-tight text-black"
|
||||||
|
>
|
||||||
|
Elijas lo que elijas, esto es lo que pasa después
|
||||||
|
</h2>
|
||||||
|
<ol className="relative mt-6 grid gap-6 md:grid-cols-3 md:gap-8">
|
||||||
|
{/* Línea que conecta los pasos en desktop */}
|
||||||
|
<div
|
||||||
|
aria-hidden="true"
|
||||||
|
className="hidden md:block absolute top-[15px] left-8 right-8 h-px bg-gray-200"
|
||||||
|
/>
|
||||||
|
{pasosDespues.map((paso, i) => (
|
||||||
|
<li key={paso.titulo} className="relative flex items-start gap-4 md:flex-col">
|
||||||
|
<span
|
||||||
|
className="relative w-8 h-8 rounded-full text-[13px] font-black flex items-center justify-center shrink-0 ring-4 ring-white"
|
||||||
|
style={{ backgroundColor: BRAND, color: BRAND_CONTRAST }}
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
{i + 1}
|
||||||
|
</span>
|
||||||
|
<div className="flex flex-col gap-1 min-w-0">
|
||||||
|
<h3 className="text-sm font-bold text-black leading-snug">{paso.titulo}</h3>
|
||||||
|
<p className="text-sm text-gray-500 leading-relaxed">{paso.body}</p>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
import type { PublicGaleriaFoto } from '@/lib/funnel/public-queries';
|
import type { PublicGaleriaFoto } from '@/lib/funnel/public-queries';
|
||||||
|
|
||||||
type GaleriaTrabajosProps = {
|
type GaleriaTrabajosProps = {
|
||||||
@@ -5,11 +8,37 @@ type GaleriaTrabajosProps = {
|
|||||||
nombreEmpresa: string;
|
nombreEmpresa: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Galería de trabajos del reformista en su landing pública. Solo se muestra si
|
// Galería de trabajos del reformista en su landing pública. Solo se muestra si el reformista ha
|
||||||
// el reformista ha subido fotos desde su panel.
|
// subido fotos desde su panel. Formato apaisado y, al pulsar una foto, se amplía en un lightbox
|
||||||
|
// con navegación entre todas las imágenes.
|
||||||
export default function GaleriaTrabajos({ fotos, nombreEmpresa }: GaleriaTrabajosProps) {
|
export default function GaleriaTrabajos({ fotos, nombreEmpresa }: GaleriaTrabajosProps) {
|
||||||
|
const [idx, setIdx] = useState<number | null>(null);
|
||||||
|
|
||||||
|
const cerrar = useCallback(() => setIdx(null), []);
|
||||||
|
const mover = useCallback(
|
||||||
|
(d: number) => setIdx((cur) => (cur === null ? cur : (cur + d + fotos.length) % fotos.length)),
|
||||||
|
[fotos.length],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (idx === null) return;
|
||||||
|
const onKey = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') cerrar();
|
||||||
|
else if (e.key === 'ArrowRight') mover(1);
|
||||||
|
else if (e.key === 'ArrowLeft') mover(-1);
|
||||||
|
};
|
||||||
|
document.addEventListener('keydown', onKey);
|
||||||
|
document.body.style.overflow = 'hidden';
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('keydown', onKey);
|
||||||
|
document.body.style.overflow = '';
|
||||||
|
};
|
||||||
|
}, [idx, cerrar, mover]);
|
||||||
|
|
||||||
if (fotos.length === 0) return null;
|
if (fotos.length === 0) return null;
|
||||||
|
|
||||||
|
const actual = idx !== null ? fotos[idx] : null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section id="galeria" className="bg-gray-50 section" aria-label="Galería de trabajos">
|
<section id="galeria" className="bg-gray-50 section" aria-label="Galería de trabajos">
|
||||||
<div className="container">
|
<div className="container">
|
||||||
@@ -24,24 +53,31 @@ export default function GaleriaTrabajos({ fotos, nombreEmpresa }: GaleriaTrabajo
|
|||||||
Reformas que ya hemos hecho
|
Reformas que ya hemos hecho
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-gray-500 mt-3 leading-relaxed">
|
<p className="text-gray-500 mt-3 leading-relaxed">
|
||||||
Una muestra real del trabajo de {nombreEmpresa}. Calidad de acabados, plazos cumplidos.
|
Una muestra real del trabajo de {nombreEmpresa}. Toca cualquier imagen para verla en grande.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-3 md:gap-4">
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3 md:gap-4">
|
||||||
{fotos.map((f) => (
|
{fotos.map((f, i) => (
|
||||||
<figure
|
<figure
|
||||||
key={f.id}
|
key={f.id}
|
||||||
className="group relative aspect-[4/3] overflow-hidden rounded-xl bg-gray-100 border border-gray-200"
|
className="group relative aspect-[3/2] overflow-hidden rounded-xl bg-gray-100 border border-gray-200"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIdx(i)}
|
||||||
|
className="block h-full w-full cursor-zoom-in"
|
||||||
|
aria-label={`Ampliar ${f.titulo ?? `reforma de ${nombreEmpresa}`}`}
|
||||||
>
|
>
|
||||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
<img
|
<img
|
||||||
src={f.url}
|
src={f.url}
|
||||||
alt={f.titulo ?? `Reforma de ${nombreEmpresa}`}
|
alt={f.titulo ?? `Reforma de ${nombreEmpresa}`}
|
||||||
className="w-full h-full object-cover transition-transform duration-500 group-hover:scale-105"
|
className="h-full w-full object-cover transition-transform duration-500 group-hover:scale-105"
|
||||||
/>
|
/>
|
||||||
|
</button>
|
||||||
{f.titulo && (
|
{f.titulo && (
|
||||||
<figcaption className="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/70 to-transparent p-3 text-white text-sm font-semibold opacity-0 group-hover:opacity-100 transition-opacity duration-300">
|
<figcaption className="pointer-events-none absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/70 to-transparent p-3 text-white text-sm font-semibold opacity-0 group-hover:opacity-100 transition-opacity duration-300">
|
||||||
{f.titulo}
|
{f.titulo}
|
||||||
</figcaption>
|
</figcaption>
|
||||||
)}
|
)}
|
||||||
@@ -49,6 +85,67 @@ export default function GaleriaTrabajos({ fotos, nombreEmpresa }: GaleriaTrabajo
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{actual && (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-[100] flex items-center justify-center bg-black/90 p-4 sm:p-8"
|
||||||
|
onClick={cerrar}
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label="Imagen ampliada"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={cerrar}
|
||||||
|
aria-label="Cerrar"
|
||||||
|
className="absolute right-4 top-4 flex h-10 w-10 items-center justify-center rounded-full bg-white/15 text-2xl leading-none text-white hover:bg-white/30"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{fotos.length > 1 && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
mover(-1);
|
||||||
|
}}
|
||||||
|
aria-label="Anterior"
|
||||||
|
className="absolute left-3 top-1/2 flex h-11 w-11 -translate-y-1/2 items-center justify-center rounded-full bg-white/15 text-3xl leading-none text-white hover:bg-white/30 sm:left-6"
|
||||||
|
>
|
||||||
|
‹
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
|
<img
|
||||||
|
src={actual.url}
|
||||||
|
alt={actual.titulo ?? `Reforma de ${nombreEmpresa}`}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
className="max-h-[86vh] max-w-[94vw] w-auto rounded-lg shadow-2xl"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{fotos.length > 1 && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
mover(1);
|
||||||
|
}}
|
||||||
|
aria-label="Siguiente"
|
||||||
|
className="absolute right-3 top-1/2 flex h-11 w-11 -translate-y-1/2 items-center justify-center rounded-full bg-white/15 text-3xl leading-none text-white hover:bg-white/30 sm:right-6"
|
||||||
|
>
|
||||||
|
›
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{actual.titulo && (
|
||||||
|
<div className="pointer-events-none absolute inset-x-0 bottom-5 text-center text-sm font-medium text-white/85">
|
||||||
|
{actual.titulo}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -291,13 +291,13 @@ const SEED_LEADS: SeedLead[] = [
|
|||||||
const STAGE_ORDER = schema.pipelineStage.enumValues;
|
const STAGE_ORDER = schema.pipelineStage.enumValues;
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
const [existing] = await db
|
// Guard de seguridad: solo sembramos si la base de datos está VACÍA (sin ningún tenant). Antes se
|
||||||
.select()
|
// comprobaba un slug concreto ("reformas-ejemplo"); si ese tenant no estaba pero había otros
|
||||||
.from(schema.tenants)
|
// (p. ej. una empresa creada por el reformista), el seed los TRUNCABA en cada deploy → pérdida de
|
||||||
.where(eq(schema.tenants.slug, 'reformas-ejemplo'))
|
// datos. Ahora cualquier tenant existente protege toda la DB. SEED_FORCE=1 fuerza el reseed (BORRA TODO).
|
||||||
.limit(1);
|
const [existing] = await db.select({ id: schema.tenants.id }).from(schema.tenants).limit(1);
|
||||||
if (existing && !process.env.SEED_FORCE) {
|
if (existing && !process.env.SEED_FORCE) {
|
||||||
console.log('Ya hay datos (tenant "reformas-ejemplo"). Saltando seed. Usa SEED_FORCE=1 para forzar.');
|
console.log('La base de datos ya tiene datos (existe al menos un tenant). Saltando seed para no borrar nada. Usa SEED_FORCE=1 para forzar (¡BORRA TODO!).');
|
||||||
await client.end();
|
await client.end();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user