Compare commits
2 Commits
b815b0532b
...
df700bcbfb
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
df700bcbfb | ||
|
|
d92d5e2f12 |
@@ -714,6 +714,40 @@ del espacio. `[url]` apunta a su formulario personal del funnel. Tono: una sola
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Onboarding del panel (tour guiado)
|
||||||
|
|
||||||
|
> Tooltips del tour del panel (driver.js). Tono cercano y útil, una idea por paso, frases cortas. Las pestañas secundarias se explican "de pasada" (una línea). Copy usado en `src/lib/onboarding/panel-tour.ts`.
|
||||||
|
|
||||||
|
### Pestaña Leads (`/panel`)
|
||||||
|
|
||||||
|
- **Intro** — *Tu panel de Reformix* · "Te enseño en 30 segundos qué hay en cada sitio. Puedes saltártelo cuando quieras con la X."
|
||||||
|
- **Leads** — "Aquí caen tus clientes con su presupuesto y su render ya preparados. Es tu día a día."
|
||||||
|
- **Precios y baremo** — "Tu tabla de precios, la mano de obra y el baremo de rentabilidad. De aquí salen los presupuestos."
|
||||||
|
- **Galería** — "Tus fotos de trabajos para enseñar en la web."
|
||||||
|
- **Opiniones** — "Reseñas de tus clientes; las apruebas tú antes de publicarlas."
|
||||||
|
- **Empresa** — "Tu marca, logo y datos de contacto."
|
||||||
|
- **Filtra por estado** — "Nuevos, contactados, presupuestados… para centrarte en lo que toca ahora."
|
||||||
|
- **Tus leads** — "Cada cliente con su estado y su presupuesto. Ábrelo para ver el detalle completo."
|
||||||
|
|
||||||
|
### Ficha del lead (`/panel/{id}`)
|
||||||
|
|
||||||
|
- **Presupuesto estimado** — "Lo que costaría la reforma según tu catálogo. Si no llega a tu baremo, lo verás en rojo."
|
||||||
|
- **Estado del lead** — "Avanza el lead por el funnel: contactado, presupuestado, ganado…"
|
||||||
|
- **Render de la reforma** — "La imagen del «después» que ve tu cliente, generada a partir de su foto y sus gustos."
|
||||||
|
- **Presupuesto desglosado** — "Edita las partidas, recalcula desde el catálogo y envíaselo al cliente por WhatsApp."
|
||||||
|
|
||||||
|
### Precios y baremo (`/panel/precios`)
|
||||||
|
|
||||||
|
- **Baremo de rentabilidad** — "El trabajo mínimo que te compensa. Solo te avisa a ti: marca en rojo los leads por debajo."
|
||||||
|
- **Mano de obra** — "Tus precios de mano de obra por m². El motor los usa para calcular cada presupuesto."
|
||||||
|
- **Tu catálogo** — "Materiales y precios por calidad. Puedes importarlos en bloque por CSV."
|
||||||
|
|
||||||
|
### Botón para repetir
|
||||||
|
|
||||||
|
- **Botón flotante** — "❓ Tour" (relanza el tour de la pestaña actual).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Principios aplicados en todo el documento
|
## Principios aplicados en todo el documento
|
||||||
|
|
||||||
1. **Beneficios > Features** — "Tus clientes verán su reforma" > "Render IA con SDXL"
|
1. **Beneficios > Features** — "Tus clientes verán su reforma" > "Render IA con SDXL"
|
||||||
|
|||||||
7
mvp/b2c/package-lock.json
generated
7
mvp/b2c/package-lock.json
generated
@@ -11,6 +11,7 @@
|
|||||||
"@react-pdf/renderer": "^4.5.1",
|
"@react-pdf/renderer": "^4.5.1",
|
||||||
"@tailwindcss/postcss": "^4.3.0",
|
"@tailwindcss/postcss": "^4.3.0",
|
||||||
"bcryptjs": "^3.0.3",
|
"bcryptjs": "^3.0.3",
|
||||||
|
"driver.js": "^1.4.0",
|
||||||
"drizzle-orm": "^0.45.2",
|
"drizzle-orm": "^0.45.2",
|
||||||
"next": "16.2.6",
|
"next": "16.2.6",
|
||||||
"nodemailer": "^8.0.10",
|
"nodemailer": "^8.0.10",
|
||||||
@@ -4546,6 +4547,12 @@
|
|||||||
"url": "https://dotenvx.com"
|
"url": "https://dotenvx.com"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/driver.js": {
|
||||||
|
"version": "1.4.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/driver.js/-/driver.js-1.4.0.tgz",
|
||||||
|
"integrity": "sha512-Gm64jm6PmcU+si21sQhBrTAM1JvUrR0QhNmjkprNLxohOBzul9+pNHXgQaT9lW84gwg9GMLB3NZGuGolsz5uew==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/drizzle-kit": {
|
"node_modules/drizzle-kit": {
|
||||||
"version": "0.31.10",
|
"version": "0.31.10",
|
||||||
"resolved": "https://registry.npmjs.org/drizzle-kit/-/drizzle-kit-0.31.10.tgz",
|
"resolved": "https://registry.npmjs.org/drizzle-kit/-/drizzle-kit-0.31.10.tgz",
|
||||||
|
|||||||
@@ -21,6 +21,7 @@
|
|||||||
"@react-pdf/renderer": "^4.5.1",
|
"@react-pdf/renderer": "^4.5.1",
|
||||||
"@tailwindcss/postcss": "^4.3.0",
|
"@tailwindcss/postcss": "^4.3.0",
|
||||||
"bcryptjs": "^3.0.3",
|
"bcryptjs": "^3.0.3",
|
||||||
|
"driver.js": "^1.4.0",
|
||||||
"drizzle-orm": "^0.45.2",
|
"drizzle-orm": "^0.45.2",
|
||||||
"next": "16.2.6",
|
"next": "16.2.6",
|
||||||
"nodemailer": "^8.0.10",
|
"nodemailer": "^8.0.10",
|
||||||
|
|||||||
@@ -1472,8 +1472,8 @@ h3, h4, h5, h6 {
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
border: 1px solid var(--surface-border);
|
border: 1px solid var(--surface-border);
|
||||||
}
|
}
|
||||||
.step .ba figure { position: relative; margin: 0; aspect-ratio: 1 / 1; }
|
.step .ba figure { position: relative; margin: 0; aspect-ratio: 4 / 3; }
|
||||||
.step .ba img { width: 100%; height: 100%; object-fit: cover; display: block; }
|
.step .ba img { width: 100%; height: 100%; object-fit: cover; display: block; cursor: zoom-in; }
|
||||||
.step .ba figcaption {
|
.step .ba figcaption {
|
||||||
position: absolute; top: 6px; left: 6px;
|
position: absolute; top: 6px; left: 6px;
|
||||||
background: rgba(0, 0, 0, 0.6); color: #fff;
|
background: rgba(0, 0, 0, 0.6); color: #fff;
|
||||||
@@ -1482,6 +1482,34 @@ h3, h4, h5, h6 {
|
|||||||
}
|
}
|
||||||
.step .ba figure.after figcaption { background: var(--color-primary-600); }
|
.step .ba figure.after figcaption { background: var(--color-primary-600); }
|
||||||
|
|
||||||
|
/* Galería de ejemplos (apaisada, ampliable) */
|
||||||
|
.gallery-grid { display: grid; grid-template-columns: 1fr; gap: var(--space-4); margin-top: var(--space-8); }
|
||||||
|
@media (min-width: 640px) { .gallery-grid { grid-template-columns: repeat(2, 1fr); } }
|
||||||
|
@media (min-width: 1024px) { .gallery-grid { grid-template-columns: repeat(3, 1fr); gap: var(--space-6); } }
|
||||||
|
.gcard {
|
||||||
|
position: relative; margin: 0; border-radius: 14px; overflow: hidden;
|
||||||
|
border: 1px solid var(--surface-border); cursor: zoom-in;
|
||||||
|
background: var(--color-neutral-100, #f4f4f5);
|
||||||
|
}
|
||||||
|
.gcard img { width: 100%; aspect-ratio: 16 / 10; object-fit: cover; display: block; transition: transform .45s ease; }
|
||||||
|
.gcard:hover img { transform: scale(1.04); }
|
||||||
|
.gcard figcaption {
|
||||||
|
position: absolute; left: 10px; bottom: 10px;
|
||||||
|
background: rgba(0, 0, 0, 0.62); color: #fff;
|
||||||
|
font-family: var(--font-mono); font-size: 11px; letter-spacing: 0.06em;
|
||||||
|
padding: 3px 8px; border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Lightbox */
|
||||||
|
#lightbox { position: fixed; inset: 0; z-index: 100; display: none; align-items: center; justify-content: center; padding: 24px; background: rgba(8, 12, 20, 0.92); }
|
||||||
|
#lightbox.open { display: flex; }
|
||||||
|
#lightbox .lb-img { max-width: min(1100px, 94vw); max-height: 86vh; width: auto; height: auto; border-radius: 12px; box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5); }
|
||||||
|
#lightbox .lb-cap { position: absolute; bottom: 18px; left: 0; right: 0; text-align: center; color: rgba(255, 255, 255, 0.85); font-family: var(--font-mono); font-size: 12px; letter-spacing: 0.06em; padding: 0 16px; }
|
||||||
|
#lightbox .lb-close { position: absolute; top: 16px; right: 18px; width: 40px; height: 40px; border: none; border-radius: 50%; background: rgba(255, 255, 255, 0.12); color: #fff; font-size: 22px; line-height: 1; cursor: pointer; }
|
||||||
|
#lightbox .lb-btn { position: absolute; top: 50%; transform: translateY(-50%); width: 46px; height: 46px; border: none; border-radius: 50%; background: rgba(255, 255, 255, 0.12); color: #fff; font-size: 26px; line-height: 1; cursor: pointer; }
|
||||||
|
#lightbox .lb-prev { left: 18px; } #lightbox .lb-next { right: 18px; }
|
||||||
|
#lightbox .lb-close:hover, #lightbox .lb-btn:hover { background: rgba(255, 255, 255, 0.26); }
|
||||||
|
|
||||||
/* Bajo reduced-motion: mostrar todo sin animar */
|
/* Bajo reduced-motion: mostrar todo sin animar */
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
html.js .reveal,
|
html.js .reveal,
|
||||||
@@ -1508,7 +1536,6 @@ h3, h4, h5, h6 {
|
|||||||
<a href="#faq">Preguntas</a>
|
<a href="#faq">Preguntas</a>
|
||||||
</nav>
|
</nav>
|
||||||
<div class="topbar-actions">
|
<div class="topbar-actions">
|
||||||
<a href="/login" class="btn btn-ghost btn-sm">Entrar</a>
|
|
||||||
<a href="/signup" class="btn btn-primary btn-sm">Empezar gratis</a>
|
<a href="/signup" class="btn btn-primary btn-sm">Empezar gratis</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1705,8 +1732,8 @@ h3, h4, h5, h6 {
|
|||||||
<h4>Se genera un render orientativo</h4>
|
<h4>Se genera un render orientativo</h4>
|
||||||
<p>A partir de las fotos del cliente, Reformix devuelve 3 propuestas visuales con calidades básica, media y premium. Ya tienes de qué hablar en la visita.</p>
|
<p>A partir de las fotos del cliente, Reformix devuelve 3 propuestas visuales con calidades básica, media y premium. Ya tienes de qué hablar en la visita.</p>
|
||||||
<div class="ba" aria-label="Comparativa antes y después del render">
|
<div class="ba" aria-label="Comparativa antes y después del render">
|
||||||
<figure><img src="/b2b-assets/img/antes.webp" alt="Cocina antes de la reforma" loading="lazy" decoding="async"><figcaption>ANTES</figcaption></figure>
|
<figure><img src="/b2b-assets/img/antes.webp" alt="Cocina antes de la reforma" data-zoom data-gallery="ba-cocina" data-zoom-cap="Cocina · antes" loading="lazy" decoding="async"><figcaption>ANTES</figcaption></figure>
|
||||||
<figure class="after"><img src="/b2b-assets/img/despues.webp" alt="Render de la cocina reformada" loading="lazy" decoding="async"><figcaption>DESPUÉS</figcaption></figure>
|
<figure class="after"><img src="/b2b-assets/img/despues.webp" alt="Render de la cocina reformada" data-zoom data-gallery="ba-cocina" data-zoom-cap="Cocina · render orientativo" loading="lazy" decoding="async"><figcaption>DESPUÉS</figcaption></figure>
|
||||||
</div>
|
</div>
|
||||||
<div class="tag">⌁ Render IA</div>
|
<div class="tag">⌁ Render IA</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1726,6 +1753,33 @@ h3, h4, h5, h6 {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<!-- ============================================
|
||||||
|
Galería de ejemplos
|
||||||
|
============================================ -->
|
||||||
|
<section class="section section-muted" id="ejemplos">
|
||||||
|
<div class="container">
|
||||||
|
<div class="section-head center">
|
||||||
|
<p class="section-kicker">Ejemplos reales</p>
|
||||||
|
<h2 class="section-title">El «después» que tu cliente ve <em>antes de que vayas</em>.</h2>
|
||||||
|
<p class="section-lede">Una foto del espacio y Reformix devuelve el render orientativo. Toca cualquiera para ampliarla.</p>
|
||||||
|
</div>
|
||||||
|
<div class="gallery-grid">
|
||||||
|
<figure class="gcard">
|
||||||
|
<img src="/b2b-assets/img/despues.webp" alt="Render de cocina reformada" data-zoom data-gallery="ejemplos" data-zoom-cap="Cocina · render orientativo" loading="lazy" decoding="async">
|
||||||
|
<figcaption>Cocina</figcaption>
|
||||||
|
</figure>
|
||||||
|
<figure class="gcard">
|
||||||
|
<img src="/b2b-assets/img/despues-bano.webp" alt="Render de baño reformado" data-zoom data-gallery="ejemplos" data-zoom-cap="Baño · render orientativo" loading="lazy" decoding="async">
|
||||||
|
<figcaption>Baño</figcaption>
|
||||||
|
</figure>
|
||||||
|
<figure class="gcard">
|
||||||
|
<img src="/b2b-assets/img/despues-comedor.webp" alt="Render de salón-comedor reformado" data-zoom data-gallery="ejemplos" data-zoom-cap="Salón-comedor · render orientativo" loading="lazy" decoding="async">
|
||||||
|
<figcaption>Salón-comedor</figcaption>
|
||||||
|
</figure>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<!-- ============================================
|
<!-- ============================================
|
||||||
Demo / WhatsApp
|
Demo / WhatsApp
|
||||||
============================================ -->
|
============================================ -->
|
||||||
@@ -2160,4 +2214,66 @@ h3, h4, h5, h6 {
|
|||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<!-- ============================================
|
||||||
|
Lightbox (galería ampliable)
|
||||||
|
============================================ -->
|
||||||
|
<div id="lightbox" aria-hidden="true" role="dialog" aria-modal="true" aria-label="Imagen ampliada">
|
||||||
|
<button class="lb-close" type="button" aria-label="Cerrar">×</button>
|
||||||
|
<button class="lb-btn lb-prev" type="button" aria-label="Anterior">‹</button>
|
||||||
|
<img class="lb-img" src="" alt="">
|
||||||
|
<button class="lb-btn lb-next" type="button" aria-label="Siguiente">›</button>
|
||||||
|
<div class="lb-cap"></div>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
var lb = document.getElementById('lightbox');
|
||||||
|
if (!lb) return;
|
||||||
|
var imgEl = lb.querySelector('.lb-img');
|
||||||
|
var capEl = lb.querySelector('.lb-cap');
|
||||||
|
var btns = lb.querySelectorAll('.lb-btn');
|
||||||
|
var group = [], idx = 0;
|
||||||
|
|
||||||
|
function render() {
|
||||||
|
var t = group[idx];
|
||||||
|
if (!t) return;
|
||||||
|
imgEl.src = t.currentSrc || t.src;
|
||||||
|
imgEl.alt = t.alt || '';
|
||||||
|
capEl.textContent = t.getAttribute('data-zoom-cap') || t.alt || '';
|
||||||
|
var multi = group.length > 1;
|
||||||
|
Array.prototype.forEach.call(btns, function (b) { b.style.display = multi ? '' : 'none'; });
|
||||||
|
}
|
||||||
|
function open(trigger) {
|
||||||
|
var g = trigger.getAttribute('data-gallery') || 'default';
|
||||||
|
group = Array.prototype.slice.call(document.querySelectorAll('[data-zoom][data-gallery="' + g + '"]'));
|
||||||
|
idx = group.indexOf(trigger);
|
||||||
|
if (idx < 0) idx = 0;
|
||||||
|
render();
|
||||||
|
lb.classList.add('open');
|
||||||
|
lb.setAttribute('aria-hidden', 'false');
|
||||||
|
document.body.style.overflow = 'hidden';
|
||||||
|
}
|
||||||
|
function close() {
|
||||||
|
lb.classList.remove('open');
|
||||||
|
lb.setAttribute('aria-hidden', 'true');
|
||||||
|
document.body.style.overflow = '';
|
||||||
|
}
|
||||||
|
function move(d) { if (group.length) { idx = (idx + d + group.length) % group.length; render(); } }
|
||||||
|
|
||||||
|
document.addEventListener('click', function (e) {
|
||||||
|
var z = e.target.closest && e.target.closest('[data-zoom]');
|
||||||
|
if (z) { e.preventDefault(); open(z); return; }
|
||||||
|
if (!lb.classList.contains('open')) return;
|
||||||
|
if (e.target === lb || (e.target.closest && e.target.closest('.lb-close'))) { close(); return; }
|
||||||
|
if (e.target.closest && e.target.closest('.lb-prev')) { move(-1); return; }
|
||||||
|
if (e.target.closest && e.target.closest('.lb-next')) { move(1); return; }
|
||||||
|
});
|
||||||
|
document.addEventListener('keydown', function (e) {
|
||||||
|
if (!lb.classList.contains('open')) return;
|
||||||
|
if (e.key === 'Escape') close();
|
||||||
|
else if (e.key === 'ArrowRight') move(1);
|
||||||
|
else if (e.key === 'ArrowLeft') move(-1);
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
|
||||||
</body></html>
|
</body></html>
|
||||||
@@ -20,9 +20,20 @@ import type { BudgetResult } from '@/budget/types';
|
|||||||
|
|
||||||
export const dynamic = 'force-dynamic';
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
function Section({ title, children }: { title: string; children: React.ReactNode }) {
|
function Section({
|
||||||
|
title,
|
||||||
|
children,
|
||||||
|
tour,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
tour?: string;
|
||||||
|
}) {
|
||||||
return (
|
return (
|
||||||
<section className="bg-white border border-gray-200 rounded-xl p-5 flex flex-col gap-3">
|
<section
|
||||||
|
data-tour={tour}
|
||||||
|
className="bg-white border border-gray-200 rounded-xl p-5 flex flex-col gap-3"
|
||||||
|
>
|
||||||
<h2 className="text-xs uppercase tracking-wide font-semibold text-gray-400">{title}</h2>
|
<h2 className="text-xs uppercase tracking-wide font-semibold text-gray-400">{title}</h2>
|
||||||
{children}
|
{children}
|
||||||
</section>
|
</section>
|
||||||
@@ -71,7 +82,7 @@ export default async function LeadDetailPage({ params }: { params: Promise<{ id:
|
|||||||
{lead.provincia ?? '—'} · entró {formatFecha(lead.createdAt)}
|
{lead.provincia ?? '—'} · entró {formatFecha(lead.createdAt)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-right">
|
<div className="text-right" data-tour="ficha-presupuesto">
|
||||||
<div className="text-xs text-gray-400">Presupuesto estimado</div>
|
<div className="text-xs text-gray-400">Presupuesto estimado</div>
|
||||||
<div className={`text-2xl font-black ${pasaBaremo === false ? 'text-red-600' : 'text-black'}`}>
|
<div className={`text-2xl font-black ${pasaBaremo === false ? 'text-red-600' : 'text-black'}`}>
|
||||||
{formatEuros(lead.presupuestoEstimado)}
|
{formatEuros(lead.presupuestoEstimado)}
|
||||||
@@ -83,11 +94,13 @@ export default async function LeadDetailPage({ params }: { params: Promise<{ id:
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<EstadoControl
|
<div data-tour="ficha-estado">
|
||||||
leadId={lead.id}
|
<EstadoControl
|
||||||
estado={lead.estado}
|
leadId={lead.id}
|
||||||
presupuestoEstimado={lead.presupuestoEstimado}
|
estado={lead.estado}
|
||||||
/>
|
presupuestoEstimado={lead.presupuestoEstimado}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Solicitar opinión al cliente */}
|
{/* Solicitar opinión al cliente */}
|
||||||
@@ -180,7 +193,7 @@ export default async function LeadDetailPage({ params }: { params: Promise<{ id:
|
|||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
{/* 4. Render */}
|
{/* 4. Render */}
|
||||||
<Section title="Render generado">
|
<Section title="Render generado" tour="ficha-render">
|
||||||
{lead.renderUrl ? (
|
{lead.renderUrl ? (
|
||||||
// eslint-disable-next-line @next/next/no-img-element
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
<img src={lead.renderUrl} alt="Render de la reforma" className="w-full rounded-lg object-cover" />
|
<img src={lead.renderUrl} alt="Render de la reforma" className="w-full rounded-lg object-cover" />
|
||||||
@@ -327,7 +340,7 @@ export default async function LeadDetailPage({ params }: { params: Promise<{ id:
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Presupuesto desglosado */}
|
{/* Presupuesto desglosado */}
|
||||||
<Section title="Presupuesto desglosado">
|
<Section title="Presupuesto desglosado" tour="ficha-desglose">
|
||||||
<div className="flex flex-wrap items-center gap-3">
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
<form action={recalcularPresupuesto.bind(null, lead.id)}>
|
<form action={recalcularPresupuesto.bind(null, lead.id)}>
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { db } from '@/db';
|
|||||||
import { tenants } from '@/db/schema';
|
import { tenants } from '@/db/schema';
|
||||||
import { eq } from 'drizzle-orm';
|
import { eq } from 'drizzle-orm';
|
||||||
import AppNav from '@/components/AppNav';
|
import AppNav from '@/components/AppNav';
|
||||||
|
import PanelTour from '@/components/panel/PanelTour';
|
||||||
|
|
||||||
const PANEL_LINKS = [
|
const PANEL_LINKS = [
|
||||||
{ href: '/panel', label: 'Leads', icon: 'leads' },
|
{ href: '/panel', label: 'Leads', icon: 'leads' },
|
||||||
@@ -44,6 +45,7 @@ export default async function PanelLayout({ children }: { children: React.ReactN
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<main className="max-w-6xl mx-auto px-6 py-8 pb-24 sm:pb-8">{children}</main>
|
<main className="max-w-6xl mx-auto px-6 py-8 pb-24 sm:pb-8">{children}</main>
|
||||||
|
<PanelTour />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ export default async function PanelPage({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Filtros por estado */}
|
{/* Filtros por estado */}
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2" data-tour="leads-filtros">
|
||||||
{FILTROS.map((f) => {
|
{FILTROS.map((f) => {
|
||||||
const active = f.value === filtro;
|
const active = f.value === filtro;
|
||||||
const count = f.value === 'todos' ? resumen.total : resumen.porEstado[f.value] ?? 0;
|
const count = f.value === 'todos' ? resumen.total : resumen.porEstado[f.value] ?? 0;
|
||||||
@@ -89,7 +89,9 @@ export default async function PanelPage({
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<LeadsView leads={leadsView} />
|
<div data-tour="leads-tabla">
|
||||||
|
<LeadsView leads={leadsView} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -82,7 +82,7 @@ export default async function PreciosPage() {
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Config general */}
|
{/* Config general */}
|
||||||
<section className="bg-white rounded-xl border border-gray-200 p-6">
|
<section className="bg-white rounded-xl border border-gray-200 p-6" data-tour="precios-config">
|
||||||
<h2 className="font-bold text-black mb-4">Configuración general</h2>
|
<h2 className="font-bold text-black mb-4">Configuración general</h2>
|
||||||
<form action={actualizarConfig} className="grid grid-cols-2 md:grid-cols-5 gap-3 items-end">
|
<form action={actualizarConfig} className="grid grid-cols-2 md:grid-cols-5 gap-3 items-end">
|
||||||
<label className="text-sm">
|
<label className="text-sm">
|
||||||
@@ -122,7 +122,7 @@ export default async function PreciosPage() {
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Baremo de rentabilidad */}
|
{/* Baremo de rentabilidad */}
|
||||||
<section className="bg-white rounded-xl border border-gray-200 p-6">
|
<section className="bg-white rounded-xl border border-gray-200 p-6" data-tour="precios-baremo">
|
||||||
<h2 className="font-bold text-black mb-1">Baremo de rentabilidad</h2>
|
<h2 className="font-bold text-black mb-1">Baremo de rentabilidad</h2>
|
||||||
<p className="text-sm text-gray-500 mb-4">
|
<p className="text-sm text-gray-500 mb-4">
|
||||||
Importe mínimo de un trabajo para que te resulte rentable. Es solo orientativo para ti: en la
|
Importe mínimo de un trabajo para que te resulte rentable. Es solo orientativo para ti: en la
|
||||||
@@ -285,7 +285,7 @@ export default async function PreciosPage() {
|
|||||||
})}
|
})}
|
||||||
|
|
||||||
{/* Import CSV */}
|
{/* Import CSV */}
|
||||||
<section className="bg-white rounded-xl border border-gray-200 p-6">
|
<section className="bg-white rounded-xl border border-gray-200 p-6" data-tour="precios-catalogo">
|
||||||
<h2 className="font-bold text-black mb-2">Importar catálogo (CSV)</h2>
|
<h2 className="font-bold text-black mb-2">Importar catálogo (CSV)</h2>
|
||||||
<p className="text-xs text-gray-500 mb-3">
|
<p className="text-xs text-gray-500 mb-3">
|
||||||
Cabecera: <code className="break-all">categoria,nombre,calidad,precio,unidad,descriptor_render,sku</code>. El
|
Cabecera: <code className="break-all">categoria,nombre,calidad,precio,unidad,descriptor_render,sku</code>. El
|
||||||
|
|||||||
@@ -116,6 +116,7 @@ export default function AppNav({ links }: { links: readonly AppNavLink[] }) {
|
|||||||
<Link
|
<Link
|
||||||
key={l.href}
|
key={l.href}
|
||||||
href={l.href}
|
href={l.href}
|
||||||
|
data-tour={`nav-${l.icon}`}
|
||||||
className={active === l.href ? 'text-primary-700 font-semibold' : 'text-gray-500 hover:text-primary-700'}
|
className={active === l.href ? 'text-primary-700 font-semibold' : 'text-gray-500 hover:text-primary-700'}
|
||||||
>
|
>
|
||||||
{l.label}
|
{l.label}
|
||||||
|
|||||||
71
mvp/b2c/src/components/panel/PanelTour.tsx
Normal file
71
mvp/b2c/src/components/panel/PanelTour.tsx
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { usePathname } from 'next/navigation';
|
||||||
|
import { driver, type DriveStep } from 'driver.js';
|
||||||
|
import 'driver.js/dist/driver.css';
|
||||||
|
import { tourForPath } from '@/lib/onboarding/panel-tour';
|
||||||
|
|
||||||
|
const SEEN_PREFIX = 'reformix_tour_v1_';
|
||||||
|
|
||||||
|
// Onboarding del panel con driver.js. Lanza el tour de la pestaña actual la primera vez que se
|
||||||
|
// visita (flag por pestaña en localStorage) y deja un botón flotante para repetirlo. Los pasos
|
||||||
|
// cuyo elemento no exista o esté oculto (p. ej. la nav de escritorio en móvil) se descartan.
|
||||||
|
export default function PanelTour() {
|
||||||
|
const pathname = usePathname();
|
||||||
|
const [hayTour, setHayTour] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const tour = tourForPath(pathname);
|
||||||
|
setHayTour(Boolean(tour));
|
||||||
|
if (!tour) return;
|
||||||
|
if (localStorage.getItem(SEEN_PREFIX + tour.key) === '1') return;
|
||||||
|
|
||||||
|
// Espera a que el contenido de la página esté montado antes de resaltar.
|
||||||
|
const t = setTimeout(() => {
|
||||||
|
localStorage.setItem(SEEN_PREFIX + tour.key, '1');
|
||||||
|
lanzar(tour.steps);
|
||||||
|
}, 700);
|
||||||
|
return () => clearTimeout(t);
|
||||||
|
}, [pathname]);
|
||||||
|
|
||||||
|
function visibles(steps: DriveStep[]): DriveStep[] {
|
||||||
|
return steps.filter((s) => {
|
||||||
|
const sel = s.element;
|
||||||
|
if (!sel || typeof sel !== 'string') return true; // paso centrado (intro)
|
||||||
|
const el = document.querySelector(sel) as HTMLElement | null;
|
||||||
|
return !!el && el.offsetParent !== null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function lanzar(steps: DriveStep[]) {
|
||||||
|
const pasos = visibles(steps);
|
||||||
|
if (pasos.length === 0) return;
|
||||||
|
driver({
|
||||||
|
showProgress: true,
|
||||||
|
overlayColor: '#0b1220',
|
||||||
|
nextBtnText: 'Siguiente',
|
||||||
|
prevBtnText: 'Atrás',
|
||||||
|
doneBtnText: 'Listo',
|
||||||
|
progressText: '{{current}} de {{total}}',
|
||||||
|
steps: pasos,
|
||||||
|
}).drive();
|
||||||
|
}
|
||||||
|
|
||||||
|
function repetir() {
|
||||||
|
const tour = tourForPath(pathname);
|
||||||
|
if (tour) lanzar(tour.steps);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hayTour) return null;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={repetir}
|
||||||
|
className="fixed right-4 bottom-20 sm:bottom-4 z-40 inline-flex items-center gap-1.5 rounded-full bg-primary-700 px-4 py-2 text-sm font-semibold text-white shadow-lg hover:bg-primary-900"
|
||||||
|
aria-label="Ver el tour de esta sección"
|
||||||
|
>
|
||||||
|
<span aria-hidden="true">❓</span> Tour
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
147
mvp/b2c/src/lib/onboarding/panel-tour.ts
Normal file
147
mvp/b2c/src/lib/onboarding/panel-tour.ts
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
import type { DriveStep } from 'driver.js';
|
||||||
|
|
||||||
|
// Pasos del onboarding del panel, por pestaña. El copy vive también en copy/COPY-GUIDE.md
|
||||||
|
// (sección "Onboarding del panel"). Los pasos cuyo elemento no exista o no esté visible se
|
||||||
|
// descartan en PanelTour (degrada con naturalidad en móvil o si una sección no aparece).
|
||||||
|
|
||||||
|
const PASOS_PANEL: DriveStep[] = [
|
||||||
|
{
|
||||||
|
popover: {
|
||||||
|
title: 'Tu panel de Reformix',
|
||||||
|
description:
|
||||||
|
'Te enseño en 30 segundos qué hay en cada sitio. Puedes saltártelo cuando quieras con la X.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
element: '[data-tour="nav-leads"]',
|
||||||
|
popover: {
|
||||||
|
title: 'Leads',
|
||||||
|
description:
|
||||||
|
'Aquí caen tus clientes con su presupuesto y su render ya preparados. Es tu día a día.',
|
||||||
|
side: 'bottom',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
element: '[data-tour="nav-precios"]',
|
||||||
|
popover: {
|
||||||
|
title: 'Precios y baremo',
|
||||||
|
description:
|
||||||
|
'Tu tabla de precios, la mano de obra y el baremo de rentabilidad. De aquí salen los presupuestos.',
|
||||||
|
side: 'bottom',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
element: '[data-tour="nav-galeria"]',
|
||||||
|
popover: { title: 'Galería', description: 'Tus fotos de trabajos para enseñar en la web.', side: 'bottom' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
element: '[data-tour="nav-opiniones"]',
|
||||||
|
popover: {
|
||||||
|
title: 'Opiniones',
|
||||||
|
description: 'Reseñas de tus clientes; las apruebas tú antes de publicarlas.',
|
||||||
|
side: 'bottom',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
element: '[data-tour="nav-empresa"]',
|
||||||
|
popover: { title: 'Empresa', description: 'Tu marca, logo y datos de contacto.', side: 'bottom' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
element: '[data-tour="leads-filtros"]',
|
||||||
|
popover: {
|
||||||
|
title: 'Filtra por estado',
|
||||||
|
description: 'Nuevos, contactados, presupuestados… para centrarte en lo que toca ahora.',
|
||||||
|
side: 'bottom',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
element: '[data-tour="leads-tabla"]',
|
||||||
|
popover: {
|
||||||
|
title: 'Tus leads',
|
||||||
|
description: 'Cada cliente con su estado y su presupuesto. Ábrelo para ver el detalle completo.',
|
||||||
|
side: 'top',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const PASOS_FICHA: DriveStep[] = [
|
||||||
|
{
|
||||||
|
element: '[data-tour="ficha-presupuesto"]',
|
||||||
|
popover: {
|
||||||
|
title: 'Presupuesto estimado',
|
||||||
|
description:
|
||||||
|
'Lo que costaría la reforma según tu catálogo. Si no llega a tu baremo, lo verás en rojo.',
|
||||||
|
side: 'bottom',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
element: '[data-tour="ficha-estado"]',
|
||||||
|
popover: {
|
||||||
|
title: 'Estado del lead',
|
||||||
|
description: 'Avanza el lead por el funnel: contactado, presupuestado, ganado…',
|
||||||
|
side: 'bottom',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
element: '[data-tour="ficha-render"]',
|
||||||
|
popover: {
|
||||||
|
title: 'Render de la reforma',
|
||||||
|
description:
|
||||||
|
'La imagen del “después” que ve tu cliente, generada a partir de su foto y sus gustos.',
|
||||||
|
side: 'top',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
element: '[data-tour="ficha-desglose"]',
|
||||||
|
popover: {
|
||||||
|
title: 'Presupuesto desglosado',
|
||||||
|
description:
|
||||||
|
'Edita las partidas, recalcula desde el catálogo y envíaselo al cliente por WhatsApp.',
|
||||||
|
side: 'top',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const PASOS_PRECIOS: DriveStep[] = [
|
||||||
|
{
|
||||||
|
element: '[data-tour="precios-baremo"]',
|
||||||
|
popover: {
|
||||||
|
title: 'Baremo de rentabilidad',
|
||||||
|
description:
|
||||||
|
'El trabajo mínimo que te compensa. Solo te avisa a ti: marca en rojo los leads por debajo.',
|
||||||
|
side: 'bottom',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
element: '[data-tour="precios-config"]',
|
||||||
|
popover: {
|
||||||
|
title: 'Mano de obra',
|
||||||
|
description: 'Tus precios de mano de obra por m². El motor los usa para calcular cada presupuesto.',
|
||||||
|
side: 'top',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
element: '[data-tour="precios-catalogo"]',
|
||||||
|
popover: {
|
||||||
|
title: 'Tu catálogo',
|
||||||
|
description: 'Materiales y precios por calidad. Puedes importarlos en bloque por CSV.',
|
||||||
|
side: 'top',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export interface PanelTour {
|
||||||
|
key: string;
|
||||||
|
steps: DriveStep[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Devuelve el tour que corresponde a la ruta actual del panel, o null si esa ruta no tiene tour.
|
||||||
|
export function tourForPath(pathname: string): PanelTour | null {
|
||||||
|
if (pathname === '/panel') return { key: 'panel', steps: PASOS_PANEL };
|
||||||
|
if (pathname === '/panel/precios') return { key: 'precios', steps: PASOS_PRECIOS };
|
||||||
|
const m = pathname.match(/^\/panel\/([^/]+)\/?$/);
|
||||||
|
if (m && !['precios', 'galeria', 'opiniones', 'empresa'].includes(m[1])) {
|
||||||
|
return { key: 'ficha', steps: PASOS_FICHA };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user