diff --git a/mvp/b2c/.env.example b/mvp/b2c/.env.example new file mode 100644 index 0000000..b65cec2 --- /dev/null +++ b/mvp/b2c/.env.example @@ -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" diff --git a/mvp/b2c/.gitignore b/mvp/b2c/.gitignore index 5ef6a52..7b8da95 100644 --- a/mvp/b2c/.gitignore +++ b/mvp/b2c/.gitignore @@ -32,6 +32,7 @@ yarn-error.log* # env files (can opt-in for committing if needed) .env* +!.env.example # vercel .vercel diff --git a/mvp/b2c/Dockerfile b/mvp/b2c/Dockerfile index 4eff316..1e4a10b 100644 --- a/mvp/b2c/Dockerfile +++ b/mvp/b2c/Dockerfile @@ -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"] diff --git a/mvp/b2c/docker-entrypoint.sh b/mvp/b2c/docker-entrypoint.sh new file mode 100644 index 0000000..2843bd6 --- /dev/null +++ b/mvp/b2c/docker-entrypoint.sh @@ -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 diff --git a/mvp/b2c/drizzle.config.ts b/mvp/b2c/drizzle.config.ts new file mode 100644 index 0000000..b6668c6 --- /dev/null +++ b/mvp/b2c/drizzle.config.ts @@ -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, +}); diff --git a/mvp/b2c/drizzle/0000_motionless_jackpot.sql b/mvp/b2c/drizzle/0000_motionless_jackpot.sql new file mode 100644 index 0000000..91dc98b --- /dev/null +++ b/mvp/b2c/drizzle/0000_motionless_jackpot.sql @@ -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"); \ No newline at end of file diff --git a/mvp/b2c/drizzle/meta/0000_snapshot.json b/mvp/b2c/drizzle/meta/0000_snapshot.json new file mode 100644 index 0000000..935afae --- /dev/null +++ b/mvp/b2c/drizzle/meta/0000_snapshot.json @@ -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": {} + } +} \ No newline at end of file diff --git a/mvp/b2c/drizzle/meta/_journal.json b/mvp/b2c/drizzle/meta/_journal.json new file mode 100644 index 0000000..359dd9b --- /dev/null +++ b/mvp/b2c/drizzle/meta/_journal.json @@ -0,0 +1,13 @@ +{ + "version": "7", + "dialect": "postgresql", + "entries": [ + { + "idx": 0, + "version": "7", + "when": 1780056789929, + "tag": "0000_motionless_jackpot", + "breakpoints": true + } + ] +} \ No newline at end of file diff --git a/mvp/b2c/package-lock.json b/mvp/b2c/package-lock.json index d97e83e..e182f2f 100644 --- a/mvp/b2c/package-lock.json +++ b/mvp/b2c/package-lock.json @@ -9,8 +9,10 @@ "version": "0.1.0", "dependencies": { "@tailwindcss/postcss": "^4.3.0", + "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" @@ -19,8 +21,11 @@ "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19", + "dotenv": "^17.4.2", + "drizzle-kit": "^0.31.10", "eslint": "^9", "eslint-config-next": "16.2.6", + "tsx": "^4.22.3", "typescript": "^5" } }, @@ -276,6 +281,13 @@ "node": ">=6.9.0" } }, + "node_modules/@drizzle-team/brocli": { + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/@drizzle-team/brocli/-/brocli-0.10.2.tgz", + "integrity": "sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w==", + "dev": true, + "license": "Apache-2.0" + }, "node_modules/@emnapi/core": { "version": "1.10.0", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", @@ -307,6 +319,884 @@ "tslib": "^2.4.0" } }, + "node_modules/@esbuild-kit/core-utils": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/@esbuild-kit/core-utils/-/core-utils-3.3.2.tgz", + "integrity": "sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ==", + "deprecated": "Merged into tsx: https://tsx.is", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.18.20", + "source-map-support": "^0.5.21" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/android-arm": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.18.20.tgz", + "integrity": "sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/android-arm64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.18.20.tgz", + "integrity": "sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/android-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.18.20.tgz", + "integrity": "sha512-8GDdlePJA8D6zlZYJV/jnrRAi6rOiNaCC/JclcXpB+KIuvfBN4owLtgzY2bsxnx666XjJx2kDPUmnTtR8qKQUg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/darwin-arm64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.18.20.tgz", + "integrity": "sha512-bxRHW5kHU38zS2lPTPOyuyTm+S+eobPUnTNkdJEfAddYgEcll4xkT8DB9d2008DtTbl7uJag2HuE5NZAZgnNEA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/darwin-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.18.20.tgz", + "integrity": "sha512-pc5gxlMDxzm513qPGbCbDukOdsGtKhfxD1zJKXjCCcU7ju50O7MeAZ8c4krSJcOIJGFR+qx21yMMVYwiQvyTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/freebsd-arm64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.18.20.tgz", + "integrity": "sha512-yqDQHy4QHevpMAaxhhIwYPMv1NECwOvIpGCZkECn8w2WFHXjEwrBn3CeNIYsibZ/iZEUemj++M26W3cNR5h+Tw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/freebsd-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.18.20.tgz", + "integrity": "sha512-tgWRPPuQsd3RmBZwarGVHZQvtzfEBOreNuxEMKFcd5DaDn2PbBxfwLcj4+aenoh7ctXcbXmOQIn8HI6mCSw5MQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-arm": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.18.20.tgz", + "integrity": "sha512-/5bHkMWnq1EgKr1V+Ybz3s1hWXok7mDFUMQ4cG10AfW3wL02PSZi5kFpYKrptDsgb2WAJIvRcDm+qIvXf/apvg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-arm64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.18.20.tgz", + "integrity": "sha512-2YbscF+UL7SQAVIpnWvYwM+3LskyDmPhe31pE7/aoTMFKKzIc9lLbyGUpmmb8a8AixOL61sQ/mFh3jEjHYFvdA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-ia32": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.18.20.tgz", + "integrity": "sha512-P4etWwq6IsReT0E1KHU40bOnzMHoH73aXp96Fs8TIT6z9Hu8G6+0SHSw9i2isWrD2nbx2qo5yUqACgdfVGx7TA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-loong64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.18.20.tgz", + "integrity": "sha512-nXW8nqBTrOpDLPgPY9uV+/1DjxoQ7DoB2N8eocyq8I9XuqJ7BiAMDMf9n1xZM9TgW0J8zrquIb/A7s3BJv7rjg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-mips64el": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.18.20.tgz", + "integrity": "sha512-d5NeaXZcHp8PzYy5VnXV3VSd2D328Zb+9dEq5HE6bw6+N86JVPExrA6O68OPwobntbNJ0pzCpUFZTo3w0GyetQ==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-ppc64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.18.20.tgz", + "integrity": "sha512-WHPyeScRNcmANnLQkq6AfyXRFr5D6N2sKgkFo2FqguP44Nw2eyDlbTdZwd9GYk98DZG9QItIiTlFLHJHjxP3FA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-riscv64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.18.20.tgz", + "integrity": "sha512-WSxo6h5ecI5XH34KC7w5veNnKkju3zBRLEQNY7mv5mtBmrP/MjNBCAlsM2u5hDBlS3NGcTQpoBvRzqBcRtpq1A==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-s390x": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.18.20.tgz", + "integrity": "sha512-+8231GMs3mAEth6Ja1iK0a1sQ3ohfcpzpRLH8uuc5/KVDFneH6jtAJLFGafpzpMRO6DzJ6AvXKze9LfFMrIHVQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.18.20.tgz", + "integrity": "sha512-UYqiqemphJcNsFEskc73jQ7B9jgwjWrSayxawS6UVFZGWrAAtkzjxSqnoclCXxWtfwLdzU+vTpcNYhpn43uP1w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/netbsd-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.18.20.tgz", + "integrity": "sha512-iO1c++VP6xUBUmltHZoMtCUdPlnPGdBom6IrO4gyKPFFVBKioIImVooR5I83nTew5UOYrk3gIJhbZh8X44y06A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/openbsd-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.18.20.tgz", + "integrity": "sha512-e5e4YSsuQfX4cxcygw/UCPIEP6wbIL+se3sxPdCiMbFLBWu0eiZOJ7WoD+ptCLrmjZBK1Wk7I6D/I3NglUGOxg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/sunos-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.18.20.tgz", + "integrity": "sha512-kDbFRFp0YpTQVVrqUd5FTYmWo45zGaXe0X8E1G/LKFC0v8x0vWrhOWSLITcCn63lmZIxfOMXtCfti/RxN/0wnQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/win32-arm64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.18.20.tgz", + "integrity": "sha512-ddYFR6ItYgoaq4v4JmQQaAI5s7npztfV4Ag6NrhiaW0RrnOXqBkgwZLofVTlq1daVTQNhtI5oieTvkRPfZrePg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/win32-ia32": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.18.20.tgz", + "integrity": "sha512-Wv7QBi3ID/rROT08SABTS7eV4hX26sVduqDOTe1MvGMjNd3EjOz4b7zeexIR62GTIEKrfJXKL9LFxTYgkyeu7g==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/win32-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.18.20.tgz", + "integrity": "sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild-kit/core-utils/node_modules/esbuild": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.18.20.tgz", + "integrity": "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/android-arm": "0.18.20", + "@esbuild/android-arm64": "0.18.20", + "@esbuild/android-x64": "0.18.20", + "@esbuild/darwin-arm64": "0.18.20", + "@esbuild/darwin-x64": "0.18.20", + "@esbuild/freebsd-arm64": "0.18.20", + "@esbuild/freebsd-x64": "0.18.20", + "@esbuild/linux-arm": "0.18.20", + "@esbuild/linux-arm64": "0.18.20", + "@esbuild/linux-ia32": "0.18.20", + "@esbuild/linux-loong64": "0.18.20", + "@esbuild/linux-mips64el": "0.18.20", + "@esbuild/linux-ppc64": "0.18.20", + "@esbuild/linux-riscv64": "0.18.20", + "@esbuild/linux-s390x": "0.18.20", + "@esbuild/linux-x64": "0.18.20", + "@esbuild/netbsd-x64": "0.18.20", + "@esbuild/openbsd-x64": "0.18.20", + "@esbuild/sunos-x64": "0.18.20", + "@esbuild/win32-arm64": "0.18.20", + "@esbuild/win32-ia32": "0.18.20", + "@esbuild/win32-x64": "0.18.20" + } + }, + "node_modules/@esbuild-kit/esm-loader": { + "version": "2.6.5", + "resolved": "https://registry.npmjs.org/@esbuild-kit/esm-loader/-/esm-loader-2.6.5.tgz", + "integrity": "sha512-FxEMIkJKnodyA1OaCUoEvbYRkoZlLZ4d/eXFu9Fh8CbBBgP5EmZxrfTRyN0qpXZ4vOvqnE5YdRdcrmUUXuU+dA==", + "deprecated": "Merged into tsx: https://tsx.is", + "dev": true, + "license": "MIT", + "dependencies": { + "@esbuild-kit/core-utils": "^3.3.2", + "get-tsconfig": "^4.7.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@eslint-community/eslint-utils": { "version": "4.9.1", "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", @@ -2542,6 +3432,13 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "dev": true, + "license": "MIT" + }, "node_modules/call-bind": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz", @@ -2845,6 +3742,160 @@ "node": ">=0.10.0" } }, + "node_modules/dotenv": { + "version": "17.4.2", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz", + "integrity": "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/drizzle-kit": { + "version": "0.31.10", + "resolved": "https://registry.npmjs.org/drizzle-kit/-/drizzle-kit-0.31.10.tgz", + "integrity": "sha512-7OZcmQUrdGI+DUNNsKBn1aW8qSoKuTH7d0mYgSP8bAzdFzKoovxEFnoGQp2dVs82EOJeYycqRtciopszwUf8bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@drizzle-team/brocli": "^0.10.2", + "@esbuild-kit/esm-loader": "^2.5.5", + "esbuild": "^0.25.4", + "tsx": "^4.21.0" + }, + "bin": { + "drizzle-kit": "bin.cjs" + } + }, + "node_modules/drizzle-orm": { + "version": "0.45.2", + "resolved": "https://registry.npmjs.org/drizzle-orm/-/drizzle-orm-0.45.2.tgz", + "integrity": "sha512-kY0BSaTNYWnoDMVoyY8uxmyHjpJW1geOmBMdSSicKo9CIIWkSxMIj2rkeSR51b8KAPB7m+qysjuHme5nKP+E5Q==", + "license": "Apache-2.0", + "peerDependencies": { + "@aws-sdk/client-rds-data": ">=3", + "@cloudflare/workers-types": ">=4", + "@electric-sql/pglite": ">=0.2.0", + "@libsql/client": ">=0.10.0", + "@libsql/client-wasm": ">=0.10.0", + "@neondatabase/serverless": ">=0.10.0", + "@op-engineering/op-sqlite": ">=2", + "@opentelemetry/api": "^1.4.1", + "@planetscale/database": ">=1.13", + "@prisma/client": "*", + "@tidbcloud/serverless": "*", + "@types/better-sqlite3": "*", + "@types/pg": "*", + "@types/sql.js": "*", + "@upstash/redis": ">=1.34.7", + "@vercel/postgres": ">=0.8.0", + "@xata.io/client": "*", + "better-sqlite3": ">=7", + "bun-types": "*", + "expo-sqlite": ">=14.0.0", + "gel": ">=2", + "knex": "*", + "kysely": "*", + "mysql2": ">=2", + "pg": ">=8", + "postgres": ">=3", + "sql.js": ">=1", + "sqlite3": ">=5" + }, + "peerDependenciesMeta": { + "@aws-sdk/client-rds-data": { + "optional": true + }, + "@cloudflare/workers-types": { + "optional": true + }, + "@electric-sql/pglite": { + "optional": true + }, + "@libsql/client": { + "optional": true + }, + "@libsql/client-wasm": { + "optional": true + }, + "@neondatabase/serverless": { + "optional": true + }, + "@op-engineering/op-sqlite": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@planetscale/database": { + "optional": true + }, + "@prisma/client": { + "optional": true + }, + "@tidbcloud/serverless": { + "optional": true + }, + "@types/better-sqlite3": { + "optional": true + }, + "@types/pg": { + "optional": true + }, + "@types/sql.js": { + "optional": true + }, + "@upstash/redis": { + "optional": true + }, + "@vercel/postgres": { + "optional": true + }, + "@xata.io/client": { + "optional": true + }, + "better-sqlite3": { + "optional": true + }, + "bun-types": { + "optional": true + }, + "expo-sqlite": { + "optional": true + }, + "gel": { + "optional": true + }, + "knex": { + "optional": true + }, + "kysely": { + "optional": true + }, + "mysql2": { + "optional": true + }, + "pg": { + "optional": true + }, + "postgres": { + "optional": true + }, + "prisma": { + "optional": true + }, + "sql.js": { + "optional": true + }, + "sqlite3": { + "optional": true + } + } + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -3064,6 +4115,48 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", @@ -3634,6 +4727,21 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/function-bind": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", @@ -5380,6 +6488,19 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/postgres": { + "version": "3.4.9", + "resolved": "https://registry.npmjs.org/postgres/-/postgres-3.4.9.tgz", + "integrity": "sha512-GD3qdB0x1z9xgFI6cdRD6xu2Sp2WCOEoe3mtnyB5Ee0XrrL5Pe+e4CCnJrRMnL1zYtRDZmQQVbvOttLnKDLnaw==", + "license": "Unlicense", + "engines": { + "node": ">=12" + }, + "funding": { + "type": "individual", + "url": "https://github.com/sponsors/porsager" + } + }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -5861,6 +6982,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -5870,6 +7001,17 @@ "node": ">=0.10.0" } }, + "node_modules/source-map-support": { + "version": "0.5.21", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", + "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, "node_modules/stable-hash": { "version": "0.0.5", "resolved": "https://registry.npmjs.org/stable-hash/-/stable-hash-0.0.5.tgz", @@ -6201,6 +7343,509 @@ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "license": "0BSD" }, + "node_modules/tsx": { + "version": "4.22.3", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.3.tgz", + "integrity": "sha512-mdoNxBC/cSQObGGVQ5Bpn5i+yv7j68gk3Nfm3wFjcJg3Z0Mix9jzAFfP12prmm5eVGmDKtp0yyArrs0Q+8gZHg==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/tsx/node_modules/@esbuild/aix-ppc64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.0.tgz", + "integrity": "sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/android-arm": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.0.tgz", + "integrity": "sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/android-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.0.tgz", + "integrity": "sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/android-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.0.tgz", + "integrity": "sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/darwin-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.0.tgz", + "integrity": "sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/darwin-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.0.tgz", + "integrity": "sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.0.tgz", + "integrity": "sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/freebsd-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.0.tgz", + "integrity": "sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-arm": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.0.tgz", + "integrity": "sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.0.tgz", + "integrity": "sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-ia32": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.0.tgz", + "integrity": "sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-loong64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.0.tgz", + "integrity": "sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-mips64el": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.0.tgz", + "integrity": "sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-ppc64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.0.tgz", + "integrity": "sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-riscv64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.0.tgz", + "integrity": "sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-s390x": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.0.tgz", + "integrity": "sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.0.tgz", + "integrity": "sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.0.tgz", + "integrity": "sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/netbsd-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.0.tgz", + "integrity": "sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.0.tgz", + "integrity": "sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/openbsd-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.0.tgz", + "integrity": "sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.0.tgz", + "integrity": "sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/sunos-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.0.tgz", + "integrity": "sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/win32-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.0.tgz", + "integrity": "sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/win32-ia32": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.0.tgz", + "integrity": "sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/win32-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.0.tgz", + "integrity": "sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/esbuild": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.0.tgz", + "integrity": "sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.0", + "@esbuild/android-arm": "0.28.0", + "@esbuild/android-arm64": "0.28.0", + "@esbuild/android-x64": "0.28.0", + "@esbuild/darwin-arm64": "0.28.0", + "@esbuild/darwin-x64": "0.28.0", + "@esbuild/freebsd-arm64": "0.28.0", + "@esbuild/freebsd-x64": "0.28.0", + "@esbuild/linux-arm": "0.28.0", + "@esbuild/linux-arm64": "0.28.0", + "@esbuild/linux-ia32": "0.28.0", + "@esbuild/linux-loong64": "0.28.0", + "@esbuild/linux-mips64el": "0.28.0", + "@esbuild/linux-ppc64": "0.28.0", + "@esbuild/linux-riscv64": "0.28.0", + "@esbuild/linux-s390x": "0.28.0", + "@esbuild/linux-x64": "0.28.0", + "@esbuild/netbsd-arm64": "0.28.0", + "@esbuild/netbsd-x64": "0.28.0", + "@esbuild/openbsd-arm64": "0.28.0", + "@esbuild/openbsd-x64": "0.28.0", + "@esbuild/openharmony-arm64": "0.28.0", + "@esbuild/sunos-x64": "0.28.0", + "@esbuild/win32-arm64": "0.28.0", + "@esbuild/win32-ia32": "0.28.0", + "@esbuild/win32-x64": "0.28.0" + } + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", diff --git a/mvp/b2c/package.json b/mvp/b2c/package.json index c1e75bf..f4f68d0 100644 --- a/mvp/b2c/package.json +++ b/mvp/b2c/package.json @@ -6,12 +6,19 @@ "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" }, "dependencies": { "@tailwindcss/postcss": "^4.3.0", + "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" @@ -20,8 +27,11 @@ "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19", + "dotenv": "^17.4.2", + "drizzle-kit": "^0.31.10", "eslint": "^9", "eslint-config-next": "16.2.6", + "tsx": "^4.22.3", "typescript": "^5" } } diff --git a/mvp/b2c/src/app/panel/[id]/page.tsx b/mvp/b2c/src/app/panel/[id]/page.tsx new file mode 100644 index 0000000..49533fc --- /dev/null +++ b/mvp/b2c/src/app/panel/[id]/page.tsx @@ -0,0 +1,213 @@ +import Link from 'next/link'; +import { notFound } from 'next/navigation'; +import { getLead } from '@/db/queries'; +import EstadoControl from '@/components/panel/EstadoControl'; +import { + PIPELINE_LABEL, + PIPELINE_NEXT, + PIPELINE_ORDER, + TIPO_LABEL, + formatEuros, + formatFecha, +} from '@/lib/funnel'; + +export const dynamic = 'force-dynamic'; + +function Section({ title, children }: { title: string; children: React.ReactNode }) { + return ( +
+

{title}

+ {children} +
+ ); +} + +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)); + + return ( +
+ + ← Volver a leads + + + {/* Cabecera + estado */} +
+
+
+

{lead.nombre}

+

+ {lead.tipoReforma ? TIPO_LABEL[lead.tipoReforma] : 'Reforma'} ·{' '} + {lead.provincia ?? '—'} · entró {formatFecha(lead.createdAt)} +

+
+
+
Presupuesto estimado
+
{formatEuros(lead.presupuestoEstimado)}
+
+
+ +
+ + {/* Timeline del funnel */} +
+
    + {PIPELINE_ORDER.map((stage) => { + const reached = reachedStages.has(stage); + const isCurrent = stage === lead.pipelineStage; + return ( +
  1. + + + {PIPELINE_LABEL[stage]} + + {isCurrent && ( + + → {PIPELINE_NEXT[stage]} + + )} +
  2. + ); + })} +
+
+ +
+ {/* 1. Datos personales */} +
+
+
+
Teléfono
+
{lead.telefono}
+
+
+
Email
+
{lead.email}
+
+
+
Provincia
+
{lead.provincia ?? '—'}
+
+
+
Consentimientos
+
+ {lead.consentPrivacidad ? 'Privacidad ✓' : 'Privacidad ✗'} ·{' '} + {lead.consentContratacion ? 'Contratación ✓' : 'Contratación ✗'} +
+
+
+
+ + {/* 4. Render */} +
+ {lead.renderUrl ? ( + // eslint-disable-next-line @next/next/no-img-element + Render de la reforma + ) : ( +

Aún no generado.

+ )} +
+ + {/* 2. Transcripción */} +
+ {lead.transcripcion ? ( +

+ {lead.transcripcion} +

+ ) : ( +

Aún no hay llamada.

+ )} +
+ + {/* 3. JSON de entidades */} +
+ {lead.entidades ? ( +
+              {JSON.stringify(lead.entidades, null, 2)}
+            
+ ) : ( +

Sin entidades aún.

+ )} +
+ + {/* 5. Audio */} +
+ {lead.audioUrl ? ( + + ) : ( +

Sin grabación.

+ )} +
+ + {/* 6. PDF */} +
+ {lead.pdfUrl ? ( + + Descargar PDF + + ) : ( +

Aún no generado.

+ )} +
+
+ + {/* Fotos subidas */} + {fotos.length > 0 && ( +
+
+ {fotos.map((f) => ( + // eslint-disable-next-line @next/next/no-img-element + + ))} +
+
+ )} + + {/* Precisión (si ganado) */} + {precision && ( +
+
+
+
Estimado
+
{formatEuros(precision.estimated)}
+
+
+
Final firmado
+
{formatEuros(precision.final)}
+
+
+
Desviación
+
+ {Number(precision.deltaPct) > 0 ? '+' : ''} + {precision.deltaPct}% +
+
+
+
+ )} +
+ ); +} diff --git a/mvp/b2c/src/app/panel/actions.ts b/mvp/b2c/src/app/panel/actions.ts new file mode 100644 index 0000000..35bac90 --- /dev/null +++ b/mvp/b2c/src/app/panel/actions.ts @@ -0,0 +1,62 @@ +'use server'; + +import { and, eq } from 'drizzle-orm'; +import { revalidatePath } from 'next/cache'; +import { db } from '@/db'; +import { leads, leadEstadoHistory, precisionHistory, tenants } from '@/db/schema'; +import { TENANT_SLUG } from '@/lib/funnel'; + +async function getTenantId(): Promise { + const [tenant] = await db.select().from(tenants).where(eq(tenants.slug, TENANT_SLUG)).limit(1); + if (!tenant) throw new Error('Tenant no encontrado.'); + return tenant.id; +} + +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(eq(leads.id, leadId)); + 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}`); +} diff --git a/mvp/b2c/src/app/panel/layout.tsx b/mvp/b2c/src/app/panel/layout.tsx new file mode 100644 index 0000000..e4722fb --- /dev/null +++ b/mvp/b2c/src/app/panel/layout.tsx @@ -0,0 +1,28 @@ +import Link from 'next/link'; +import type { Metadata } from 'next'; + +export const metadata: Metadata = { + title: 'Panel · Reformas Ejemplo', + description: 'Panel de leads del reformista', +}; + +export default function PanelLayout({ children }: { children: React.ReactNode }) { + return ( +
+
+
+ + + R + + Reformix + / + Reformas Ejemplo + + Panel del reformista +
+
+
{children}
+
+ ); +} diff --git a/mvp/b2c/src/app/panel/page.tsx b/mvp/b2c/src/app/panel/page.tsx new file mode 100644 index 0000000..16efe43 --- /dev/null +++ b/mvp/b2c/src/app/panel/page.tsx @@ -0,0 +1,152 @@ +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'; + +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()]); + + return ( +
+
+

Leads

+

+ {resumen.total} leads en total · {resumen.porEstado['nuevo'] ?? 0} sin contactar +

+
+ + {/* Filtros por estado */} +
+ {FILTROS.map((f) => { + const active = f.value === filtro; + const count = f.value === 'todos' ? resumen.total : resumen.porEstado[f.value] ?? 0; + return ( + + {f.label} {count} + + ); + })} +
+ + {/* Tabla (desktop) */} +
+ + + + + + + + + + + + + {leads.map((l) => ( + + + + + + + + + ))} + +
RenderClienteFechaEstadoPresupuestoSiguiente paso
+ + {l.renderUrl ? ( + // eslint-disable-next-line @next/next/no-img-element + + ) : ( + + sin render + + )} + + + + {l.nombre} + +
{l.telefono}
+
{formatFecha(l.createdAt)} + + {ESTADO_LABEL[l.estado]} + + + {formatEuros(l.presupuestoEstimado)} + +
{PIPELINE_LABEL[l.pipelineStage]}
+ {PIPELINE_NEXT[l.pipelineStage]} +
+ {leads.length === 0 && ( +
No hay leads con este estado.
+ )} +
+ + {/* Cards (mobile) */} +
+ {leads.map((l) => ( + +
+ {l.renderUrl ? ( + // eslint-disable-next-line @next/next/no-img-element + + ) : null} +
+
+
+ {l.nombre} + + {ESTADO_LABEL[l.estado]} + +
+
{l.telefono}
+
+ {formatEuros(l.presupuestoEstimado)} + {formatFecha(l.createdAt)} +
+
{PIPELINE_NEXT[l.pipelineStage]}
+
+ + ))} + {leads.length === 0 && ( +
No hay leads con este estado.
+ )} +
+
+ ); +} diff --git a/mvp/b2c/src/components/panel/EstadoControl.tsx b/mvp/b2c/src/components/panel/EstadoControl.tsx new file mode 100644 index 0000000..fc3e359 --- /dev/null +++ b/mvp/b2c/src/components/panel/EstadoControl.tsx @@ -0,0 +1,118 @@ +'use client'; + +import { useState, useTransition } from 'react'; +import { cambiarEstado, marcarGanado } from '@/app/panel/actions'; +import { ESTADOS, ESTADO_BADGE, ESTADO_LABEL, formatEuros } from '@/lib/funnel'; + +type Estado = (typeof ESTADOS)[number]; + +export default function EstadoControl({ + leadId, + estado, + presupuestoEstimado, +}: { + leadId: string; + estado: Estado; + presupuestoEstimado: number | null; +}) { + const [pending, startTransition] = useTransition(); + const [modalOpen, setModalOpen] = useState(false); + const [precio, setPrecio] = useState( + presupuestoEstimado != null ? String(Math.round(presupuestoEstimado / 100)) : '' + ); + const [error, setError] = useState(null); + + function onSelect(nuevo: Estado) { + if (nuevo === estado) return; + if (nuevo === 'ganado') { + setModalOpen(true); + return; + } + setError(null); + startTransition(async () => { + await cambiarEstado(leadId, nuevo); + }); + } + + function confirmarGanado() { + const euros = Number(precio); + if (!Number.isFinite(euros) || euros <= 0) { + setError('Introduce un precio final válido.'); + return; + } + setError(null); + startTransition(async () => { + await marcarGanado(leadId, euros); + setModalOpen(false); + }); + } + + return ( +
+
+ {ESTADOS.map((e) => { + const active = e === estado; + return ( + + ); + })} +
+ + {modalOpen && ( +
+
+
+

Marcar como Ganado

+

+ Introduce el precio final firmado. Calcularemos la desviación vs el presupuesto + estimado ({formatEuros(presupuestoEstimado)}). +

+
+ + {error &&

{error}

} +
+ + +
+
+
+ )} +
+ ); +} diff --git a/mvp/b2c/src/db/index.ts b/mvp/b2c/src/db/index.ts new file mode 100644 index 0000000..f32d4fa --- /dev/null +++ b/mvp/b2c/src/db/index.ts @@ -0,0 +1,29 @@ +import { drizzle, type PostgresJsDatabase } from 'drizzle-orm/postgres-js'; +import postgres from 'postgres'; +import * as schema from './schema'; + +// Cliente perezoso: solo se conecta en el primer acceso real a la DB. +// Evita que `next build` (que importa los módulos de ruta) falle si no hay +// DATABASE_URL en tiempo de build. +let _db: PostgresJsDatabase | null = null; + +function getDb(): PostgresJsDatabase { + if (_db) return _db; + const connectionString = process.env.DATABASE_URL; + if (!connectionString) { + throw new Error('DATABASE_URL no está definida. Copia .env.example a .env.local y rellénala.'); + } + const client = postgres(connectionString, { prepare: false }); + _db = drizzle(client, { schema }); + return _db; +} + +export const db = new Proxy({} as PostgresJsDatabase, { + get(_target, prop) { + const instance = getDb(); + const value = instance[prop as keyof typeof instance]; + return typeof value === 'function' ? value.bind(instance) : value; + }, +}); + +export { schema }; diff --git a/mvp/b2c/src/db/queries.ts b/mvp/b2c/src/db/queries.ts new file mode 100644 index 0000000..457da53 --- /dev/null +++ b/mvp/b2c/src/db/queries.ts @@ -0,0 +1,66 @@ +import { and, asc, desc, eq } from 'drizzle-orm'; +import { db } from './index'; +import { + leads, + leadFotos, + leadEstadoHistory, + leadPipelineEventos, + precisionHistory, + tenants, +} from './schema'; +import { TENANT_SLUG } from '@/lib/funnel'; + +async function getTenantId(): Promise { + const [tenant] = await db.select().from(tenants).where(eq(tenants.slug, TENANT_SLUG)).limit(1); + if (!tenant) throw new Error(`Tenant "${TENANT_SLUG}" no existe. ¿Has corrido npm run db:seed?`); + return tenant.id; +} + +export type LeadFiltro = (typeof leads.estado.enumValues)[number] | 'todos'; + +export async function getLeads(filtro: LeadFiltro = 'todos') { + const tenantId = await getTenantId(); + const where = + filtro === 'todos' + ? eq(leads.tenantId, tenantId) + : and(eq(leads.tenantId, tenantId), eq(leads.estado, filtro)); + + return db.select().from(leads).where(where).orderBy(desc(leads.createdAt)); +} + +export async function getLead(id: string) { + const tenantId = await getTenantId(); + const [lead] = await db + .select() + .from(leads) + .where(and(eq(leads.id, id), eq(leads.tenantId, tenantId))) + .limit(1); + + if (!lead) return null; + + const [fotos, eventos, historial, precision] = await Promise.all([ + db.select().from(leadFotos).where(eq(leadFotos.leadId, id)).orderBy(asc(leadFotos.orden)), + db + .select() + .from(leadPipelineEventos) + .where(eq(leadPipelineEventos.leadId, id)) + .orderBy(asc(leadPipelineEventos.occurredAt)), + db + .select() + .from(leadEstadoHistory) + .where(eq(leadEstadoHistory.leadId, id)) + .orderBy(asc(leadEstadoHistory.changedAt)), + db.select().from(precisionHistory).where(eq(precisionHistory.leadId, id)), + ]); + + return { lead, fotos, eventos, historial, precision: precision[0] ?? null }; +} + +export async function getResumen() { + const all = await getLeads('todos'); + const porEstado = all.reduce>((acc, l) => { + acc[l.estado] = (acc[l.estado] ?? 0) + 1; + return acc; + }, {}); + return { total: all.length, porEstado }; +} diff --git a/mvp/b2c/src/db/schema.ts b/mvp/b2c/src/db/schema.ts new file mode 100644 index 0000000..f9e026f --- /dev/null +++ b/mvp/b2c/src/db/schema.ts @@ -0,0 +1,153 @@ +import { + pgTable, + pgEnum, + uuid, + text, + integer, + boolean, + numeric, + timestamp, + jsonb, + index, +} from 'drizzle-orm/pg-core'; + +// Estado comercial del lead — RF-D-03. Lo que el reformista gestiona a mano. +export const leadEstado = pgEnum('lead_estado', [ + 'nuevo', + 'contactado', + 'visita_agendada', + 'presupuesto_enviado', + 'ganado', + 'perdido', +]); + +// Avance técnico en el funnel B2C (docs/funnel.md, superficie C). +// Cada valor = un momento del pipeline; el "siguiente paso" se deriva de aquí. +export const pipelineStage = pgEnum('pipeline_stage', [ + 'form_completado', // 2. dejó nombre+tel+email+opt-in + 'fotos_subidas', // 3. subió 2-4 fotos + 'prellamada_enviada', // 4. SMS + WhatsApp pre-llamada + 'llamada_completada', // 5. agente IA terminó la cualificación + 'render_generado', // 6. Nano Banana generó el render + 'presupuesto_generado', // 6. motor de presupuesto + PDF listos + 'whatsapp_entregado', // 7. entregado al cliente + lead caliente al panel +]); + +export const tipoReforma = pgEnum('tipo_reforma', [ + 'cocina', + 'bano', + 'salon', + 'comedor', + 'integral', + 'otro', +]); + +// Reformista (cliente del SaaS). MVP = "Reformas Ejemplo" hardcoded. +// Multi-tenant real es F1.5; la tabla ya queda lista para ello. +export const tenants = pgTable('tenants', { + id: uuid('id').primaryKey().defaultRandom(), + slug: text('slug').notNull().unique(), + nombreEmpresa: text('nombre_empresa').notNull(), + logoUrl: text('logo_url'), + provincia: text('provincia'), + whatsappBusiness: text('whatsapp_business'), + createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), +}); + +export const leads = pgTable( + 'leads', + { + id: uuid('id').primaryKey().defaultRandom(), + tenantId: uuid('tenant_id') + .notNull() + .references(() => tenants.id, { onDelete: 'cascade' }), + + createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(), + + // Datos del cliente final (paso 2 del funnel) + nombre: text('nombre').notNull(), + telefono: text('telefono').notNull(), + email: text('email').notNull(), + provincia: text('provincia'), + tipoReforma: tipoReforma('tipo_reforma'), + + // Consentimientos LSSI-CE + RGPD (RF-LEG-01) + consentPrivacidad: boolean('consent_privacidad').notNull().default(false), + consentContratacion: boolean('consent_contratacion').notNull().default(false), + + // Posición en el funnel y estado comercial + pipelineStage: pipelineStage('pipeline_stage').notNull().default('form_completado'), + estado: leadEstado('estado').notNull().default('nuevo'), + + // Presupuesto orientativo en céntimos de euro (evita floats) + presupuestoEstimado: integer('presupuesto_estimado'), + + // Artefactos generados por el pipeline (RF-D-02) + transcripcion: text('transcripcion'), + entidades: jsonb('entidades'), + renderUrl: text('render_url'), + pdfUrl: text('pdf_url'), + audioUrl: text('audio_url'), + + notas: text('notas'), + }, + (table) => [ + index('leads_tenant_created_idx').on(table.tenantId, table.createdAt), + index('leads_estado_idx').on(table.estado), + ] +); + +// Fotos subidas por el cliente (paso 3, 2-4 fotos) +export const leadFotos = pgTable('lead_fotos', { + id: uuid('id').primaryKey().defaultRandom(), + leadId: uuid('lead_id') + .notNull() + .references(() => leads.id, { onDelete: 'cascade' }), + url: text('url').notNull(), + orden: integer('orden').notNull().default(0), + createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), +}); + +// Histórico de cambios de estado comercial (RF-D-03: persistir y reflejar) +export const leadEstadoHistory = pgTable('lead_estado_history', { + id: uuid('id').primaryKey().defaultRandom(), + leadId: uuid('lead_id') + .notNull() + .references(() => leads.id, { onDelete: 'cascade' }), + estado: leadEstado('estado').notNull(), + changedAt: timestamp('changed_at', { withTimezone: true }).notNull().defaultNow(), + changedBy: text('changed_by'), +}); + +// Timeline del funnel: una fila por cada paso alcanzado. +// Permite ver dónde está el lead y cuál es el siguiente paso. +export const leadPipelineEventos = pgTable('lead_pipeline_eventos', { + id: uuid('id').primaryKey().defaultRandom(), + leadId: uuid('lead_id') + .notNull() + .references(() => leads.id, { onDelete: 'cascade' }), + stage: pipelineStage('stage').notNull(), + occurredAt: timestamp('occurred_at', { withTimezone: true }).notNull().defaultNow(), + metadata: jsonb('metadata'), +}); + +// Bucle de precisión (RF-D-04): precio final firmado vs estimado. +export const precisionHistory = pgTable('precision_history', { + id: uuid('id').primaryKey().defaultRandom(), + leadId: uuid('lead_id') + .notNull() + .references(() => leads.id, { onDelete: 'cascade' }), + estimated: integer('estimated').notNull(), // céntimos + final: integer('final').notNull(), // céntimos + deltaPct: numeric('delta_pct', { precision: 6, scale: 2 }).notNull(), + createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), +}); + +export type Tenant = typeof tenants.$inferSelect; +export type Lead = typeof leads.$inferSelect; +export type NewLead = typeof leads.$inferInsert; +export type LeadFoto = typeof leadFotos.$inferSelect; +export type LeadEstadoHistory = typeof leadEstadoHistory.$inferSelect; +export type LeadPipelineEvento = typeof leadPipelineEventos.$inferSelect; +export type PrecisionHistory = typeof precisionHistory.$inferSelect; diff --git a/mvp/b2c/src/db/seed.ts b/mvp/b2c/src/db/seed.ts new file mode 100644 index 0000000..6aff07b --- /dev/null +++ b/mvp/b2c/src/db/seed.ts @@ -0,0 +1,373 @@ +import 'dotenv/config'; +import { drizzle } from 'drizzle-orm/postgres-js'; +import postgres from 'postgres'; +import * as schema from './schema'; +import { eq, sql } from 'drizzle-orm'; + +const connectionString = process.env.DATABASE_URL; +if (!connectionString) { + throw new Error('DATABASE_URL no está definida.'); +} + +const client = postgres(connectionString, { prepare: false }); +const db = drizzle(client, { schema }); + +const euros = (n: number) => Math.round(n * 100); // a céntimos + +// Cada lead vive en un momento distinto del funnel para poder analizar +// cuál es el siguiente paso de cada uno. days = hace cuántos días entró. +type SeedLead = { + nombre: string; + telefono: string; + email: string; + provincia: string; + tipoReforma: (typeof schema.tipoReforma.enumValues)[number]; + pipelineStage: (typeof schema.pipelineStage.enumValues)[number]; + estado: (typeof schema.leadEstado.enumValues)[number]; + presupuestoEstimado: number | null; + transcripcion: string | null; + entidades: Record | null; + renderUrl: string | null; + pdfUrl: string | null; + audioUrl: string | null; + fotos: string[]; + daysAgo: number; + precioFinal?: number; // solo para ganados +}; + +const SEED_LEADS: SeedLead[] = [ + { + // 1. Acaba de dejar sus datos. Siguiente paso: subir fotos. + nombre: 'Lucía Fernández', + telefono: '+34 612 003 451', + email: 'lucia.fernandez@example.com', + provincia: 'Madrid', + tipoReforma: 'cocina', + pipelineStage: 'form_completado', + estado: 'nuevo', + presupuestoEstimado: null, + transcripcion: null, + entidades: null, + renderUrl: null, + pdfUrl: null, + audioUrl: null, + fotos: [], + daysAgo: 0, + }, + { + // 2. Subió fotos. Siguiente paso: pre-llamada SMS+WhatsApp. + nombre: 'Javier Ortega', + telefono: '+34 633 118 902', + email: 'javier.ortega@example.com', + provincia: 'Valencia', + tipoReforma: 'bano', + pipelineStage: 'fotos_subidas', + estado: 'nuevo', + presupuestoEstimado: null, + transcripcion: null, + entidades: null, + renderUrl: null, + pdfUrl: null, + audioUrl: null, + fotos: ['/antes-bano.webp'], + daysAgo: 0, + }, + { + // 3. Pre-llamada enviada, pendiente de descolgar. Siguiente: llamada agente. + nombre: 'Marta Ruiz', + telefono: '+34 655 740 213', + email: 'marta.ruiz@example.com', + provincia: 'Sevilla', + tipoReforma: 'comedor', + pipelineStage: 'prellamada_enviada', + estado: 'nuevo', + presupuestoEstimado: null, + transcripcion: null, + entidades: null, + renderUrl: null, + pdfUrl: null, + audioUrl: null, + fotos: ['/antes-comedor.webp'], + daysAgo: 1, + }, + { + // 4. Llamada completada. Siguiente: render IA. + nombre: 'Andrés Gil', + telefono: '+34 677 552 008', + email: 'andres.gil@example.com', + provincia: 'Málaga', + tipoReforma: 'cocina', + pipelineStage: 'llamada_completada', + estado: 'nuevo', + presupuestoEstimado: null, + transcripcion: + 'Agente: Hola Andrés, te llamo de Reformas Ejemplo. Te aviso de que soy un asistente con IA y de que la llamada se graba. ¿Quieres reformar la cocina entera? Andrés: Sí, son unos 12 metros, quiero quitar un tabique...', + entidades: { + espacio: 'cocina', + m2_aprox: 12, + tirar_tabique: true, + calidad: 'media', + licencia_urbanistica: 'posible', + }, + renderUrl: null, + pdfUrl: null, + audioUrl: '/demo/audio-andres.mp3', + fotos: ['/antes.webp'], + daysAgo: 1, + }, + { + // 5. Render generado, falta presupuesto+PDF. Siguiente: motor presupuesto. + nombre: 'Patricia Núñez', + telefono: '+34 688 410 776', + email: 'patricia.nunez@example.com', + provincia: 'Zaragoza', + tipoReforma: 'bano', + pipelineStage: 'render_generado', + estado: 'nuevo', + presupuestoEstimado: null, + transcripcion: + 'Agente: Hola Patricia... Patricia: Quiero cambiar la bañera por un plato de ducha y alicatar todo.', + entidades: { + espacio: 'bano', + m2_aprox: 6, + banera_por_ducha: true, + alicatado_completo: true, + calidad: 'media', + }, + renderUrl: '/despues-bano.webp', + pdfUrl: null, + audioUrl: '/demo/audio-patricia.mp3', + fotos: ['/antes-bano.webp'], + daysAgo: 2, + }, + { + // 6. Presupuesto + PDF listos, sin entregar aún. Siguiente: WhatsApp. + nombre: 'Roberto Salas', + telefono: '+34 699 320 145', + email: 'roberto.salas@example.com', + provincia: 'Barcelona', + tipoReforma: 'integral', + pipelineStage: 'presupuesto_generado', + estado: 'nuevo', + presupuestoEstimado: euros(28400), + transcripcion: + 'Agente: Hola Roberto... Roberto: Es un piso de 70 metros, lo quiero reformar entero, suelo, baño, cocina y pintura.', + entidades: { + espacio: 'integral', + m2_aprox: 70, + incluye: ['suelo', 'bano', 'cocina', 'pintura'], + calidad: 'media', + }, + renderUrl: '/despues.webp', + pdfUrl: '/demo/presupuesto-roberto.pdf', + audioUrl: '/demo/audio-roberto.mp3', + fotos: ['/antes.webp', '/antes-comedor.webp'], + daysAgo: 2, + }, + { + // 7. Entregado al cliente por WhatsApp. Lead caliente recién llegado al panel. + nombre: 'Elena Castro', + telefono: '+34 600 781 459', + email: 'elena.castro@example.com', + provincia: 'Madrid', + tipoReforma: 'cocina', + pipelineStage: 'whatsapp_entregado', + estado: 'nuevo', + presupuestoEstimado: euros(15600), + transcripcion: + 'Agente: Hola Elena... Elena: Quiero renovar la cocina, muebles nuevos y encimera de cuarzo.', + entidades: { + espacio: 'cocina', + m2_aprox: 10, + muebles_nuevos: true, + encimera: 'cuarzo', + calidad: 'media-alta', + }, + renderUrl: '/despues.webp', + pdfUrl: '/demo/presupuesto-elena.pdf', + audioUrl: '/demo/audio-elena.mp3', + fotos: ['/antes.webp'], + daysAgo: 3, + }, + { + // Entregado y ya contactado por el reformista. Siguiente: agendar visita. + nombre: 'Tomás Herrero', + telefono: '+34 611 902 334', + email: 'tomas.herrero@example.com', + provincia: 'Bilbao', + tipoReforma: 'bano', + pipelineStage: 'whatsapp_entregado', + estado: 'contactado', + presupuestoEstimado: euros(9800), + transcripcion: 'Agente: Hola Tomás... Tomás: Solo el baño, cambiar todo el alicatado y sanitarios.', + entidades: { espacio: 'bano', m2_aprox: 5, sanitarios_nuevos: true, calidad: 'media' }, + renderUrl: '/despues-bano.webp', + pdfUrl: '/demo/presupuesto-tomas.pdf', + audioUrl: '/demo/audio-tomas.mp3', + fotos: ['/antes-bano.webp'], + daysAgo: 5, + }, + { + // Visita agendada. Siguiente: hacer la visita y enviar presupuesto firmado. + nombre: 'Carmen Ibáñez', + telefono: '+34 622 145 870', + email: 'carmen.ibanez@example.com', + provincia: 'Madrid', + tipoReforma: 'comedor', + pipelineStage: 'whatsapp_entregado', + estado: 'visita_agendada', + presupuestoEstimado: euros(7200), + transcripcion: 'Agente: Hola Carmen... Carmen: Quiero abrir el comedor al salón y poner tarima.', + entidades: { espacio: 'comedor', m2_aprox: 20, tarima: true, calidad: 'media' }, + renderUrl: '/despues-comedor.webp', + pdfUrl: '/demo/presupuesto-carmen.pdf', + audioUrl: '/demo/audio-carmen.mp3', + fotos: ['/antes-comedor.webp'], + daysAgo: 8, + }, + { + // Ganado: precio final firmado, alimenta precision_history. RF-D-04. + nombre: 'Diego Romero', + telefono: '+34 633 770 218', + email: 'diego.romero@example.com', + provincia: 'Valencia', + tipoReforma: 'integral', + pipelineStage: 'whatsapp_entregado', + estado: 'ganado', + presupuestoEstimado: euros(31000), + transcripcion: 'Agente: Hola Diego... Diego: Reforma integral de un piso de 85 metros heredado.', + entidades: { espacio: 'integral', m2_aprox: 85, calidad: 'alta' }, + renderUrl: '/despues.webp', + pdfUrl: '/demo/presupuesto-diego.pdf', + audioUrl: '/demo/audio-diego.mp3', + fotos: ['/antes.webp'], + daysAgo: 25, + precioFinal: euros(33500), + }, + { + // Perdido: el cliente no siguió adelante. + nombre: 'Sara Blanco', + telefono: '+34 644 318 092', + email: 'sara.blanco@example.com', + provincia: 'Sevilla', + tipoReforma: 'cocina', + pipelineStage: 'whatsapp_entregado', + estado: 'perdido', + presupuestoEstimado: euros(14200), + transcripcion: 'Agente: Hola Sara... Sara: Quería una idea de precio para la cocina.', + entidades: { espacio: 'cocina', m2_aprox: 9, calidad: 'media' }, + renderUrl: '/despues.webp', + pdfUrl: '/demo/presupuesto-sara.pdf', + audioUrl: '/demo/audio-sara.mp3', + fotos: ['/antes.webp'], + daysAgo: 18, + }, +]; + +// Orden cronológico del funnel para reconstruir el timeline de cada lead. +const STAGE_ORDER = schema.pipelineStage.enumValues; + +async function main() { + const [existing] = await db + .select() + .from(schema.tenants) + .where(eq(schema.tenants.slug, 'reformas-ejemplo')) + .limit(1); + if (existing && !process.env.SEED_FORCE) { + console.log('Ya hay datos (tenant "reformas-ejemplo"). Saltando seed. Usa SEED_FORCE=1 para forzar.'); + await client.end(); + return; + } + + console.log('Limpiando datos previos...'); + await db.execute( + sql`TRUNCATE TABLE ${schema.precisionHistory}, ${schema.leadPipelineEventos}, ${schema.leadEstadoHistory}, ${schema.leadFotos}, ${schema.leads}, ${schema.tenants} RESTART IDENTITY CASCADE` + ); + + console.log('Creando tenant "Reformas Ejemplo"...'); + const [tenant] = await db + .insert(schema.tenants) + .values({ + slug: 'reformas-ejemplo', + nombreEmpresa: 'Reformas Ejemplo', + provincia: 'Madrid', + whatsappBusiness: '+34 600 000 000', + }) + .returning(); + + console.log(`Insertando ${SEED_LEADS.length} leads...`); + for (const l of SEED_LEADS) { + const createdAt = new Date(Date.now() - l.daysAgo * 24 * 60 * 60 * 1000); + + const [lead] = await db + .insert(schema.leads) + .values({ + tenantId: tenant.id, + createdAt, + updatedAt: createdAt, + nombre: l.nombre, + telefono: l.telefono, + email: l.email, + provincia: l.provincia, + tipoReforma: l.tipoReforma, + consentPrivacidad: true, + consentContratacion: true, + pipelineStage: l.pipelineStage, + estado: l.estado, + presupuestoEstimado: l.presupuestoEstimado, + transcripcion: l.transcripcion, + entidades: l.entidades, + renderUrl: l.renderUrl, + pdfUrl: l.pdfUrl, + audioUrl: l.audioUrl, + }) + .returning(); + + // Fotos + if (l.fotos.length) { + await db.insert(schema.leadFotos).values( + l.fotos.map((url, orden) => ({ leadId: lead.id, url, orden })) + ); + } + + // Timeline del funnel: un evento por cada paso alcanzado hasta el actual. + const reached = STAGE_ORDER.slice(0, STAGE_ORDER.indexOf(l.pipelineStage) + 1); + await db.insert(schema.leadPipelineEventos).values( + reached.map((stage, i) => ({ + leadId: lead.id, + stage, + occurredAt: new Date(createdAt.getTime() + i * 5 * 60 * 1000), + })) + ); + + // Histórico de estado (siempre nace en 'nuevo') + const estados: (typeof schema.leadEstado.enumValues)[number][] = + l.estado === 'nuevo' ? ['nuevo'] : ['nuevo', l.estado]; + await db.insert(schema.leadEstadoHistory).values( + estados.map((estado, i) => ({ + leadId: lead.id, + estado, + changedAt: new Date(createdAt.getTime() + i * 60 * 60 * 1000), + })) + ); + + // Precisión para ganados (RF-D-04) + if (l.estado === 'ganado' && l.precioFinal && l.presupuestoEstimado) { + const deltaPct = ((l.precioFinal - l.presupuestoEstimado) / l.presupuestoEstimado) * 100; + await db.insert(schema.precisionHistory).values({ + leadId: lead.id, + estimated: l.presupuestoEstimado, + final: l.precioFinal, + deltaPct: deltaPct.toFixed(2), + }); + } + } + + console.log('Seed completado.'); + await client.end(); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/mvp/b2c/src/lib/funnel.ts b/mvp/b2c/src/lib/funnel.ts new file mode 100644 index 0000000..6f1d14b --- /dev/null +++ b/mvp/b2c/src/lib/funnel.ts @@ -0,0 +1,93 @@ +import type { leadEstado, pipelineStage, tipoReforma } from '@/db/schema'; + +export const TENANT_SLUG = 'reformas-ejemplo'; + +type Estado = (typeof leadEstado.enumValues)[number]; +type Stage = (typeof pipelineStage.enumValues)[number]; +type Tipo = (typeof tipoReforma.enumValues)[number]; + +export const ESTADOS: Estado[] = [ + 'nuevo', + 'contactado', + 'visita_agendada', + 'presupuesto_enviado', + 'ganado', + 'perdido', +]; + +export const ESTADO_LABEL: Record = { + nuevo: 'Nuevo', + contactado: 'Contactado', + visita_agendada: 'Visita agendada', + presupuesto_enviado: 'Presupuesto enviado', + ganado: 'Ganado', + perdido: 'Perdido', +}; + +// Clases Tailwind para el badge de cada estado (fondo + texto). +export const ESTADO_BADGE: Record = { + nuevo: 'bg-blue-100 text-blue-700', + contactado: 'bg-amber-100 text-amber-700', + visita_agendada: 'bg-violet-100 text-violet-700', + presupuesto_enviado: 'bg-cyan-100 text-cyan-700', + ganado: 'bg-green-100 text-green-700', + perdido: 'bg-gray-200 text-gray-600', +}; + +export const PIPELINE_ORDER: Stage[] = [ + 'form_completado', + 'fotos_subidas', + 'prellamada_enviada', + 'llamada_completada', + 'render_generado', + 'presupuesto_generado', + 'whatsapp_entregado', +]; + +export const PIPELINE_LABEL: Record = { + form_completado: 'Datos recibidos', + fotos_subidas: 'Fotos subidas', + prellamada_enviada: 'Pre-llamada enviada', + llamada_completada: 'Llamada completada', + render_generado: 'Render generado', + presupuesto_generado: 'Presupuesto generado', + whatsapp_entregado: 'Entregado por WhatsApp', +}; + +// Qué falta para que el lead avance. Permite analizar el siguiente paso. +export const PIPELINE_NEXT: Record = { + form_completado: 'Esperando que suba fotos', + fotos_subidas: 'Lanzar pre-llamada (SMS + WhatsApp)', + prellamada_enviada: 'Llamada del agente IA', + llamada_completada: 'Generar render IA', + render_generado: 'Calcular presupuesto + PDF', + presupuesto_generado: 'Entregar al cliente por WhatsApp', + whatsapp_entregado: 'Lead listo: contactar al cliente', +}; + +export const TIPO_LABEL: Record = { + cocina: 'Cocina', + bano: 'Baño', + salon: 'Salón', + comedor: 'Comedor', + integral: 'Reforma integral', + otro: 'Otro', +}; + +export function formatEuros(cents: number | null): string { + if (cents == null) return '—'; + return new Intl.NumberFormat('es-ES', { + style: 'currency', + currency: 'EUR', + maximumFractionDigits: 0, + }).format(cents / 100); +} + +export function formatFecha(date: Date): string { + return new Intl.DateTimeFormat('es-ES', { + day: '2-digit', + month: 'short', + hour: '2-digit', + minute: '2-digit', + }).format(date); +}