feat: budget wizard with Gemini AI estimation
This commit is contained in:
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>
|
||||
<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
1030
src/pages/presupuesto.astro
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user