feat: budget wizard with Gemini AI estimation

This commit is contained in:
Mambo
2026-04-02 18:22:59 +02:00
parent e24100e3a9
commit 4165ac5578
6 changed files with 1494 additions and 28 deletions

220
src/pages/api/budget.ts Normal file
View 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`
};
}

View File

@@ -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>
<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="form-group">
<label for="email">Email</label>
<input type="email" id="email" name="email" required autocomplete="email" />
<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>
</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

File diff suppressed because it is too large Load Diff