Merge branch 'main' of https://github.com/McGregory99/reformix-hackaton
This commit is contained in:
@@ -186,6 +186,7 @@ CREATE TABLE "pricing_config" (
|
||||
"factor_zona" jsonb DEFAULT '{}'::jsonb NOT NULL,
|
||||
"mano_obra" jsonb DEFAULT '{}'::jsonb NOT NULL,
|
||||
"extras" jsonb DEFAULT '{"tuberias":0,"boletin":0,"distribucion":0}'::jsonb NOT NULL,
|
||||
"baremo_minimo" integer,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "pricing_config_tenant_id_unique" UNIQUE("tenant_id")
|
||||
);
|
||||
|
||||
1
mvp/b2c/drizzle/0012_lame_sentinel.sql
Normal file
1
mvp/b2c/drizzle/0012_lame_sentinel.sql
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TABLE "pricing_config" ADD COLUMN "baremo_minimo" integer;
|
||||
2458
mvp/b2c/drizzle/meta/0012_snapshot.json
Normal file
2458
mvp/b2c/drizzle/meta/0012_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -85,6 +85,13 @@
|
||||
"when": 1780593183911,
|
||||
"tag": "0011_warm_post",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 12,
|
||||
"version": "7",
|
||||
"when": 1781189893331,
|
||||
"tag": "0012_lame_sentinel",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
7
mvp/b2c/package-lock.json
generated
7
mvp/b2c/package-lock.json
generated
@@ -11,6 +11,7 @@
|
||||
"@react-pdf/renderer": "^4.5.1",
|
||||
"@tailwindcss/postcss": "^4.3.0",
|
||||
"bcryptjs": "^3.0.3",
|
||||
"driver.js": "^1.4.0",
|
||||
"drizzle-orm": "^0.45.2",
|
||||
"next": "16.2.6",
|
||||
"nodemailer": "^8.0.10",
|
||||
@@ -4546,6 +4547,12 @@
|
||||
"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": {
|
||||
"version": "0.31.10",
|
||||
"resolved": "https://registry.npmjs.org/drizzle-kit/-/drizzle-kit-0.31.10.tgz",
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
"@react-pdf/renderer": "^4.5.1",
|
||||
"@tailwindcss/postcss": "^4.3.0",
|
||||
"bcryptjs": "^3.0.3",
|
||||
"driver.js": "^1.4.0",
|
||||
"drizzle-orm": "^0.45.2",
|
||||
"next": "16.2.6",
|
||||
"nodemailer": "^8.0.10",
|
||||
|
||||
@@ -46,7 +46,7 @@ export default async function FunnelPage({ params }: { params: Promise<{ slug: s
|
||||
className={theme.heading === 'serif' ? 'theme-serif' : undefined}
|
||||
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">
|
||||
<Hero slug={tenant.slug} />
|
||||
<ReformaSlider />
|
||||
|
||||
@@ -58,6 +58,20 @@
|
||||
--transition-fast: 150ms ease;
|
||||
--transition-base: 250ms 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 {
|
||||
|
||||
@@ -2,6 +2,7 @@ import Link from 'next/link';
|
||||
import { headers } from 'next/headers';
|
||||
import { notFound } from 'next/navigation';
|
||||
import { getLead } from '@/db/queries';
|
||||
import { getPricingConfigFor } from '@/db/pricing-queries';
|
||||
import EstadoControl from '@/components/panel/EstadoControl';
|
||||
import ConceptosEditor from '@/components/panel/ConceptosEditor';
|
||||
import OpinionLinkBox from '@/components/panel/OpinionLinkBox';
|
||||
@@ -19,9 +20,20 @@ import type { BudgetResult } from '@/budget/types';
|
||||
|
||||
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 (
|
||||
<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>
|
||||
{children}
|
||||
</section>
|
||||
@@ -41,6 +53,14 @@ export default async function LeadDetailPage({ params }: { params: Promise<{ id:
|
||||
const prefs = lead.preferencesSnapshot as import('@/lib/voice/preferences').AbstractedPreferences | null;
|
||||
const yaEnviado = lead.pipelineStage === 'whatsapp_entregado';
|
||||
|
||||
// Baremo de rentabilidad del reformista (informativo): si el presupuesto estimado no lo alcanza,
|
||||
// se marca en rojo. null = sin baremo o sin presupuesto aún (no se marca nada).
|
||||
const baremoMinimo = (await getPricingConfigFor(lead.tenantId)).baremoMinimo ?? null;
|
||||
const pasaBaremo =
|
||||
baremoMinimo != null && lead.presupuestoEstimado != null
|
||||
? lead.presupuestoEstimado >= baremoMinimo
|
||||
: null;
|
||||
|
||||
const h = await headers();
|
||||
const host = h.get('x-forwarded-host') ?? h.get('host') ?? 'reformix.dv3.com.es';
|
||||
const proto = h.get('x-forwarded-proto') ?? 'https';
|
||||
@@ -62,16 +82,25 @@ export default async function LeadDetailPage({ params }: { params: Promise<{ id:
|
||||
{lead.provincia ?? '—'} · entró {formatFecha(lead.createdAt)}
|
||||
</p>
|
||||
</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-2xl font-black text-black">{formatEuros(lead.presupuestoEstimado)}</div>
|
||||
<div className={`text-2xl font-black ${pasaBaremo === false ? 'text-red-600' : 'text-black'}`}>
|
||||
{formatEuros(lead.presupuestoEstimado)}
|
||||
</div>
|
||||
{pasaBaremo === false && baremoMinimo != null && (
|
||||
<div className="mt-0.5 text-xs font-semibold text-red-600">
|
||||
Por debajo de tu baremo ({formatEuros(baremoMinimo)})
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<EstadoControl
|
||||
leadId={lead.id}
|
||||
estado={lead.estado}
|
||||
presupuestoEstimado={lead.presupuestoEstimado}
|
||||
/>
|
||||
<div data-tour="ficha-estado">
|
||||
<EstadoControl
|
||||
leadId={lead.id}
|
||||
estado={lead.estado}
|
||||
presupuestoEstimado={lead.presupuestoEstimado}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Solicitar opinión al cliente */}
|
||||
@@ -164,7 +193,7 @@ export default async function LeadDetailPage({ params }: { params: Promise<{ id:
|
||||
</Section>
|
||||
|
||||
{/* 4. Render */}
|
||||
<Section title="Render generado">
|
||||
<Section title="Render generado" tour="ficha-render">
|
||||
{lead.renderUrl ? (
|
||||
// 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" />
|
||||
@@ -311,7 +340,7 @@ export default async function LeadDetailPage({ params }: { params: Promise<{ id:
|
||||
)}
|
||||
|
||||
{/* Presupuesto desglosado */}
|
||||
<Section title="Presupuesto desglosado">
|
||||
<Section title="Presupuesto desglosado" tour="ficha-desglose">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<form action={recalcularPresupuesto.bind(null, lead.id)}>
|
||||
<button
|
||||
|
||||
@@ -5,6 +5,7 @@ import { db } from '@/db';
|
||||
import { tenants } from '@/db/schema';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import AppNav from '@/components/AppNav';
|
||||
import PanelTour from '@/components/panel/PanelTour';
|
||||
|
||||
const PANEL_LINKS = [
|
||||
{ href: '/panel', label: 'Leads', icon: 'leads' },
|
||||
@@ -44,6 +45,7 @@ export default async function PanelLayout({ children }: { children: React.ReactN
|
||||
</div>
|
||||
</header>
|
||||
<main className="max-w-6xl mx-auto px-6 py-8 pb-24 sm:pb-8">{children}</main>
|
||||
<PanelTour />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -69,7 +69,7 @@ export default async function PanelPage({
|
||||
</div>
|
||||
|
||||
{/* Filtros por estado */}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<div className="flex flex-wrap gap-2" data-tour="leads-filtros">
|
||||
{FILTROS.map((f) => {
|
||||
const active = f.value === filtro;
|
||||
const count = f.value === 'todos' ? resumen.total : resumen.porEstado[f.value] ?? 0;
|
||||
@@ -89,7 +89,9 @@ export default async function PanelPage({
|
||||
})}
|
||||
</div>
|
||||
|
||||
<LeadsView leads={leadsView} />
|
||||
<div data-tour="leads-tabla">
|
||||
<LeadsView leads={leadsView} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -93,6 +93,19 @@ export async function actualizarExtras(formData: FormData) {
|
||||
revalidatePath('/panel/precios');
|
||||
}
|
||||
|
||||
export async function actualizarBaremo(formData: FormData) {
|
||||
const tenantId = await getTenantId();
|
||||
const raw = formData.get('baremoMinimo');
|
||||
const txt = typeof raw === 'string' ? raw.trim() : '';
|
||||
// Vacío = sin baremo (null). Con valor = euros → céntimos.
|
||||
const baremoMinimo = txt === '' ? null : eurosToCents(raw, 'baremo de rentabilidad');
|
||||
await db
|
||||
.update(pricingConfig)
|
||||
.set({ baremoMinimo, updatedAt: new Date() })
|
||||
.where(eq(pricingConfig.tenantId, tenantId));
|
||||
revalidatePath('/panel/precios');
|
||||
}
|
||||
|
||||
export async function actualizarEnvio(formData: FormData) {
|
||||
const tenantId = await getTenantId();
|
||||
const modo = formData.get('modo');
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
borrarMaterial,
|
||||
actualizarConfig,
|
||||
actualizarExtras,
|
||||
actualizarBaremo,
|
||||
actualizarEnvio,
|
||||
importarCatalogoCsv,
|
||||
} from './actions';
|
||||
@@ -81,7 +82,7 @@ export default async function PreciosPage() {
|
||||
</section>
|
||||
|
||||
{/* 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>
|
||||
<form action={actualizarConfig} className="grid grid-cols-2 md:grid-cols-5 gap-3 items-end">
|
||||
<label className="text-sm">
|
||||
@@ -120,6 +121,34 @@ export default async function PreciosPage() {
|
||||
</form>
|
||||
</section>
|
||||
|
||||
{/* Baremo de rentabilidad */}
|
||||
<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>
|
||||
<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
|
||||
ficha de cada lead verás marcados en otro color los presupuestos que no lleguen a este valor.
|
||||
No afecta a lo que ve el cliente ni a la conversación de los agentes. Déjalo vacío para no usar
|
||||
baremo.
|
||||
</p>
|
||||
<form action={actualizarBaremo} className="flex flex-wrap items-end gap-3">
|
||||
<label className="text-sm">
|
||||
<span className="block text-xs text-gray-500 mb-1">Baremo mínimo (€)</span>
|
||||
<input
|
||||
name="baremoMinimo"
|
||||
type="number"
|
||||
step="1"
|
||||
min="0"
|
||||
defaultValue={config.baremoMinimo != null ? config.baremoMinimo / 100 : ''}
|
||||
placeholder="Sin baremo"
|
||||
className="w-40 border border-gray-300 rounded-lg px-2 py-1.5"
|
||||
/>
|
||||
</label>
|
||||
<button className="bg-primary-700 text-white rounded-lg px-4 py-2 text-sm font-medium">
|
||||
Guardar baremo
|
||||
</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
{/* Extras fijos */}
|
||||
<section className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<h2 className="font-bold text-black mb-1">Extras fijos</h2>
|
||||
@@ -256,7 +285,7 @@ export default async function PreciosPage() {
|
||||
})}
|
||||
|
||||
{/* 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>
|
||||
<p className="text-xs text-gray-500 mb-3">
|
||||
Cabecera: <code className="break-all">categoria,nombre,calidad,precio,unidad,descriptor_render,sku</code>. El
|
||||
|
||||
@@ -1,82 +1,295 @@
|
||||
import Link from 'next/link';
|
||||
import { notFound } from 'next/navigation';
|
||||
import { getPublicLead } from '@/lib/funnel/public-queries';
|
||||
import { resolveTheme, themeStyle } from '@/lib/funnel/themes';
|
||||
import TenantBrand from '@/components/funnel/TenantBrand';
|
||||
|
||||
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 = [
|
||||
{
|
||||
slug: 'llamada',
|
||||
icon: '📞',
|
||||
icon: <IconLlamada />,
|
||||
titulo: 'Que te llamemos',
|
||||
descripcion:
|
||||
'Un asistente te llama y te hace unas preguntas rápidas. Lo más cómodo: no escribes nada.',
|
||||
cta: 'Quiero que me llamen',
|
||||
badge: 'La más rápida',
|
||||
},
|
||||
{
|
||||
slug: 'whatsapp',
|
||||
icon: '💬',
|
||||
icon: <IconWhatsapp />,
|
||||
titulo: 'Por WhatsApp',
|
||||
descripcion:
|
||||
'Seguimos por chat a tu ritmo. Puedes mandar fotos y notas cuando quieras.',
|
||||
descripcion: 'Seguimos por chat a tu ritmo. Puedes mandar fotos y notas cuando quieras.',
|
||||
cta: 'Seguir por WhatsApp',
|
||||
badge: null,
|
||||
},
|
||||
{
|
||||
slug: 'formulario',
|
||||
icon: '📝',
|
||||
icon: <IconFormulario />,
|
||||
titulo: 'Rellenar un formulario',
|
||||
descripcion:
|
||||
'Tú lo cuentas zona por zona y subes las fotos. Recibes el presupuesto al instante.',
|
||||
cta: 'Rellenar el formulario',
|
||||
badge: null,
|
||||
},
|
||||
] 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 }> }) {
|
||||
const { id } = await params;
|
||||
const data = await getPublicLead(id);
|
||||
if (!data) notFound();
|
||||
|
||||
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 (
|
||||
<>
|
||||
<div
|
||||
className={theme.heading === 'serif' ? 'theme-serif' : undefined}
|
||||
style={themeStyle(tenant?.themePreset, tenant?.themeColor)}
|
||||
>
|
||||
{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">
|
||||
<span className="text-xs font-semibold uppercase tracking-widest text-gray-400">
|
||||
Elige cómo seguir
|
||||
</span>
|
||||
<h1 className="text-2xl font-black tracking-tight text-black">
|
||||
¿Cómo prefieres contarnos tu reforma, {lead.nombre.split(' ')[0]}?
|
||||
</h1>
|
||||
<p className="text-sm text-gray-500 leading-relaxed">
|
||||
Tú eliges. Por cualquiera de las tres nos das lo que necesitamos para preparar tu render
|
||||
y tu presupuesto.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
{CANALES.map((c) => (
|
||||
<Link
|
||||
key={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"
|
||||
>
|
||||
<span className="text-3xl shrink-0" aria-hidden="true">
|
||||
{c.icon}
|
||||
</span>
|
||||
<div className="flex flex-col gap-0.5 min-w-0">
|
||||
<span className="text-base font-bold text-black">{c.titulo}</span>
|
||||
<span className="text-sm text-gray-500 leading-snug">{c.descripcion}</span>
|
||||
<span className="text-sm font-semibold text-[color:var(--brand,#0a0a0a)] mt-1">
|
||||
{c.cta} →
|
||||
<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">
|
||||
Elige cómo seguir
|
||||
</span>
|
||||
<h1 className="text-3xl md:text-4xl font-black tracking-tight text-black leading-[1.1] text-balance">
|
||||
¿Cómo prefieres contarnos tu reforma, {nombrePila}?
|
||||
</h1>
|
||||
<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 y tu presupuesto.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div className="grid gap-3 md:grid-cols-3 md:gap-4">
|
||||
{CANALES.map((c, i) => (
|
||||
<Link
|
||||
key={c.slug}
|
||||
href={`/solicitud/${id}/${c.slug}`}
|
||||
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"
|
||||
>
|
||||
{c.icon}
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
<span className="flex flex-col gap-1.5 min-w-0 flex-1">
|
||||
<h2 className="text-lg font-black tracking-tight text-black leading-snug">
|
||||
{c.titulo}
|
||||
</h2>
|
||||
<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>
|
||||
</Link>
|
||||
))}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -46,6 +46,7 @@ export interface PricingConfig {
|
||||
factorZona: Record<string, number>; // provincia -> multiplicador
|
||||
manoObra: Record<ManoObraKey, number>; // céntimos por m² de suelo
|
||||
extras?: ExtrasFijos; // importes fijos en céntimos
|
||||
baremoMinimo?: number | null; // céntimos; trabajo mínimo rentable (informativo, no lo usan los agentes)
|
||||
}
|
||||
|
||||
export interface BudgetInputs {
|
||||
|
||||
@@ -116,6 +116,7 @@ export default function AppNav({ links }: { links: readonly AppNavLink[] }) {
|
||||
<Link
|
||||
key={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'}
|
||||
>
|
||||
{l.label}
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import type { PublicGaleriaFoto } from '@/lib/funnel/public-queries';
|
||||
|
||||
type GaleriaTrabajosProps = {
|
||||
@@ -5,11 +8,37 @@ type GaleriaTrabajosProps = {
|
||||
nombreEmpresa: string;
|
||||
};
|
||||
|
||||
// Galería de trabajos del reformista en su landing pública. Solo se muestra si
|
||||
// el reformista ha subido fotos desde su panel.
|
||||
// Galería de trabajos del reformista en su landing pública. Solo se muestra si el reformista ha
|
||||
// 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) {
|
||||
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;
|
||||
|
||||
const actual = idx !== null ? fotos[idx] : null;
|
||||
|
||||
return (
|
||||
<section id="galeria" className="bg-gray-50 section" aria-label="Galería de trabajos">
|
||||
<div className="container">
|
||||
@@ -24,24 +53,31 @@ export default function GaleriaTrabajos({ fotos, nombreEmpresa }: GaleriaTrabajo
|
||||
Reformas que ya hemos hecho
|
||||
</h2>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-3 md:gap-4">
|
||||
{fotos.map((f) => (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3 md:gap-4">
|
||||
{fotos.map((f, i) => (
|
||||
<figure
|
||||
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"
|
||||
>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={f.url}
|
||||
alt={f.titulo ?? `Reforma de ${nombreEmpresa}`}
|
||||
className="w-full h-full object-cover transition-transform duration-500 group-hover:scale-105"
|
||||
/>
|
||||
<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 */}
|
||||
<img
|
||||
src={f.url}
|
||||
alt={f.titulo ?? `Reforma de ${nombreEmpresa}`}
|
||||
className="h-full w-full object-cover transition-transform duration-500 group-hover:scale-105"
|
||||
/>
|
||||
</button>
|
||||
{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}
|
||||
</figcaption>
|
||||
)}
|
||||
@@ -49,6 +85,67 @@ export default function GaleriaTrabajos({ fotos, nombreEmpresa }: GaleriaTrabajo
|
||||
))}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -42,6 +42,7 @@ export async function getPricingConfigFor(tenantId: string): Promise<PricingConf
|
||||
factorZona: {},
|
||||
manoObra: { ...MANO_OBRA_DEFAULT },
|
||||
extras: { ...EXTRAS_DEFAULT },
|
||||
baremoMinimo: null,
|
||||
};
|
||||
}
|
||||
return {
|
||||
@@ -49,6 +50,7 @@ export async function getPricingConfigFor(tenantId: string): Promise<PricingConf
|
||||
factorZona: row.factorZona,
|
||||
manoObra: { ...MANO_OBRA_DEFAULT, ...(row.manoObra as Record<ManoObraKey, number>) },
|
||||
extras: { ...EXTRAS_DEFAULT, ...(row.extras ?? {}) },
|
||||
baremoMinimo: row.baremoMinimo ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -396,6 +396,10 @@ export const pricingConfig = pgTable('pricing_config', {
|
||||
.$type<{ tuberias: number; boletin: number; distribucion: number }>()
|
||||
.notNull()
|
||||
.default({ tuberias: 0, boletin: 0, distribucion: 0 }),
|
||||
// Baremo de rentabilidad (céntimos): importe mínimo que el reformista considera rentable. Solo
|
||||
// informativo en el panel (marca en otro color los leads por debajo); los agentes NO lo usan para
|
||||
// decidir nada. Null = sin baremo configurado.
|
||||
baremoMinimo: integer('baremo_minimo'),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
});
|
||||
|
||||
|
||||
@@ -291,13 +291,13 @@ const SEED_LEADS: SeedLead[] = [
|
||||
const STAGE_ORDER = schema.pipelineStage.enumValues;
|
||||
|
||||
async function main() {
|
||||
const [existing] = await db
|
||||
.select()
|
||||
.from(schema.tenants)
|
||||
.where(eq(schema.tenants.slug, 'reformas-ejemplo'))
|
||||
.limit(1);
|
||||
// Guard de seguridad: solo sembramos si la base de datos está VACÍA (sin ningún tenant). Antes se
|
||||
// comprobaba un slug concreto ("reformas-ejemplo"); si ese tenant no estaba pero había otros
|
||||
// (p. ej. una empresa creada por el reformista), el seed los TRUNCABA en cada deploy → pérdida de
|
||||
// datos. Ahora cualquier tenant existente protege toda la DB. SEED_FORCE=1 fuerza el reseed (BORRA TODO).
|
||||
const [existing] = await db.select({ id: schema.tenants.id }).from(schema.tenants).limit(1);
|
||||
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();
|
||||
return;
|
||||
}
|
||||
|
||||
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