This commit is contained in:
unknown
2026-05-31 22:07:12 -04:00
153 changed files with 23582 additions and 654 deletions

4
mvp/b2b/Dockerfile Normal file
View File

@@ -0,0 +1,4 @@
FROM nginx:alpine
COPY index.html /usr/share/nginx/html/index.html
COPY assets /usr/share/nginx/html/assets
EXPOSE 80

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 203 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 198 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 153 KiB

View File

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64">
<rect width="64" height="64" rx="14" fill="#2F5C46"/>
<text x="32" y="48" text-anchor="middle" font-family="Georgia, 'Instrument Serif', 'Times New Roman', serif" font-style="italic" font-size="48" fill="#F6F4EF">R</text>
</svg>

After

Width:  |  Height:  |  Size: 317 B

2136
mvp/b2b/index.html Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

3
mvp/b2c/.env.example Normal file
View File

@@ -0,0 +1,3 @@
# Postgres — panel del reformista (Superficie D) y persistencia del funnel B2C.
# Local con Docker: docker run --name reformix-pg -e POSTGRES_PASSWORD=reformix -e POSTGRES_DB=reformix -p 5432:5432 -d postgres:17
DATABASE_URL="postgresql://postgres:reformix@localhost:5432/reformix"

1
mvp/b2c/.gitignore vendored
View File

@@ -32,6 +32,7 @@ yarn-error.log*
# env files (can opt-in for committing if needed)
.env*
!.env.example
# vercel
.vercel

View File

@@ -13,5 +13,12 @@ COPY --from=builder /app/package.json /app/package-lock.json ./
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/.next ./.next
COPY --from=builder /app/public ./public
# Necesario para migrar y sembrar al arrancar (drizzle-kit + tsx + seed)
COPY --from=builder /app/drizzle ./drizzle
COPY --from=builder /app/drizzle.config.ts ./drizzle.config.ts
COPY --from=builder /app/tsconfig.json ./tsconfig.json
COPY --from=builder /app/src ./src
COPY --from=builder /app/docker-entrypoint.sh ./docker-entrypoint.sh
RUN chmod +x ./docker-entrypoint.sh
EXPOSE 3000
CMD ["npm", "run", "start"]
CMD ["./docker-entrypoint.sh"]

View File

@@ -91,6 +91,14 @@ npm run lint # ESLint — análisis estático del código
---
## 💶 Panel y motor de presupuesto
- **`/panel`** — listado de leads y detalle (`/panel/[id]`) con presupuesto desglosado y botón *Recalcular*.
- **`/panel/precios`** — tabla de precios editable (config general + catálogo por categoría) e importación de catálogo vía CSV.
- **Motor** (`src/budget/`) — función pura `computeBudget` que calcula el presupuesto por partidas a partir de medidas mínimas + calidad, escalando con más datos.
---
## 🔗 Repositorio
GitHub: [McGregory99/reformix-hackaton](https://github.com/McGregory99/reformix-hackaton)

View File

@@ -0,0 +1,11 @@
#!/bin/sh
set -e
echo "==> Aplicando migraciones (drizzle-kit migrate)"
npm run db:migrate
echo "==> Sembrando datos demo (si la DB está vacía)"
npm run db:seed
echo "==> Arrancando Next.js"
exec npm run start

13
mvp/b2c/drizzle.config.ts Normal file
View File

@@ -0,0 +1,13 @@
import 'dotenv/config';
import { defineConfig } from 'drizzle-kit';
export default defineConfig({
schema: './src/db/schema.ts',
out: './drizzle',
dialect: 'postgresql',
dbCredentials: {
url: process.env.DATABASE_URL!,
},
verbose: true,
strict: true,
});

View File

@@ -0,0 +1,77 @@
CREATE TYPE "public"."lead_estado" AS ENUM('nuevo', 'contactado', 'visita_agendada', 'presupuesto_enviado', 'ganado', 'perdido');--> statement-breakpoint
CREATE TYPE "public"."pipeline_stage" AS ENUM('form_completado', 'fotos_subidas', 'prellamada_enviada', 'llamada_completada', 'render_generado', 'presupuesto_generado', 'whatsapp_entregado');--> statement-breakpoint
CREATE TYPE "public"."tipo_reforma" AS ENUM('cocina', 'bano', 'salon', 'comedor', 'integral', 'otro');--> statement-breakpoint
CREATE TABLE "lead_estado_history" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"lead_id" uuid NOT NULL,
"estado" "lead_estado" NOT NULL,
"changed_at" timestamp with time zone DEFAULT now() NOT NULL,
"changed_by" text
);
--> statement-breakpoint
CREATE TABLE "lead_fotos" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"lead_id" uuid NOT NULL,
"url" text NOT NULL,
"orden" integer DEFAULT 0 NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "lead_pipeline_eventos" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"lead_id" uuid NOT NULL,
"stage" "pipeline_stage" NOT NULL,
"occurred_at" timestamp with time zone DEFAULT now() NOT NULL,
"metadata" jsonb
);
--> statement-breakpoint
CREATE TABLE "leads" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"tenant_id" uuid NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
"nombre" text NOT NULL,
"telefono" text NOT NULL,
"email" text NOT NULL,
"provincia" text,
"tipo_reforma" "tipo_reforma",
"consent_privacidad" boolean DEFAULT false NOT NULL,
"consent_contratacion" boolean DEFAULT false NOT NULL,
"pipeline_stage" "pipeline_stage" DEFAULT 'form_completado' NOT NULL,
"estado" "lead_estado" DEFAULT 'nuevo' NOT NULL,
"presupuesto_estimado" integer,
"transcripcion" text,
"entidades" jsonb,
"render_url" text,
"pdf_url" text,
"audio_url" text,
"notas" text
);
--> statement-breakpoint
CREATE TABLE "precision_history" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"lead_id" uuid NOT NULL,
"estimated" integer NOT NULL,
"final" integer NOT NULL,
"delta_pct" numeric(6, 2) NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "tenants" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"slug" text NOT NULL,
"nombre_empresa" text NOT NULL,
"logo_url" text,
"provincia" text,
"whatsapp_business" text,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "tenants_slug_unique" UNIQUE("slug")
);
--> statement-breakpoint
ALTER TABLE "lead_estado_history" ADD CONSTRAINT "lead_estado_history_lead_id_leads_id_fk" FOREIGN KEY ("lead_id") REFERENCES "public"."leads"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "lead_fotos" ADD CONSTRAINT "lead_fotos_lead_id_leads_id_fk" FOREIGN KEY ("lead_id") REFERENCES "public"."leads"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "lead_pipeline_eventos" ADD CONSTRAINT "lead_pipeline_eventos_lead_id_leads_id_fk" FOREIGN KEY ("lead_id") REFERENCES "public"."leads"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "leads" ADD CONSTRAINT "leads_tenant_id_tenants_id_fk" FOREIGN KEY ("tenant_id") REFERENCES "public"."tenants"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "precision_history" ADD CONSTRAINT "precision_history_lead_id_leads_id_fk" FOREIGN KEY ("lead_id") REFERENCES "public"."leads"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "leads_tenant_created_idx" ON "leads" USING btree ("tenant_id","created_at");--> statement-breakpoint
CREATE INDEX "leads_estado_idx" ON "leads" USING btree ("estado");

View File

@@ -0,0 +1,36 @@
CREATE TYPE "public"."calidad" AS ENUM('basica', 'media', 'premium');--> statement-breakpoint
CREATE TYPE "public"."categoria_material" AS ENUM('suelo', 'pared', 'pintura', 'mobiliario');--> statement-breakpoint
CREATE TYPE "public"."unidad_medida" AS ENUM('m2', 'ml', 'ud');--> statement-breakpoint
CREATE TABLE "catalog_items" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"tenant_id" uuid NOT NULL,
"categoria" "categoria_material" NOT NULL,
"nombre" text NOT NULL,
"calidad" "calidad" NOT NULL,
"precio_unit" integer NOT NULL,
"unidad" "unidad_medida" NOT NULL,
"descriptor_render" text DEFAULT '' NOT NULL,
"es_default" boolean DEFAULT false NOT NULL,
"sku" text NOT NULL
);
--> statement-breakpoint
CREATE TABLE "pricing_config" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"tenant_id" uuid NOT NULL,
"altura_techo_default" double precision DEFAULT 2.5 NOT NULL,
"factor_zona" jsonb DEFAULT '{}'::jsonb NOT NULL,
"mano_obra" jsonb DEFAULT '{}'::jsonb NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "pricing_config_tenant_id_unique" UNIQUE("tenant_id")
);
--> statement-breakpoint
ALTER TABLE "leads" ADD COLUMN "m2_suelo" double precision;--> statement-breakpoint
ALTER TABLE "leads" ADD COLUMN "altura_techo" double precision;--> statement-breakpoint
ALTER TABLE "leads" ADD COLUMN "calidad_global" "calidad";--> statement-breakpoint
ALTER TABLE "leads" ADD COLUMN "estructural" boolean DEFAULT false NOT NULL;--> statement-breakpoint
ALTER TABLE "leads" ADD COLUMN "material_selections" jsonb DEFAULT '{}'::jsonb NOT NULL;--> statement-breakpoint
ALTER TABLE "leads" ADD COLUMN "desglose_snapshot" jsonb;--> statement-breakpoint
ALTER TABLE "catalog_items" ADD CONSTRAINT "catalog_items_tenant_id_tenants_id_fk" FOREIGN KEY ("tenant_id") REFERENCES "public"."tenants"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "pricing_config" ADD CONSTRAINT "pricing_config_tenant_id_tenants_id_fk" FOREIGN KEY ("tenant_id") REFERENCES "public"."tenants"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "catalog_tenant_idx" ON "catalog_items" USING btree ("tenant_id");--> statement-breakpoint
CREATE UNIQUE INDEX "catalog_tenant_sku_idx" ON "catalog_items" USING btree ("tenant_id","sku");

View File

@@ -0,0 +1,45 @@
CREATE TYPE "public"."subscription_status" AS ENUM('trial', 'activo', 'cancelado', 'vencido');--> statement-breakpoint
CREATE TYPE "public"."user_role" AS ENUM('reformista', 'admin');--> statement-breakpoint
CREATE TYPE "public"."user_status" AS ENUM('activo', 'deshabilitado');--> statement-breakpoint
CREATE TABLE "plans" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"slug" text NOT NULL,
"nombre" text NOT NULL,
"precio_mensual" integer NOT NULL,
"leads_incluidos" integer NOT NULL,
"features" jsonb DEFAULT '[]'::jsonb NOT NULL,
"activo" boolean DEFAULT true NOT NULL,
CONSTRAINT "plans_slug_unique" UNIQUE("slug")
);
--> statement-breakpoint
CREATE TABLE "sessions" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"user_id" uuid NOT NULL,
"token_hash" text NOT NULL,
"expires_at" timestamp with time zone NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "sessions_token_hash_unique" UNIQUE("token_hash")
);
--> statement-breakpoint
CREATE TABLE "users" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"email" text NOT NULL,
"password_hash" text NOT NULL,
"nombre" text,
"role" "user_role" DEFAULT 'reformista' NOT NULL,
"tenant_id" uuid,
"status" "user_status" DEFAULT 'activo' NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "users_email_unique" UNIQUE("email")
);
--> statement-breakpoint
ALTER TABLE "tenants" ADD COLUMN "plan_id" uuid;--> statement-breakpoint
ALTER TABLE "tenants" ADD COLUMN "subscription_status" "subscription_status" DEFAULT 'trial' NOT NULL;--> statement-breakpoint
ALTER TABLE "tenants" ADD COLUMN "trial_ends_at" timestamp with time zone;--> statement-breakpoint
ALTER TABLE "tenants" ADD COLUMN "stripe_customer_id" text;--> statement-breakpoint
ALTER TABLE "sessions" ADD CONSTRAINT "sessions_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "users" ADD CONSTRAINT "users_tenant_id_tenants_id_fk" FOREIGN KEY ("tenant_id") REFERENCES "public"."tenants"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "sessions_user_idx" ON "sessions" USING btree ("user_id");--> statement-breakpoint
CREATE INDEX "users_tenant_idx" ON "users" USING btree ("tenant_id");--> statement-breakpoint
ALTER TABLE "tenants" ADD CONSTRAINT "tenants_plan_id_plans_id_fk" FOREIGN KEY ("plan_id") REFERENCES "public"."plans"("id") ON DELETE no action ON UPDATE no action;

View File

@@ -0,0 +1,2 @@
CREATE TYPE "public"."envio_presupuesto_mode" AS ENUM('automatico', 'revision');--> statement-breakpoint
ALTER TABLE "tenants" ADD COLUMN "envio_presupuesto" "envio_presupuesto_mode" DEFAULT 'automatico' NOT NULL;

View File

@@ -0,0 +1,5 @@
ALTER TABLE "tenants" ADD COLUMN "cif" text;--> statement-breakpoint
ALTER TABLE "tenants" ADD COLUMN "direccion" text;--> statement-breakpoint
ALTER TABLE "tenants" ADD COLUMN "telefono" text;--> statement-breakpoint
ALTER TABLE "tenants" ADD COLUMN "email" text;--> statement-breakpoint
ALTER TABLE "tenants" ADD COLUMN "web" text;

View File

@@ -0,0 +1,5 @@
CREATE TYPE "public"."urgencia" AS ENUM('alta', 'media', 'baja');--> statement-breakpoint
ALTER TABLE "leads" ADD COLUMN "urgencia" "urgencia";--> statement-breakpoint
ALTER TABLE "leads" ADD COLUMN "presupuesto_target" integer;--> statement-breakpoint
ALTER TABLE "leads" ADD COLUMN "taste_text" text;--> statement-breakpoint
ALTER TABLE "leads" ADD COLUMN "preferences_snapshot" jsonb;

View File

@@ -0,0 +1,561 @@
{
"id": "66acce06-f292-49db-adc1-fa9cfcc7d2a9",
"prevId": "00000000-0000-0000-0000-000000000000",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.lead_estado_history": {
"name": "lead_estado_history",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"lead_id": {
"name": "lead_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"estado": {
"name": "estado",
"type": "lead_estado",
"typeSchema": "public",
"primaryKey": false,
"notNull": true
},
"changed_at": {
"name": "changed_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"changed_by": {
"name": "changed_by",
"type": "text",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {
"lead_estado_history_lead_id_leads_id_fk": {
"name": "lead_estado_history_lead_id_leads_id_fk",
"tableFrom": "lead_estado_history",
"tableTo": "leads",
"columnsFrom": [
"lead_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.lead_fotos": {
"name": "lead_fotos",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"lead_id": {
"name": "lead_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"url": {
"name": "url",
"type": "text",
"primaryKey": false,
"notNull": true
},
"orden": {
"name": "orden",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 0
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"lead_fotos_lead_id_leads_id_fk": {
"name": "lead_fotos_lead_id_leads_id_fk",
"tableFrom": "lead_fotos",
"tableTo": "leads",
"columnsFrom": [
"lead_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.lead_pipeline_eventos": {
"name": "lead_pipeline_eventos",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"lead_id": {
"name": "lead_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"stage": {
"name": "stage",
"type": "pipeline_stage",
"typeSchema": "public",
"primaryKey": false,
"notNull": true
},
"occurred_at": {
"name": "occurred_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"metadata": {
"name": "metadata",
"type": "jsonb",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {
"lead_pipeline_eventos_lead_id_leads_id_fk": {
"name": "lead_pipeline_eventos_lead_id_leads_id_fk",
"tableFrom": "lead_pipeline_eventos",
"tableTo": "leads",
"columnsFrom": [
"lead_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.leads": {
"name": "leads",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"tenant_id": {
"name": "tenant_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"nombre": {
"name": "nombre",
"type": "text",
"primaryKey": false,
"notNull": true
},
"telefono": {
"name": "telefono",
"type": "text",
"primaryKey": false,
"notNull": true
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": true
},
"provincia": {
"name": "provincia",
"type": "text",
"primaryKey": false,
"notNull": false
},
"tipo_reforma": {
"name": "tipo_reforma",
"type": "tipo_reforma",
"typeSchema": "public",
"primaryKey": false,
"notNull": false
},
"consent_privacidad": {
"name": "consent_privacidad",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
},
"consent_contratacion": {
"name": "consent_contratacion",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
},
"pipeline_stage": {
"name": "pipeline_stage",
"type": "pipeline_stage",
"typeSchema": "public",
"primaryKey": false,
"notNull": true,
"default": "'form_completado'"
},
"estado": {
"name": "estado",
"type": "lead_estado",
"typeSchema": "public",
"primaryKey": false,
"notNull": true,
"default": "'nuevo'"
},
"presupuesto_estimado": {
"name": "presupuesto_estimado",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"transcripcion": {
"name": "transcripcion",
"type": "text",
"primaryKey": false,
"notNull": false
},
"entidades": {
"name": "entidades",
"type": "jsonb",
"primaryKey": false,
"notNull": false
},
"render_url": {
"name": "render_url",
"type": "text",
"primaryKey": false,
"notNull": false
},
"pdf_url": {
"name": "pdf_url",
"type": "text",
"primaryKey": false,
"notNull": false
},
"audio_url": {
"name": "audio_url",
"type": "text",
"primaryKey": false,
"notNull": false
},
"notas": {
"name": "notas",
"type": "text",
"primaryKey": false,
"notNull": false
}
},
"indexes": {
"leads_tenant_created_idx": {
"name": "leads_tenant_created_idx",
"columns": [
{
"expression": "tenant_id",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "created_at",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"leads_estado_idx": {
"name": "leads_estado_idx",
"columns": [
{
"expression": "estado",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"leads_tenant_id_tenants_id_fk": {
"name": "leads_tenant_id_tenants_id_fk",
"tableFrom": "leads",
"tableTo": "tenants",
"columnsFrom": [
"tenant_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.precision_history": {
"name": "precision_history",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"lead_id": {
"name": "lead_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"estimated": {
"name": "estimated",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"final": {
"name": "final",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"delta_pct": {
"name": "delta_pct",
"type": "numeric(6, 2)",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"precision_history_lead_id_leads_id_fk": {
"name": "precision_history_lead_id_leads_id_fk",
"tableFrom": "precision_history",
"tableTo": "leads",
"columnsFrom": [
"lead_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.tenants": {
"name": "tenants",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"slug": {
"name": "slug",
"type": "text",
"primaryKey": false,
"notNull": true
},
"nombre_empresa": {
"name": "nombre_empresa",
"type": "text",
"primaryKey": false,
"notNull": true
},
"logo_url": {
"name": "logo_url",
"type": "text",
"primaryKey": false,
"notNull": false
},
"provincia": {
"name": "provincia",
"type": "text",
"primaryKey": false,
"notNull": false
},
"whatsapp_business": {
"name": "whatsapp_business",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"tenants_slug_unique": {
"name": "tenants_slug_unique",
"nullsNotDistinct": false,
"columns": [
"slug"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {
"public.lead_estado": {
"name": "lead_estado",
"schema": "public",
"values": [
"nuevo",
"contactado",
"visita_agendada",
"presupuesto_enviado",
"ganado",
"perdido"
]
},
"public.pipeline_stage": {
"name": "pipeline_stage",
"schema": "public",
"values": [
"form_completado",
"fotos_subidas",
"prellamada_enviada",
"llamada_completada",
"render_generado",
"presupuesto_generado",
"whatsapp_entregado"
]
},
"public.tipo_reforma": {
"name": "tipo_reforma",
"schema": "public",
"values": [
"cocina",
"bano",
"salon",
"comedor",
"integral",
"otro"
]
}
},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}

View File

@@ -0,0 +1,834 @@
{
"id": "57e8d006-18f6-4aba-a61a-02d155a80bbc",
"prevId": "66acce06-f292-49db-adc1-fa9cfcc7d2a9",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.catalog_items": {
"name": "catalog_items",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"tenant_id": {
"name": "tenant_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"categoria": {
"name": "categoria",
"type": "categoria_material",
"typeSchema": "public",
"primaryKey": false,
"notNull": true
},
"nombre": {
"name": "nombre",
"type": "text",
"primaryKey": false,
"notNull": true
},
"calidad": {
"name": "calidad",
"type": "calidad",
"typeSchema": "public",
"primaryKey": false,
"notNull": true
},
"precio_unit": {
"name": "precio_unit",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"unidad": {
"name": "unidad",
"type": "unidad_medida",
"typeSchema": "public",
"primaryKey": false,
"notNull": true
},
"descriptor_render": {
"name": "descriptor_render",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "''"
},
"es_default": {
"name": "es_default",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
},
"sku": {
"name": "sku",
"type": "text",
"primaryKey": false,
"notNull": true
}
},
"indexes": {
"catalog_tenant_idx": {
"name": "catalog_tenant_idx",
"columns": [
{
"expression": "tenant_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"catalog_tenant_sku_idx": {
"name": "catalog_tenant_sku_idx",
"columns": [
{
"expression": "tenant_id",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "sku",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": true,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"catalog_items_tenant_id_tenants_id_fk": {
"name": "catalog_items_tenant_id_tenants_id_fk",
"tableFrom": "catalog_items",
"tableTo": "tenants",
"columnsFrom": [
"tenant_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.lead_estado_history": {
"name": "lead_estado_history",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"lead_id": {
"name": "lead_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"estado": {
"name": "estado",
"type": "lead_estado",
"typeSchema": "public",
"primaryKey": false,
"notNull": true
},
"changed_at": {
"name": "changed_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"changed_by": {
"name": "changed_by",
"type": "text",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {
"lead_estado_history_lead_id_leads_id_fk": {
"name": "lead_estado_history_lead_id_leads_id_fk",
"tableFrom": "lead_estado_history",
"tableTo": "leads",
"columnsFrom": [
"lead_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.lead_fotos": {
"name": "lead_fotos",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"lead_id": {
"name": "lead_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"url": {
"name": "url",
"type": "text",
"primaryKey": false,
"notNull": true
},
"orden": {
"name": "orden",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 0
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"lead_fotos_lead_id_leads_id_fk": {
"name": "lead_fotos_lead_id_leads_id_fk",
"tableFrom": "lead_fotos",
"tableTo": "leads",
"columnsFrom": [
"lead_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.lead_pipeline_eventos": {
"name": "lead_pipeline_eventos",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"lead_id": {
"name": "lead_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"stage": {
"name": "stage",
"type": "pipeline_stage",
"typeSchema": "public",
"primaryKey": false,
"notNull": true
},
"occurred_at": {
"name": "occurred_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"metadata": {
"name": "metadata",
"type": "jsonb",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {
"lead_pipeline_eventos_lead_id_leads_id_fk": {
"name": "lead_pipeline_eventos_lead_id_leads_id_fk",
"tableFrom": "lead_pipeline_eventos",
"tableTo": "leads",
"columnsFrom": [
"lead_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.leads": {
"name": "leads",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"tenant_id": {
"name": "tenant_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"nombre": {
"name": "nombre",
"type": "text",
"primaryKey": false,
"notNull": true
},
"telefono": {
"name": "telefono",
"type": "text",
"primaryKey": false,
"notNull": true
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": true
},
"provincia": {
"name": "provincia",
"type": "text",
"primaryKey": false,
"notNull": false
},
"tipo_reforma": {
"name": "tipo_reforma",
"type": "tipo_reforma",
"typeSchema": "public",
"primaryKey": false,
"notNull": false
},
"consent_privacidad": {
"name": "consent_privacidad",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
},
"consent_contratacion": {
"name": "consent_contratacion",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
},
"pipeline_stage": {
"name": "pipeline_stage",
"type": "pipeline_stage",
"typeSchema": "public",
"primaryKey": false,
"notNull": true,
"default": "'form_completado'"
},
"estado": {
"name": "estado",
"type": "lead_estado",
"typeSchema": "public",
"primaryKey": false,
"notNull": true,
"default": "'nuevo'"
},
"presupuesto_estimado": {
"name": "presupuesto_estimado",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"transcripcion": {
"name": "transcripcion",
"type": "text",
"primaryKey": false,
"notNull": false
},
"entidades": {
"name": "entidades",
"type": "jsonb",
"primaryKey": false,
"notNull": false
},
"render_url": {
"name": "render_url",
"type": "text",
"primaryKey": false,
"notNull": false
},
"pdf_url": {
"name": "pdf_url",
"type": "text",
"primaryKey": false,
"notNull": false
},
"audio_url": {
"name": "audio_url",
"type": "text",
"primaryKey": false,
"notNull": false
},
"notas": {
"name": "notas",
"type": "text",
"primaryKey": false,
"notNull": false
},
"m2_suelo": {
"name": "m2_suelo",
"type": "double precision",
"primaryKey": false,
"notNull": false
},
"altura_techo": {
"name": "altura_techo",
"type": "double precision",
"primaryKey": false,
"notNull": false
},
"calidad_global": {
"name": "calidad_global",
"type": "calidad",
"typeSchema": "public",
"primaryKey": false,
"notNull": false
},
"estructural": {
"name": "estructural",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
},
"material_selections": {
"name": "material_selections",
"type": "jsonb",
"primaryKey": false,
"notNull": true,
"default": "'{}'::jsonb"
},
"desglose_snapshot": {
"name": "desglose_snapshot",
"type": "jsonb",
"primaryKey": false,
"notNull": false
}
},
"indexes": {
"leads_tenant_created_idx": {
"name": "leads_tenant_created_idx",
"columns": [
{
"expression": "tenant_id",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "created_at",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"leads_estado_idx": {
"name": "leads_estado_idx",
"columns": [
{
"expression": "estado",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"leads_tenant_id_tenants_id_fk": {
"name": "leads_tenant_id_tenants_id_fk",
"tableFrom": "leads",
"tableTo": "tenants",
"columnsFrom": [
"tenant_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.precision_history": {
"name": "precision_history",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"lead_id": {
"name": "lead_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"estimated": {
"name": "estimated",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"final": {
"name": "final",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"delta_pct": {
"name": "delta_pct",
"type": "numeric(6, 2)",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"precision_history_lead_id_leads_id_fk": {
"name": "precision_history_lead_id_leads_id_fk",
"tableFrom": "precision_history",
"tableTo": "leads",
"columnsFrom": [
"lead_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.pricing_config": {
"name": "pricing_config",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"tenant_id": {
"name": "tenant_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"altura_techo_default": {
"name": "altura_techo_default",
"type": "double precision",
"primaryKey": false,
"notNull": true,
"default": 2.5
},
"factor_zona": {
"name": "factor_zona",
"type": "jsonb",
"primaryKey": false,
"notNull": true,
"default": "'{}'::jsonb"
},
"mano_obra": {
"name": "mano_obra",
"type": "jsonb",
"primaryKey": false,
"notNull": true,
"default": "'{}'::jsonb"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"pricing_config_tenant_id_tenants_id_fk": {
"name": "pricing_config_tenant_id_tenants_id_fk",
"tableFrom": "pricing_config",
"tableTo": "tenants",
"columnsFrom": [
"tenant_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"pricing_config_tenant_id_unique": {
"name": "pricing_config_tenant_id_unique",
"nullsNotDistinct": false,
"columns": [
"tenant_id"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.tenants": {
"name": "tenants",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"slug": {
"name": "slug",
"type": "text",
"primaryKey": false,
"notNull": true
},
"nombre_empresa": {
"name": "nombre_empresa",
"type": "text",
"primaryKey": false,
"notNull": true
},
"logo_url": {
"name": "logo_url",
"type": "text",
"primaryKey": false,
"notNull": false
},
"provincia": {
"name": "provincia",
"type": "text",
"primaryKey": false,
"notNull": false
},
"whatsapp_business": {
"name": "whatsapp_business",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"tenants_slug_unique": {
"name": "tenants_slug_unique",
"nullsNotDistinct": false,
"columns": [
"slug"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {
"public.calidad": {
"name": "calidad",
"schema": "public",
"values": [
"basica",
"media",
"premium"
]
},
"public.categoria_material": {
"name": "categoria_material",
"schema": "public",
"values": [
"suelo",
"pared",
"pintura",
"mobiliario"
]
},
"public.lead_estado": {
"name": "lead_estado",
"schema": "public",
"values": [
"nuevo",
"contactado",
"visita_agendada",
"presupuesto_enviado",
"ganado",
"perdido"
]
},
"public.pipeline_stage": {
"name": "pipeline_stage",
"schema": "public",
"values": [
"form_completado",
"fotos_subidas",
"prellamada_enviada",
"llamada_completada",
"render_generado",
"presupuesto_generado",
"whatsapp_entregado"
]
},
"public.tipo_reforma": {
"name": "tipo_reforma",
"schema": "public",
"values": [
"cocina",
"bano",
"salon",
"comedor",
"integral",
"otro"
]
},
"public.unidad_medida": {
"name": "unidad_medida",
"schema": "public",
"values": [
"m2",
"ml",
"ud"
]
}
},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,48 @@
{
"version": "7",
"dialect": "postgresql",
"entries": [
{
"idx": 0,
"version": "7",
"when": 1780056789929,
"tag": "0000_motionless_jackpot",
"breakpoints": true
},
{
"idx": 1,
"version": "7",
"when": 1780137082579,
"tag": "0001_bored_preak",
"breakpoints": true
},
{
"idx": 2,
"version": "7",
"when": 1780162638625,
"tag": "0002_overjoyed_the_renegades",
"breakpoints": true
},
{
"idx": 3,
"version": "7",
"when": 1780169328805,
"tag": "0003_youthful_white_queen",
"breakpoints": true
},
{
"idx": 4,
"version": "7",
"when": 1780170597963,
"tag": "0004_even_stranger",
"breakpoints": true
},
{
"idx": 5,
"version": "7",
"when": 1780237037524,
"tag": "0005_tearful_maverick",
"breakpoints": true
}
]
}

View File

@@ -1,6 +1,8 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
// @react-pdf/renderer usa módulos nativos/wasm (yoga, fontkit) que no deben bundlearse.
serverExternalPackages: ['@react-pdf/renderer'],
async rewrites() {
return [
// Landing B2B estática (mvp/b2b) servida en /b2b. El fichero vive en public/b2b.html.

3648
mvp/b2c/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -6,22 +6,40 @@
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "eslint"
"lint": "eslint",
"db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate",
"db:push": "drizzle-kit push",
"db:studio": "drizzle-kit studio",
"db:seed": "tsx src/db/seed.ts",
"test": "vitest run",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage"
},
"dependencies": {
"@react-pdf/renderer": "^4.5.1",
"@tailwindcss/postcss": "^4.3.0",
"bcryptjs": "^3.0.3",
"drizzle-orm": "^0.45.2",
"next": "16.2.6",
"postcss": "^8.5.15",
"postgres": "^3.4.9",
"react": "19.2.4",
"react-dom": "19.2.4",
"tailwindcss": "^4.3.0"
"tailwindcss": "^4.3.0",
"zod": "^4.4.3"
},
"devDependencies": {
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"@vitest/coverage-v8": "^4.1.7",
"dotenv": "^17.4.2",
"drizzle-kit": "^0.31.10",
"eslint": "^9",
"eslint-config-next": "16.2.6",
"typescript": "^5"
"tsx": "^4.22.3",
"typescript": "^5",
"vitest": "^4.1.7"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 203 KiB

BIN
mvp/b2c/public/antes.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 198 KiB

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

BIN
mvp/b2c/public/despues.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 153 KiB

4
mvp/b2c/public/icon.svg Normal file
View File

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64">
<rect width="64" height="64" rx="14" fill="#2F5C46"/>
<text x="32" y="48" text-anchor="middle" font-family="Georgia, 'Instrument Serif', 'Times New Roman', serif" font-style="italic" font-size="48" fill="#F6F4EF">R</text>
</svg>

After

Width:  |  Height:  |  Size: 317 B

View File

@@ -0,0 +1,32 @@
import Link from 'next/link';
import type { Metadata } from 'next';
import { requireAdmin } from '@/lib/auth/current-user';
import AppNav from '@/components/AppNav';
export const metadata: Metadata = { title: 'Admin · Reformix' };
const ADMIN_LINKS = [
{ href: '/admin', label: 'Resumen', icon: 'resumen' },
{ href: '/admin/usuarios', label: 'Usuarios', icon: 'usuarios' },
{ href: '/admin/planes', label: 'Planes', icon: 'planes' },
] as const;
export default async function AdminLayout({ children }: { children: React.ReactNode }) {
await requireAdmin();
return (
<div className="min-h-screen bg-gray-50">
<header className="sticky top-0 z-20 bg-white border-b border-gray-200">
<div className="relative max-w-6xl mx-auto px-6 h-16 flex items-center justify-between">
<Link href="/admin" className="flex items-center gap-2 min-w-0">
<span className="inline-flex shrink-0 items-center justify-center w-8 h-8 rounded-lg bg-black text-white font-black italic text-lg leading-none">R</span>
<span className="font-extrabold tracking-tight text-black">Reformix</span>
<span className="hidden sm:inline text-gray-300">/</span>
<span className="hidden sm:inline text-sm font-medium text-gray-600">Admin</span>
</Link>
<AppNav links={ADMIN_LINKS} />
</div>
</header>
<main className="max-w-6xl mx-auto px-6 py-8 pb-24 sm:pb-8">{children}</main>
</div>
);
}

View File

@@ -0,0 +1,26 @@
import { listTenants, listUsers, listPlans } from '@/db/admin-queries';
export const dynamic = 'force-dynamic';
export default async function AdminHome() {
const [tenants, users, plans] = await Promise.all([listTenants(), listUsers(), listPlans()]);
const cards = [
{ label: 'Reformistas (tenants)', value: tenants.length },
{ label: 'Usuarios', value: users.length },
{ label: 'Planes activos', value: plans.length },
{ label: 'En trial', value: tenants.filter((t) => t.subscriptionStatus === 'trial').length },
];
return (
<div className="flex flex-col gap-6">
<h1 className="text-2xl font-black tracking-tight text-black">Resumen</h1>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
{cards.map((c) => (
<div key={c.label} className="bg-white border border-gray-200 rounded-xl p-5">
<div className="text-3xl font-black text-black">{c.value}</div>
<div className="text-xs text-gray-500 mt-1">{c.label}</div>
</div>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,16 @@
'use server';
import { revalidatePath } from 'next/cache';
import { requireAdmin } from '@/lib/auth/current-user';
import { assignPlan, setSubscriptionStatus } from '@/db/admin-queries';
import { tenants } from '@/db/schema';
export async function asignarPlan(formData: FormData) {
await requireAdmin();
const tenantId = String(formData.get('tenantId'));
const planId = String(formData.get('planId'));
const status = String(formData.get('status')) as (typeof tenants.subscriptionStatus.enumValues)[number];
await assignPlan(tenantId, planId);
await setSubscriptionStatus(tenantId, status);
revalidatePath('/admin/planes');
}

View File

@@ -0,0 +1,61 @@
import { listPlans, listTenants } from '@/db/admin-queries';
import { asignarPlan } from './actions';
import { formatEuros } from '@/lib/funnel';
export const dynamic = 'force-dynamic';
const ESTADOS = ['trial', 'activo', 'cancelado', 'vencido'] as const;
export default async function PlanesPage() {
const [plans, tenants] = await Promise.all([listPlans(), listTenants()]);
const nombrePlan = new Map(plans.map((p) => [p.id, p.nombre]));
return (
<div className="flex flex-col gap-8">
<h1 className="text-2xl font-black tracking-tight text-black">Planes</h1>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{plans.map((p) => (
<div key={p.id} className="bg-white border border-gray-200 rounded-xl p-5">
<div className="flex items-baseline justify-between">
<h2 className="font-bold text-black">{p.nombre}</h2>
<span className="text-lg font-black text-black">{formatEuros(p.precioMensual)}/mes</span>
</div>
<ul className="mt-3 flex flex-col gap-1 text-xs text-gray-500">
{p.features.map((f) => <li key={f}>· {f}</li>)}
</ul>
</div>
))}
</div>
<div className="bg-white border border-gray-200 rounded-xl overflow-hidden">
<table className="w-full text-sm">
<thead><tr className="border-b border-gray-200 text-left text-xs uppercase tracking-wide text-gray-400">
<th className="px-4 py-3">Reformista</th><th className="px-4 py-3">Plan actual</th>
<th className="px-4 py-3">Estado</th><th className="px-4 py-3">Asignar</th>
</tr></thead>
<tbody>
{tenants.map((t) => (
<tr key={t.id} className="border-b border-gray-100 last:border-0">
<td className="px-4 py-3 font-medium text-black">{t.nombreEmpresa}</td>
<td className="px-4 py-3 text-gray-600">{t.planId ? nombrePlan.get(t.planId) ?? '—' : '—'}</td>
<td className="px-4 py-3 text-gray-600">{t.subscriptionStatus}</td>
<td className="px-4 py-3">
<form action={asignarPlan} className="flex items-center gap-2">
<input type="hidden" name="tenantId" value={t.id} />
<select name="planId" defaultValue={t.planId ?? plans[0]?.id} className="border border-gray-300 rounded-md px-2 py-1 text-xs">
{plans.map((p) => <option key={p.id} value={p.id}>{p.nombre}</option>)}
</select>
<select name="status" defaultValue={t.subscriptionStatus} className="border border-gray-300 rounded-md px-2 py-1 text-xs">
{ESTADOS.map((e) => <option key={e} value={e}>{e}</option>)}
</select>
<button type="submit" className="bg-black text-white rounded-md px-3 py-1 text-xs font-semibold">Guardar</button>
</form>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}

View File

@@ -0,0 +1,22 @@
'use client';
import { useActionState } from 'react';
import { crearReformista } from './actions';
export function CrearReformistaForm() {
const [error, action, pending] = useActionState(crearReformista, null);
return (
<form action={action} className="bg-white border border-gray-200 rounded-xl p-5 grid grid-cols-1 md:grid-cols-2 gap-3">
<h2 className="md:col-span-2 font-bold text-black">Crear reformista</h2>
<input name="nombre" placeholder="Nombre" required className="border border-gray-300 rounded-md px-3 py-2 text-sm" />
<input name="empresa" placeholder="Empresa" required className="border border-gray-300 rounded-md px-3 py-2 text-sm" />
<input name="email" type="email" placeholder="Email" required className="border border-gray-300 rounded-md px-3 py-2 text-sm" />
<input name="provincia" placeholder="Provincia" required className="border border-gray-300 rounded-md px-3 py-2 text-sm" />
<input name="password" type="password" placeholder="Contraseña (mín. 8)" required className="border border-gray-300 rounded-md px-3 py-2 text-sm" />
{error && <p className="md:col-span-2 text-sm text-red-600">{error}</p>}
<button type="submit" disabled={pending} className="md:col-span-2 bg-black text-white rounded-md py-2 font-semibold disabled:opacity-60">
{pending ? 'Creando…' : 'Crear reformista'}
</button>
</form>
);
}

View File

@@ -0,0 +1,39 @@
'use server';
import { revalidatePath } from 'next/cache';
import { requireAdmin } from '@/lib/auth/current-user';
import { signupSchema, slugify } from '@/lib/validation/signup';
import { getUserByEmail, createTenantWithOwner, slugDisponible } from '@/db/auth-queries';
import { setUserStatus } from '@/db/admin-queries';
import { hashPassword } from '@/lib/auth/password';
export async function crearReformista(_prev: string | null, formData: FormData): Promise<string | null> {
await requireAdmin();
const parsed = signupSchema.safeParse(Object.fromEntries(formData));
if (!parsed.success) return parsed.error.issues[0]?.message ?? 'Datos no válidos.';
const data = parsed.data;
if (await getUserByEmail(data.email)) return 'Ya existe una cuenta con ese email.';
let slug = slugify(data.empresa);
let n = 1;
while (!(await slugDisponible(slug))) slug = `${slugify(data.empresa)}-${++n}`;
await createTenantWithOwner({
nombreEmpresa: data.empresa,
slug,
provincia: data.provincia,
email: data.email,
passwordHash: await hashPassword(data.password),
nombre: data.nombre,
});
revalidatePath('/admin/usuarios');
return null;
}
export async function toggleUsuario(formData: FormData) {
await requireAdmin();
const userId = String(formData.get('userId'));
const next = String(formData.get('next')) as 'activo' | 'deshabilitado';
await setUserStatus(userId, next);
revalidatePath('/admin/usuarios');
}

View File

@@ -0,0 +1,45 @@
import { listUsers, listTenants } from '@/db/admin-queries';
import { toggleUsuario } from './actions';
import { CrearReformistaForm } from './CrearReformistaForm';
export const dynamic = 'force-dynamic';
export default async function UsuariosPage() {
const [users, tenants] = await Promise.all([listUsers(), listTenants()]);
const empresaDe = new Map(tenants.map((t) => [t.id, t.nombreEmpresa]));
return (
<div className="flex flex-col gap-8">
<h1 className="text-2xl font-black tracking-tight text-black">Usuarios</h1>
<CrearReformistaForm />
<div className="bg-white border border-gray-200 rounded-xl overflow-hidden">
<table className="w-full text-sm">
<thead><tr className="border-b border-gray-200 text-left text-xs uppercase tracking-wide text-gray-400">
<th className="px-4 py-3">Email</th><th className="px-4 py-3">Rol</th>
<th className="px-4 py-3">Empresa</th><th className="px-4 py-3">Estado</th><th className="px-4 py-3"></th>
</tr></thead>
<tbody>
{users.map((u) => (
<tr key={u.id} className="border-b border-gray-100 last:border-0">
<td className="px-4 py-3 font-medium text-black">{u.email}</td>
<td className="px-4 py-3 text-gray-600">{u.role}</td>
<td className="px-4 py-3 text-gray-600">{u.tenantId ? empresaDe.get(u.tenantId) ?? '—' : '—'}</td>
<td className="px-4 py-3 text-gray-600">{u.status}</td>
<td className="px-4 py-3 text-right">
{u.role !== 'admin' && (
<form action={toggleUsuario}>
<input type="hidden" name="userId" value={u.id} />
<input type="hidden" name="next" value={u.status === 'activo' ? 'deshabilitado' : 'activo'} />
<button type="submit" className="text-xs font-medium text-gray-500 hover:text-black">
{u.status === 'activo' ? 'Deshabilitar' : 'Habilitar'}
</button>
</form>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

View File

@@ -51,7 +51,8 @@
color: var(--color-dark);
background-color: var(--color-white);
line-height: 1.6;
overflow-x: hidden;
overflow-x: hidden; /* fallback navegadores antiguos */
overflow-x: clip; /* no crea contenedor de scroll: evita romper position:fixed (barra inferior) */
}
/* Custom Scrollbar */

View File

@@ -2,13 +2,14 @@ import type { Metadata } from 'next';
import './globals.css';
export const metadata: Metadata = {
title: 'FlowSync — El SaaS que impulsa tu equipo',
title: 'Reformix — Tu presupuesto de reforma en 5 minutos',
description:
'Automatiza flujos de trabajo, conecta equipos y escala tu negocio con FlowSync. La plataforma SaaS todo-en-uno para equipos modernos.',
keywords: ['SaaS', 'productividad', 'automatización', 'equipos', 'gestión de proyectos'],
'Sube fotos de tu cocina o baño y recibe un render con el presupuesto orientativo en minutos. Te llamamos en menos de 2.',
keywords: ['reforma', 'presupuesto reforma', 'render reforma', 'cocina', 'baño', 'reformistas'],
icons: { icon: '/icon.svg' },
openGraph: {
title: 'FlowSync — El SaaS que impulsa tu equipo',
description: 'Automatiza flujos de trabajo y escala tu negocio con FlowSync.',
title: 'Reformix — Tu presupuesto de reforma en 5 minutos',
description: 'Render y presupuesto orientativo de tu reforma en minutos, por WhatsApp.',
type: 'website',
},
};

View File

@@ -0,0 +1,19 @@
'use server';
import { redirect } from 'next/navigation';
import { getUserByEmail } from '@/db/auth-queries';
import { verifyPassword } from '@/lib/auth/password';
import { createSession } from '@/lib/auth/session';
export async function login(_prev: string | null, formData: FormData): Promise<string | null> {
const email = String(formData.get('email') ?? '').trim().toLowerCase();
const password = String(formData.get('password') ?? '');
if (!email || !password) return 'Introduce email y contraseña.';
const user = await getUserByEmail(email);
if (!user || user.status !== 'activo') return 'Credenciales incorrectas.';
if (!(await verifyPassword(password, user.passwordHash))) return 'Credenciales incorrectas.';
await createSession(user.id);
redirect(user.role === 'admin' ? '/admin' : '/panel');
}

View File

@@ -0,0 +1,27 @@
'use client';
import { useActionState } from 'react';
import { login } from './actions';
export default function LoginPage() {
const [error, formAction, pending] = useActionState(login, null);
return (
<main className="min-h-screen flex items-center justify-center bg-gray-50 px-6">
<form action={formAction} className="w-full max-w-sm bg-white border border-gray-200 rounded-xl p-8 flex flex-col gap-4">
<h1 className="text-xl font-black tracking-tight text-black">Entra en tu panel</h1>
<label className="flex flex-col gap-1 text-sm">
<span className="font-medium text-gray-700">Email</span>
<input name="email" type="email" required className="border border-gray-300 rounded-md px-3 py-2" />
</label>
<label className="flex flex-col gap-1 text-sm">
<span className="font-medium text-gray-700">Contraseña</span>
<input name="password" type="password" required className="border border-gray-300 rounded-md px-3 py-2" />
</label>
{error && <p className="text-sm text-red-600">{error}</p>}
<button type="submit" disabled={pending} className="bg-black text-white rounded-md py-2 font-semibold disabled:opacity-60">
{pending ? 'Entrando…' : 'Entrar'}
</button>
</form>
</main>
);
}

View File

@@ -0,0 +1,7 @@
import { redirect } from 'next/navigation';
import { destroySession } from '@/lib/auth/session';
export async function POST() {
await destroySession();
redirect('/login');
}

View File

@@ -2,7 +2,7 @@ import Navbar from '@/components/Navbar/Navbar';
import Hero from '@/components/Hero/Hero';
import ReformaSlider from '@/components/ReformaSlider/ReformaSlider';
import Features from '@/components/Features/Features';
import Pricing from '@/components/Pricing/Pricing';
import Testimonials from '@/components/Testimonials/Testimonials';
import ContactForm from '@/components/ContactForm/ContactForm';
import Footer from '@/components/Footer/Footer';
@@ -14,7 +14,7 @@ export default function Home() {
<Hero />
<ReformaSlider />
<Features />
<Pricing />
<Testimonials />
</main>
<Footer />
</>

View File

@@ -0,0 +1,369 @@
import Link from 'next/link';
import { notFound } from 'next/navigation';
import { getLead } from '@/db/queries';
import EstadoControl from '@/components/panel/EstadoControl';
import ConceptosEditor from '@/components/panel/ConceptosEditor';
import {
PIPELINE_LABEL,
PIPELINE_NEXT,
PIPELINE_ORDER,
TIPO_LABEL,
formatEuros,
formatFecha,
} from '@/lib/funnel';
import { recalcularPresupuesto, enviarPresupuesto } from '../actions';
import type { BudgetResult } from '@/budget/types';
export const dynamic = 'force-dynamic';
function Section({ title, children }: { title: string; children: React.ReactNode }) {
return (
<section 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>
);
}
export default async function LeadDetailPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const data = await getLead(id);
if (!data) notFound();
const { lead, fotos, eventos, precision } = data;
const reachedStages = new Set(eventos.map((e) => e.stage));
const snapshot = lead.desgloseSnapshot as { result: BudgetResult } | null;
const desglose = snapshot?.result ?? null;
const prefs = lead.preferencesSnapshot as import('@/lib/voice/preferences').AbstractedPreferences | null;
const yaEnviado = lead.pipelineStage === 'whatsapp_entregado';
return (
<div className="flex flex-col gap-6">
<Link href="/panel" className="text-sm text-gray-500 hover:text-black w-fit">
Volver a leads
</Link>
{/* Cabecera + estado */}
<div className="bg-white border border-gray-200 rounded-xl p-5 flex flex-col gap-4">
<div className="flex flex-wrap items-start justify-between gap-4">
<div className="flex flex-col gap-1">
<h1 className="text-2xl font-black tracking-tight text-black">{lead.nombre}</h1>
<p className="text-sm text-gray-500">
{lead.tipoReforma ? TIPO_LABEL[lead.tipoReforma] : 'Reforma'} ·{' '}
{lead.provincia ?? '—'} · entró {formatFecha(lead.createdAt)}
</p>
</div>
<div className="text-right">
<div className="text-xs text-gray-400">Presupuesto estimado</div>
<div className="text-2xl font-black text-black">{formatEuros(lead.presupuestoEstimado)}</div>
</div>
</div>
<EstadoControl
leadId={lead.id}
estado={lead.estado}
presupuestoEstimado={lead.presupuestoEstimado}
/>
</div>
{/* Timeline del funnel */}
<Section title="Progreso en el funnel">
<ol className="flex flex-col gap-2">
{PIPELINE_ORDER.map((stage) => {
const reached = reachedStages.has(stage);
const isCurrent = stage === lead.pipelineStage;
return (
<li key={stage} className="flex items-center gap-3 text-sm">
<span
className={`w-2.5 h-2.5 rounded-full shrink-0 ${
reached ? 'bg-green-500' : 'bg-gray-200'
}`}
/>
<span className={reached ? 'text-black font-medium' : 'text-gray-400'}>
{PIPELINE_LABEL[stage]}
</span>
{isCurrent && (
<span className="ml-auto text-xs text-amber-600 font-medium">
{PIPELINE_NEXT[stage]}
</span>
)}
</li>
);
})}
</ol>
</Section>
<div className="grid md:grid-cols-2 gap-6">
{/* 1. Datos personales */}
<Section title="Datos personales">
<dl className="text-sm flex flex-col gap-2">
<div className="flex justify-between gap-4">
<dt className="text-gray-500">Teléfono</dt>
<dd className="text-black font-medium">{lead.telefono}</dd>
</div>
<div className="flex justify-between gap-4">
<dt className="text-gray-500">Email</dt>
<dd className="text-black font-medium break-all">{lead.email}</dd>
</div>
<div className="flex justify-between gap-4">
<dt className="text-gray-500">Provincia</dt>
<dd className="text-black font-medium">{lead.provincia ?? '—'}</dd>
</div>
<div className="flex justify-between gap-4">
<dt className="text-gray-500">Consentimientos</dt>
<dd className="text-black font-medium">
{lead.consentPrivacidad ? 'Privacidad ✓' : 'Privacidad ✗'} ·{' '}
{lead.consentContratacion ? 'Contratación ✓' : 'Contratación ✗'}
</dd>
</div>
</dl>
</Section>
{/* 4. Render */}
<Section title="Render generado">
{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" />
) : (
<p className="text-sm text-gray-400">Aún no generado.</p>
)}
</Section>
{/* Preferencias detectadas */}
<Section title="Preferencias detectadas">
{prefs ? (
<div className="flex flex-col gap-3 text-sm">
<p className="text-gray-700">{prefs.resumen}</p>
{prefs.estiloRender.length > 0 && (
<div className="flex flex-wrap gap-2">
{prefs.estiloRender.map((e) => (
<span key={e} className="px-2 py-0.5 rounded-full bg-gray-100 text-gray-700 text-xs">
{e}
</span>
))}
</div>
)}
{prefs.elementos.length > 0 && (
<ul className="flex flex-col gap-1">
{prefs.elementos.map((el) => (
<li key={el.key} className="flex justify-between">
<span className="text-gray-600">{el.label}</span>
<span className="text-black font-medium">{formatEuros(el.importe)}</span>
</li>
))}
</ul>
)}
{prefs.ajustes.length > 0 && (
<ul className="text-xs text-gray-500 flex flex-col gap-1">
{prefs.ajustes.map((a, i) => (
<li key={i}>
<span className="font-medium text-gray-700">{a.label}</span> {a.motivo}
</li>
))}
</ul>
)}
<div className="text-xs text-gray-400">
Confianza de la extracción: {prefs.confianza}
{prefs.camposFaltantes.length > 0 && ` · faltan: ${prefs.camposFaltantes.join(', ')}`}
</div>
</div>
) : (
<p className="text-sm text-gray-400">Sin preferencias procesadas aún.</p>
)}
</Section>
{/* 2. Transcripción */}
<Section title="Transcripción de la llamada">
{lead.transcripcion ? (
<p className="text-sm text-gray-700 leading-relaxed whitespace-pre-wrap max-h-64 overflow-auto">
{lead.transcripcion}
</p>
) : (
<p className="text-sm text-gray-400">Aún no hay llamada.</p>
)}
</Section>
{/* 3. JSON de entidades */}
<Section title="Entidades extraídas (JSON)">
{lead.entidades ? (
<pre className="text-xs bg-gray-900 text-gray-100 rounded-lg p-4 overflow-auto max-h-64">
{JSON.stringify(lead.entidades, null, 2)}
</pre>
) : (
<p className="text-sm text-gray-400">Sin entidades aún.</p>
)}
</Section>
{/* 5. Audio */}
<Section title="Audio de la llamada">
{lead.audioUrl ? (
<audio controls src={lead.audioUrl} className="w-full">
Tu navegador no soporta audio.
</audio>
) : (
<p className="text-sm text-gray-400">Sin grabación.</p>
)}
</Section>
{/* 6. PDF */}
<Section title="Presupuesto (PDF)">
{desglose ? (
<div className="flex flex-wrap items-center gap-3">
<a
href={`/panel/${lead.id}/presupuesto?download=1`}
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-black text-white text-sm font-semibold w-fit hover:bg-gray-800"
>
Descargar PDF
</a>
<a
href={`/panel/${lead.id}/presupuesto`}
target="_blank"
rel="noopener"
className="text-sm font-medium text-gray-500 hover:text-black"
>
Ver en el navegador
</a>
</div>
) : (
<p className="text-sm text-gray-400">
Recalcula el presupuesto desde el catálogo para generarlo.
</p>
)}
</Section>
</div>
{/* Fotos subidas */}
{fotos.length > 0 && (
<Section title="Fotos subidas por el cliente">
<div className="flex flex-wrap gap-3">
{fotos.map((f) => (
// eslint-disable-next-line @next/next/no-img-element
<img key={f.id} src={f.url} alt="" className="w-32 h-24 object-cover rounded-lg border border-gray-200" />
))}
</div>
</Section>
)}
{/* Precisión (si ganado) */}
{precision && (
<Section title="Precisión del presupuesto">
<div className="flex flex-wrap gap-8 text-sm">
<div>
<div className="text-gray-400 text-xs">Estimado</div>
<div className="text-black font-bold text-lg">{formatEuros(precision.estimated)}</div>
</div>
<div>
<div className="text-gray-400 text-xs">Final firmado</div>
<div className="text-black font-bold text-lg">{formatEuros(precision.final)}</div>
</div>
<div>
<div className="text-gray-400 text-xs">Desviación</div>
<div
className={`font-bold text-lg ${
Math.abs(Number(precision.deltaPct)) <= 15 ? 'text-green-600' : 'text-amber-600'
}`}
>
{Number(precision.deltaPct) > 0 ? '+' : ''}
{precision.deltaPct}%
</div>
</div>
</div>
</Section>
)}
{/* Presupuesto desglosado */}
<Section title="Presupuesto desglosado">
<div className="flex flex-wrap items-center gap-3">
<form action={recalcularPresupuesto.bind(null, lead.id)}>
<button
type="submit"
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-black text-white text-sm font-semibold w-fit hover:bg-gray-800"
>
Recalcular desde el catálogo
</button>
</form>
{desglose && (
<a
href={`/panel/${lead.id}/presupuesto`}
target="_blank"
rel="noopener"
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg border border-gray-300 text-sm font-semibold text-gray-700 hover:border-gray-500"
>
Revisar PDF
</a>
)}
</div>
{desglose ? (
<div className="flex flex-col gap-4 mt-2">
{yaEnviado && (
<div className="text-sm font-medium text-green-700 bg-green-50 rounded-lg px-3 py-2">
Presupuesto enviado al cliente por WhatsApp
</div>
)}
{/* Conceptos editables + subtotal/factor zona/total */}
<ConceptosEditor
leadId={lead.id}
partidas={desglose.partidas}
factorZona={desglose.factorZona}
bloqueado={yaEnviado}
/>
{/* Rango */}
<div className="flex justify-between text-sm">
<span className="text-gray-500">Rango orientativo</span>
<span className="text-black font-medium">
{formatEuros(desglose.rango.min)} {formatEuros(desglose.rango.max)}
</span>
</div>
{/* Confianza */}
<div className="flex items-center gap-2 text-sm">
<span className="text-gray-500">Confianza del cálculo</span>
<span
className={`px-2 py-0.5 rounded-full text-xs font-semibold ${
desglose.confianza === 'alta'
? 'bg-green-100 text-green-700'
: desglose.confianza === 'media'
? 'bg-amber-100 text-amber-700'
: 'bg-red-100 text-red-700'
}`}
>
{desglose.confianza}
</span>
</div>
{/* Avisos */}
{desglose.avisos.length > 0 && (
<ul className="text-xs text-amber-700 bg-amber-50 rounded-lg p-3 flex flex-col gap-1">
{desglose.avisos.map((aviso, i) => (
<li key={i}> {aviso}</li>
))}
</ul>
)}
{/* Disclaimer RF-B-09 */}
<p className="text-xs text-gray-400">
Presupuesto orientativo. El precio final puede variar según la visita técnica.
</p>
{/* Enviar al cliente (envío simulado: registra la entrega por WhatsApp) */}
{!yaEnviado && (
<form action={enviarPresupuesto.bind(null, lead.id)} className="border-t border-gray-200 pt-4">
<button
type="submit"
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-green-600 text-white text-sm font-semibold w-fit hover:bg-green-700"
>
Enviar al cliente por WhatsApp
</button>
</form>
)}
</div>
) : (
<p className="text-sm text-gray-400">Aún no se ha calculado el presupuesto.</p>
)}
</Section>
</div>
);
}

View File

@@ -0,0 +1,48 @@
import { notFound } from 'next/navigation';
import { renderToBuffer } from '@react-pdf/renderer';
import { getLead } from '@/db/queries';
import { getTenantPerfil } from '@/db/tenant-queries';
import { TIPO_LABEL } from '@/lib/funnel';
import { PresupuestoDoc } from '@/lib/pdf/PresupuestoDoc';
import type { BudgetResult } from '@/budget/types';
export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';
export async function GET(
req: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params;
const data = await getLead(id);
if (!data) notFound();
const descargar = new URL(req.url).searchParams.get('download') === '1';
const { lead } = data;
const empresa = await getTenantPerfil();
const snapshot = lead.desgloseSnapshot as { result: BudgetResult } | null;
const desglose = snapshot?.result ?? null;
const buffer = await renderToBuffer(
PresupuestoDoc({
empresa,
cliente: { nombre: lead.nombre, telefono: lead.telefono, provincia: lead.provincia },
reforma: {
tipoLabel: lead.tipoReforma ? TIPO_LABEL[lead.tipoReforma] : 'Reforma',
fecha: lead.createdAt,
},
desglose,
})
);
const slug = lead.nombre.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '');
return new Response(new Uint8Array(buffer), {
headers: {
'Content-Type': 'application/pdf',
'Content-Disposition': `${descargar ? 'attachment' : 'inline'}; filename="presupuesto-${slug || lead.id}.pdf"`,
'Cache-Control': 'no-store',
},
});
}

View File

@@ -0,0 +1,172 @@
'use server';
import { and, eq } from 'drizzle-orm';
import { revalidatePath } from 'next/cache';
import { db } from '@/db';
import { leads, leadEstadoHistory, leadPipelineEventos, precisionHistory } from '@/db/schema';
import { getCurrentTenantId as getTenantId } from '@/lib/auth/current-user';
import { getPricingConfig, getCatalog } from '@/db/pricing-queries';
import { computeBudget } from '@/budget';
import { applyConceptoEdits } from '@/budget/edit';
import type { BudgetInputs, BudgetResult, PartidaResult } from '@/budget/types';
type Estado = (typeof leads.estado.enumValues)[number];
export async function cambiarEstado(leadId: string, estado: Estado) {
const tenantId = await getTenantId();
const [updated] = await db
.update(leads)
.set({ estado, updatedAt: new Date() })
.where(and(eq(leads.id, leadId), eq(leads.tenantId, tenantId)))
.returning();
if (!updated) throw new Error('Lead no encontrado.');
await db.insert(leadEstadoHistory).values({ leadId, estado });
revalidatePath('/panel');
revalidatePath(`/panel/${leadId}`);
}
export async function marcarGanado(leadId: string, precioFinalEuros: number) {
const tenantId = await getTenantId();
const [lead] = await db
.select()
.from(leads)
.where(and(eq(leads.id, leadId), eq(leads.tenantId, tenantId)))
.limit(1);
if (!lead) throw new Error('Lead no encontrado.');
if (lead.presupuestoEstimado == null) {
throw new Error('El lead no tiene presupuesto estimado, no se puede calcular la precisión.');
}
const finalCents = Math.round(precioFinalEuros * 100);
const deltaPct = ((finalCents - lead.presupuestoEstimado) / lead.presupuestoEstimado) * 100;
await db
.update(leads)
.set({ estado: 'ganado', updatedAt: new Date() })
.where(and(eq(leads.id, leadId), eq(leads.tenantId, tenantId)));
await db.insert(leadEstadoHistory).values({ leadId, estado: 'ganado' });
await db.insert(precisionHistory).values({
leadId,
estimated: lead.presupuestoEstimado,
final: finalCents,
deltaPct: deltaPct.toFixed(2),
});
revalidatePath('/panel');
revalidatePath(`/panel/${leadId}`);
}
export async function editarConceptos(leadId: string, formData: FormData) {
const tenantId = await getTenantId();
const [lead] = await db
.select()
.from(leads)
.where(and(eq(leads.id, leadId), eq(leads.tenantId, tenantId)))
.limit(1);
if (!lead) throw new Error('Lead no encontrado.');
const snapshot = lead.desgloseSnapshot as ({ result: BudgetResult } & Record<string, unknown>) | null;
if (!snapshot?.result) {
throw new Error('El lead no tiene presupuesto que editar.');
}
const keys = formData.getAll('key').map(String);
const labels = formData.getAll('label').map(String);
const importes = formData.getAll('importeEuros').map((v) => Number(v));
const partidas: PartidaResult[] = labels.map((label, i) => {
const euros = importes[i];
const importe = Number.isFinite(euros) ? Math.round(euros * 100) : 0;
return { key: keys[i] || `custom-${i}`, label, importe };
});
const edited = applyConceptoEdits(snapshot.result, partidas);
await db
.update(leads)
.set({
presupuestoEstimado: edited.total,
desgloseSnapshot: { ...snapshot, result: edited },
updatedAt: new Date(),
})
.where(and(eq(leads.id, leadId), eq(leads.tenantId, tenantId)));
revalidatePath('/panel');
revalidatePath(`/panel/${leadId}`);
}
export async function enviarPresupuesto(leadId: string) {
const tenantId = await getTenantId();
const [lead] = await db
.select()
.from(leads)
.where(and(eq(leads.id, leadId), eq(leads.tenantId, tenantId)))
.limit(1);
if (!lead) throw new Error('Lead no encontrado.');
if (lead.presupuestoEstimado == null) {
throw new Error('El lead no tiene presupuesto que enviar.');
}
await db
.update(leads)
.set({ estado: 'presupuesto_enviado', pipelineStage: 'whatsapp_entregado', updatedAt: new Date() })
.where(and(eq(leads.id, leadId), eq(leads.tenantId, tenantId)));
await db.insert(leadEstadoHistory).values({ leadId, estado: 'presupuesto_enviado' });
await db.insert(leadPipelineEventos).values({
leadId,
stage: 'whatsapp_entregado',
metadata: { via: 'whatsapp', simulado: true, total: lead.presupuestoEstimado },
});
revalidatePath('/panel');
revalidatePath(`/panel/${leadId}`);
}
export async function recalcularPresupuesto(leadId: string) {
const tenantId = await getTenantId();
const [lead] = await db
.select()
.from(leads)
.where(and(eq(leads.id, leadId), eq(leads.tenantId, tenantId)))
.limit(1);
if (!lead) throw new Error('Lead no encontrado.');
const [config, catalog] = await Promise.all([getPricingConfig(), getCatalog()]);
const inputs: BudgetInputs = {
tipoReforma: lead.tipoReforma ?? 'otro',
m2Suelo: lead.m2Suelo ?? null,
alturaTecho: lead.alturaTecho ?? null,
calidadGlobal: lead.calidadGlobal ?? 'media',
estructural: lead.estructural,
provincia: lead.provincia ?? null,
materialSelections: (lead.materialSelections as Record<string, string>) ?? {},
};
const result = computeBudget(inputs, config, catalog);
await db
.update(leads)
.set({
presupuestoEstimado: result.total,
desgloseSnapshot: { stage: lead.pipelineStage, result },
updatedAt: new Date(),
})
.where(and(eq(leads.id, leadId), eq(leads.tenantId, tenantId)));
await db.insert(leadPipelineEventos).values({
leadId,
stage: 'presupuesto_generado',
metadata: { total: result.total, confianza: result.confianza },
});
revalidatePath('/panel');
revalidatePath(`/panel/${leadId}`);
}

View File

@@ -0,0 +1,66 @@
'use server';
import { eq } from 'drizzle-orm';
import { revalidatePath } from 'next/cache';
import { db } from '@/db';
import { tenants } from '@/db/schema';
import { getCurrentTenantId as getTenantId } from '@/lib/auth/current-user';
const LOGO_MAX_BYTES = 500_000;
const LOGO_TIPOS = ['image/png', 'image/jpeg', 'image/webp', 'image/svg+xml'];
function limpiar(raw: FormDataEntryValue | null): string | null {
const s = String(raw ?? '').trim();
return s.length > 0 ? s : null;
}
export async function actualizarEmpresa(formData: FormData) {
const tenantId = await getTenantId();
const nombreEmpresa = limpiar(formData.get('nombreEmpresa'));
if (!nombreEmpresa) {
throw new Error('El nombre de la empresa es obligatorio.');
}
await db
.update(tenants)
.set({
nombreEmpresa,
cif: limpiar(formData.get('cif')),
direccion: limpiar(formData.get('direccion')),
provincia: limpiar(formData.get('provincia')),
telefono: limpiar(formData.get('telefono')),
email: limpiar(formData.get('email')),
web: limpiar(formData.get('web')),
})
.where(eq(tenants.id, tenantId));
revalidatePath('/panel/empresa');
revalidatePath('/panel');
}
export type LogoResult = { ok: boolean; error?: string };
export async function subirLogo(_prev: LogoResult | null, formData: FormData): Promise<LogoResult> {
const tenantId = await getTenantId();
const file = formData.get('logo');
if (!(file instanceof File) || file.size === 0) {
return { ok: false, error: 'Selecciona un archivo de imagen.' };
}
if (!LOGO_TIPOS.includes(file.type)) {
return { ok: false, error: 'Formato no válido. Usa PNG, JPG, WEBP o SVG.' };
}
if (file.size > LOGO_MAX_BYTES) {
return { ok: false, error: 'El logo no puede superar los 500 KB.' };
}
const base64 = Buffer.from(await file.arrayBuffer()).toString('base64');
const dataUri = `data:${file.type};base64,${base64}`;
await db.update(tenants).set({ logoUrl: dataUri }).where(eq(tenants.id, tenantId));
revalidatePath('/panel/empresa');
revalidatePath('/panel');
return { ok: true };
}
export async function quitarLogo() {
const tenantId = await getTenantId();
await db.update(tenants).set({ logoUrl: null }).where(eq(tenants.id, tenantId));
revalidatePath('/panel/empresa');
revalidatePath('/panel');
}

View File

@@ -0,0 +1,93 @@
import { getTenantPerfil } from '@/db/tenant-queries';
import { actualizarEmpresa } from './actions';
import LogoUploader from '@/components/panel/LogoUploader';
export const dynamic = 'force-dynamic';
export default async function EmpresaPage() {
const perfil = await getTenantPerfil();
return (
<div className="space-y-10 max-w-2xl">
<div>
<h1 className="text-2xl font-extrabold tracking-tight text-black">Datos de empresa</h1>
<p className="text-sm text-gray-500 mt-1">
Estos datos y el logo aparecen en la cabecera de los presupuestos en PDF que recibe el
cliente. Manténlos al día.
</p>
</div>
<section className="bg-white rounded-xl border border-gray-200 p-6">
<h2 className="font-bold text-black mb-4">Logo</h2>
<LogoUploader logoUrl={perfil.logoUrl} />
</section>
<section className="bg-white rounded-xl border border-gray-200 p-6">
<h2 className="font-bold text-black mb-4">Identidad</h2>
<form action={actualizarEmpresa} className="grid grid-cols-1 md:grid-cols-2 gap-4">
<label className="text-sm md:col-span-2">
<span className="block text-gray-500 mb-1">Nombre de la empresa *</span>
<input
name="nombreEmpresa"
required
defaultValue={perfil.nombreEmpresa}
className="w-full border border-gray-300 rounded-lg px-3 py-2"
/>
</label>
<label className="text-sm">
<span className="block text-gray-500 mb-1">CIF / NIF</span>
<input
name="cif"
defaultValue={perfil.cif ?? ''}
className="w-full border border-gray-300 rounded-lg px-3 py-2"
/>
</label>
<label className="text-sm">
<span className="block text-gray-500 mb-1">Provincia</span>
<input
name="provincia"
defaultValue={perfil.provincia ?? ''}
className="w-full border border-gray-300 rounded-lg px-3 py-2"
/>
</label>
<label className="text-sm md:col-span-2">
<span className="block text-gray-500 mb-1">Dirección</span>
<input
name="direccion"
defaultValue={perfil.direccion ?? ''}
className="w-full border border-gray-300 rounded-lg px-3 py-2"
/>
</label>
<label className="text-sm">
<span className="block text-gray-500 mb-1">Teléfono</span>
<input
name="telefono"
defaultValue={perfil.telefono ?? ''}
className="w-full border border-gray-300 rounded-lg px-3 py-2"
/>
</label>
<label className="text-sm">
<span className="block text-gray-500 mb-1">Email</span>
<input
name="email"
type="email"
defaultValue={perfil.email ?? ''}
className="w-full border border-gray-300 rounded-lg px-3 py-2"
/>
</label>
<label className="text-sm md:col-span-2">
<span className="block text-gray-500 mb-1">Web</span>
<input
name="web"
defaultValue={perfil.web ?? ''}
className="w-full border border-gray-300 rounded-lg px-3 py-2"
/>
</label>
<button className="md:col-span-2 justify-self-start bg-black text-white rounded-lg px-4 py-2 text-sm font-medium">
Guardar datos
</button>
</form>
</section>
</div>
);
}

View File

@@ -0,0 +1,47 @@
import Link from 'next/link';
import type { Metadata } from 'next';
import { requireUser } from '@/lib/auth/current-user';
import { db } from '@/db';
import { tenants } from '@/db/schema';
import { eq } from 'drizzle-orm';
import AppNav from '@/components/AppNav';
const PANEL_LINKS = [
{ href: '/panel', label: 'Leads', icon: 'leads' },
{ href: '/panel/precios', label: 'Precios', icon: 'precios' },
{ href: '/panel/empresa', label: 'Empresa', icon: 'empresa' },
] as const;
export const metadata: Metadata = {
title: 'Panel · Reformix',
description: 'Panel de leads del reformista',
};
export default async function PanelLayout({ children }: { children: React.ReactNode }) {
const user = await requireUser();
const [tenant] = user.tenantId
? await db.select().from(tenants).where(eq(tenants.id, user.tenantId)).limit(1)
: [];
const nombreEmpresa = tenant?.nombreEmpresa ?? 'Reformix';
return (
<div className="min-h-screen bg-gray-50">
<header className="sticky top-0 z-20 bg-white border-b border-gray-200">
<div className="relative max-w-6xl mx-auto px-6 h-16 flex items-center justify-between">
<Link href="/panel" className="flex items-center gap-2 min-w-0">
<span className="inline-flex shrink-0 items-center justify-center w-8 h-8 rounded-lg bg-black text-white font-black italic text-lg leading-none">
R
</span>
<span className="font-extrabold tracking-tight text-black">Reformix</span>
<span className="hidden sm:inline text-gray-300">/</span>
<span className="hidden sm:inline text-sm font-medium text-gray-600 truncate">
{nombreEmpresa}
</span>
</Link>
<AppNav links={PANEL_LINKS} />
</div>
</header>
<main className="max-w-6xl mx-auto px-6 py-8 pb-24 sm:pb-8">{children}</main>
</div>
);
}

View File

@@ -0,0 +1,177 @@
import Link from 'next/link';
import { getLeads, getResumen, type LeadFiltro } from '@/db/queries';
import {
ESTADOS,
ESTADO_BADGE,
ESTADO_LABEL,
PIPELINE_LABEL,
PIPELINE_NEXT,
formatEuros,
formatFecha,
} from '@/lib/funnel';
import { getCurrentTenantId } from '@/lib/auth/current-user';
import { db } from '@/db';
import { tenants, plans } from '@/db/schema';
import { eq } from 'drizzle-orm';
import { formatPlanBadge } from '@/lib/billing/plan';
import { STRIPE_ENABLED } from '@/lib/billing/stripe';
export const dynamic = 'force-dynamic';
const FILTROS: { value: LeadFiltro; label: string }[] = [
{ value: 'todos', label: 'Todos' },
...ESTADOS.map((e) => ({ value: e as LeadFiltro, label: ESTADO_LABEL[e] })),
];
export default async function PanelPage({
searchParams,
}: {
searchParams: Promise<{ estado?: string }>;
}) {
const { estado } = await searchParams;
const filtro: LeadFiltro = (FILTROS.find((f) => f.value === estado)?.value ?? 'todos') as LeadFiltro;
const [leads, resumen] = await Promise.all([getLeads(filtro), getResumen()]);
const tenantId = await getCurrentTenantId();
const [tenant] = await db.select().from(tenants).where(eq(tenants.id, tenantId)).limit(1);
const [plan] = tenant?.planId
? await db.select().from(plans).where(eq(plans.id, tenant.planId)).limit(1)
: [];
const badge = formatPlanBadge(plan?.nombre ?? null, tenant?.subscriptionStatus ?? 'trial', tenant?.trialEndsAt ?? null);
return (
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-1">
<h1 className="text-2xl font-black tracking-tight text-black">Leads</h1>
<p className="text-sm text-gray-500">
{resumen.total} leads en total · {resumen.porEstado['nuevo'] ?? 0} sin contactar
</p>
</div>
<div className="flex items-center justify-between bg-white border border-gray-200 rounded-xl px-4 py-3">
<span className="text-sm font-medium text-gray-700">{badge}</span>
<button
type="button"
disabled={!STRIPE_ENABLED}
title="Próximamente"
className="text-xs font-semibold text-gray-400 cursor-not-allowed"
>
Gestionar pago
</button>
</div>
{/* Filtros por estado */}
<div className="flex flex-wrap gap-2">
{FILTROS.map((f) => {
const active = f.value === filtro;
const count = f.value === 'todos' ? resumen.total : resumen.porEstado[f.value] ?? 0;
return (
<Link
key={f.value}
href={f.value === 'todos' ? '/panel' : `/panel?estado=${f.value}`}
className={`px-3 py-1.5 rounded-full text-sm font-medium border transition-colors ${
active
? 'bg-black text-white border-black'
: 'bg-white text-gray-600 border-gray-200 hover:border-gray-400'
}`}
>
{f.label} <span className={active ? 'text-gray-300' : 'text-gray-400'}>{count}</span>
</Link>
);
})}
</div>
{/* Tabla (desktop) */}
<div className="hidden md:block bg-white border border-gray-200 rounded-xl overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-gray-200 text-left text-xs uppercase tracking-wide text-gray-400">
<th className="px-4 py-3 font-semibold">Render</th>
<th className="px-4 py-3 font-semibold">Cliente</th>
<th className="px-4 py-3 font-semibold">Fecha</th>
<th className="px-4 py-3 font-semibold">Estado</th>
<th className="px-4 py-3 font-semibold">Presupuesto</th>
<th className="px-4 py-3 font-semibold">Siguiente paso</th>
</tr>
</thead>
<tbody>
{leads.map((l) => (
<tr key={l.id} className="border-b border-gray-100 last:border-0 hover:bg-gray-50">
<td className="px-4 py-3">
<Link href={`/panel/${l.id}`} className="block w-16 h-12 rounded-md overflow-hidden bg-gray-100">
{l.renderUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={l.renderUrl} alt="" className="w-full h-full object-cover" />
) : (
<span className="flex w-full h-full items-center justify-center text-[10px] text-gray-400">
sin render
</span>
)}
</Link>
</td>
<td className="px-4 py-3">
<Link href={`/panel/${l.id}`} className="font-semibold text-black hover:underline">
{l.nombre}
</Link>
<div className="text-gray-500">{l.telefono}</div>
</td>
<td className="px-4 py-3 text-gray-600 whitespace-nowrap">{formatFecha(l.createdAt)}</td>
<td className="px-4 py-3">
<span className={`inline-block px-2.5 py-1 rounded-full text-xs font-semibold ${ESTADO_BADGE[l.estado]}`}>
{ESTADO_LABEL[l.estado]}
</span>
</td>
<td className="px-4 py-3 font-semibold text-black whitespace-nowrap">
{formatEuros(l.presupuestoEstimado)}
</td>
<td className="px-4 py-3 text-gray-500">
<div className="text-xs text-gray-400">{PIPELINE_LABEL[l.pipelineStage]}</div>
{PIPELINE_NEXT[l.pipelineStage]}
</td>
</tr>
))}
</tbody>
</table>
{leads.length === 0 && (
<div className="px-4 py-12 text-center text-gray-400">No hay leads con este estado.</div>
)}
</div>
{/* Cards (mobile) */}
<div className="md:hidden flex flex-col gap-3">
{leads.map((l) => (
<Link
key={l.id}
href={`/panel/${l.id}`}
className="bg-white border border-gray-200 rounded-xl p-4 flex gap-3"
>
<div className="w-16 h-16 rounded-md overflow-hidden bg-gray-100 shrink-0">
{l.renderUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={l.renderUrl} alt="" className="w-full h-full object-cover" />
) : null}
</div>
<div className="flex flex-col gap-1 min-w-0">
<div className="flex items-center justify-between gap-2">
<span className="font-semibold text-black truncate">{l.nombre}</span>
<span className={`shrink-0 px-2 py-0.5 rounded-full text-[11px] font-semibold ${ESTADO_BADGE[l.estado]}`}>
{ESTADO_LABEL[l.estado]}
</span>
</div>
<div className="text-sm text-gray-500">{l.telefono}</div>
<div className="flex items-center justify-between text-sm">
<span className="font-semibold text-black">{formatEuros(l.presupuestoEstimado)}</span>
<span className="text-gray-400">{formatFecha(l.createdAt)}</span>
</div>
<div className="text-xs text-gray-400">{PIPELINE_NEXT[l.pipelineStage]}</div>
</div>
</Link>
))}
{leads.length === 0 && (
<div className="px-4 py-12 text-center text-gray-400">No hay leads con este estado.</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,118 @@
'use server';
import { and, eq } from 'drizzle-orm';
import { revalidatePath } from 'next/cache';
import { db } from '@/db';
import { catalogItems, pricingConfig, tenants } from '@/db/schema';
import { getTenantId, type EnvioMode } from '@/db/pricing-queries';
import { parseCatalogCsv } from '@/budget/csv';
// Valida un importe en euros del formulario y lo convierte a céntimos.
// Lanza un error en español si el valor no es un número finito >= 0.
function eurosToCents(raw: FormDataEntryValue | null, campo: string): number {
const euros = Number(raw);
if (!Number.isFinite(euros) || euros < 0) {
throw new Error(`El valor de "${campo}" debe ser un número mayor o igual que 0.`);
}
return Math.round(euros * 100);
}
function parsePositive(raw: FormDataEntryValue | null, campo: string): number {
const n = Number(raw);
if (!Number.isFinite(n) || n <= 0) {
throw new Error(`El valor de "${campo}" debe ser un número mayor que 0.`);
}
return n;
}
export async function crearMaterial(formData: FormData) {
const tenantId = await getTenantId();
await db.insert(catalogItems).values({
tenantId,
categoria: formData.get('categoria') as 'suelo' | 'pared' | 'pintura' | 'mobiliario',
nombre: String(formData.get('nombre') ?? ''),
calidad: formData.get('calidad') as 'basica' | 'media' | 'premium',
precioUnit: eurosToCents(formData.get('precioEuros'), 'precio'),
unidad: formData.get('unidad') as 'm2' | 'ml' | 'ud',
descriptorRender: String(formData.get('descriptorRender') ?? ''),
esDefault: formData.get('esDefault') === 'on',
sku: String(formData.get('sku') ?? ''),
});
revalidatePath('/panel/precios');
}
export async function actualizarPrecio(formData: FormData) {
const tenantId = await getTenantId();
const id = String(formData.get('id') ?? '');
await db
.update(catalogItems)
.set({ precioUnit: eurosToCents(formData.get('precioEuros'), 'precio') })
.where(and(eq(catalogItems.id, id), eq(catalogItems.tenantId, tenantId)));
revalidatePath('/panel/precios');
}
export async function borrarMaterial(formData: FormData) {
const tenantId = await getTenantId();
const id = String(formData.get('id') ?? '');
await db.delete(catalogItems).where(and(eq(catalogItems.id, id), eq(catalogItems.tenantId, tenantId)));
revalidatePath('/panel/precios');
}
export async function actualizarConfig(formData: FormData) {
const tenantId = await getTenantId();
await db
.update(pricingConfig)
.set({
alturaTechoDefault: parsePositive(formData.get('alturaTechoDefault'), 'altura de techo'),
manoObra: {
demolicion: eurosToCents(formData.get('mo_demolicion'), 'demolición'),
fontaneria: eurosToCents(formData.get('mo_fontaneria'), 'fontanería'),
electricidad: eurosToCents(formData.get('mo_electricidad'), 'electricidad'),
mano_de_obra: eurosToCents(formData.get('mo_mano_de_obra'), 'mano de obra'),
},
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');
if (modo !== 'automatico' && modo !== 'revision') {
throw new Error('Modo de envío no válido.');
}
await db
.update(tenants)
.set({ envioPresupuesto: modo as EnvioMode })
.where(eq(tenants.id, tenantId));
revalidatePath('/panel/precios');
}
export type ImportResult = { ok: boolean; inserted: number; errors: { line: number; message: string }[] };
export async function importarCatalogoCsv(_prev: ImportResult | null, formData: FormData): Promise<ImportResult> {
const tenantId = await getTenantId();
const csv = String(formData.get('csv') ?? '');
const { rows, errors } = parseCatalogCsv(csv);
if (errors.length > 0) return { ok: false, inserted: 0, errors };
for (const r of rows) {
await db
.insert(catalogItems)
.values({ tenantId, ...r })
.onConflictDoUpdate({
target: [catalogItems.tenantId, catalogItems.sku],
set: {
categoria: r.categoria,
nombre: r.nombre,
calidad: r.calidad,
precioUnit: r.precioUnit,
unidad: r.unidad,
descriptorRender: r.descriptorRender,
},
});
}
revalidatePath('/panel/precios');
return { ok: true, inserted: rows.length, errors: [] };
}

View File

@@ -0,0 +1,244 @@
import { getPricingConfig, getCatalog, getEnvioMode } from '@/db/pricing-queries';
import {
crearMaterial,
actualizarPrecio,
borrarMaterial,
actualizarConfig,
actualizarEnvio,
importarCatalogoCsv,
} from './actions';
export const dynamic = 'force-dynamic';
const CATEGORIAS = ['suelo', 'pared', 'pintura', 'mobiliario'] as const;
const CATEGORIA_LABEL: Record<(typeof CATEGORIAS)[number], string> = {
suelo: 'Suelos',
pared: 'Paredes / alicatado',
pintura: 'Pinturas',
mobiliario: 'Mobiliario',
};
export default async function PreciosPage() {
const [config, catalog, envioMode] = await Promise.all([
getPricingConfig(),
getCatalog(),
getEnvioMode(),
]);
return (
<div className="space-y-10">
<div>
<h1 className="text-2xl font-extrabold tracking-tight text-black">Tabla de precios</h1>
<p className="text-sm text-gray-500 mt-1">
Define los precios unitarios y la mano de obra. El motor calcula el presupuesto a
partir de estos valores y las medidas del lead.
</p>
</div>
{/* Envío de presupuestos */}
<section className="bg-white rounded-xl border border-gray-200 p-6">
<h2 className="font-bold text-black mb-1">Envío de presupuestos</h2>
<p className="text-sm text-gray-500 mb-4">
Decide si el presupuesto se entrega al cliente automáticamente al final del funnel o si
quieres revisarlo y editar los conceptos antes de enviarlo.
</p>
<form action={actualizarEnvio} className="flex flex-col gap-3">
<label className="flex items-start gap-3 text-sm cursor-pointer">
<input
type="radio"
name="modo"
value="automatico"
defaultChecked={envioMode === 'automatico'}
className="mt-1"
/>
<span>
<span className="block font-medium text-black">Envío automático</span>
<span className="block text-gray-500">
El cliente recibe el presupuesto por WhatsApp en cuanto el funnel lo genera.
</span>
</span>
</label>
<label className="flex items-start gap-3 text-sm cursor-pointer">
<input
type="radio"
name="modo"
value="revision"
defaultChecked={envioMode === 'revision'}
className="mt-1"
/>
<span>
<span className="block font-medium text-black">Revisar antes de enviar</span>
<span className="block text-gray-500">
El funnel se detiene en cada lead para que revises los conceptos y pulses enviar.
</span>
</span>
</label>
<button className="self-start bg-black text-white rounded-lg px-4 py-2 text-sm font-medium">
Guardar preferencia
</button>
</form>
</section>
{/* Config general */}
<section className="bg-white rounded-xl border border-gray-200 p-6">
<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">
<span className="block text-xs text-gray-500 mb-1">Altura techo (m)</span>
<input
name="alturaTechoDefault"
type="number"
step="0.1"
defaultValue={config.alturaTechoDefault}
className="w-full border border-gray-300 rounded-lg px-2 py-1.5"
/>
</label>
{(
[
['demolicion', 'Demolición'],
['fontaneria', 'Fontanería'],
['electricidad', 'Electricidad'],
['mano_de_obra', 'Mano de obra'],
] as const
).map(([k, etiqueta]) => (
<label key={k} className="text-sm">
<span className="block text-xs text-gray-500 mb-1">{etiqueta} (/m²)</span>
<input
name={`mo_${k}`}
type="number"
step="0.01"
defaultValue={(config.manoObra[k] ?? 0) / 100}
className="w-full border border-gray-300 rounded-lg px-2 py-1.5"
/>
</label>
))}
<button className="col-span-2 md:col-span-5 justify-self-start bg-black text-white rounded-lg px-4 py-2 text-sm font-medium">
Guardar configuración
</button>
</form>
</section>
{/* Catálogo por categoría */}
{CATEGORIAS.map((categoria) => {
const items = catalog.filter((c) => c.categoria === categoria);
return (
<section key={categoria} className="bg-white rounded-xl border border-gray-200 p-6">
<h2 className="font-bold text-black mb-4">{CATEGORIA_LABEL[categoria]}</h2>
<div className="space-y-2">
{items.length === 0 && <p className="text-sm text-gray-400">Sin materiales.</p>}
{items.map((item) => (
<div
key={item.id}
className="flex flex-col gap-2 border-b border-gray-100 pb-3 text-sm sm:flex-row sm:items-center sm:gap-3 sm:pb-2"
>
<div className="min-w-0 sm:flex-1">
<div className="font-medium text-black">{item.nombre}</div>
<div className="mt-0.5 flex items-center gap-2 text-xs text-gray-500">
<span className="capitalize">{item.calidad}</span>
<span className="text-gray-300">·</span>
<span>{item.unidad}</span>
{item.esDefault && (
<span className="bg-green-100 text-green-700 rounded px-1.5 py-0.5">default</span>
)}
</div>
</div>
<div className="flex items-center gap-3">
<form action={actualizarPrecio} className="flex items-center gap-2">
<input type="hidden" name="id" value={item.id} />
<div className="relative">
<input
name="precioEuros"
type="number"
step="0.01"
defaultValue={item.precioUnit / 100}
className="w-28 border border-gray-300 rounded-lg pl-3 pr-7 py-1.5 text-right"
aria-label={`Precio de ${item.nombre}`}
/>
<span className="pointer-events-none absolute right-2.5 top-1/2 -translate-y-1/2 text-gray-400">
</span>
</div>
<button className="text-xs font-medium text-blue-600 hover:underline">Guardar</button>
</form>
<form action={borrarMaterial}>
<input type="hidden" name="id" value={item.id} />
<button className="text-xs text-red-500 hover:underline">Borrar</button>
</form>
</div>
</div>
))}
</div>
<form
action={crearMaterial}
className="mt-5 grid grid-cols-2 gap-2 border-t border-gray-100 pt-4 text-sm sm:flex sm:flex-wrap sm:items-end sm:border-t-0 sm:pt-0"
>
<input type="hidden" name="categoria" value={categoria} />
<input
name="nombre"
placeholder="Nombre"
required
className="col-span-2 border border-gray-300 rounded-lg px-2 py-1.5 sm:col-auto"
/>
<select name="calidad" className="border border-gray-300 rounded-lg px-2 py-1.5">
<option value="basica">Básica</option>
<option value="media">Media</option>
<option value="premium">Premium</option>
</select>
<input
name="precioEuros"
type="number"
step="0.01"
placeholder="Precio €"
required
className="border border-gray-300 rounded-lg px-2 py-1.5 sm:w-24"
/>
<select name="unidad" className="border border-gray-300 rounded-lg px-2 py-1.5">
<option value="m2">m²</option>
<option value="ml">ml</option>
<option value="ud">ud</option>
</select>
<input
name="sku"
placeholder="SKU"
required
className="border border-gray-300 rounded-lg px-2 py-1.5 sm:w-28"
/>
<input
name="descriptorRender"
placeholder="Descriptor render (opcional)"
className="col-span-2 border border-gray-300 rounded-lg px-2 py-1.5 sm:col-auto sm:flex-1 sm:min-w-40"
/>
<label className="col-span-2 flex items-center gap-2 text-gray-500 sm:col-auto">
<input type="checkbox" name="esDefault" /> Marcar como default
</label>
<button className="col-span-2 bg-black text-white rounded-lg px-3 py-2 font-medium sm:col-auto sm:py-1.5">
Añadir
</button>
</form>
</section>
);
})}
{/* Import CSV */}
<section className="bg-white rounded-xl border border-gray-200 p-6">
<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
precio en euros. Actualiza por SKU.
</p>
<form action={importarCatalogoCsv as unknown as (fd: FormData) => void}>
<textarea
name="csv"
rows={5}
placeholder="categoria,nombre,calidad,precio,unidad,descriptor_render,sku"
className="w-full border border-gray-300 rounded-lg px-3 py-2 font-mono text-xs"
/>
<button className="mt-2 bg-black text-white rounded-lg px-4 py-2 text-sm font-medium">
Importar
</button>
</form>
</section>
</div>
);
}

View File

@@ -0,0 +1,32 @@
'use server';
import { redirect } from 'next/navigation';
import { signupSchema, slugify } from '@/lib/validation/signup';
import { getUserByEmail, createTenantWithOwner, slugDisponible } from '@/db/auth-queries';
import { hashPassword } from '@/lib/auth/password';
import { createSession } from '@/lib/auth/session';
export async function signup(_prev: string | null, formData: FormData): Promise<string | null> {
const parsed = signupSchema.safeParse(Object.fromEntries(formData));
if (!parsed.success) return parsed.error.issues[0]?.message ?? 'Datos no válidos.';
const data = parsed.data;
if (await getUserByEmail(data.email)) return 'Ya existe una cuenta con ese email.';
let slug = slugify(data.empresa);
let n = 1;
while (!(await slugDisponible(slug))) slug = `${slugify(data.empresa)}-${++n}`;
const passwordHash = await hashPassword(data.password);
const { user } = await createTenantWithOwner({
nombreEmpresa: data.empresa,
slug,
provincia: data.provincia,
email: data.email,
passwordHash,
nombre: data.nombre,
});
await createSession(user.id);
redirect('/panel');
}

View File

@@ -0,0 +1,29 @@
'use client';
import { useActionState } from 'react';
import { signup } from './actions';
export default function SignupPage() {
const [error, formAction, pending] = useActionState(signup, null);
return (
<main className="min-h-screen flex items-center justify-center bg-gray-50 px-6 py-12">
<form action={formAction} className="w-full max-w-md bg-white border border-gray-200 rounded-xl p-8 flex flex-col gap-4">
<h1 className="text-xl font-black tracking-tight text-black">Empieza gratis 14 días</h1>
<p className="text-sm text-gray-500">Sin tarjeta. Configura tu catálogo y recibe leads.</p>
<input name="nombre" placeholder="Tu nombre" required className="border border-gray-300 rounded-md px-3 py-2 text-sm" />
<input name="empresa" placeholder="Nombre de tu empresa" required className="border border-gray-300 rounded-md px-3 py-2 text-sm" />
<input name="email" type="email" placeholder="Email" required className="border border-gray-300 rounded-md px-3 py-2 text-sm" />
<input name="provincia" placeholder="Provincia" required className="border border-gray-300 rounded-md px-3 py-2 text-sm" />
<input name="password" type="password" placeholder="Contraseña (mín. 8)" required className="border border-gray-300 rounded-md px-3 py-2 text-sm" />
<label className="flex items-center gap-2 text-xs text-gray-500">
<input name="optInMarketing" type="checkbox" /> Quiero recibir novedades de Reformix
</label>
{error && <p className="text-sm text-red-600">{error}</p>}
<button type="submit" disabled={pending} className="bg-black text-white rounded-md py-2 font-semibold disabled:opacity-60">
{pending ? 'Creando cuenta…' : 'Crear cuenta'}
</button>
<a href="/login" className="text-xs text-gray-400 text-center hover:text-black">Ya tengo cuenta</a>
</form>
</main>
);
}

View File

@@ -0,0 +1,133 @@
import { notFound } from 'next/navigation';
import { getPublicLead } from '@/lib/funnel/public-queries';
import { PIPELINE_ORDER, PIPELINE_LABEL, TIPO_LABEL, formatEuros } from '@/lib/funnel';
import type { BudgetResult } from '@/budget/types';
export const dynamic = 'force-dynamic';
export default async function EstadoPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const data = await getPublicLead(id);
if (!data) notFound();
const { lead, eventos } = data;
const reachedStages = new Set(eventos.map((e) => e.stage));
const snapshot = lead.desgloseSnapshot as { result: BudgetResult } | null;
const desglose = snapshot?.result ?? null;
const entregado = lead.pipelineStage === 'whatsapp_entregado';
const tipo = lead.tipoReforma ? TIPO_LABEL[lead.tipoReforma] : 'tu reforma';
return (
<div className="flex flex-col gap-6">
{/* Cabecera de éxito */}
<div className="flex flex-col items-center text-center gap-3">
<div className="w-14 h-14 bg-black text-white rounded-full flex items-center justify-center">
<svg width="26" height="26" viewBox="0 0 32 32" fill="none" aria-hidden="true">
<path
d="M6 16l7 7L26 9"
stroke="currentColor"
strokeWidth="3"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</div>
<h1 className="text-2xl font-black tracking-tight text-black">
¡Tu presupuesto está listo, {lead.nombre.split(' ')[0]}!
</h1>
<p className="text-sm text-gray-500 leading-relaxed max-w-md">
{entregado
? 'Te lo hemos enviado por WhatsApp. Aquí tienes un adelanto del render y el presupuesto orientativo de tu reforma.'
: 'Estamos preparando tu render y presupuesto. En breve lo recibirás por WhatsApp.'}
</p>
</div>
{/* Render */}
{lead.renderUrl && (
<div className="bg-white border border-gray-200 rounded-xl overflow-hidden shadow-sm">
<div className="px-5 py-3 border-b border-gray-100">
<h2 className="text-xs uppercase tracking-wide font-semibold text-gray-400">
Render de {tipo.toLowerCase()}
</h2>
</div>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={lead.renderUrl} alt="Render de tu reforma" className="w-full object-cover" />
</div>
)}
{/* Presupuesto */}
{desglose ? (
<div className="bg-white border border-gray-200 rounded-xl p-6 shadow-sm flex flex-col gap-4">
<div className="flex items-end justify-between gap-4">
<div>
<div className="text-xs text-gray-400 uppercase tracking-wide">
Presupuesto orientativo
</div>
<div className="text-3xl font-black text-black">{formatEuros(desglose.total)}</div>
</div>
<div className="text-right text-sm text-gray-500">
<div className="text-xs text-gray-400">Rango estimado</div>
{formatEuros(desglose.rango.min)} {formatEuros(desglose.rango.max)}
</div>
</div>
<ul className="flex flex-col divide-y divide-gray-100 text-sm">
{desglose.partidas.map((p) => (
<li key={p.key} className="flex justify-between py-2">
<span className="text-gray-600">{p.label}</span>
<span className="text-black font-medium">{formatEuros(p.importe)}</span>
</li>
))}
</ul>
{desglose.avisos.length > 0 && (
<ul className="text-xs text-amber-700 bg-amber-50 rounded-lg p-3 flex flex-col gap-1">
{desglose.avisos.map((aviso, i) => (
<li key={i}> {aviso}</li>
))}
</ul>
)}
<p className="text-xs text-gray-400">
Presupuesto orientativo. El precio final puede variar según la visita técnica.
</p>
</div>
) : (
<div className="bg-white border border-gray-200 rounded-xl p-6 shadow-sm text-center text-sm text-gray-500">
Calculando tu presupuesto
</div>
)}
{/* Progreso del pipeline */}
<div className="bg-white border border-gray-200 rounded-xl p-6 shadow-sm">
<h2 className="text-xs uppercase tracking-wide font-semibold text-gray-400 mb-3">
Estado de tu solicitud
</h2>
<ol className="flex flex-col gap-2">
{PIPELINE_ORDER.map((stage) => {
const reached = reachedStages.has(stage);
return (
<li key={stage} className="flex items-center gap-3 text-sm">
<span
className={`w-2.5 h-2.5 rounded-full shrink-0 ${
reached ? 'bg-green-500' : 'bg-gray-200'
}`}
/>
<span className={reached ? 'text-black font-medium' : 'text-gray-400'}>
{PIPELINE_LABEL[stage]}
</span>
</li>
);
})}
</ol>
</div>
{entregado && (
<div className="text-sm font-medium text-green-700 bg-green-50 rounded-lg px-4 py-3 text-center">
Presupuesto enviado a tu WhatsApp
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,35 @@
import { notFound } from 'next/navigation';
import { getPublicLead } from '@/lib/funnel/public-queries';
import { guardarDetallesYFotos } from '../../actions';
import FotosUploader from '@/components/funnel/FotosUploader';
export const dynamic = 'force-dynamic';
export default async function FotosPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const data = await getPublicLead(id);
if (!data) notFound();
const { lead } = data;
return (
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-2">
<span className="text-xs font-semibold uppercase tracking-widest text-gray-400">
Paso 2 de 2
</span>
<h1 className="text-2xl font-black tracking-tight text-black">
Hola {lead.nombre.split(' ')[0]}, cuéntanos sobre tu reforma
</h1>
<p className="text-sm text-gray-500 leading-relaxed">
Sube unas fotos del espacio y dinos qué tienes en mente. Con eso preparamos tu render y un
presupuesto orientativo en menos de un minuto.
</p>
</div>
<div className="bg-white border border-gray-200 rounded-xl p-6 md:p-8 shadow-sm">
<FotosUploader action={guardarDetallesYFotos.bind(null, id)} />
</div>
</div>
);
}

View File

@@ -0,0 +1,154 @@
'use server';
import { z } from 'zod';
import { and, eq } from 'drizzle-orm';
import { redirect } from 'next/navigation';
import { revalidatePath } from 'next/cache';
import { db } from '@/db';
import { leads, leadFotos, leadPipelineEventos } from '@/db/schema';
import { getDemoTenantId } from '@/lib/funnel/public-queries';
import { procesarLead } from '@/lib/funnel/orchestrator';
const MAX_FOTOS = 4;
const MAX_FOTO_BYTES = 6 * 1024 * 1024; // 6 MB por foto
const crearLeadSchema = z.object({
nombre: z.string().trim().min(2, 'El nombre es obligatorio'),
email: z.string().trim().email('Introduce un email válido'),
telefono: z
.string()
.trim()
.regex(/^[+\d\s\-().]{7,20}$/, 'Introduce un teléfono válido'),
consentPrivacidad: z.boolean(),
consentContratacion: z.boolean(),
});
export type CrearLeadInput = z.input<typeof crearLeadSchema>;
export type CrearLeadResult =
| { ok: true; leadId: string }
| { ok: false; error: string };
export async function crearLead(input: CrearLeadInput): Promise<CrearLeadResult> {
const parsed = crearLeadSchema.safeParse(input);
if (!parsed.success) {
return { ok: false, error: parsed.error.issues[0]?.message ?? 'Datos inválidos.' };
}
const data = parsed.data;
// RF-LEG-01: los dos consentimientos son obligatorios para iniciar el funnel.
if (!data.consentPrivacidad || !data.consentContratacion) {
return { ok: false, error: 'Debes aceptar la política de privacidad y las condiciones.' };
}
const tenantId = await getDemoTenantId();
const [lead] = await db
.insert(leads)
.values({
tenantId,
nombre: data.nombre,
email: data.email,
telefono: data.telefono,
consentPrivacidad: data.consentPrivacidad,
consentContratacion: data.consentContratacion,
pipelineStage: 'form_completado',
estado: 'nuevo',
})
.returning({ id: leads.id });
await db.insert(leadPipelineEventos).values({
leadId: lead.id,
stage: 'form_completado',
metadata: { origen: 'landing' },
});
return { ok: true, leadId: lead.id };
}
const TIPOS = ['cocina', 'bano', 'salon', 'comedor', 'integral', 'otro'] as const;
const CALIDADES = ['basica', 'media', 'premium'] as const;
async function fileToDataUri(file: File): Promise<string | null> {
if (file.size === 0 || file.size > MAX_FOTO_BYTES) return null;
if (!file.type.startsWith('image/')) return null;
const buffer = Buffer.from(await file.arrayBuffer());
return `data:${file.type};base64,${buffer.toString('base64')}`;
}
// Paso 3 del funnel: el cliente sube fotos y confirma los datos clave de la reforma.
// Guardamos las fotos como data URI (no hay storage externo en esta fase) y disparamos
// el orquestador que simula la llamada/render y calcula el presupuesto real.
export async function guardarDetallesYFotos(leadId: string, formData: FormData): Promise<void> {
const tenantId = await getDemoTenantId();
const [lead] = await db
.select()
.from(leads)
.where(and(eq(leads.id, leadId), eq(leads.tenantId, tenantId)))
.limit(1);
if (!lead) throw new Error('Solicitud no encontrada.');
const tipoRaw = String(formData.get('tipoReforma') ?? '');
const calidadRaw = String(formData.get('calidad') ?? '');
const m2Raw = Number(formData.get('m2'));
const provincia = String(formData.get('provincia') ?? '').trim() || null;
const tipoReforma = (TIPOS as readonly string[]).includes(tipoRaw)
? (tipoRaw as (typeof TIPOS)[number])
: 'otro';
const calidadGlobal = (CALIDADES as readonly string[]).includes(calidadRaw)
? (calidadRaw as (typeof CALIDADES)[number])
: 'media';
const m2Suelo = Number.isFinite(m2Raw) && m2Raw > 0 ? m2Raw : null;
const urgenciaRaw = String(formData.get('urgencia') ?? '');
const urgencia = (['alta', 'media', 'baja'] as const).includes(urgenciaRaw as 'alta')
? (urgenciaRaw as 'alta' | 'media' | 'baja')
: null;
const targetEuros = Number(formData.get('presupuestoTarget'));
const presupuestoTarget =
Number.isFinite(targetEuros) && targetEuros > 0 ? Math.round(targetEuros * 100) : null;
const estructural = formData.get('estructural') === 'on';
const tasteText = String(formData.get('tasteText') ?? '').trim() || null;
const archivos = formData.getAll('fotos').filter((f): f is File => f instanceof File);
const dataUris: string[] = [];
for (const file of archivos.slice(0, MAX_FOTOS)) {
const uri = await fileToDataUri(file);
if (uri) dataUris.push(uri);
}
if (dataUris.length > 0) {
await db.insert(leadFotos).values(
dataUris.map((url, orden) => ({ leadId, url, orden }))
);
}
await db
.update(leads)
.set({
tipoReforma,
calidadGlobal,
m2Suelo,
provincia,
urgencia,
presupuestoTarget,
estructural,
tasteText,
pipelineStage: 'fotos_subidas',
updatedAt: new Date(),
})
.where(and(eq(leads.id, leadId), eq(leads.tenantId, tenantId)));
await db.insert(leadPipelineEventos).values({
leadId,
stage: 'fotos_subidas',
metadata: { fotos: dataUris.length },
});
// Dispara el resto del pipeline (llamada simulada → render → presupuesto real → WhatsApp).
await procesarLead(leadId);
revalidatePath('/panel');
redirect(`/solicitud/${leadId}/estado`);
}

View File

@@ -0,0 +1,26 @@
import Link from 'next/link';
export default function SolicitudLayout({ children }: { children: React.ReactNode }) {
return (
<div className="min-h-screen bg-gray-50 flex flex-col">
<header className="bg-white border-b border-gray-200">
<div className="container py-4 flex items-center justify-between">
<Link href="/" className="text-lg font-black tracking-tight text-black">
Reformix
</Link>
<span className="text-xs font-semibold uppercase tracking-widest text-gray-400">
Tu presupuesto
</span>
</div>
</header>
<main className="flex-1">
<div className="container py-10 max-w-2xl">{children}</div>
</main>
<footer className="border-t border-gray-200 bg-white">
<div className="container py-6 text-xs text-gray-400 text-center">
Reformix · Presupuesto orientativo. El precio final puede variar según la visita técnica.
</div>
</footer>
</div>
);
}

View File

@@ -0,0 +1,110 @@
import { deriveCantidades } from './derive';
import { resolvePrecioUnitario } from './resolve';
import { PARTIDA_LABEL, PARTIDA_ORDER } from './labels';
import type {
BudgetInputs,
BudgetResult,
CategoriaMaterial,
CatalogItem,
PartidaKey,
PricingConfig,
} from './types';
const LICENCIA_MIN = 30000; // 300 €
const LICENCIA_MAX = 150000; // 1.500 €
// A qué partida contribuye el material de cada categoría.
const MATERIAL_PARTIDA: Record<CategoriaMaterial, PartidaKey> = {
suelo: 'alicatado',
pared: 'alicatado',
pintura: 'extras',
mobiliario: 'carpinteria',
};
const CATEGORIAS: CategoriaMaterial[] = ['suelo', 'pared', 'pintura', 'mobiliario'];
export function computeBudget(
inputs: BudgetInputs,
config: PricingConfig,
catalog: CatalogItem[],
): BudgetResult {
const cant = deriveCantidades(inputs, config);
const avisos: string[] = [];
const materialesRender: string[] = [];
const importes: Record<PartidaKey, number> = {
demolicion: 0,
alicatado: 0,
fontaneria: 0,
electricidad: 0,
carpinteria: 0,
mano_de_obra: 0,
extras: 0,
licencia: 0,
};
const cantidadPorCategoria: Record<CategoriaMaterial, number> = {
suelo: cant.m2Suelo,
pared: cant.m2Pared,
pintura: cant.m2Pared,
mobiliario: cant.mlMobiliario,
};
for (const categoria of CATEGORIAS) {
const cantidad = cantidadPorCategoria[categoria];
if (cantidad <= 0) continue;
const { item } = resolvePrecioUnitario(
categoria,
inputs.calidadGlobal,
catalog,
inputs.materialSelections,
);
if (!item) {
avisos.push(`Sin precio para ${categoria} (calidad ${inputs.calidadGlobal})`);
continue;
}
importes[MATERIAL_PARTIDA[categoria]] += Math.round(cantidad * item.precioUnit);
if (item.descriptorRender) materialesRender.push(item.descriptorRender);
}
importes.demolicion += Math.round(cant.m2Suelo * config.manoObra.demolicion);
importes.fontaneria += Math.round(cant.m2Suelo * config.manoObra.fontaneria);
importes.electricidad += Math.round(cant.m2Suelo * config.manoObra.electricidad);
importes.mano_de_obra += Math.round(cant.m2Suelo * config.manoObra.mano_de_obra);
if (inputs.estructural) importes.licencia += LICENCIA_MIN;
const partidas = PARTIDA_ORDER.filter((k) => importes[k] > 0).map((k) => ({
key: k,
label: PARTIDA_LABEL[k],
importe: importes[k],
}));
const subtotal = partidas.reduce((s, p) => s + p.importe, 0);
const factorZona = config.factorZona[inputs.provincia ?? ''] ?? 1;
const total = Math.round(subtotal * factorZona);
const hasExact = (Object.values(inputs.materialSelections) as string[]).some(
(id) => catalog.some((c) => c.id === id),
);
const hasM2 = inputs.m2Suelo != null && inputs.m2Suelo > 0;
let confianza: BudgetResult['confianza'];
let band: number;
if (hasM2 && hasExact) {
confianza = 'alta';
band = 0.1;
} else if (hasM2 || hasExact) {
confianza = 'media';
band = 0.15;
} else {
confianza = 'baja';
band = 0.25;
}
const rango = {
min: Math.round(total * (1 - band)),
max: Math.round(total * (1 + band)) + (inputs.estructural ? LICENCIA_MAX - LICENCIA_MIN : 0),
};
return { partidas, subtotal, factorZona, total, rango, confianza, materialesRender, avisos };
}

76
mvp/b2c/src/budget/csv.ts Normal file
View File

@@ -0,0 +1,76 @@
import { z } from 'zod';
import type { CategoriaMaterial, Calidad, Unidad } from './types';
export interface ParsedCatalogRow {
categoria: CategoriaMaterial;
nombre: string;
calidad: Calidad;
precioUnit: number; // céntimos
unidad: Unidad;
descriptorRender: string;
sku: string;
}
export interface CsvError {
line: number; // 1-indexed (la cabecera es la línea 1)
message: string;
}
const HEADER = ['categoria', 'nombre', 'calidad', 'precio', 'unidad', 'descriptor_render', 'sku'];
const rowSchema = z.object({
categoria: z.enum(['suelo', 'pared', 'pintura', 'mobiliario']),
nombre: z.string().min(1),
calidad: z.enum(['basica', 'media', 'premium']),
precio: z
.string()
.transform((s) => Number(s))
.refine((n) => Number.isFinite(n) && n > 0, 'precio inválido'),
unidad: z.enum(['m2', 'ml', 'ud']),
descriptor_render: z.string(),
sku: z.string().min(1),
});
export function parseCatalogCsv(text: string): { rows: ParsedCatalogRow[]; errors: CsvError[] } {
const lines = text
.split(/\r?\n/)
.map((l) => l.trim())
.filter((l) => l.length > 0);
if (lines.length === 0) {
return { rows: [], errors: [{ line: 1, message: 'CSV vacío' }] };
}
const header = lines[0].split(',').map((h) => h.trim());
if (HEADER.some((h, i) => header[i] !== h)) {
return {
rows: [],
errors: [{ line: 1, message: `Cabecera inválida. Esperada: ${HEADER.join(',')}` }],
};
}
const rows: ParsedCatalogRow[] = [];
const errors: CsvError[] = [];
for (let i = 1; i < lines.length; i++) {
const cells = lines[i].split(',').map((c) => c.trim());
const record = Object.fromEntries(HEADER.map((h, idx) => [h, cells[idx] ?? '']));
const parsed = rowSchema.safeParse(record);
if (!parsed.success) {
errors.push({ line: i + 1, message: parsed.error.issues[0]?.message ?? 'fila inválida' });
continue;
}
const d = parsed.data;
rows.push({
categoria: d.categoria,
nombre: d.nombre,
calidad: d.calidad,
precioUnit: Math.round(d.precio * 100),
unidad: d.unidad,
descriptorRender: d.descriptor_render,
sku: d.sku,
});
}
return { rows, errors };
}

View File

@@ -0,0 +1,39 @@
import type { BudgetInputs, PricingConfig, TipoReforma } from './types';
export interface Cantidades {
m2Suelo: number;
m2Pared: number;
mlMobiliario: number;
perimetro: number;
alturaTecho: number;
}
const M2_MEDIANA: Record<TipoReforma, number> = {
cocina: 10,
bano: 5,
salon: 20,
comedor: 16,
integral: 70,
otro: 12,
};
// Metros lineales de mobiliario por metro de perímetro. Solo cocina/baño.
const FACTOR_MOBILIARIO: Partial<Record<TipoReforma, number>> = {
cocina: 0.5,
bano: 0.3,
};
export function deriveCantidades(inputs: BudgetInputs, config: PricingConfig): Cantidades {
const m2Suelo =
inputs.m2Suelo != null && inputs.m2Suelo > 0
? inputs.m2Suelo
: M2_MEDIANA[inputs.tipoReforma];
const alturaTecho =
inputs.alturaTecho != null && inputs.alturaTecho > 0
? inputs.alturaTecho
: config.alturaTechoDefault;
const perimetro = 4 * Math.sqrt(m2Suelo);
const m2Pared = perimetro * alturaTecho;
const mlMobiliario = perimetro * (FACTOR_MOBILIARIO[inputs.tipoReforma] ?? 0);
return { m2Suelo, m2Pared, mlMobiliario, perimetro, alturaTecho };
}

View File

@@ -0,0 +1,30 @@
import type { BudgetResult, PartidaResult } from './types';
export const AVISO_EDITADO = 'Presupuesto ajustado manualmente por el reformista.';
// Aplica la edición manual de conceptos del reformista sobre un presupuesto ya calculado.
// Conserva el factor de zona del cálculo original; el reformista ha validado las cifras,
// así que la confianza pasa a alta y el rango colapsa al total.
export function applyConceptoEdits(prev: BudgetResult, partidas: PartidaResult[]): BudgetResult {
const clean: PartidaResult[] = partidas
.map((p) => ({
key: p.key,
label: p.label.trim(),
importe: Math.max(0, Math.round(p.importe)),
}))
.filter((p) => p.label.length > 0);
const subtotal = clean.reduce((s, p) => s + p.importe, 0);
const total = Math.round(subtotal * prev.factorZona);
const avisos = prev.avisos.includes(AVISO_EDITADO) ? prev.avisos : [...prev.avisos, AVISO_EDITADO];
return {
...prev,
partidas: clean,
subtotal,
total,
rango: { min: total, max: total },
confianza: 'alta',
avisos,
};
}

View File

@@ -0,0 +1,6 @@
export * from './types';
export { PARTIDA_LABEL, PARTIDA_ORDER } from './labels';
export { deriveCantidades } from './derive';
export { resolvePrecioUnitario } from './resolve';
export { computeBudget } from './compute';
export { parseCatalogCsv } from './csv';

View File

@@ -0,0 +1,23 @@
import type { PartidaKey } from './types';
export const PARTIDA_ORDER: PartidaKey[] = [
'demolicion',
'alicatado',
'fontaneria',
'electricidad',
'carpinteria',
'mano_de_obra',
'extras',
'licencia',
];
export const PARTIDA_LABEL: Record<PartidaKey, string> = {
demolicion: 'Demolición',
alicatado: 'Alicatado y solado',
fontaneria: 'Fontanería',
electricidad: 'Electricidad',
carpinteria: 'Carpintería y mobiliario',
mano_de_obra: 'Mano de obra',
extras: 'Pintura y extras',
licencia: 'Licencia + Proyecto técnico',
};

View File

@@ -0,0 +1,18 @@
import type { Calidad, CategoriaMaterial, CatalogItem } from './types';
export function resolvePrecioUnitario(
categoria: CategoriaMaterial,
calidad: Calidad,
catalog: CatalogItem[],
selections: Partial<Record<CategoriaMaterial, string>>,
): { item: CatalogItem | null } {
const selectedId = selections[categoria];
if (selectedId) {
const selected = catalog.find((c) => c.id === selectedId);
if (selected) return { item: selected };
}
const def = catalog.find(
(c) => c.categoria === categoria && c.calidad === calidad && c.esDefault,
);
return { item: def ?? null };
}

View File

@@ -0,0 +1,63 @@
export type Calidad = 'basica' | 'media' | 'premium';
export type Unidad = 'm2' | 'ml' | 'ud';
export type CategoriaMaterial = 'suelo' | 'pared' | 'pintura' | 'mobiliario';
export type TipoReforma = 'cocina' | 'bano' | 'salon' | 'comedor' | 'integral' | 'otro';
export type PartidaKey =
| 'demolicion'
| 'alicatado'
| 'fontaneria'
| 'electricidad'
| 'carpinteria'
| 'mano_de_obra'
| 'extras'
| 'licencia';
export type ManoObraKey = 'demolicion' | 'fontaneria' | 'electricidad' | 'mano_de_obra';
export interface CatalogItem {
id: string;
categoria: CategoriaMaterial;
nombre: string;
calidad: Calidad;
precioUnit: number; // céntimos por unidad
unidad: Unidad;
descriptorRender: string;
esDefault: boolean;
sku: string;
}
export interface PricingConfig {
alturaTechoDefault: number; // metros
factorZona: Record<string, number>; // provincia -> multiplicador
manoObra: Record<ManoObraKey, number>; // céntimos por m² de suelo
}
export interface BudgetInputs {
tipoReforma: TipoReforma;
m2Suelo: number | null;
alturaTecho: number | null;
calidadGlobal: Calidad;
estructural: boolean;
provincia: string | null;
materialSelections: Partial<Record<CategoriaMaterial, string>>; // categoria -> catalogItemId
}
export interface PartidaResult {
// PartidaKey para las partidas que genera el motor; string libre (p. ej. 'custom-1')
// para las que añade el reformista a mano en la revisión.
key: PartidaKey | string;
label: string;
importe: number; // céntimos (base, antes de factor zona)
}
export interface BudgetResult {
partidas: PartidaResult[];
subtotal: number; // céntimos
factorZona: number;
total: number; // céntimos = round(subtotal * factorZona)
rango: { min: number; max: number }; // céntimos
confianza: 'baja' | 'media' | 'alta';
materialesRender: string[]; // descriptores para el prompt del render
avisos: string[];
}

View File

@@ -0,0 +1,157 @@
'use client';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
export type AppNavIcon =
| 'leads'
| 'precios'
| 'empresa'
| 'resumen'
| 'usuarios'
| 'planes';
export type AppNavLink = { href: string; label: string; icon: AppNavIcon };
// El enlace activo es el href más específico (más largo) que prefija el pathname.
function activeHref(pathname: string, links: readonly AppNavLink[]): string {
let best = '';
for (const l of links) {
if ((pathname === l.href || pathname.startsWith(l.href + '/')) && l.href.length > best.length) {
best = l.href;
}
}
return best;
}
const ICON_PATHS: Record<AppNavIcon | 'salir', React.ReactNode> = {
leads: (
<>
<path d="M3 4h18v12H6l-3 3V4Z" />
<path d="M7 9h10M7 12h6" />
</>
),
precios: (
<>
<path d="M20 12 12.5 4.5A2 2 0 0 0 11 4H5a1 1 0 0 0-1 1v6a2 2 0 0 0 .6 1.4L12 20l8-8Z" />
<circle cx="8.5" cy="8.5" r="1.2" />
</>
),
empresa: (
<>
<path d="M4 21V6l8-3 8 3v15" />
<path d="M4 21h16M9 21v-5h6v5M8 9h.01M12 9h.01M16 9h.01M8 13h.01M12 13h.01M16 13h.01" />
</>
),
resumen: (
<>
<rect x="3" y="3" width="7" height="9" rx="1" />
<rect x="14" y="3" width="7" height="5" rx="1" />
<rect x="14" y="12" width="7" height="9" rx="1" />
<rect x="3" y="16" width="7" height="5" rx="1" />
</>
),
usuarios: (
<>
<circle cx="9" cy="8" r="3" />
<path d="M3.5 20a5.5 5.5 0 0 1 11 0" />
<path d="M16 5.5a3 3 0 0 1 0 5.5M17 14.5a5.5 5.5 0 0 1 3.5 5" />
</>
),
planes: (
<>
<path d="M12 3 3 8l9 5 9-5-9-5Z" />
<path d="M3 13l9 5 9-5M3 18l9 5 9-5" />
</>
),
salir: (
<>
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" />
<path d="M16 17l5-5-5-5M21 12H9" />
</>
),
};
function Icon({ name }: { name: AppNavIcon | 'salir' }) {
return (
<svg
width="22"
height="22"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.75"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
{ICON_PATHS[name]}
</svg>
);
}
export default function AppNav({ links }: { links: readonly AppNavLink[] }) {
const pathname = usePathname();
const active = activeHref(pathname, links);
return (
<>
{/* Escritorio: navegación horizontal en la cabecera */}
<nav className="hidden sm:flex items-center gap-4 text-xs font-medium">
{links.map((l) => (
<Link
key={l.href}
href={l.href}
className={active === l.href ? 'text-black font-semibold' : 'text-gray-500 hover:text-black'}
>
{l.label}
</Link>
))}
<form action="/logout" method="post">
<button type="submit" className="text-gray-500 hover:text-black">
Salir
</button>
</form>
</nav>
{/* Móvil: barra de pestañas fija inferior (thumb-friendly) */}
<nav
className="sm:hidden fixed inset-x-0 bottom-0 z-30 border-t border-gray-200 bg-white/95 backdrop-blur supports-[backdrop-filter]:bg-white/80"
style={{ paddingBottom: 'env(safe-area-inset-bottom)' }}
aria-label="Navegación principal"
>
<div className="grid grid-cols-4">
{links.map((l) => {
const isActive = active === l.href;
return (
<Link
key={l.href}
href={l.href}
aria-current={isActive ? 'page' : undefined}
className={
'relative flex flex-col items-center justify-center gap-1 pt-2.5 pb-2 text-[11px] font-medium transition-colors ' +
(isActive ? 'text-black' : 'text-gray-400 hover:text-gray-600')
}
>
{isActive && (
<span className="absolute top-0 h-0.5 w-8 rounded-full bg-black" aria-hidden="true" />
)}
<Icon name={l.icon} />
<span className="leading-none">{l.label}</span>
</Link>
);
})}
<form action="/logout" method="post" className="contents">
<button
type="submit"
className="flex flex-col items-center justify-center gap-1 pt-2.5 pb-2 text-[11px] font-medium text-gray-400 transition-colors hover:text-gray-600"
>
<Icon name="salir" />
<span className="leading-none">Salir</span>
</button>
</form>
</div>
</nav>
</>
);
}

View File

@@ -1,13 +1,13 @@
'use client';
import { useState, useRef, useEffect, FormEvent } from 'react';
import { useRouter } from 'next/navigation';
import { crearLead } from '@/app/solicitud/actions';
type FormData = {
name: string;
email: string;
company: string;
phone: string;
message: string;
};
type FormErrors = Partial<Record<keyof FormData, string>>;
@@ -16,36 +16,38 @@ type SubmitStatus = 'idle' | 'loading' | 'success' | 'error';
const initialData: FormData = {
name: '',
email: '',
company: '',
phone: '',
message: '',
};
const initialConsents = {
privacy: false,
contracting: false,
};
function validateForm(data: FormData): FormErrors {
const errors: FormErrors = {};
if (!data.name.trim()) errors.name = 'El nombre es requerido';
if (!data.name.trim()) errors.name = 'El nombre es obligatorio';
if (!data.email.trim()) {
errors.email = 'El email es requerido';
errors.email = 'El email es obligatorio';
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(data.email)) {
errors.email = 'Ingresa un email válido';
errors.email = 'Introduce un email válido';
}
if (!data.company.trim()) errors.company = 'La empresa es requerida';
if (data.phone && !/^[+\d\s\-().]{7,20}$/.test(data.phone)) {
errors.phone = 'Ingresa un teléfono válido';
}
if (!data.message.trim()) {
errors.message = 'El mensaje es requerido';
} else if (data.message.trim().length < 10) {
errors.message = 'El mensaje debe tener al menos 10 caracteres';
if (!data.phone.trim()) {
errors.phone = 'El teléfono es obligatorio';
} else if (!/^[+\d\s\-().]{7,20}$/.test(data.phone)) {
errors.phone = 'Introduce un teléfono válido';
}
return errors;
}
export default function ContactForm() {
const [formData, setFormData] = useState<FormData>(initialData);
const [consents, setConsents] = useState(initialConsents);
const [errors, setErrors] = useState<FormErrors>({});
const [touched, setTouched] = useState<Partial<Record<keyof FormData, boolean>>>({});
const [status, setStatus] = useState<SubmitStatus>('idle');
const [submitError, setSubmitError] = useState<string | null>(null);
const router = useRouter();
const sectionRef = useRef<HTMLElement>(null);
useEffect(() => {
@@ -65,9 +67,7 @@ export default function ContactForm() {
return () => observer.disconnect();
}, []);
const handleChange = (
e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>
) => {
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const { name, value } = e.target;
setFormData((prev) => ({ ...prev, [name]: value }));
if (touched[name as keyof FormData]) {
@@ -76,41 +76,48 @@ export default function ContactForm() {
}
};
const handleBlur = (
e: React.FocusEvent<HTMLInputElement | HTMLTextAreaElement>
) => {
const handleBlur = (e: React.FocusEvent<HTMLInputElement>) => {
const { name } = e.target;
setTouched((prev) => ({ ...prev, [name]: true }));
const newErrors = validateForm(formData);
setErrors((prev) => ({ ...prev, [name]: newErrors[name as keyof FormData] }));
};
// El submit queda deshabilitado hasta que los dos consentimientos estén marcados (RF-B-04).
// La validación de campos se ejecuta on submit/blur para que los errores sean visibles.
const consentsGranted = consents.privacy && consents.contracting;
const handleSubmit = async (e: FormEvent) => {
e.preventDefault();
const allTouched = Object.keys(formData).reduce(
(acc, k) => ({ ...acc, [k]: true }),
{} as Record<keyof FormData, boolean>
);
setTouched(allTouched);
setTouched({ name: true, email: true, phone: true });
const validationErrors = validateForm(formData);
if (Object.keys(validationErrors).length > 0) {
setErrors(validationErrors);
return;
}
if (!consentsGranted) return;
setStatus('loading');
// Simulate API call
await new Promise((resolve) => setTimeout(resolve, 1800));
setStatus('success');
setFormData(initialData);
setTouched({});
setErrors({});
setSubmitError(null);
const result = await crearLead({
nombre: formData.name,
email: formData.email,
telefono: formData.phone,
consentPrivacidad: consents.privacy,
consentContratacion: consents.contracting,
});
if (!result.ok) {
setStatus('error');
setSubmitError(result.error);
return;
}
router.push(`/solicitud/${result.leadId}/fotos`);
};
const handleReset = () => {
setStatus('idle');
setFormData(initialData);
setConsents(initialConsents);
setErrors({});
setTouched({});
};
@@ -127,18 +134,19 @@ export default function ContactForm() {
{/* Left info panel */}
<div className="reveal opacity-0 translate-y-6 transition-all duration-700 ease-out flex flex-col gap-6 lg:sticky lg:top-[104px]">
<div className="mb-2">
<span className="badge badge-dark">Contacto</span>
<span className="badge badge-dark">Empieza tu reforma</span>
</div>
<h2
id="contact-heading"
className="text-[clamp(1.875rem,5vw,3rem)] font-black tracking-[-0.04em] leading-[1.05] text-black"
>
Hablemos de
Tu presupuesto,
<br />
tu reforma
en 5 minutos
</h2>
<p className="text-lg text-gray-600 leading-relaxed">
Cuéntanos qué tienes en mente. Un asesor de tu provincia te responderá en menos de 24 horas con una propuesta a medida.
Déjanos tu teléfono y te llamamos en menos de 2 minutos. Te enviamos el render
y el presupuesto orientativo por WhatsApp.
</p>
{/* Contact details */}
@@ -185,7 +193,7 @@ export default function ContactForm() {
</div>
<div>
<div className="text-xs font-semibold uppercase tracking-widest text-gray-400">Respuesta</div>
<div className="text-base font-semibold text-black">Menos de 24 horas</div>
<div className="text-base font-semibold text-black">Llamada en &lt; 2 min</div>
</div>
</div>
</div>
@@ -227,17 +235,18 @@ export default function ContactForm() {
</svg>
</div>
<h3 className="text-2xl font-extrabold tracking-tight text-black">
¡Mensaje enviado!
¡Te llamamos enseguida!
</h3>
<p className="text-base text-gray-600 max-w-[320px] leading-relaxed mb-4">
Gracias por contactarnos. Nuestro equipo te responderá en menos de 24 horas.
<p className="text-base text-gray-600 max-w-[340px] leading-relaxed mb-4">
En menos de 2 minutos te llamamos al teléfono que nos has dejado.
Te enviaremos el render y el presupuesto por WhatsApp.
</p>
<button
className="btn btn-secondary"
onClick={handleReset}
id="contact-send-another-btn"
>
Enviar otro mensaje
Pedir otro presupuesto
</button>
</div>
) : (
@@ -245,178 +254,182 @@ export default function ContactForm() {
className="flex flex-col gap-5"
onSubmit={handleSubmit}
noValidate
aria-label="Formulario de contacto"
aria-label="Formulario de captación de lead"
id="contact-form"
>
<div className="mb-2">
<h3 className="text-2xl font-extrabold tracking-tight text-black">
Envíanos un mensaje
Pide tu presupuesto
</h3>
<p className="text-sm text-gray-400 mt-1">
Todos los campos marcados con * son requeridos
Los 3 campos son obligatorios.
</p>
</div>
{/* Row: Name + Email */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="flex flex-col gap-2">
<label htmlFor="contact-name" className="text-sm font-semibold text-dark">
Nombre completo <span className="text-error">*</span>
</label>
<input
id="contact-name"
name="name"
type="text"
className={`w-full px-4 py-3 text-base font-sans text-dark bg-white border-[1.5px] rounded-lg transition-all duration-150 outline-none placeholder:text-gray-400 focus:border-black focus:shadow-[0_0_0_3px_rgba(0,0,0,0.06)] ${errors.name && touched.name
? 'border-error shadow-[0_0_0_3px_rgba(255,59,59,0.08)]'
: 'border-gray-200'
}`}
placeholder="Juan García"
value={formData.name}
onChange={handleChange}
onBlur={handleBlur}
autoComplete="name"
aria-required="true"
aria-describedby={errors.name && touched.name ? 'name-error' : undefined}
aria-invalid={!!(errors.name && touched.name)}
/>
{errors.name && touched.name && (
<span id="name-error" className="text-xs text-error font-medium flex items-center gap-1" role="alert">
{errors.name}
</span>
)}
</div>
<div className="flex flex-col gap-2">
<label htmlFor="contact-email" className="text-sm font-semibold text-dark">
Email <span className="text-error">*</span>
</label>
<input
id="contact-email"
name="email"
type="email"
className={`w-full px-4 py-3 text-base font-sans text-dark bg-white border-[1.5px] rounded-lg transition-all duration-150 outline-none placeholder:text-gray-400 focus:border-black focus:shadow-[0_0_0_3px_rgba(0,0,0,0.06)] ${errors.email && touched.email
? 'border-error shadow-[0_0_0_3px_rgba(255,59,59,0.08)]'
: 'border-gray-200'
}`}
placeholder="juan@empresa.com"
value={formData.email}
onChange={handleChange}
onBlur={handleBlur}
autoComplete="email"
aria-required="true"
aria-describedby={errors.email && touched.email ? 'email-error' : undefined}
aria-invalid={!!(errors.email && touched.email)}
/>
{errors.email && touched.email && (
<span id="email-error" className="text-xs text-error font-medium flex items-center gap-1" role="alert">
{errors.email}
</span>
)}
</div>
</div>
{/* Row: Company + Phone */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="flex flex-col gap-2">
<label htmlFor="contact-company" className="text-sm font-semibold text-dark">
Empresa <span className="text-error">*</span>
</label>
<input
id="contact-company"
name="company"
type="text"
className={`w-full px-4 py-3 text-base font-sans text-dark bg-white border-[1.5px] rounded-lg transition-all duration-150 outline-none placeholder:text-gray-400 focus:border-black focus:shadow-[0_0_0_3px_rgba(0,0,0,0.06)] ${errors.company && touched.company
? 'border-error shadow-[0_0_0_3px_rgba(255,59,59,0.08)]'
: 'border-gray-200'
}`}
placeholder="Mi Empresa S.A."
value={formData.company}
onChange={handleChange}
onBlur={handleBlur}
autoComplete="organization"
aria-required="true"
aria-describedby={errors.company && touched.company ? 'company-error' : undefined}
aria-invalid={!!(errors.company && touched.company)}
/>
{errors.company && touched.company && (
<span id="company-error" className="text-xs text-error font-medium flex items-center gap-1" role="alert">
{errors.company}
</span>
)}
</div>
<div className="flex flex-col gap-2">
<label htmlFor="contact-phone" className="text-sm font-semibold text-dark">
Teléfono
<span className="font-normal text-gray-400"> (opcional)</span>
</label>
<input
id="contact-phone"
name="phone"
type="tel"
className={`w-full px-4 py-3 text-base font-sans text-dark bg-white border-[1.5px] rounded-lg transition-all duration-150 outline-none placeholder:text-gray-400 focus:border-black focus:shadow-[0_0_0_3px_rgba(0,0,0,0.06)] ${errors.phone && touched.phone
? 'border-error shadow-[0_0_0_3px_rgba(255,59,59,0.08)]'
: 'border-gray-200'
}`}
placeholder="+52 55 1234 5678"
value={formData.phone}
onChange={handleChange}
onBlur={handleBlur}
autoComplete="tel"
aria-describedby={errors.phone && touched.phone ? 'phone-error' : undefined}
aria-invalid={!!(errors.phone && touched.phone)}
/>
{errors.phone && touched.phone && (
<span id="phone-error" className="text-xs text-error font-medium flex items-center gap-1" role="alert">
{errors.phone}
</span>
)}
</div>
</div>
{/* Message */}
{/* Name */}
<div className="flex flex-col gap-2">
<label htmlFor="contact-message" className="text-sm font-semibold text-dark">
Mensaje <span className="text-error">*</span>
<label htmlFor="contact-name" className="text-sm font-semibold text-dark">
Nombre <span className="text-error">*</span>
</label>
<textarea
id="contact-message"
name="message"
className={`w-full px-4 py-3 text-base font-sans text-dark bg-white border-[1.5px] rounded-lg transition-all duration-150 outline-none placeholder:text-gray-400 focus:border-black focus:shadow-[0_0_0_3px_rgba(0,0,0,0.06)] resize-y min-h-[120px] ${errors.message && touched.message
<input
id="contact-name"
name="name"
type="text"
className={`w-full px-4 py-3 text-base font-sans text-dark bg-white border-[1.5px] rounded-lg transition-all duration-150 outline-none placeholder:text-gray-400 focus:border-black focus:shadow-[0_0_0_3px_rgba(0,0,0,0.06)] ${errors.name && touched.name
? 'border-error shadow-[0_0_0_3px_rgba(255,59,59,0.08)]'
: 'border-gray-200'
}`}
placeholder="Cuéntanos sobre tu reforma: qué espacio quieres reformar, cuál es tu presupuesto aproximado y en qué ciudad vives..."
rows={5}
value={formData.message}
placeholder="Juan García"
value={formData.name}
onChange={handleChange}
onBlur={handleBlur}
autoComplete="name"
required
aria-required="true"
aria-describedby={errors.message && touched.message ? 'message-error' : undefined}
aria-invalid={!!(errors.message && touched.message)}
aria-describedby={errors.name && touched.name ? 'name-error' : undefined}
aria-invalid={!!(errors.name && touched.name)}
/>
<div className="flex justify-between items-center">
{errors.message && touched.message ? (
<span id="message-error" className="text-xs text-error font-medium flex items-center gap-1" role="alert">
{errors.message}
</span>
) : (
<span />
)}
<span className="text-xs text-gray-400 ml-auto">
{formData.message.length} caracteres
{errors.name && touched.name && (
<span id="name-error" className="text-xs text-error font-medium" role="alert">
{errors.name}
</span>
</div>
)}
</div>
{/* Email */}
<div className="flex flex-col gap-2">
<label htmlFor="contact-email" className="text-sm font-semibold text-dark">
Email <span className="text-error">*</span>
</label>
<input
id="contact-email"
name="email"
type="email"
className={`w-full px-4 py-3 text-base font-sans text-dark bg-white border-[1.5px] rounded-lg transition-all duration-150 outline-none placeholder:text-gray-400 focus:border-black focus:shadow-[0_0_0_3px_rgba(0,0,0,0.06)] ${errors.email && touched.email
? 'border-error shadow-[0_0_0_3px_rgba(255,59,59,0.08)]'
: 'border-gray-200'
}`}
placeholder="juan@email.com"
value={formData.email}
onChange={handleChange}
onBlur={handleBlur}
autoComplete="email"
required
aria-required="true"
aria-describedby={errors.email && touched.email ? 'email-error' : undefined}
aria-invalid={!!(errors.email && touched.email)}
/>
{errors.email && touched.email && (
<span id="email-error" className="text-xs text-error font-medium" role="alert">
{errors.email}
</span>
)}
</div>
{/* Phone */}
<div className="flex flex-col gap-2">
<label htmlFor="contact-phone" className="text-sm font-semibold text-dark">
Teléfono <span className="text-error">*</span>
</label>
<input
id="contact-phone"
name="phone"
type="tel"
className={`w-full px-4 py-3 text-base font-sans text-dark bg-white border-[1.5px] rounded-lg transition-all duration-150 outline-none placeholder:text-gray-400 focus:border-black focus:shadow-[0_0_0_3px_rgba(0,0,0,0.06)] ${errors.phone && touched.phone
? 'border-error shadow-[0_0_0_3px_rgba(255,59,59,0.08)]'
: 'border-gray-200'
}`}
placeholder="+34 612 345 678"
value={formData.phone}
onChange={handleChange}
onBlur={handleBlur}
autoComplete="tel"
inputMode="tel"
required
aria-required="true"
aria-describedby={errors.phone && touched.phone ? 'phone-error' : undefined}
aria-invalid={!!(errors.phone && touched.phone)}
/>
{errors.phone && touched.phone && (
<span id="phone-error" className="text-xs text-error font-medium" role="alert">
{errors.phone}
</span>
)}
</div>
{/* Consents */}
<fieldset className="flex flex-col gap-3 mt-2 pt-4 border-t border-gray-200">
<legend className="sr-only">Consentimientos</legend>
<label
htmlFor="consent-privacy"
className="flex items-start gap-3 cursor-pointer text-sm text-gray-700 leading-relaxed"
>
<input
id="consent-privacy"
type="checkbox"
checked={consents.privacy}
onChange={(e) =>
setConsents((c) => ({ ...c, privacy: e.target.checked }))
}
className="mt-1 w-4 h-4 accent-black shrink-0 cursor-pointer"
required
aria-required="true"
/>
<span>
He leído y acepto la{' '}
<a
href="#"
className="text-black underline underline-offset-2 hover:no-underline"
>
política de privacidad
</a>
.
</span>
</label>
<label
htmlFor="consent-contracting"
className="flex items-start gap-3 cursor-pointer text-sm text-gray-700 leading-relaxed"
>
<input
id="consent-contracting"
type="checkbox"
checked={consents.contracting}
onChange={(e) =>
setConsents((c) => ({ ...c, contracting: e.target.checked }))
}
className="mt-1 w-4 h-4 accent-black shrink-0 cursor-pointer"
required
aria-required="true"
/>
<span>
He leído y acepto las{' '}
<a
href="#"
className="text-black underline underline-offset-2 hover:no-underline"
>
condiciones de contratación
</a>
.
</span>
</label>
</fieldset>
{submitError && (
<p className="text-sm text-error font-medium" role="alert">
{submitError}
</p>
)}
{/* Submit */}
<button
type="submit"
className="btn btn-primary btn-lg w-full justify-center mt-2 disabled:opacity-70 disabled:cursor-not-allowed disabled:transform-none"
disabled={status === 'loading'}
className="btn btn-primary btn-lg w-full justify-center mt-2 disabled:opacity-60 disabled:cursor-not-allowed disabled:transform-none"
disabled={status === 'loading' || !consentsGranted}
id="contact-submit-btn"
aria-busy={status === 'loading'}
aria-disabled={status === 'loading' || !consentsGranted}
>
{status === 'loading' ? (
<>
@@ -428,7 +441,7 @@ export default function ContactForm() {
</>
) : (
<>
Enviar mensaje
Pedir presupuesto
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
<path
d="M2 8h12M10 4l4 4-4 4"
@@ -441,14 +454,6 @@ export default function ContactForm() {
</>
)}
</button>
<p className="text-xs text-gray-400 text-center leading-relaxed">
Al enviar, aceptas nuestra{' '}
<a href="#" className="text-gray-600 underline underline-offset-2 transition-colors duration-150 hover:text-black">
política de privacidad
</a>
. Nunca compartiremos tu información.
</p>
</form>
)}
</div>

View File

@@ -76,7 +76,7 @@ export default function Features() {
return (
<section
className="bg-gray-50 border-y border-gray-200 py-24"
className="bg-gray-50 border-y border-gray-200 py-16 md:py-24"
id="features"
ref={sectionRef}
aria-labelledby="features-heading"
@@ -100,7 +100,7 @@ export default function Features() {
</div>
{/* Grid */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-5 md:gap-6">
{features.map((feature, i) => (
<article
key={feature.title}
@@ -112,7 +112,7 @@ export default function Features() {
{feature.icon}
</div>
<div className="flex flex-col gap-2">
<div className="text-[6rem] font-black leading-none text-gray-300 select-none -mt-2 -mb-2">
<div className="text-[4rem] sm:text-[6rem] font-black leading-none text-gray-300 select-none -mt-2 -mb-2">
{feature.tag}
</div>
<h3 className="text-xl font-extrabold tracking-tight text-black leading-tight">

View File

@@ -1,13 +1,13 @@
'use client';
import { useEffect, useRef, useState } from 'react';
import { useRouter } from 'next/navigation';
import { crearLead } from '@/app/solicitud/actions';
type FormData = {
name: string;
email: string;
company: string;
phone: string;
message: string;
};
type FormErrors = Partial<Record<keyof FormData, string>>;
@@ -16,35 +16,40 @@ type SubmitStatus = 'idle' | 'loading' | 'success' | 'error';
const initialData: FormData = {
name: '',
email: '',
company: '',
phone: '',
message: '',
};
const initialConsents = {
privacy: false,
contracting: false,
};
function validateForm(data: FormData): FormErrors {
const errors: FormErrors = {};
if (!data.name.trim()) errors.name = 'El nombre es requerido';
if (!data.name.trim()) errors.name = 'El nombre es obligatorio';
if (!data.email.trim()) {
errors.email = 'El email es requerido';
errors.email = 'El email es obligatorio';
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(data.email)) {
errors.email = 'Ingresa un email válido';
errors.email = 'Introduce un email válido';
}
if (!data.company.trim()) errors.company = 'La empresa es requerida';
if (data.phone && !/^[+\d\s\-().]{7,20}$/.test(data.phone)) {
errors.phone = 'Ingresa un teléfono válido';
if (!data.phone.trim()) {
errors.phone = 'El teléfono es obligatorio';
} else if (!/^[+\d\s\-().]{7,20}$/.test(data.phone)) {
errors.phone = 'Introduce un teléfono válido';
}
return errors;
}
function LeadForm() {
const router = useRouter();
const [formData, setFormData] = useState<FormData>(initialData);
const [consents, setConsents] = useState(initialConsents);
const [errors, setErrors] = useState<FormErrors>({});
const [touched, setTouched] = useState<Partial<Record<keyof FormData, boolean>>>({});
const [status, setStatus] = useState<SubmitStatus>('idle');
const [submitError, setSubmitError] = useState<string | null>(null);
const handleChange = (
e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>
) => {
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const { name, value } = e.target;
setFormData((prev) => ({ ...prev, [name]: value }));
if (touched[name as keyof FormData]) {
@@ -53,40 +58,46 @@ function LeadForm() {
}
};
const handleBlur = (
e: React.FocusEvent<HTMLInputElement | HTMLTextAreaElement>
) => {
const handleBlur = (e: React.FocusEvent<HTMLInputElement>) => {
const { name } = e.target;
setTouched((prev) => ({ ...prev, [name]: true }));
const newErrors = validateForm(formData);
setErrors((prev) => ({ ...prev, [name]: newErrors[name as keyof FormData] }));
};
const consentsGranted = consents.privacy && consents.contracting;
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
const allTouched = Object.keys(formData).reduce(
(acc, k) => ({ ...acc, [k]: true }),
{} as Record<keyof FormData, boolean>
);
setTouched(allTouched);
setTouched({ name: true, email: true, phone: true });
const validationErrors = validateForm(formData);
if (Object.keys(validationErrors).length > 0) {
setErrors(validationErrors);
return;
}
if (!consentsGranted) return;
setStatus('loading');
await new Promise((resolve) => setTimeout(resolve, 1800));
setStatus('success');
setFormData(initialData);
setTouched({});
setErrors({});
setSubmitError(null);
const result = await crearLead({
nombre: formData.name,
email: formData.email,
telefono: formData.phone,
consentPrivacidad: consents.privacy,
consentContratacion: consents.contracting,
});
if (!result.ok) {
setStatus('error');
setSubmitError(result.error);
return;
}
router.push(`/solicitud/${result.leadId}/fotos`);
};
const handleReset = () => {
setStatus('idle');
setFormData(initialData);
setConsents(initialConsents);
setErrors({});
setTouched({});
};
@@ -94,12 +105,12 @@ function LeadForm() {
if (status === 'success') {
return (
<div
className="flex flex-col items-center justify-center text-center gap-4 py-16 px-8 animate-scaleIn"
className="flex flex-col items-center justify-center text-center gap-4 py-10 px-4 animate-scaleIn"
role="alert"
aria-live="polite"
>
<div className="w-18 h-18 bg-black text-white rounded-full flex items-center justify-center mb-2 p-4">
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" aria-hidden="true">
<div className="w-16 h-16 bg-black text-white rounded-full flex items-center justify-center mb-2">
<svg width="28" height="28" viewBox="0 0 32 32" fill="none" aria-hidden="true">
<path
d="M6 16l7 7L26 9"
stroke="currentColor"
@@ -109,17 +120,18 @@ function LeadForm() {
/>
</svg>
</div>
<h3 className="text-2xl font-extrabold tracking-tight text-black">
¡Mensaje enviado!
<h3 className="text-xl font-extrabold tracking-tight text-black">
¡Te llamamos enseguida!
</h3>
<p className="text-base text-gray-600 max-w-[320px] leading-relaxed mb-4">
Gracias por contactarnos. Nuestro equipo te responderá en menos de 24 horas.
<p className="text-sm text-gray-600 max-w-[300px] leading-relaxed">
En menos de 2 minutos te llamamos al teléfono que nos has dejado.
Tendrás el render y el presupuesto en tu WhatsApp.
</p>
<button
className="btn btn-secondary text-sm px-4 py-2 bg-gray-100 hover:bg-gray-200 text-black font-semibold rounded-lg transition-colors"
className="text-sm font-semibold text-black underline underline-offset-2 hover:no-underline mt-2"
onClick={handleReset}
>
Enviar otro mensaje
Pedir otro presupuesto
</button>
</div>
);
@@ -127,15 +139,16 @@ function LeadForm() {
return (
<form
className="flex flex-col gap-5"
className="flex flex-col gap-4"
onSubmit={handleSubmit}
noValidate
aria-label="Formulario de contacto"
aria-label="Formulario de captación de lead"
>
{/* Name + Email */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="flex flex-col gap-2">
<label htmlFor="lead-name" className="text-sm font-semibold text-dark">
Nombre completo <span className="text-error">*</span>
Nombre <span className="text-error">*</span>
</label>
<input
id="lead-name"
@@ -150,12 +163,13 @@ function LeadForm() {
onChange={handleChange}
onBlur={handleBlur}
autoComplete="name"
required
aria-required="true"
aria-describedby={errors.name && touched.name ? 'name-error' : undefined}
aria-describedby={errors.name && touched.name ? 'lead-name-error' : undefined}
aria-invalid={!!(errors.name && touched.name)}
/>
{errors.name && touched.name && (
<span id="name-error" className="text-xs text-error font-medium flex items-center gap-1" role="alert">
<span id="lead-name-error" className="text-xs text-error font-medium" role="alert">
{errors.name}
</span>
)}
@@ -173,85 +187,117 @@ function LeadForm() {
? 'border-error shadow-[0_0_0_3px_rgba(255,59,59,0.08)]'
: 'border-gray-200'
}`}
placeholder="juan@empresa.com"
placeholder="juan@email.com"
value={formData.email}
onChange={handleChange}
onBlur={handleBlur}
autoComplete="email"
required
aria-required="true"
aria-describedby={errors.email && touched.email ? 'email-error' : undefined}
aria-describedby={errors.email && touched.email ? 'lead-email-error' : undefined}
aria-invalid={!!(errors.email && touched.email)}
/>
{errors.email && touched.email && (
<span id="email-error" className="text-xs text-error font-medium flex items-center gap-1" role="alert">
<span id="lead-email-error" className="text-xs text-error font-medium" role="alert">
{errors.email}
</span>
)}
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="flex flex-col gap-2">
<label htmlFor="lead-company" className="text-sm font-semibold text-dark">
Empresa <span className="text-error">*</span>
</label>
<input
id="lead-company"
name="company"
type="text"
className={`w-full px-4 py-3 text-base font-sans text-dark bg-white border-[1.5px] rounded-lg transition-all duration-150 outline-none placeholder:text-gray-400 focus:border-black focus:shadow-[0_0_0_3px_rgba(0,0,0,0.06)] ${errors.company && touched.company
? 'border-error shadow-[0_0_0_3px_rgba(255,59,59,0.08)]'
: 'border-gray-200'
}`}
placeholder="Mi Empresa S.A."
value={formData.company}
onChange={handleChange}
onBlur={handleBlur}
autoComplete="organization"
aria-required="true"
aria-describedby={errors.company && touched.company ? 'company-error' : undefined}
aria-invalid={!!(errors.company && touched.company)}
/>
{errors.company && touched.company && (
<span id="company-error" className="text-xs text-error font-medium flex items-center gap-1" role="alert">
{errors.company}
</span>
)}
</div>
<div className="flex flex-col gap-2">
<label htmlFor="lead-phone" className="text-sm font-semibold text-dark">
Teléfono <span className="font-normal text-gray-400">(opcional)</span>
</label>
<input
id="lead-phone"
name="phone"
type="tel"
className={`w-full px-4 py-3 text-base font-sans text-dark bg-white border-[1.5px] rounded-lg transition-all duration-150 outline-none placeholder:text-gray-400 focus:border-black focus:shadow-[0_0_0_3px_rgba(0,0,0,0.06)] ${errors.phone && touched.phone
? 'border-error shadow-[0_0_0_3px_rgba(255,59,59,0.08)]'
: 'border-gray-200'
}`}
placeholder="+52 55 1234 5678"
value={formData.phone}
onChange={handleChange}
onBlur={handleBlur}
autoComplete="tel"
aria-describedby={errors.phone && touched.phone ? 'phone-error' : undefined}
aria-invalid={!!(errors.phone && touched.phone)}
/>
{errors.phone && touched.phone && (
<span id="phone-error" className="text-xs text-error font-medium flex items-center gap-1" role="alert">
{errors.phone}
</span>
)}
</div>
{/* Phone */}
<div className="flex flex-col gap-2">
<label htmlFor="lead-phone" className="text-sm font-semibold text-dark">
Teléfono <span className="text-error">*</span>
</label>
<input
id="lead-phone"
name="phone"
type="tel"
className={`w-full px-4 py-3 text-base font-sans text-dark bg-white border-[1.5px] rounded-lg transition-all duration-150 outline-none placeholder:text-gray-400 focus:border-black focus:shadow-[0_0_0_3px_rgba(0,0,0,0.06)] ${errors.phone && touched.phone
? 'border-error shadow-[0_0_0_3px_rgba(255,59,59,0.08)]'
: 'border-gray-200'
}`}
placeholder="+34 612 345 678"
value={formData.phone}
onChange={handleChange}
onBlur={handleBlur}
autoComplete="tel"
inputMode="tel"
required
aria-required="true"
aria-describedby={errors.phone && touched.phone ? 'lead-phone-error' : undefined}
aria-invalid={!!(errors.phone && touched.phone)}
/>
{errors.phone && touched.phone && (
<span id="lead-phone-error" className="text-xs text-error font-medium" role="alert">
{errors.phone}
</span>
)}
</div>
{/* Consents */}
<fieldset className="flex flex-col gap-2.5 mt-1 pt-4 border-t border-gray-100">
<legend className="sr-only">Consentimientos</legend>
<label
htmlFor="lead-consent-privacy"
className="flex items-start gap-2.5 cursor-pointer text-xs text-gray-600 leading-relaxed"
>
<input
id="lead-consent-privacy"
type="checkbox"
checked={consents.privacy}
onChange={(e) => setConsents((c) => ({ ...c, privacy: e.target.checked }))}
className="mt-0.5 w-4 h-4 accent-black shrink-0 cursor-pointer"
required
aria-required="true"
/>
<span>
He leído y acepto la{' '}
<a href="#" className="text-black underline underline-offset-2 hover:no-underline">
política de privacidad
</a>
.
</span>
</label>
<label
htmlFor="lead-consent-contracting"
className="flex items-start gap-2.5 cursor-pointer text-xs text-gray-600 leading-relaxed"
>
<input
id="lead-consent-contracting"
type="checkbox"
checked={consents.contracting}
onChange={(e) => setConsents((c) => ({ ...c, contracting: e.target.checked }))}
className="mt-0.5 w-4 h-4 accent-black shrink-0 cursor-pointer"
required
aria-required="true"
/>
<span>
He leído y acepto las{' '}
<a href="#" className="text-black underline underline-offset-2 hover:no-underline">
condiciones de contratación
</a>
.
</span>
</label>
</fieldset>
{submitError && (
<p className="text-xs text-error font-medium" role="alert">
{submitError}
</p>
)}
{/* Submit */}
<button
type="submit"
className="w-full bg-black text-white py-4 text-sm font-medium rounded-lg transition-opacity disabled:opacity-40 disabled:cursor-not-allowed hover:opacity-90 flex justify-center items-center gap-2 mt-2"
disabled={status === 'loading'}
className="btn btn-primary w-full justify-center mt-1 disabled:opacity-50 disabled:cursor-not-allowed disabled:transform-none"
disabled={status === 'loading' || !consentsGranted}
aria-busy={status === 'loading'}
aria-disabled={status === 'loading' || !consentsGranted}
>
{status === 'loading' ? (
<>
@@ -263,7 +309,7 @@ function LeadForm() {
</>
) : (
<>
Enviar mensaje
Pedir presupuesto
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
<path
d="M2 8h12M10 4l4 4-4 4"
@@ -276,14 +322,6 @@ function LeadForm() {
</>
)}
</button>
<p className="text-xs text-gray-400 text-center leading-relaxed">
Al enviar, aceptas nuestra{' '}
<a href="#" className="text-gray-600 underline underline-offset-2 hover:text-black">
política de privacidad
</a>
.
</p>
</form>
);
}
@@ -310,14 +348,14 @@ export default function Hero() {
return (
<section className="bg-white overflow-hidden" id="hero" ref={heroRef} aria-label="Sección principal">
<div className="container py-16 md:pt-24 pb-8">
<div className="container pt-12 md:pt-24 pb-8">
{/* Grid 2 columnas */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-12 md:gap-16 items-start">
<div className="grid grid-cols-1 md:grid-cols-2 gap-10 md:gap-16 items-start">
{/* Columna izquierda — textos */}
<div className="flex flex-col gap-6">
<h1 className="reveal opacity-0 translate-y-6 transition-all duration-700 ease-out text-[clamp(2.5rem,5vw,4rem)] font-black tracking-[-0.04em] leading-[1.05] text-black">
<h1 className="reveal opacity-0 translate-y-6 transition-all duration-700 ease-out text-[clamp(2.25rem,5vw,4rem)] font-black tracking-[-0.04em] leading-[1.05] text-black">
Tu reforma,
<br />
<em className="italic font-black">presupuestada</em>
@@ -325,44 +363,45 @@ export default function Hero() {
en 5 minutos.
</h1>
<p className="reveal opacity-0 translate-y-6 transition-all duration-700 ease-out delay-100 text-lg text-gray-500 leading-relaxed max-w-md">
<p className="reveal opacity-0 translate-y-6 transition-all duration-700 ease-out delay-100 text-base sm:text-lg text-gray-500 leading-relaxed max-w-md">
Deja tu teléfono, sube una foto de tu cocina o baño y te llamamos desde tu provincia en menos de 2 minutos. Al colgar recibirás por WhatsApp el render de tu reforma + presupuesto desglosado.
</p>
{/* Stats */}
<div className="reveal opacity-0 translate-y-6 transition-all duration-700 ease-out delay-200 flex flex-wrap gap-3">
{/* CTAs */}
<div className="reveal opacity-0 translate-y-6 transition-all duration-700 ease-out delay-200 flex flex-col sm:flex-row gap-3">
<button
className="btn btn-primary btn-lg"
className="btn btn-primary btn-lg w-full sm:w-auto"
onClick={() => document.querySelector('#contact')?.scrollIntoView({ behavior: 'smooth' })}
>
Empieza gratis
Calcular mi reforma gratis
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
<path d="M3 8h10M9 4l4 4-4 4" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</button>
<button
className="btn btn-secondary btn-lg"
onClick={() => document.querySelector('#features')?.scrollIntoView({ behavior: 'smooth' })}
className="btn btn-secondary btn-lg w-full sm:w-auto"
onClick={() => document.querySelector('#ver-reforma')?.scrollIntoView({ behavior: 'smooth' })}
>
Ver características
Ver una reforma
</button>
</div>
</div>
{/* Columna derecha — formulario */}
<div className="reveal opacity-0 translate-y-6 transition-all duration-700 ease-out delay-150 border border-gray-100 rounded-xl p-8 bg-white shadow-sm">
<div className="mb-6">
<h2 className="text-xl font-black tracking-tight text-black">Recibe tu presupuesto gratis</h2>
<p className="text-sm text-gray-400 mt-1">Sin compromiso · En menos de 5 minutos</p>
<div className="reveal opacity-0 translate-y-6 transition-all duration-700 ease-out delay-150 border border-gray-100 rounded-xl p-6 md:p-8 bg-white shadow-sm">
<div className="mb-5 md:mb-6">
<h2 className="text-xl font-black tracking-tight text-black">Pide tu presupuesto</h2>
<p className="text-sm text-gray-400 mt-1">En menos de 2 minutos te llamamos · Render por WhatsApp</p>
</div>
<LeadForm />
</div>
</div>
<hr className="border-gray-300 mt-12 pb-8" />
<hr className="border-gray-300 mt-12 md:mt-16 mb-8" />
{/* Servicios */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-8 justify-center">
<div className="grid grid-cols-1 sm:grid-cols-3 gap-8 sm:gap-6 lg:gap-8 justify-center">
{[
{
icon: (
@@ -397,7 +436,7 @@ export default function Hero() {
<div className="w-12 h-12 rounded-full bg-black flex items-center justify-center text-white">
{icon}
</div>
<h3 className="text-xl font-black tracking-tight text-black">{title}</h3>
<h3 className="text-lg font-black tracking-tight text-black">{title}</h3>
<p className="text-gray-400 leading-relaxed text-sm max-w-[280px]">{description}</p>
</div>
))}
@@ -406,4 +445,4 @@ export default function Hero() {
</div>
</section>
);
}
}

View File

@@ -1,273 +0,0 @@
'use client';
import { useEffect, useRef, useState } from 'react';
const plans = [
{
id: 'esencial',
name: 'Presupuesto',
price: { monthly: 0, annual: 0 },
description: 'Para quien quiere saber cuánto cuesta antes de comprometerse.',
cta: 'Solicitar presupuesto',
highlight: false,
features: [
'Llamada en menos de 2 minutos',
'Presupuesto desglosado por partidas',
'Render visual de tu espacio por WhatsApp',
'Sin compromiso de contratación',
'Asesor desde tu provincia',
'Válido para cocinas y baños',
],
},
{
id: 'reforma',
name: 'Reforma',
price: { monthly: 199, annual: 159 },
description: 'Para quien ya tiene claro que quiere reformar y necesita un equipo de confianza.',
cta: 'Empezar mi reforma',
highlight: true,
badge: 'Más contratado',
features: [
'Todo lo del plan Presupuesto',
'Proyecto de reforma completo',
'Gestión de materiales y proveedores',
'Coordinador de obra dedicado',
'Seguimiento fotográfico semanal',
'Garantía de 2 años en mano de obra',
'Plazos de entrega garantizados',
'Atención post-obra incluida',
],
},
{
id: 'integral',
name: 'Integral',
price: { monthly: 499, annual: 399 },
description: 'Para reformas completas de viviendas o comunidades con gestión total.',
cta: 'Hablar con un asesor',
highlight: false,
features: [
'Todo lo del plan Reforma',
'Reforma integral de vivienda completa',
'Diseño de interiores incluido',
'Renders 3D de todos los espacios',
'Gestión de licencias y permisos',
'Financiación flexible disponible',
'Acceso a catálogo premium de materiales',
'Garantía extendida de 5 años',
'Servicio de mudanza coordinado',
'Revisión técnica anual',
],
},
];
export default function Pricing() {
const [annual, setAnnual] = useState(false);
const sectionRef = useRef<HTMLElement>(null);
useEffect(() => {
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
entry.target.classList.add('opacity-100', 'translate-y-0');
entry.target.classList.remove('opacity-0', 'translate-y-6');
}
});
},
{ threshold: 0.1 }
);
const elements = sectionRef.current?.querySelectorAll('.reveal');
elements?.forEach((el) => observer.observe(el));
return () => observer.disconnect();
}, []);
const handleContactScroll = () => {
document.querySelector('#contact')?.scrollIntoView({ behavior: 'smooth' });
};
return (
<section className="section" id="pricing" ref={sectionRef} aria-labelledby="pricing-heading">
<div className="container">
{/* Header */}
<div className="reveal opacity-0 translate-y-6 transition-all duration-700 ease-out flex flex-col items-center text-center gap-4 mb-16">
<span className="badge badge-dark">Servicios</span>
<h2
id="pricing-heading"
className="text-[clamp(1.875rem,5vw,3rem)] font-black tracking-[-0.04em] leading-[1.1] text-black"
>
Reformas con precio claro
<br />
desde el primer minuto.
</h2>
<p className="text-lg text-gray-600">
Sin letras pequeñas, sin presupuestos que se disparan. Sabes lo que pagas antes de que empiece la obra.
</p>
{/* Toggle */}
<div className="flex items-center gap-3 mt-2">
<span
className={`text-sm transition-colors duration-150 ease-out ${
!annual ? 'font-semibold text-black' : 'font-medium text-gray-400'
}`}
>
Mensual
</span>
<button
className={`w-12 h-[26px] rounded-full border-none cursor-pointer relative transition-colors duration-250 ease-out ${
annual ? 'bg-black' : 'bg-gray-200'
}`}
onClick={() => setAnnual(!annual)}
aria-pressed={annual}
aria-label="Cambiar a facturación anual"
id="pricing-toggle-btn"
>
<span
className={`absolute top-[3px] left-[3px] w-5 h-5 bg-white rounded-full transition-transform duration-250 ease-out shadow-[0_1px_4px_rgba(0,0,0,0.2)] ${
annual ? 'translate-x-[22px]' : ''
}`}
/>
</button>
<span
className={`text-sm flex items-center transition-colors duration-150 ease-out ${
annual ? 'font-semibold text-black' : 'font-medium text-gray-400'
}`}
>
Anual
<span className="inline-block ml-2 px-2 py-[2px] bg-green-100 text-green-700 text-xs font-bold rounded-full">
Ahorra 20%
</span>
</span>
</div>
</div>
{/* Plans */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6 items-start max-w-[480px] lg:max-w-none mx-auto">
{plans.map((plan, i) => (
<article
key={plan.id}
className={`reveal opacity-0 translate-y-6 transition-all duration-700 ease-out border rounded-2xl p-8 flex flex-col gap-6 relative group ${
plan.highlight
? 'bg-black border-black text-white hover:shadow-[0_24px_64px_rgba(0,0,0,0.2)]'
: 'bg-white border-gray-200 hover:shadow-lg hover:-translate-y-0.5'
}`}
style={{ transitionDelay: `${i * 100}ms` }}
aria-label={`Plan ${plan.name}`}
>
{plan.badge && (
<div
className="absolute -top-[14px] left-1/2 -translate-x-1/2 bg-accent text-white text-xs font-bold px-4 py-1 rounded-full whitespace-nowrap tracking-wider"
aria-label="Plan más popular"
>
{plan.badge}
</div>
)}
<div className="flex flex-col gap-2">
<h3
className={`text-2xl font-extrabold tracking-tight ${
plan.highlight ? 'text-white' : ''
}`}
>
{plan.name}
</h3>
<p
className={`text-sm leading-relaxed ${
plan.highlight ? 'text-white/60' : 'text-gray-600'
}`}
>
{plan.description}
</p>
</div>
<div className="flex items-end gap-[2px]">
{plan.price.monthly === 0 ? (
<span className="text-[clamp(2.25rem,6vw,3.5rem)] font-black tracking-[-0.05em] leading-none">
Gratis
</span>
) : (
<>
<span className="text-xl font-bold pb-1">$</span>
<span className="text-[clamp(2.25rem,6vw,3.5rem)] font-black tracking-[-0.05em] leading-none">
{annual ? plan.price.annual : plan.price.monthly}
</span>
<span
className={`text-sm pb-1 ml-1 ${
plan.highlight ? 'text-white/50' : 'text-gray-400'
}`}
>
/mes
</span>
</>
)}
</div>
{annual && plan.price.monthly > 0 && (
<p
className={`text-xs -mt-6 ${
plan.highlight ? 'text-white/40' : 'text-gray-400'
}`}
>
Facturado anualmente ${plan.price.annual * 12}/año
</p>
)}
<button
className={`btn btn-lg ${
plan.highlight ? 'btn-accent' : 'btn-secondary'
}`}
id={`pricing-${plan.id}-btn`}
onClick={handleContactScroll}
aria-label={`${plan.cta} — Plan ${plan.name}`}
>
{plan.cta}
</button>
<ul className="list-none flex flex-col gap-3" role="list">
{plan.features.map((f) => (
<li
key={f}
className={`flex items-center gap-3 text-sm ${
plan.highlight ? 'text-white/80' : 'text-gray-700'
}`}
>
<svg
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
aria-hidden="true"
className={`shrink-0 ${plan.highlight ? 'text-white' : 'text-black'}`}
>
<path
d="M3 8l3.5 3.5L13 4.5"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
{f}
</li>
))}
</ul>
</article>
))}
</div>
{/* Enterprise note */}
<div className="reveal opacity-0 translate-y-6 transition-all duration-700 ease-out text-center mt-12 text-base text-gray-600">
<p>
¿Tienes un proyecto especial o una comunidad de vecinos? &nbsp;
<button
className="bg-transparent border-none cursor-pointer font-sans text-base font-semibold text-black underline underline-offset-4 transition-opacity duration-150 ease-out hover:opacity-60"
onClick={handleContactScroll}
id="pricing-enterprise-contact-btn"
>
Cuéntanos tu caso
</button>
</p>
</div>
</div>
</section>
);
}

View File

@@ -5,18 +5,18 @@ import { useState, useRef, useEffect } from 'react';
const slides = [
{
label: 'Cocina',
before: 'https://images.unsplash.com/photo-1556909114-f6e7ad7d3136?w=900',
after: 'https://images.unsplash.com/photo-1556909172-54557c7e4fb7?w=900',
before: '/antes.webp',
after: '/despues.webp',
},
{
label: 'Baño',
before: 'https://images.unsplash.com/photo-1552321554-5fefe8c9ef14?w=900',
after: 'https://images.unsplash.com/photo-1584622650111-993a426fbf0a?w=900',
before: '/antes-bano.webp',
after: '/despues-bano.webp',
},
{
label: 'Salón',
before: 'https://images.unsplash.com/photo-1484101403633-562f891dc89a?w=900',
after: 'https://images.unsplash.com/photo-1618221195710-dd6b41faaea6?w=900',
label: 'Comedor',
before: '/antes-comedor.webp',
after: '/despues-comedor.webp',
},
];
@@ -63,9 +63,9 @@ export default function ReformaSlider() {
};
return (
<section className="bg-white py-16 md:py-24" ref={sectionRef}>
<section id="ver-reforma" className="bg-white py-16 md:py-24" ref={sectionRef}>
<div className="container text-center">
<h2 className="text-[clamp(2.5rem,5vw,4rem)] font-black tracking-[-0.04em] leading-[1.05] text-black mb-6 reveal opacity-0 translate-y-6 transition-all duration-700 ease-out">
<h2 className="text-[clamp(1.875rem,5vw,3rem)] font-black tracking-[-0.04em] leading-[1.1] text-black mb-10 md:mb-12 reveal opacity-0 translate-y-6 transition-all duration-700 ease-out">
Ve cómo quedará tu reforma
</h2>
<div className="flex flex-col gap-4 reveal opacity-0 translate-y-6 transition-all duration-700 ease-out delay-100">
@@ -119,22 +119,24 @@ export default function ReformaSlider() {
</div>
</div>
<div className="flex gap-3 justify-center mt-2">
{slides.map((slide, i) => (
<button
key={slide.label}
onClick={() => switchSlide(i)}
className={`relative rounded-lg overflow-hidden w-24 h-16 shrink-0 transition-all duration-200 ${i === activeSlide ? 'ring-2 ring-black ring-offset-2' : 'opacity-50 hover:opacity-80'
}`}
aria-label={`Ver reforma de ${slide.label}`}
>
<img src={slide.before} alt={slide.label} className="w-full h-full object-cover" />
<span className="absolute bottom-1 left-1/2 -translate-x-1/2 bg-black/70 text-white text-[10px] px-2 py-0.5 rounded-full whitespace-nowrap">
{slide.label}
</span>
</button>
))}
</div>
{slides.length > 1 && (
<div className="flex gap-3 justify-center mt-2">
{slides.map((slide, i) => (
<button
key={slide.label}
onClick={() => switchSlide(i)}
className={`relative rounded-lg overflow-hidden w-24 h-16 shrink-0 transition-all duration-200 ${i === activeSlide ? 'ring-2 ring-black ring-offset-2' : 'opacity-50 hover:opacity-80'
}`}
aria-label={`Ver reforma de ${slide.label}`}
>
<img src={slide.before} alt={slide.label} className="w-full h-full object-cover" />
<span className="absolute bottom-1 left-1/2 -translate-x-1/2 bg-black/70 text-white text-[10px] px-2 py-0.5 rounded-full whitespace-nowrap">
{slide.label}
</span>
</button>
))}
</div>
)}
</div>
</div>
</section>

Some files were not shown because too many files have changed in this diff Show More