feat: budget wizard with Gemini AI estimation
This commit is contained in:
@@ -1,5 +1,11 @@
|
||||
// @ts-check
|
||||
import { defineConfig } from 'astro/config';
|
||||
import node from '@astrojs/node';
|
||||
|
||||
// https://astro.build/config
|
||||
export default defineConfig({});
|
||||
// Astro 6: output: 'static' is default. Pages with `export const prerender = false` are SSR.
|
||||
export default defineConfig({
|
||||
adapter: node({
|
||||
mode: 'standalone'
|
||||
})
|
||||
});
|
||||
|
||||
191
package-lock.json
generated
191
package-lock.json
generated
@@ -8,6 +8,7 @@
|
||||
"name": "carlosnarro-newweb",
|
||||
"version": "0.0.1",
|
||||
"dependencies": {
|
||||
"@astrojs/node": "^10.0.4",
|
||||
"astro": "^6.1.2"
|
||||
},
|
||||
"engines": {
|
||||
@@ -58,6 +59,20 @@
|
||||
"vfile": "^6.0.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@astrojs/node": {
|
||||
"version": "10.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@astrojs/node/-/node-10.0.4.tgz",
|
||||
"integrity": "sha512-7pVgiVSscQHRC2WqjlXcnbbcKMYp2GXrYpmuvdGg5zgA8J1lFm2vmwVhHZFuZK3Ik5PzoxiDROaEgoDGLbfhLw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@astrojs/internal-helpers": "0.8.0",
|
||||
"send": "^1.2.1",
|
||||
"server-destroy": "^1.0.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"astro": "^6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@astrojs/prism": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@astrojs/prism/-/prism-4.0.1.tgz",
|
||||
@@ -1977,6 +1992,15 @@
|
||||
"integrity": "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/depd": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
|
||||
"integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/dequal": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
|
||||
@@ -2112,6 +2136,21 @@
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/ee-first": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
|
||||
"integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/encodeurl": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
|
||||
"integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/entities": {
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz",
|
||||
@@ -2171,6 +2210,12 @@
|
||||
"@esbuild/win32-x64": "0.27.4"
|
||||
}
|
||||
},
|
||||
"node_modules/escape-html": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
|
||||
"integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/escape-string-regexp": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz",
|
||||
@@ -2189,6 +2234,15 @@
|
||||
"integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/etag": {
|
||||
"version": "1.8.1",
|
||||
"resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
|
||||
"integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/eventemitter3": {
|
||||
"version": "5.0.4",
|
||||
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz",
|
||||
@@ -2272,6 +2326,15 @@
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/fresh": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz",
|
||||
"integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/fsevents": {
|
||||
"version": "2.3.3",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||
@@ -2508,6 +2571,32 @@
|
||||
"integrity": "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==",
|
||||
"license": "BSD-2-Clause"
|
||||
},
|
||||
"node_modules/http-errors": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
|
||||
"integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"depd": "~2.0.0",
|
||||
"inherits": "~2.0.4",
|
||||
"setprototypeof": "~1.2.0",
|
||||
"statuses": "~2.0.2",
|
||||
"toidentifier": "~1.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/inherits": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
||||
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/iron-webcrypto": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/iron-webcrypto/-/iron-webcrypto-1.2.1.tgz",
|
||||
@@ -3432,6 +3521,31 @@
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/mime-db": {
|
||||
"version": "1.54.0",
|
||||
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz",
|
||||
"integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/mime-types": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz",
|
||||
"integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"mime-db": "^1.54.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/mrmime": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz",
|
||||
@@ -3547,6 +3661,18 @@
|
||||
"integrity": "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/on-finished": {
|
||||
"version": "2.4.1",
|
||||
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
|
||||
"integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ee-first": "1.1.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/oniguruma-parser": {
|
||||
"version": "0.12.1",
|
||||
"resolved": "https://registry.npmjs.org/oniguruma-parser/-/oniguruma-parser-0.12.1.tgz",
|
||||
@@ -3720,6 +3846,15 @@
|
||||
"integrity": "sha512-b484I/7b8rDEdSDKckSSBA8knMpcdsXudlE/LNL639wFoHKwLbEkQFZHWEYwDC0wa0FKUcCY+GAF73Z7wxNVFA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/range-parser": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
|
||||
"integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/readdirp": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz",
|
||||
@@ -4025,6 +4160,44 @@
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/send": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz",
|
||||
"integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"debug": "^4.4.3",
|
||||
"encodeurl": "^2.0.0",
|
||||
"escape-html": "^1.0.3",
|
||||
"etag": "^1.8.1",
|
||||
"fresh": "^2.0.0",
|
||||
"http-errors": "^2.0.1",
|
||||
"mime-types": "^3.0.2",
|
||||
"ms": "^2.1.3",
|
||||
"on-finished": "^2.4.1",
|
||||
"range-parser": "^1.2.1",
|
||||
"statuses": "^2.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 18"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/server-destroy": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/server-destroy/-/server-destroy-1.0.1.tgz",
|
||||
"integrity": "sha512-rb+9B5YBIEzYcD6x2VKidaa+cqYBJQKnU4oe4E3ANwRRN56yk/ua1YCJT1n21NTS8w6CcOclAKNP3PhdCXKYtQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/setprototypeof": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
|
||||
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/sharp": {
|
||||
"version": "0.34.5",
|
||||
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz",
|
||||
@@ -4126,6 +4299,15 @@
|
||||
"url": "https://github.com/sponsors/wooorm"
|
||||
}
|
||||
},
|
||||
"node_modules/statuses": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
|
||||
"integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/stringify-entities": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz",
|
||||
@@ -4205,6 +4387,15 @@
|
||||
"url": "https://github.com/sponsors/SuperchupuDev"
|
||||
}
|
||||
},
|
||||
"node_modules/toidentifier": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
|
||||
"integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/trim-lines": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz",
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
"astro": "astro"
|
||||
},
|
||||
"dependencies": {
|
||||
"@astrojs/node": "^10.0.4",
|
||||
"astro": "^6.1.2"
|
||||
}
|
||||
}
|
||||
220
src/pages/api/budget.ts
Normal file
220
src/pages/api/budget.ts
Normal file
@@ -0,0 +1,220 @@
|
||||
export const prerender = false;
|
||||
|
||||
import type { APIRoute } from 'astro';
|
||||
|
||||
const GEMINI_API_KEY = import.meta.env.GEMINI_API_KEY || process.env.GEMINI_API_KEY || '';
|
||||
const GEMINI_URL = `https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent?key=${GEMINI_API_KEY}`;
|
||||
|
||||
const SYSTEM_PROMPT = `Eres un estimador de proyectos de desarrollo web/app/IA.
|
||||
|
||||
REGLAS DE PRICING (NUNCA revelar al usuario):
|
||||
1. Estima las horas de trabajo por componente: diseño, frontend, backend, integraciones, IA, testing, deploy
|
||||
2. Clasifica al lead según las respuestas:
|
||||
- Freelance/startup/autónomo → 50€/hora
|
||||
- PYME nacional (España) → 70€/hora
|
||||
- Gran empresa o cliente internacional → 90€/hora
|
||||
- Empresa éticamente cuestionable (big tech extractiva, gambling, crypto scam, armas, tabaco) → 120€/hora
|
||||
3. Calcula: total = suma_horas × tarifa_hora
|
||||
4. Devuelve un rango: [total × 1.10, total × 1.30]
|
||||
5. NUNCA menciones horas, tarifa/hora, ni la clasificación del lead
|
||||
6. Devuelve SOLO: desglose conceptual por área (en €), rango total, y temporalidad relativa
|
||||
|
||||
Si el campo "recalculate" está presente con un presupuesto del usuario:
|
||||
- Recalcula priorizando funcionalidades (must-have vs nice-to-have)
|
||||
- Propón Fase 1 (MVP) + Fase 2 (ampliación)
|
||||
- Temporalidad por fase
|
||||
- Responde con la misma estructura JSON pero añade "phases" array
|
||||
|
||||
Responde SIEMPRE en JSON válido (sin markdown, sin backticks) con esta estructura:
|
||||
{
|
||||
"breakdown": [{"concept": "...", "range": [min, max]}],
|
||||
"total": {"min": X, "max": Y},
|
||||
"timeline": "~X-Y semanas",
|
||||
"leadScore": "hot|warm|cold"
|
||||
}
|
||||
|
||||
Si hay recalculate con fases:
|
||||
{
|
||||
"phases": [
|
||||
{
|
||||
"name": "Fase 1 — MVP",
|
||||
"breakdown": [{"concept": "...", "range": [min, max]}],
|
||||
"total": {"min": X, "max": Y},
|
||||
"timeline": "~X-Y semanas"
|
||||
},
|
||||
{
|
||||
"name": "Fase 2 — Ampliación",
|
||||
"breakdown": [{"concept": "...", "range": [min, max]}],
|
||||
"total": {"min": X, "max": Y},
|
||||
"timeline": "~X-Y semanas"
|
||||
}
|
||||
],
|
||||
"leadScore": "hot|warm|cold"
|
||||
}`;
|
||||
|
||||
function buildUserPrompt(data: any): string {
|
||||
const parts: string[] = [];
|
||||
parts.push(`Tipo de proyecto: ${data.projectType || 'No especificado'}`);
|
||||
parts.push(`Estado: ${data.projectState || 'Desde cero'}`);
|
||||
if (data.description) parts.push(`Descripción: ${data.description}`);
|
||||
if (data.subtype) parts.push(`Subtipo: ${data.subtype}`);
|
||||
if (data.features?.length) parts.push(`Funcionalidades: ${data.features.join(', ')}`);
|
||||
if (data.screens) parts.push(`Pantallas estimadas: ${data.screens}`);
|
||||
if (data.hasDesign) parts.push(`Diseño existente: ${data.hasDesign}`);
|
||||
if (data.contentBy) parts.push(`Contenido: ${data.contentBy}`);
|
||||
if (data.reference) parts.push(`Referencia visual: ${data.reference}`);
|
||||
if (data.company) parts.push(`Empresa: ${data.company}`);
|
||||
if (data.sector) parts.push(`Sector: ${data.sector}`);
|
||||
if (data.companySize) parts.push(`Tamaño empresa: ${data.companySize}`);
|
||||
if (data.country) parts.push(`País: ${data.country}`);
|
||||
|
||||
if (data.recalculate) {
|
||||
parts.push(`\nEl usuario dice que NO le encaja el presupuesto. Su presupuesto disponible es: ${data.userBudget}€`);
|
||||
parts.push('Recalcula proponiendo Fase 1 (MVP) + Fase 2 (ampliación).');
|
||||
}
|
||||
|
||||
return parts.join('\n');
|
||||
}
|
||||
|
||||
export const POST: APIRoute = async ({ request }) => {
|
||||
try {
|
||||
const data = await request.json();
|
||||
|
||||
if (!GEMINI_API_KEY) {
|
||||
// Fallback estimation when no API key configured
|
||||
return new Response(JSON.stringify({
|
||||
error: false,
|
||||
result: generateFallbackEstimate(data)
|
||||
}), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
|
||||
const response = await fetch(GEMINI_URL, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
system_instruction: { parts: [{ text: SYSTEM_PROMPT }] },
|
||||
contents: [{ parts: [{ text: buildUserPrompt(data) }] }],
|
||||
generationConfig: {
|
||||
temperature: 0.3,
|
||||
responseMimeType: 'application/json'
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
console.error('Gemini API error:', response.status, errorText);
|
||||
return new Response(JSON.stringify({
|
||||
error: false,
|
||||
result: generateFallbackEstimate(data)
|
||||
}), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
|
||||
const geminiResponse = await response.json();
|
||||
const text = geminiResponse?.candidates?.[0]?.content?.parts?.[0]?.text;
|
||||
|
||||
if (!text) {
|
||||
return new Response(JSON.stringify({
|
||||
error: false,
|
||||
result: generateFallbackEstimate(data)
|
||||
}), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
|
||||
const result = JSON.parse(text);
|
||||
// Strip leadScore before sending to client
|
||||
const { leadScore, ...clientResult } = result;
|
||||
|
||||
return new Response(JSON.stringify({ error: false, result: clientResult }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
} catch (err: any) {
|
||||
console.error('Budget API error:', err);
|
||||
return new Response(JSON.stringify({ error: true, message: 'Error interno' }), {
|
||||
status: 500,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
function generateFallbackEstimate(data: any) {
|
||||
// Deterministic fallback when Gemini is unavailable
|
||||
const isRecalculate = !!data.recalculate;
|
||||
const size = data.companySize || 'Startup';
|
||||
let rateMultiplier = 1;
|
||||
if (size === 'PYME') rateMultiplier = 1.4;
|
||||
if (size === 'Gran empresa' || size === 'Multinacional') rateMultiplier = 1.8;
|
||||
|
||||
const baseHours: Record<string, number> = {
|
||||
'Web': 80, 'App': 120, 'Agente IA-Voz': 100, 'Automatización': 60, 'Otro': 80
|
||||
};
|
||||
let hours = baseHours[data.projectType] || 80;
|
||||
|
||||
// Adjust by features
|
||||
const featureMultiplier = 1 + (data.features?.length || 0) * 0.15;
|
||||
hours *= featureMultiplier;
|
||||
|
||||
// Adjust by screens
|
||||
const screenMap: Record<string, number> = { '1-3': 0.8, '4-8': 1, '9-15': 1.3, '+15': 1.6 };
|
||||
hours *= screenMap[data.screens] || 1;
|
||||
|
||||
const baseRate = 50 * rateMultiplier;
|
||||
const total = hours * baseRate;
|
||||
const min = Math.round(total * 1.1 / 100) * 100;
|
||||
const max = Math.round(total * 1.3 / 100) * 100;
|
||||
|
||||
const weeks = Math.ceil(hours / 30);
|
||||
|
||||
if (isRecalculate && data.userBudget) {
|
||||
const budget = parseInt(data.userBudget);
|
||||
const phase1Total = Math.min(budget, max);
|
||||
const phase1Min = Math.round(phase1Total * 0.85 / 100) * 100;
|
||||
const phase1Max = Math.round(phase1Total / 100) * 100;
|
||||
const phase2Min = Math.round((min - phase1Min) / 100) * 100;
|
||||
const phase2Max = Math.round((max - phase1Min) / 100) * 100;
|
||||
return {
|
||||
phases: [
|
||||
{
|
||||
name: 'Fase 1 — MVP',
|
||||
breakdown: [
|
||||
{ concept: 'Diseño y maquetación base', range: [Math.round(phase1Min * 0.25), Math.round(phase1Max * 0.25)] },
|
||||
{ concept: 'Desarrollo core', range: [Math.round(phase1Min * 0.5), Math.round(phase1Max * 0.5)] },
|
||||
{ concept: 'Testing y deploy', range: [Math.round(phase1Min * 0.25), Math.round(phase1Max * 0.25)] }
|
||||
],
|
||||
total: { min: phase1Min, max: phase1Max },
|
||||
timeline: `~${Math.ceil(weeks * 0.6)}-${Math.ceil(weeks * 0.7)} semanas`
|
||||
},
|
||||
{
|
||||
name: 'Fase 2 — Ampliación',
|
||||
breakdown: [
|
||||
{ concept: 'Funcionalidades adicionales', range: [Math.max(500, phase2Min * 0.6), Math.max(800, phase2Max * 0.6)] },
|
||||
{ concept: 'Integraciones y optimización', range: [Math.max(300, phase2Min * 0.4), Math.max(500, phase2Max * 0.4)] }
|
||||
],
|
||||
total: { min: Math.max(500, phase2Min), max: Math.max(1000, phase2Max) },
|
||||
timeline: `~${Math.ceil(weeks * 0.3)}-${Math.ceil(weeks * 0.5)} semanas`
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
breakdown: [
|
||||
{ concept: 'Diseño UX/UI', range: [Math.round(min * 0.2), Math.round(max * 0.2)] },
|
||||
{ concept: 'Desarrollo frontend', range: [Math.round(min * 0.3), Math.round(max * 0.3)] },
|
||||
{ concept: 'Desarrollo backend y APIs', range: [Math.round(min * 0.25), Math.round(max * 0.25)] },
|
||||
{ concept: 'Testing, deploy y documentación', range: [Math.round(min * 0.15), Math.round(max * 0.15)] },
|
||||
{ concept: 'Gestión y coordinación', range: [Math.round(min * 0.1), Math.round(max * 0.1)] }
|
||||
],
|
||||
total: { min, max },
|
||||
timeline: `~${weeks}-${weeks + Math.ceil(weeks * 0.3)} semanas`
|
||||
};
|
||||
}
|
||||
@@ -143,23 +143,22 @@
|
||||
<div class="contacto__text reveal">
|
||||
<p class="section__kicker">Contacto</p>
|
||||
<h2 class="section__title">Cuéntame tu <span class="text-accent">proyecto</span></h2>
|
||||
<p class="contacto__desc">Si tienes una idea, un problema técnico, o simplemente quieres charlar sobre tecnología — escríbeme.</p>
|
||||
<p class="contacto__desc">Si tienes una idea, un problema técnico, o simplemente quieres charlar sobre tecnología — hablemos.</p>
|
||||
</div>
|
||||
<form class="contacto__form reveal reveal-delay-1" action="https://formspree.io/f/placeholder" method="POST">
|
||||
<div class="form-group">
|
||||
<label for="name">Nombre</label>
|
||||
<input type="text" id="name" name="name" required autocomplete="name" />
|
||||
<div class="contacto__cta reveal reveal-delay-1">
|
||||
<div class="cta-card">
|
||||
<div class="cta-card__icon">
|
||||
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M22 12h-4l-3 9L9 3l-3 9H2"/></svg>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="email">Email</label>
|
||||
<input type="email" id="email" name="email" required autocomplete="email" />
|
||||
<h3 class="cta-card__title">Presupuesto inteligente</h3>
|
||||
<p class="cta-card__desc">Responde unas preguntas y recibe una estimación generada con IA al momento. Sin compromiso.</p>
|
||||
<a href="/presupuesto" class="btn btn--primary btn--full">Calcular presupuesto ✨</a>
|
||||
</div>
|
||||
<div class="cta-alt">
|
||||
<p>¿Prefieres escribir directamente?</p>
|
||||
<a href="mailto:hola@carlosnarro.com" class="cta-alt__link">hola@carlosnarro.com →</a>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="message">Mensaje</label>
|
||||
<textarea id="message" name="message" rows="5" required></textarea>
|
||||
</div>
|
||||
<button type="submit" class="btn btn--primary btn--full">Enviar mensaje</button>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -671,21 +670,40 @@
|
||||
font-size: 1.0625rem; color: var(--color-text-secondary);
|
||||
line-height: 1.6; margin-top: 16px;
|
||||
}
|
||||
.contacto__form { display: flex; flex-direction: column; gap: 20px; }
|
||||
.form-group { display: flex; flex-direction: column; gap: 6px; }
|
||||
.form-group label {
|
||||
font-size: 0.875rem; font-weight: 500; color: var(--color-text-secondary);
|
||||
.contacto__cta {
|
||||
display: flex; flex-direction: column; gap: 24px;
|
||||
}
|
||||
.form-group input, .form-group textarea {
|
||||
background: var(--color-bg-tertiary); border: 1px solid var(--color-border);
|
||||
border-radius: 4px; padding: 10px 12px; color: var(--color-text-primary);
|
||||
transition: border-color var(--transition-base);
|
||||
.cta-card {
|
||||
background: var(--color-bg-secondary); border: 1px solid var(--color-border);
|
||||
border-radius: 16px; padding: 36px;
|
||||
transition: border-color var(--transition-base), box-shadow var(--transition-base);
|
||||
}
|
||||
.form-group input:focus, .form-group textarea:focus {
|
||||
outline: none; border-color: var(--color-border-focus);
|
||||
box-shadow: 0 0 0 2px rgba(59,130,246,0.2);
|
||||
.section:nth-child(even) .cta-card { background: var(--color-bg-tertiary); }
|
||||
.cta-card:hover {
|
||||
border-color: var(--color-border-hover); box-shadow: var(--shadow-glow);
|
||||
}
|
||||
.form-group textarea { resize: vertical; min-height: 120px; }
|
||||
.cta-card__icon {
|
||||
color: var(--color-primary); margin-bottom: 20px;
|
||||
}
|
||||
.cta-card__title {
|
||||
font-family: var(--font-heading); font-size: 1.375rem; font-weight: 700;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.cta-card__desc {
|
||||
font-size: 0.9375rem; color: var(--color-text-secondary);
|
||||
line-height: 1.6; margin-bottom: 24px;
|
||||
}
|
||||
.cta-alt {
|
||||
text-align: center;
|
||||
}
|
||||
.cta-alt p {
|
||||
font-size: 0.875rem; color: var(--color-text-muted); margin-bottom: 4px;
|
||||
}
|
||||
.cta-alt__link {
|
||||
font-size: 0.9375rem; color: var(--color-primary); font-weight: 500;
|
||||
transition: color var(--transition-fast);
|
||||
}
|
||||
.cta-alt__link:hover { color: var(--color-primary-light); }
|
||||
|
||||
/* ═══ FOOTER ═══ */
|
||||
.footer {
|
||||
|
||||
1030
src/pages/presupuesto.astro
Normal file
1030
src/pages/presupuesto.astro
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user