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

View File

@@ -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
View File

@@ -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",

View File

@@ -12,6 +12,7 @@
"astro": "astro"
},
"dependencies": {
"@astrojs/node": "^10.0.4",
"astro": "^6.1.2"
}
}

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