Añade onboarding guiado del panel (tour con driver.js)

Tour por pestañas que explica los puntos clave: en Leads recorre la navegación
(las pestañas secundarias de pasada) + filtros y tabla; en la ficha del lead el
presupuesto/baremo, estado, render y desglose; en Precios el baremo, la mano de
obra y el catálogo. Auto-arranca la primera vez por pestaña (flag en localStorage)
y deja un botón flotante " Tour" para repetir. Pasos sin elemento visible se
descartan (degrada en móvil).

- Dependencia: driver.js (librería estándar de tours, ~5kb, sin más deps;
  evita reinventar overlay/posicionamiento/foco/accesibilidad).
- src/lib/onboarding/panel-tour.ts: pasos por ruta. PanelTour.tsx: cliente que
  lanza driver.js. data-tour en nav, leads, ficha y precios.
- Copy en COPY-GUIDE.md (sección "Onboarding del panel").

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Carlos Narro
2026-06-11 17:19:01 +02:00
parent b815b0532b
commit d92d5e2f12
10 changed files with 293 additions and 15 deletions

View File

@@ -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"

View File

@@ -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",

View File

@@ -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",

View File

@@ -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,12 +94,14 @@ export default async function LeadDetailPage({ params }: { params: Promise<{ id:
)} )}
</div> </div>
</div> </div>
<div data-tour="ficha-estado">
<EstadoControl <EstadoControl
leadId={lead.id} leadId={lead.id}
estado={lead.estado} estado={lead.estado}
presupuestoEstimado={lead.presupuestoEstimado} presupuestoEstimado={lead.presupuestoEstimado}
/> />
</div> </div>
</div>
{/* Solicitar opinión al cliente */} {/* Solicitar opinión al cliente */}
<Section title="Opinión del cliente"> <Section title="Opinión del 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

View File

@@ -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>
); );
} }

View File

@@ -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>
<div data-tour="leads-tabla">
<LeadsView leads={leadsView} /> <LeadsView leads={leadsView} />
</div> </div>
</div>
); );
} }

View File

@@ -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

View File

@@ -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}

View 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>
);
}

View 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;
}