diff --git a/apps/web/docs/MAINTENANCE.md b/apps/web/docs/MAINTENANCE.md new file mode 100644 index 0000000..cbc2875 --- /dev/null +++ b/apps/web/docs/MAINTENANCE.md @@ -0,0 +1,35 @@ +# Sorglos-Betrieb – Damit Sie sich auf Ihr Geschäft konzentrieren können + +Eine Website ist ein lebendiger Teil Ihres Unternehmens. Sie soll funktionieren, sicher sein und gut aussehen – ohne dass Sie sich ständig darum kümmern müssen. + +Genau dafür gibt es meinen Sorglos-Betrieb. + +Das ist keine automatische Server-Überwachung und kein reiner Wartungsvertrag. Es ist meine persönliche Verantwortung, Ihre Website so stabil und aktuell wie möglich zu halten. Ich schaue regelmäßig vorbei, behebe Probleme frühzeitig und reagiere schnell, wenn doch einmal etwas nicht rund läuft. + +## Was Sie erwarten können: + +### Ich kümmere mich proaktiv + +Regelmäßige Checks, Updates von Systemen und Plugins, Sicherheitsüberprüfungen – alles, was typische Ausfälle und Schwachstellen verhindert oder minimiert. + +### Schnelle Reaktion bei Problemen + +Websites können mal down gehen – durch Hoster-Störungen, plötzliche Updates von Drittanbietern oder andere unvorhergesehene Dinge. In solchen Fällen bin ich für Sie da: Ich analysiere, behebe und informiere Sie transparent. Sie müssen nicht selbst recherchieren oder panisch Support-Tickets schreiben. + +### Sicherheit und Aktualität bleiben im Blick + +Ich halte die bestehende Technik auf dem neuesten Stand, korrigiere kleine Fehler und passe Inhalte an, wenn nötig – alles innerhalb des vereinbarten Rahmens. So bleibt Ihre Website vertrauenswürdig und nutzerfreundlich. + +### Klare Grenzen – faire Erwartungen + +Der Sorglos-Betrieb deckt die Instandhaltung und Pflege der bestehenden Website ab: Technischer Betrieb, Sicherheit, kleine Korrekturen und Aktualisierungen. +Neue Inhalte erstellen, große Umstrukturierungen, neue Features oder umfangreiche Redaktionsarbeit gehören nicht dazu – das besprechen und bepreisen wir separat und transparent. + +### Kurz gesagt: + +Ich nehme Ihnen so viel wie möglich vom technischen Alltag ab, damit Sie sich auf das konzentrieren können, was Sie am besten können – Ihr Geschäft führen. +Ich kann nicht jede Störung im Voraus verhindern (das kann niemand), aber ich sorge dafür, dass solche Momente selten bleiben und schnell wieder behoben sind. + +### Sorglos-Betrieb bedeutet für mich: + +Ich kümmere mich – verlässlich, ehrlich und mit dem gleichen Anspruch, mit dem ich Ihre Website gebaut habe. diff --git a/apps/web/docs/STANDARDS.md b/apps/web/docs/STANDARDS.md new file mode 100644 index 0000000..88fb4c6 --- /dev/null +++ b/apps/web/docs/STANDARDS.md @@ -0,0 +1,36 @@ +# Meine Standards + +Ich entwickle Websites nach Prinzipien, die über das reine Funktionieren hinausgehen. +Das Ziel ist eine Seite, die nicht nur heute überzeugt, sondern in den nächsten Jahren stabil, kosteneffizient, sicher und verantwortungsvoll bleibt – ohne dass Sie ständig nachbessern, abmahnen lassen oder sich für unnötigen Ressourcenverbrauch rechtfertigen müssen. + +## Was das für Sie konkret bedeutet: + +### Deutlich geringerer Energie- und CO₂-Verbrauch + +Durch konsequente Optimierung von Code, Bildern, Schriften und Ladeverhalten entsteht eine schlanke Website. +→ Ihre Besucher laden die Seite spürbar schneller, Ihre Hosting-Kosten bleiben niedrig, und der CO₂-Fußabdruck pro Aufruf liegt oft um 70–90 % unter dem Durchschnitt vergleichbarer Projekte. Das ist heute für viele Unternehmen ein echter Wettbewerbs- und Imagevorteil – ohne dass Sie Kompromisse bei Design oder Funktionalität eingehen müssen. + +### Technologische Souveränität & Unabhängigkeit + +Keine Abhängigkeit von Big Tech, geschlossenen Baukasten-Systemen oder undurchsichtigen Cloud-Plattformen. Wir setzen konsequent auf Self-Hosting und Open-Source-Kerntechnologien. +→ Alles, was für Ihre Website entwickelt wird, gehört Ihnen. Der Code ist custom-coded, die Infrastruktur ist unabhängig. Wenn ein großer Anbieter seine Preise erhöht, Bedingungen ändert oder Dienste einstellt, bleibt Ihre Website davon unberührt. Sie behalten die volle Kontrolle über Ihre digitale Identität – dauerhaft und ohne "Lock-in"-Effekte. + +### Vertrauen bei Ihren Besuchern + +Kein Cookie-Banner, kein heimliches Tracking, keine versteckten Datenabfragen. +→ Viele Menschen erkennen intuitiv, dass hier mitgedacht wurde. Das schafft ein deutlich besseres Gefühl – besonders bei Kunden, die selbst Wert auf Datenschutz legen, in regulierten Branchen tätig sind oder einfach ein seriöses Unternehmen erwarten. + +### Sicherheit von Grund auf eingebaut + +Sichere Formulare, Schutz vor typischen Angriffsvektoren, keine veralteten oder riskanten Bibliotheken, lokale Ressourcen wo immer möglich. +→ Die Website wird nicht schon nach wenigen Monaten zum Sicherheitsrisiko. Sie sparen sich spätere teure Sicherheits-Updates, Penetrationstests oder im Worst Case den Umgang mit einem Datenleck. Mehr Ruhe und weniger unvorhergesehene Kosten. + +### DSGVO-Konformität ohne Grauzonen oder Tricks + +Es wird nur verarbeitet, was technisch unbedingt erforderlich ist und ohne aktive Einwilligung erlaubt bleibt. +→ Sie können das Thema Datenschutz mit gutem Gewissen abhaken. Keine Abmahn-Risiken, keine nervösen Kundenanfragen, keine teuren Nachbesserungen. + +### Langfristig wartungsarm und zukunftssicher + +Weil Abhängigkeiten minimiert und bewährte, schlanke Techniken bevorzugt werden, altert die Website deutlich langsamer. +→ Sie zahlen weniger für regelmäßige Updates, haben seltener böse Überraschungen und können Ihr Budget gezielt in Inhalte, Marketing oder neue Funktionen investieren – statt in Notfall-Reparaturen. diff --git a/apps/web/scripts/ai-estimate.ts b/apps/web/scripts/ai-estimate.ts index f2ff233..3ef0452 100644 --- a/apps/web/scripts/ai-estimate.ts +++ b/apps/web/scripts/ai-estimate.ts @@ -1,182 +1,233 @@ -import { CheerioCrawler, RequestQueue } from 'crawlee'; -import * as path from 'node:path'; -import * as fs from 'node:fs/promises'; -import { existsSync } from 'node:fs'; -import { URL } from 'node:url'; -import { execSync } from 'node:child_process'; -import axios from 'axios'; -import { FileCacheAdapter } from '../src/utils/cache/file-adapter.js'; +import { CheerioCrawler, RequestQueue } from "crawlee"; +import * as path from "node:path"; +import * as fs from "node:fs/promises"; +import { existsSync } from "node:fs"; +import { URL } from "node:url"; +import { execSync } from "node:child_process"; +import axios from "axios"; +import { FileCacheAdapter } from "../src/utils/cache/file-adapter.js"; -import { initialState, PRICING } from '../src/logic/pricing/constants.js'; -import { calculateTotals } from '../src/logic/pricing/calculator.js'; +import { initialState, PRICING } from "../src/logic/pricing/constants.js"; +import { calculateTotals } from "../src/logic/pricing/calculator.js"; async function main() { - const OPENROUTER_KEY = process.env.OPENROUTER_KEY; - if (!OPENROUTER_KEY) { - console.error('❌ Error: OPENROUTER_KEY not found in environment.'); - process.exit(1); + const OPENROUTER_KEY = process.env.OPENROUTER_KEY; + if (!OPENROUTER_KEY) { + console.error("❌ Error: OPENROUTER_KEY not found in environment."); + process.exit(1); + } + + let briefing = ""; + let targetUrl: string | null = null; + let comments: string | null = null; + let budget: string | null = null; + let cacheKey: string | null = null; + + let jsonStatePath: string | null = null; + + const isEstimation = + process.argv.includes("--estimation") || process.argv.includes("-E"); + + const args = process.argv.slice(2); + for (let i = 0; i < args.length; i++) { + const arg = args[i]; + if (arg === "--url") { + targetUrl = args[++i]; + } else if (arg === "--comments" || arg === "--notes") { + comments = args[++i]; + } else if (arg === "--budget") { + budget = args[++i]; + } else if (arg === "--cache-key") { + cacheKey = args[++i]; + } else if (arg === "--json") { + jsonStatePath = args[++i]; + } else if (arg === "--estimation" || arg === "-E") { + // Handled above + } else if (!arg.startsWith("--")) { + briefing = arg; } + } - let briefing = ''; - let targetUrl: string | null = null; - let comments: string | null = null; - let budget: string | null = null; - let cacheKey: string | null = null; + if (briefing && briefing.startsWith("@")) { + const rawPath = briefing.substring(1); + const filePath = rawPath.startsWith("/") + ? rawPath + : path.resolve(process.cwd(), rawPath); + briefing = await fs.readFile(filePath, "utf8"); + } - let jsonStatePath: string | null = null; - - const isEstimation = process.argv.includes('--estimation') || process.argv.includes('-E'); - - const args = process.argv.slice(2); - for (let i = 0; i < args.length; i++) { - const arg = args[i]; - if (arg === '--url') { - targetUrl = args[++i]; - } else if (arg === '--comments' || arg === '--notes') { - comments = args[++i]; - } else if (arg === '--budget') { - budget = args[++i]; - } else if (arg === '--cache-key') { - cacheKey = args[++i]; - } else if (arg === '--json') { - jsonStatePath = args[++i]; - } else if (arg === '--estimation' || arg === '-E') { - // Handled above - } else if (!arg.startsWith('--')) { - briefing = arg; - } + // Discovery ONLY if not provided + if (!targetUrl && briefing) { + const urlMatch = briefing.match(/https?:\/\/[^\s]+/); + if (urlMatch) { + targetUrl = urlMatch[0]; + console.log(`🔗 Discovered URL in briefing: ${targetUrl}`); } + } - if (briefing && briefing.startsWith('@')) { - const rawPath = briefing.substring(1); - const filePath = rawPath.startsWith('/') ? rawPath : path.resolve(process.cwd(), rawPath); - briefing = await fs.readFile(filePath, 'utf8'); + if (!briefing && !targetUrl && !comments && !jsonStatePath) { + console.error( + '❌ Usage: npm run ai-estimate -- "Briefing text" [--url https://example.com] [--comments "Manual notes"]', + ); + console.error( + " Or: npm run ai-estimate -- @briefing.txt [--url https://example.com]", + ); + console.error(" Or: npm run ai-estimate -- --json path/to/state.json"); + process.exit(1); + } + + const clearCache = process.argv.includes("--clear-cache"); + if (clearCache) { + console.log("🧹 Clearing cache..."); + const cacheFiles = await fs.readdir(path.join(process.cwd(), ".cache")); + for (const file of cacheFiles) { + if (file.startsWith("ai_est_")) { + await fs.unlink(path.join(process.cwd(), ".cache", file)); + } } + } - // Discovery ONLY if not provided - if (!targetUrl && briefing) { - const urlMatch = briefing.match(/https?:\/\/[^\s]+/); - if (urlMatch) { - targetUrl = urlMatch[0]; - console.log(`🔗 Discovered URL in briefing: ${targetUrl}`); - } + const cache = new FileCacheAdapter({ prefix: "ai_est_" }); + const finalCacheKey = + cacheKey || `${briefing}_${targetUrl}_${comments}_${budget}`; + + // 1. Crawl if URL provided + let crawlContext = ""; + if (targetUrl) { + console.log(`🔍 Crawling ${targetUrl} for context...`); + const cachedCrawl = await cache.get(`crawl_${targetUrl}`); + if (cachedCrawl && !clearCache) { + console.log("📦 Using cached crawl results."); + crawlContext = cachedCrawl; + } else { + crawlContext = await performCrawl(targetUrl); + await cache.set(`crawl_${targetUrl}`, crawlContext, 86400); // 24h cache } + } - if (!briefing && !targetUrl && !comments && !jsonStatePath) { - console.error('❌ Usage: npm run ai-estimate -- "Briefing text" [--url https://example.com] [--comments "Manual notes"]'); - console.error(' Or: npm run ai-estimate -- @briefing.txt [--url https://example.com]'); - console.error(' Or: npm run ai-estimate -- --json path/to/state.json'); - process.exit(1); + // 2. Distill Crawl Context (Context Filtering) + let distilledCrawl = ""; + if (crawlContext) { + const cachedDistilled = await cache.get(`distilled_${targetUrl}`); + if (cachedDistilled && !clearCache) { + distilledCrawl = cachedDistilled; + } else { + distilledCrawl = await distillCrawlContext(crawlContext, OPENROUTER_KEY); + await cache.set(`distilled_${targetUrl}`, distilledCrawl, 86400); } - - const clearCache = process.argv.includes('--clear-cache'); - if (clearCache) { - console.log('🧹 Clearing cache...'); - const cacheFiles = await fs.readdir(path.join(process.cwd(), '.cache')); - for (const file of cacheFiles) { - if (file.startsWith('ai_est_')) { - await fs.unlink(path.join(process.cwd(), '.cache', file)); - } - } - } - - const cache = new FileCacheAdapter({ prefix: 'ai_est_' }); - const finalCacheKey = cacheKey || `${briefing}_${targetUrl}_${comments}_${budget}`; - - // 1. Crawl if URL provided - let crawlContext = ''; - if (targetUrl) { - console.log(`🔍 Crawling ${targetUrl} for context...`); - const cachedCrawl = await cache.get(`crawl_${targetUrl}`); - if (cachedCrawl && !clearCache) { - console.log('📦 Using cached crawl results.'); - crawlContext = cachedCrawl; - } else { - crawlContext = await performCrawl(targetUrl); - await cache.set(`crawl_${targetUrl}`, crawlContext, 86400); // 24h cache - } - } - - // 2. Distill Crawl Context (Context Filtering) - let distilledCrawl = ''; - if (crawlContext) { - const cachedDistilled = await cache.get(`distilled_${targetUrl}`); - if (cachedDistilled && !clearCache) { - distilledCrawl = cachedDistilled; - } else { - distilledCrawl = await distillCrawlContext(crawlContext, OPENROUTER_KEY); - await cache.set(`distilled_${targetUrl}`, distilledCrawl, 86400); - } - } else if (targetUrl) { - distilledCrawl = `WARNING: The crawl of ${targetUrl} failed (ENOTFOUND or timeout). + } else if (targetUrl) { + distilledCrawl = `WARNING: The crawl of ${targetUrl} failed (ENOTFOUND or timeout). The AI must NOT hallucinate details about the current website. Focus ONLY on the BRIEFING provided. If details are missing, mark them as 'unknown'.`; - console.warn('⚠️ Crawl failed. AI will be notified to avoid hallucinations.'); - } + console.warn( + "⚠️ Crawl failed. AI will be notified to avoid hallucinations.", + ); + } - // 3. AI Prompting - console.log('🤖 Consultating Gemini 3 Flash...'); - const cachedAi = !clearCache ? await cache.get(finalCacheKey) : null; - let formState: any; - let usage: { prompt: number, completion: number, cost: number } = { prompt: 0, completion: 0, cost: 0 }; + // 3. AI Prompting + console.log("🤖 Consultating Gemini 3 Flash..."); + const cachedAi = !clearCache ? await cache.get(finalCacheKey) : null; + let formState: any; + let usage: { prompt: number; completion: number; cost: number } = { + prompt: 0, + completion: 0, + cost: 0, + }; - // Load Context Documents - const principles = await fs.readFile(path.resolve(process.cwd(), 'docs/PRINCIPLES.md'), 'utf8'); - const techStandards = await fs.readFile(path.resolve(process.cwd(), 'docs/TECH.md'), 'utf8'); - const tone = await fs.readFile(path.resolve(process.cwd(), 'docs/TONE.md'), 'utf8'); + // Load Context Documents + const principles = await fs.readFile( + path.resolve(process.cwd(), "docs/PRINCIPLES.md"), + "utf8", + ); + const techStandards = await fs.readFile( + path.resolve(process.cwd(), "docs/TECH.md"), + "utf8", + ); + const tone = await fs.readFile( + path.resolve(process.cwd(), "docs/TONE.md"), + "utf8", + ); - if (jsonStatePath) { - console.log(`📂 Loading state from JSON: ${jsonStatePath}`); - const rawJson = await fs.readFile(path.resolve(process.cwd(), jsonStatePath), 'utf8'); - formState = JSON.parse(rawJson); - } else if (cachedAi) { - console.log('📦 Using cached AI response.'); - formState = cachedAi; - } else { - const result = await getAiEstimation(briefing, distilledCrawl, comments, budget, OPENROUTER_KEY, principles, techStandards, tone); - formState = result.state; - usage = result.usage; - await cache.set(finalCacheKey, formState); - } + if (jsonStatePath) { + console.log(`📂 Loading state from JSON: ${jsonStatePath}`); + const rawJson = await fs.readFile( + path.resolve(process.cwd(), jsonStatePath), + "utf8", + ); + formState = JSON.parse(rawJson); + } else if (cachedAi) { + console.log("📦 Using cached AI response."); + formState = cachedAi; + } else { + const result = await getAiEstimation( + briefing, + distilledCrawl, + comments, + budget, + OPENROUTER_KEY, + principles, + techStandards, + tone, + ); + formState = result.state; + usage = result.usage; + await cache.set(finalCacheKey, formState); + } - // 3. Save Data & Generate PDF - const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); - const jsonOutDir = path.resolve(process.cwd(), 'out/estimations/json'); - if (!existsSync(jsonOutDir)) await fs.mkdir(jsonOutDir, { recursive: true }); + // 3. Save Data & Generate PDF + const timestamp = new Date().toISOString().replace(/[:.]/g, "-"); + const jsonOutDir = path.resolve(process.cwd(), "out/estimations/json"); + if (!existsSync(jsonOutDir)) await fs.mkdir(jsonOutDir, { recursive: true }); - const finalJsonPath = path.join(jsonOutDir, `${formState.companyName || 'unknown'}_${timestamp}.json`); - await fs.writeFile(finalJsonPath, JSON.stringify(formState, null, 2)); + const finalJsonPath = path.join( + jsonOutDir, + `${formState.companyName || "unknown"}_${timestamp}.json`, + ); + await fs.writeFile(finalJsonPath, JSON.stringify(formState, null, 2)); - const tempJsonPath = path.resolve(process.cwd(), '.cache', `temp_state_${Date.now()}.json`); - await fs.writeFile(tempJsonPath, JSON.stringify(formState, null, 2)); + const tempJsonPath = path.resolve( + process.cwd(), + ".cache", + `temp_state_${Date.now()}.json`, + ); + await fs.writeFile(tempJsonPath, JSON.stringify(formState, null, 2)); - console.log(`📦 Saved detailed state to: ${finalJsonPath}`); - console.log('📄 Generating PDF estimation...'); - try { - const genArgs = isEstimation ? '--estimation' : ''; - execSync(`npx tsx ./scripts/generate-estimate.ts --input ${tempJsonPath} ${genArgs}`, { stdio: 'inherit' }); - } finally { - // await fs.unlink(tempJsonPath); - } + console.log(`📦 Saved detailed state to: ${finalJsonPath}`); + console.log("📄 Generating PDF estimation..."); + try { + const genArgs = isEstimation ? "--estimation" : ""; + execSync( + `npx tsx ./scripts/generate-estimate.ts --input ${tempJsonPath} ${genArgs}`, + { stdio: "inherit" }, + ); + } finally { + // await fs.unlink(tempJsonPath); + } - console.log('\n✨ AI Estimation Complete!'); - if (usage.prompt > 0) { - console.log('--------------------------------------------------'); - console.log('📊 ACCUMULATED API USAGE (SUM OF 6 PASSES)'); - console.log(` Model: google/gemini-3-flash-preview`); - console.log(` Total Prompt: ${usage.prompt.toLocaleString()}`); - console.log(` Total Completion: ${usage.completion.toLocaleString()}`); - console.log(` Total Tokens: ${(usage.prompt + usage.completion).toLocaleString()}`); - console.log(` Total Cost (USD): $${usage.cost.toFixed(6)}`); - console.log('--------------------------------------------------\n'); - } + console.log("\n✨ AI Estimation Complete!"); + if (usage.prompt > 0) { + console.log("--------------------------------------------------"); + console.log("📊 ACCUMULATED API USAGE (SUM OF 6 PASSES)"); + console.log(` Model: google/gemini-3-flash-preview`); + console.log(` Total Prompt: ${usage.prompt.toLocaleString()}`); + console.log(` Total Completion: ${usage.completion.toLocaleString()}`); + console.log( + ` Total Tokens: ${(usage.prompt + usage.completion).toLocaleString()}`, + ); + console.log(` Total Cost (USD): $${usage.cost.toFixed(6)}`); + console.log("--------------------------------------------------\n"); + } } -async function distillCrawlContext(rawCrawl: string, apiKey: string): Promise { - if (!rawCrawl || rawCrawl.trim().length === 0) return "Keine Crawl-Daten vorhanden."; +async function distillCrawlContext( + rawCrawl: string, + apiKey: string, +): Promise { + if (!rawCrawl || rawCrawl.trim().length === 0) + return "Keine Crawl-Daten vorhanden."; - console.log(' ↳ Distilling Crawl Context (Noise Filtering)...'); - const systemPrompt = ` + console.log(" ↳ Distilling Crawl Context (Noise Filtering)..."); + const systemPrompt = ` You are a context distiller. Your goal is to strip away HTML noise, legal footers, and generic fluff from a website crawl. Extract the "Company DNA" in 5-8 bullet points (GERMAN). @@ -190,118 +241,194 @@ Extract the "Company DNA" in 5-8 bullet points (GERMAN). ### OUTPUT: Return ONLY the bullet points. No intro/outro. `; - const resp = await axios.post('https://openrouter.ai/api/v1/chat/completions', { - model: 'google/gemini-3-flash-preview', - messages: [{ role: 'system', content: systemPrompt }, { role: 'user', content: `RAW_CRAWL:\n${rawCrawl.substring(0, 30000)}` }], - }, { headers: { 'Authorization': `Bearer ${apiKey}`, 'Content-Type': 'application/json' } }); + const resp = await axios.post( + "https://openrouter.ai/api/v1/chat/completions", + { + model: "google/gemini-3-flash-preview", + messages: [ + { role: "system", content: systemPrompt }, + { + role: "user", + content: `RAW_CRAWL:\n${rawCrawl.substring(0, 30000)}`, + }, + ], + }, + { + headers: { + Authorization: `Bearer ${apiKey}`, + "Content-Type": "application/json", + }, + }, + ); - return resp.data.choices[0].message.content; + return resp.data.choices[0].message.content; } async function performCrawl(url: string): Promise { - const pages: { url: string, content: string, type: string }[] = []; - const origin = new URL(url).origin; + const pages: { url: string; content: string; type: string }[] = []; + const origin = new URL(url).origin; - const crawler = new CheerioCrawler({ - maxRequestsPerCrawl: 20, - async requestHandler({ $, request, enqueueLinks }) { - const title = $('title').text(); - const urlObj = new URL(request.url); - const urlPath = urlObj.pathname.toLowerCase(); + const crawler = new CheerioCrawler({ + maxRequestsPerCrawl: 20, + async requestHandler({ $, request, enqueueLinks }) { + const title = $("title").text(); + const urlObj = new URL(request.url); + const urlPath = urlObj.pathname.toLowerCase(); - let type = 'other'; - if (urlPath === '/' || urlPath === '') type = 'home'; - else if (urlPath.includes('service') || urlPath.includes('leistung')) type = 'service'; - else if (urlPath.includes('blog') || urlPath.includes('news') || urlPath.includes('aktuelles') || urlPath.includes('magazin')) type = 'blog'; - else if (urlPath.includes('contact') || urlPath.includes('kontakt')) type = 'contact'; - else if (urlPath.includes('job') || urlPath.includes('karriere') || urlPath.includes('career') || urlPath.includes('human-resources')) type = 'career'; - else if (urlPath.includes('portfolio') || urlPath.includes('referenz') || urlPath.includes('projekt') || urlPath.includes('case-study')) type = 'portfolio'; - else if (urlPath.includes('legal') || urlPath.includes('impressum') || urlPath.includes('datenschutz') || urlPath.includes('privacy')) type = 'legal'; + let type = "other"; + if (urlPath === "/" || urlPath === "") type = "home"; + else if (urlPath.includes("service") || urlPath.includes("leistung")) + type = "service"; + else if ( + urlPath.includes("blog") || + urlPath.includes("news") || + urlPath.includes("aktuelles") || + urlPath.includes("magazin") + ) + type = "blog"; + else if (urlPath.includes("contact") || urlPath.includes("kontakt")) + type = "contact"; + else if ( + urlPath.includes("job") || + urlPath.includes("karriere") || + urlPath.includes("career") || + urlPath.includes("human-resources") + ) + type = "career"; + else if ( + urlPath.includes("portfolio") || + urlPath.includes("referenz") || + urlPath.includes("projekt") || + urlPath.includes("case-study") + ) + type = "portfolio"; + else if ( + urlPath.includes("legal") || + urlPath.includes("impressum") || + urlPath.includes("datenschutz") || + urlPath.includes("privacy") + ) + type = "legal"; - const h1s = $('h1').map((_, el) => $(el).text()).get(); - const navLinks = $('nav a').map((_, el) => $(el).text().trim()).get().filter(t => t.length > 0); - const bodyText = $('body').text().replace(/\s+/g, ' ').substring(0, 50000); - const html = $.html(); - const hexColors = html.match(/#(?:[0-9a-fA-F]{3}){1,2}\b/g) || []; - const uniqueColors = Array.from(new Set(hexColors)).slice(0, 5); + const h1s = $("h1") + .map((_, el) => $(el).text()) + .get(); + const navLinks = $("nav a") + .map((_, el) => $(el).text().trim()) + .get() + .filter((t) => t.length > 0); + const bodyText = $("body") + .text() + .replace(/\s+/g, " ") + .substring(0, 50000); + const html = $.html(); + const hexColors = html.match(/#(?:[0-9a-fA-F]{3}){1,2}\b/g) || []; + const uniqueColors = Array.from(new Set(hexColors)).slice(0, 5); - pages.push({ - url: request.url, - type, - content: `Title: ${title}\nType: ${type}\nHeadings: ${h1s.join(', ')}\nNav: ${navLinks.join(', ')}\nColors: ${uniqueColors.join(', ')}\nText: ${bodyText}` - }); + pages.push({ + url: request.url, + type, + content: `Title: ${title}\nType: ${type}\nHeadings: ${h1s.join(", ")}\nNav: ${navLinks.join(", ")}\nColors: ${uniqueColors.join(", ")}\nText: ${bodyText}`, + }); - await enqueueLinks({ - limit: 15, - transformRequestFunction: (req) => { - const reqUrl = new URL(req.url); - if (reqUrl.origin !== origin) return false; - // Skip assets - if (reqUrl.pathname.match(/\.(pdf|zip|jpg|png|svg|webp)$/i)) return false; - return req; - } - }); + await enqueueLinks({ + limit: 15, + transformRequestFunction: (req) => { + const reqUrl = new URL(req.url); + if (reqUrl.origin !== origin) return false; + // Skip assets + if (reqUrl.pathname.match(/\.(pdf|zip|jpg|png|svg|webp)$/i)) + return false; + return req; }, - }); + }); + }, + }); - await crawler.run([url]); + await crawler.run([url]); - const typeCounts = pages.reduce((acc, p) => { - acc[p.type] = (acc[p.type] || 0) + 1; - return acc; - }, {} as Record); + const typeCounts = pages.reduce( + (acc, p) => { + acc[p.type] = (acc[p.type] || 0) + 1; + return acc; + }, + {} as Record, + ); - let summary = `\nCrawl Summary: Identified ${pages.length} pages total on ${origin}.\n`; - summary += Object.entries(typeCounts).map(([type, count]) => `- ${type}: ${count}`).join('\n') + '\n\n'; + let summary = `\nCrawl Summary: Identified ${pages.length} pages total on ${origin}.\n`; + summary += + Object.entries(typeCounts) + .map(([type, count]) => `- ${type}: ${count}`) + .join("\n") + "\n\n"; - return summary + pages.map(p => `--- PAGE: ${p.url} ---\n${p.content}`).join('\n\n'); + return ( + summary + + pages.map((p) => `--- PAGE: ${p.url} ---\n${p.content}`).join("\n\n") + ); } const cleanJson = (str: string) => { - // Remove markdown code blocks if present - let cleaned = str.replace(/```json\n?|```/g, '').trim(); + // Remove markdown code blocks if present + let cleaned = str.replace(/```json\n?|```/g, "").trim(); - // Remove potential control characters that break JSON.parse - // We keep \n \r \t for now as they might be escaped or need handling - cleaned = cleaned.replace(/[\u0000-\u0009\u000B\u000C\u000E-\u001F\u007F-\u009F]/g, " "); + // Remove potential control characters that break JSON.parse + // We keep \n \r \t for now as they might be escaped or need handling + cleaned = cleaned.replace( + /[\u0000-\u0009\u000B\u000C\u000E-\u001F\u007F-\u009F]/g, + " ", + ); - // Specific fix for Gemini: raw newlines inside strings - // This is tricky. We'll try to escape newlines that are NOT followed by a quote and colon (property start) - // or a closing brace/bracket. This is heuristic. - // A better way is to replace all raw newlines that are preceded by a non-backslash with \n - // but only if they are inside double quotes. + // Specific fix for Gemini: raw newlines inside strings + // This is tricky. We'll try to escape newlines that are NOT followed by a quote and colon (property start) + // or a closing brace/bracket. This is heuristic. + // A better way is to replace all raw newlines that are preceded by a non-backslash with \n + // but only if they are inside double quotes. - // Simplest robust approach: Remove trailing commas and hope response_format does its job. - cleaned = cleaned.replace(/,\s*([\]}])/g, '$1'); + // Simplest robust approach: Remove trailing commas and hope response_format does its job. + cleaned = cleaned.replace(/,\s*([\]}])/g, "$1"); - return cleaned; + return cleaned; }; -const getAiEstimation = async (briefing: string, distilledCrawl: string, comments: string | null, budget: string | null, apiKey: string, principles: string, techStandards: string, tone: string) => { - let usage = { prompt: 0, completion: 0, cost: 0 }; - const addUsage = (data: any) => { - if (data?.usage) { - usage.prompt += data.usage.prompt_tokens || 0; - usage.completion += data.usage.completion_tokens || 0; - // OpenRouter provides 'cost' field in USD (as per documentation) - // If missing, we use a fallback calculation for transparency - if (data.usage.cost !== undefined) { - usage.cost += data.usage.cost; - } else { - // Fallback: Gemini 3 Flash Flash pricing (~$0.1 / 1M prompt, ~$0.4 / 1M completion) - usage.cost += (data.usage.prompt_tokens || 0) * (0.1 / 1000000) + (data.usage.completion_tokens || 0) * (0.4 / 1000000); - } - } - }; +const getAiEstimation = async ( + briefing: string, + distilledCrawl: string, + comments: string | null, + budget: string | null, + apiKey: string, + principles: string, + techStandards: string, + tone: string, +) => { + let usage = { prompt: 0, completion: 0, cost: 0 }; + const addUsage = (data: any) => { + if (data?.usage) { + usage.prompt += data.usage.prompt_tokens || 0; + usage.completion += data.usage.completion_tokens || 0; + // OpenRouter provides 'cost' field in USD (as per documentation) + // If missing, we use a fallback calculation for transparency + if (data.usage.cost !== undefined) { + usage.cost += data.usage.cost; + } else { + // Fallback: Gemini 3 Flash Flash pricing (~$0.1 / 1M prompt, ~$0.4 / 1M completion) + usage.cost += + (data.usage.prompt_tokens || 0) * (0.1 / 1000000) + + (data.usage.completion_tokens || 0) * (0.4 / 1000000); + } + } + }; - // 1. PASS 1: Fact Extraction (Briefing Sensor) - console.log(' ↳ Pass 1: Fact Extraction (Briefing Sensor)...'); - const pass1SystemPrompt = ` + // 1. PASS 1: Fact Extraction (Briefing Sensor) + console.log(" ↳ Pass 1: Fact Extraction (Briefing Sensor)..."); + const pass1SystemPrompt = ` You are a precision sensor. Analyze the BRIEFING and extract ONLY the raw facts. Tone: Literal, non-interpretive. Output language: GERMAN (Strict). Output format: ROOT LEVEL JSON (No wrapper keys like '0' or 'data'). +### LEGAL CONSTRAINT (MANDATORY): +Do NOT use the term "rechtssicher" (legally secure) or make any claims about legal compliance. We provide technical infrastructure, not legal advice. Use terms like "DSGVO-konforme Konfiguration" or "Datenschutz-Standard" instead. + ### MISSION: Focus 100% on the BRIEFING text provided by the user. Use the DISTILLED_CRAWL only as background context for terms or company details. If there is a conflict, the BRIEFING is the absolute source of truth. @@ -311,16 +438,19 @@ Focus 100% on the BRIEFING text provided by the user. Use the DISTILLED_CRAWL on - System-Modules (Features): 1.500 € / stk - Logic-Functions: 800 € / stk - API Integrations: 800 € / stk -- CMS Setup: 1.500 € (optional) -- Visual Staging/Interactions: 1.500 € - 2.000 € +- Inhalts-Verwaltung (CMS-Modul): 1.500 € (optional) -${budget ? `### BUDGET LOGIC (ULTRA-STRICT): +${ + budget + ? `### BUDGET LOGIC (ULTRA-STRICT): 1. **Mental Calculation**: Start with 7.000 €. Add items based on the reference above. 2. **Hard Ceiling**: If total > ${budget}, you MUST discard lower priority items. 3. **Priority**: High-End Design and Core Pages > Features. 4. **Restriction**: For ${budget}, do NOT exceed 2 features and 4 extra pages. 5. THE TOTAL COST CALCULATED BY THESE RULES MUST BE <= ${budget}. -6. Do NOT mention the budget in any string fields.` : ''} +6. Do NOT mention the budget in any string fields.` + : "" +} - ** features **: Items from the FEATURE_REFERENCE. - ** ABSOLUTE CONSERVATIVE RULE **: Only use features if the briefing implies *dynamic complexity* (CMS, filtering, search, database). @@ -335,7 +465,7 @@ ${budget ? `### BUDGET LOGIC (ULTRA-STRICT): - ** isRelaunch **: Set to TRUE if the briefing mentions an existing website, a URL, or if the company is an established entity. - ** CRITICAL LOGIC **: If a URL is mentioned, isRelaunch MUST be TRUE. - For all textual values: USE GERMAN. -- ** multilang **: ONLY if the briefing mentions multiple target languages. +- ** multilang **: ONLY if the briefing EXPLICITLY mentions multiple target languages (e.g. "Seite soll auch auf Englisch verfügbar sein"). ABSOLUTE DEFAULT IS FALSE. - ** maps **: If "Google Maps" or location maps are mentioned or implicit. - ** CRITICAL **: Do NOT include "social" in apiSystems unless the user explicitly wants to SYNC / POST content. @@ -366,21 +496,36 @@ ${budget ? `### BUDGET LOGIC (ULTRA-STRICT): "employeeCount": "ca. 10+" | "ca. 50+" | "ca. 100+" | "ca. 250+" | "ca. 500+" | "ca. 1000+" } `; - const pass1UserPrompt = `BRIEFING(TRUTH SOURCE): \n${briefing} \n\nCOMMENTS: \n${comments} \n\nDISTILLED_CRAWL(CONTEXT ONLY): \n${distilledCrawl} `; - const p1Resp = await axios.post('https://openrouter.ai/api/v1/chat/completions', { - model: 'google/gemini-3-flash-preview', - messages: [{ role: 'system', content: pass1SystemPrompt }, { role: 'user', content: pass1UserPrompt }], - response_format: { type: 'json_object' } - }, { headers: { 'Authorization': `Bearer ${apiKey} `, 'Content-Type': 'application/json' } }); - if (!p1Resp.data.choices?.[0]?.message?.content) { - console.error('❌ Pass 1 failed. Response:', JSON.stringify(p1Resp.data, null, 2)); - throw new Error('Pass 1: No content in response'); - } - const facts = JSON.parse(cleanJson(p1Resp.data.choices[0].message.content)); + const pass1UserPrompt = `BRIEFING(TRUTH SOURCE): \n${briefing} \n\nCOMMENTS: \n${comments} \n\nDISTILLED_CRAWL(CONTEXT ONLY): \n${distilledCrawl} `; + const p1Resp = await axios.post( + "https://openrouter.ai/api/v1/chat/completions", + { + model: "google/gemini-3-flash-preview", + messages: [ + { role: "system", content: pass1SystemPrompt }, + { role: "user", content: pass1UserPrompt }, + ], + response_format: { type: "json_object" }, + }, + { + headers: { + Authorization: `Bearer ${apiKey} `, + "Content-Type": "application/json", + }, + }, + ); + if (!p1Resp.data.choices?.[0]?.message?.content) { + console.error( + "❌ Pass 1 failed. Response:", + JSON.stringify(p1Resp.data, null, 2), + ); + throw new Error("Pass 1: No content in response"); + } + const facts = JSON.parse(cleanJson(p1Resp.data.choices[0].message.content)); - // 1.5. PASS 1.5: The Feature Auditor (Skeptical Review) - console.log(' ↳ Pass 1.5: The Feature Auditor (Skeptical Review)...'); - const pass15SystemPrompt = ` + // 1.5. PASS 1.5: The Feature Auditor (Skeptical Review) + console.log(" ↳ Pass 1.5: The Feature Auditor (Skeptical Review)..."); + const pass15SystemPrompt = ` You are a "Strict Cost Controller". Your mission is to prevent over-billing. Review the extracted FEATURES and the BRIEFING. @@ -399,24 +544,40 @@ Return only the corrected 'features' and 'otherPages' arrays. "otherPages": string[] } `; - const p15Resp = await axios.post('https://openrouter.ai/api/v1/chat/completions', { - model: 'google/gemini-3-flash-preview', - messages: [ - { role: 'system', content: pass15SystemPrompt }, - { role: 'user', content: `EXTRACTED_FEATURES: ${JSON.stringify(facts.features)} \nBRIEFING: \n${briefing}` } - ], - response_format: { type: 'json_object' } - }, { headers: { 'Authorization': `Bearer ${apiKey}`, 'Content-Type': 'application/json' } }); - addUsage(p15Resp.data); - const auditResult = JSON.parse(cleanJson(p15Resp.data.choices[0].message.content)); + const p15Resp = await axios.post( + "https://openrouter.ai/api/v1/chat/completions", + { + model: "google/gemini-3-flash-preview", + messages: [ + { role: "system", content: pass15SystemPrompt }, + { + role: "user", + content: `EXTRACTED_FEATURES: ${JSON.stringify(facts.features)} \nBRIEFING: \n${briefing}`, + }, + ], + response_format: { type: "json_object" }, + }, + { + headers: { + Authorization: `Bearer ${apiKey}`, + "Content-Type": "application/json", + }, + }, + ); + addUsage(p15Resp.data); + const auditResult = JSON.parse( + cleanJson(p15Resp.data.choices[0].message.content), + ); - // Apply Audit: Downgrade features to otherPages - facts.features = auditResult.features || []; - facts.otherPages = Array.from(new Set([...(facts.otherPages || []), ...(auditResult.otherPages || [])])); + // Apply Audit: Downgrade features to otherPages + facts.features = auditResult.features || []; + facts.otherPages = Array.from( + new Set([...(facts.otherPages || []), ...(auditResult.otherPages || [])]), + ); - // 2. PASS 2: Feature Deep-Dive - console.log(' ↳ Pass 2: Feature Deep-Dive...'); - const pass2SystemPrompt = ` + // 2. PASS 2: Feature Deep-Dive + console.log(" ↳ Pass 2: Feature Deep-Dive..."); + const pass2SystemPrompt = ` You are a detail - oriented Solution Architect. For EVERY item selected in Pass 1(pages, features, functions, apiSystems), write a specific justification and technical scope. @@ -439,21 +600,36 @@ ${JSON.stringify(facts, null, 2)} "apiDetails": { "crm_erp": string, ... } } `; - const p2Resp = await axios.post('https://openrouter.ai/api/v1/chat/completions', { - model: 'google/gemini-3-flash-preview', - messages: [{ role: 'system', content: pass2SystemPrompt }, { role: 'user', content: briefing }], - response_format: { type: 'json_object' } - }, { headers: { 'Authorization': `Bearer ${apiKey} `, 'Content-Type': 'application/json' } }); - addUsage(p2Resp.data); - if (!p2Resp.data.choices?.[0]?.message?.content) { - console.error('❌ Pass 2 failed. Response:', JSON.stringify(p2Resp.data, null, 2)); - throw new Error('Pass 2: No content in response'); - } - const details = JSON.parse(cleanJson(p2Resp.data.choices[0].message.content)); + const p2Resp = await axios.post( + "https://openrouter.ai/api/v1/chat/completions", + { + model: "google/gemini-3-flash-preview", + messages: [ + { role: "system", content: pass2SystemPrompt }, + { role: "user", content: briefing }, + ], + response_format: { type: "json_object" }, + }, + { + headers: { + Authorization: `Bearer ${apiKey} `, + "Content-Type": "application/json", + }, + }, + ); + addUsage(p2Resp.data); + if (!p2Resp.data.choices?.[0]?.message?.content) { + console.error( + "❌ Pass 2 failed. Response:", + JSON.stringify(p2Resp.data, null, 2), + ); + throw new Error("Pass 2: No content in response"); + } + const details = JSON.parse(cleanJson(p2Resp.data.choices[0].message.content)); - // 3. PASS 3: Strategic Content (Bespoke Strategy) - console.log(' ↳ Pass 3: Strategic Content (Bespoke Strategy)...'); - const pass3SystemPrompt = ` + // 3. PASS 3: Strategic Content (Bespoke Strategy) + console.log(" ↳ Pass 3: Strategic Content (Bespoke Strategy)..."); + const pass3SystemPrompt = ` You are a high - end Digital Architect.Your goal is to make the CUSTOMER feel 100 % understood. Analyze the BRIEFING and the EXISTING WEBSITE context. @@ -479,24 +655,41 @@ ${tone} "designVision": string } `; - const p3Resp = await axios.post('https://openrouter.ai/api/v1/chat/completions', { - model: 'google/gemini-3-flash-preview', - messages: [ - { role: 'system', content: pass3SystemPrompt }, - { role: 'user', content: `BRIEFING(TRUTH SOURCE): \n${briefing} \n\nEXISTING WEBSITE(CONTEXT): \n${distilledCrawl} \n\nEXTRACTED FACTS: \n${JSON.stringify(facts, null, 2)} ` } - ], - response_format: { type: 'json_object' } - }, { headers: { 'Authorization': `Bearer ${apiKey} `, 'Content-Type': 'application/json' } }); - addUsage(p3Resp.data); - if (!p3Resp.data.choices?.[0]?.message?.content) { - console.error('❌ Pass 3 failed. Response:', JSON.stringify(p3Resp.data, null, 2)); - throw new Error('Pass 3: No content in response'); - } - const strategy = JSON.parse(cleanJson(p3Resp.data.choices[0].message.content)); + const p3Resp = await axios.post( + "https://openrouter.ai/api/v1/chat/completions", + { + model: "google/gemini-3-flash-preview", + messages: [ + { role: "system", content: pass3SystemPrompt }, + { + role: "user", + content: `BRIEFING(TRUTH SOURCE): \n${briefing} \n\nEXISTING WEBSITE(CONTEXT): \n${distilledCrawl} \n\nEXTRACTED FACTS: \n${JSON.stringify(facts, null, 2)} `, + }, + ], + response_format: { type: "json_object" }, + }, + { + headers: { + Authorization: `Bearer ${apiKey} `, + "Content-Type": "application/json", + }, + }, + ); + addUsage(p3Resp.data); + if (!p3Resp.data.choices?.[0]?.message?.content) { + console.error( + "❌ Pass 3 failed. Response:", + JSON.stringify(p3Resp.data, null, 2), + ); + throw new Error("Pass 3: No content in response"); + } + const strategy = JSON.parse( + cleanJson(p3Resp.data.choices[0].message.content), + ); - // 4. PASS 4: Information Architecture (Sitemap) - console.log(' ↳ Pass 4: Information Architecture...'); - const pass4SystemPrompt = ` + // 4. PASS 4: Information Architecture (Sitemap) + console.log(" ↳ Pass 4: Information Architecture..."); + const pass4SystemPrompt = ` You are a Senior UX Architect.Design a hierarchical sitemap following the 'Industrial Logic' principle. EVERYTHING MUST BE IN GERMAN. @@ -516,41 +709,60 @@ ${JSON.stringify({ facts, strategy }, null, 2)} "sitemap": [{ "category": string, "pages": [{ "title": string, "desc": string }] }] } `; - const p4Resp = await axios.post('https://openrouter.ai/api/v1/chat/completions', { - model: 'google/gemini-3-flash-preview', - messages: [{ role: 'system', content: pass4SystemPrompt }, { role: 'user', content: `BRIEFING(TRUTH SOURCE): \n${briefing} \n\nDISTILLED_CRAWL(CONTEXT): \n${distilledCrawl} ` }], - response_format: { type: 'json_object' } - }, { headers: { 'Authorization': `Bearer ${apiKey} `, 'Content-Type': 'application/json' } }); - addUsage(p4Resp.data); - if (!p4Resp.data.choices?.[0]?.message?.content) { - console.error('❌ Pass 4 failed. Response:', JSON.stringify(p4Resp.data, null, 2)); - throw new Error('Pass 4: No content in response'); - } - const ia = JSON.parse(cleanJson(p4Resp.data.choices[0].message.content)); + const p4Resp = await axios.post( + "https://openrouter.ai/api/v1/chat/completions", + { + model: "google/gemini-3-flash-preview", + messages: [ + { role: "system", content: pass4SystemPrompt }, + { + role: "user", + content: `BRIEFING(TRUTH SOURCE): \n${briefing} \n\nDISTILLED_CRAWL(CONTEXT): \n${distilledCrawl} `, + }, + ], + response_format: { type: "json_object" }, + }, + { + headers: { + Authorization: `Bearer ${apiKey} `, + "Content-Type": "application/json", + }, + }, + ); + addUsage(p4Resp.data); + if (!p4Resp.data.choices?.[0]?.message?.content) { + console.error( + "❌ Pass 4 failed. Response:", + JSON.stringify(p4Resp.data, null, 2), + ); + throw new Error("Pass 4: No content in response"); + } + const ia = JSON.parse(cleanJson(p4Resp.data.choices[0].message.content)); - // 5. PASS 5: Position Synthesis & Pricing Transparency - console.log(' ↳ Pass 5: Position Synthesis...'); + // 5. PASS 5: Position Synthesis & Pricing Transparency + console.log(" ↳ Pass 5: Position Synthesis..."); - // Determine which positions are actually relevant to avoid hallucinations - const requiredPositions = [ - "Das technische Fundament", - facts.selectedPages.length + facts.otherPages.length > 0 ? "Individuelle Seiten" : null, - facts.features.length > 0 ? "System-Module (Features)" : null, - facts.functions.length > 0 ? "Logik-Funktionen" : null, - facts.apiSystems.length > 0 ? "Schnittstellen (API)" : null, - facts.cmsSetup ? "Inhaltsverwaltung (CMS)" : null, - "Inszenierung & Interaktion", // Always include for high-end strategy - facts.multilang ? "Mehrsprachigkeit" : null, - "Inhaltliche Initial-Pflege", - "Sorglos-Paket (Betrieb & Pflege)" - ].filter(Boolean); + // Determine which positions are actually relevant to avoid hallucinations + const requiredPositions = [ + "Das technische Fundament", + facts.selectedPages.length + facts.otherPages.length > 0 + ? "Individuelle Seiten" + : null, + facts.features.length > 0 ? "System-Module (Features)" : null, + facts.functions.length > 0 ? "Logik-Funktionen" : null, + facts.apiSystems.length > 0 ? "Schnittstellen (API)" : null, + facts.cmsSetup ? "Inhalts-Verwaltung" : null, + facts.multilang ? "Mehrsprachigkeit" : null, + "Inhaltliche Initial-Pflege", + "Sorglos Betrieb", + ].filter(Boolean); - const pass5SystemPrompt = ` + const pass5SystemPrompt = ` You are a Senior Solution Architect. Your goal is ABSOLUTE TRANSPARENCY and professionalism. Each position in the quote must be perfectly justified and detailed using an objective, technical tone. ### REQUIRED POSITION TITLES (STRICT - ONLY DESCRIBE THESE): -${requiredPositions.map(p => `"${p}"`).join(", ")} +${requiredPositions.map((p) => `"${p}"`).join(", ")} ### MAPPING RULES (STRICT): - ** Das technische Fundament **: Infrastructure, Hosting setup, SEO-Basics, Analytics, Environments. @@ -558,11 +770,10 @@ ${requiredPositions.map(p => `"${p}"`).join(", ")} - ** System-Module (Features) **: Functional systems like Blog, News, Products, Jobs, References. ** RULE **: Describe exactly 1 thing if qty is 1. If qty is 0, DO NOT DESCRIBE THIS. - ** Logik-Funktionen **: Logic modules like Search, Filter, Forms, PDF-Export. - ** Schnittstellen (API) **: Data Syncs with CRM, ERP, Payment systems. -- ** Inhaltsverwaltung (CMS) **: Setup and mapping for CMS. -- ** Inszenierung & Interaktion **: Hero-stories, visual effects, configurators. +- ** Inhalts-Verwaltung **: Setup and mapping of the administration system for self-managed content. - ** Mehrsprachigkeit **: Architecture scaling for multiple languages. - ** Inhaltliche Initial-Pflege **: Manual data entry / cleanup. -- ** Sorglos-Paket (Betrieb & Pflege) **: ** RULE **: Describe as "1 Jahr Sicherung des technischen Betriebs, Instandhaltung, Sicherheits-Updates und Inhalts-Aktualisierungen gemäß AGB Punkt 7a." +- ** Sorglos Betrieb **: ** RULE **: Describe as "Inklusive 1 Jahr Sicherung des technischen Betriebs, Hosting, Instandhaltung, Sicherheits-Updates und techn. Support gemäß AGB Punkt 7a." ### RULES FOR positionDescriptions(STRICT): 1. ** ABSOLUTE RULE: NO FIRST PERSON **: NEVER use "Ich", "Mein", "Wir" or "Unser". Lead with nouns or passive verbs. @@ -594,21 +805,38 @@ ${JSON.stringify({ facts, details, strategy, ia }, null, 2)} "positionDescriptions": { "Das technische Fundament": string, ... } } `; - const p5Resp = await axios.post('https://openrouter.ai/api/v1/chat/completions', { - model: 'google/gemini-3-flash-preview', - messages: [{ role: 'system', content: pass5SystemPrompt }, { role: 'user', content: briefing }], - response_format: { type: 'json_object' } - }, { headers: { 'Authorization': `Bearer ${apiKey} `, 'Content-Type': 'application/json' } }); - addUsage(p5Resp.data); - if (!p5Resp.data.choices?.[0]?.message?.content) { - console.error('❌ Pass 5 failed. Response:', JSON.stringify(p5Resp.data, null, 2)); - throw new Error('Pass 5: No content in response'); - } - const positionsData = JSON.parse(cleanJson(p5Resp.data.choices[0].message.content)); + const p5Resp = await axios.post( + "https://openrouter.ai/api/v1/chat/completions", + { + model: "google/gemini-3-flash-preview", + messages: [ + { role: "system", content: pass5SystemPrompt }, + { role: "user", content: briefing }, + ], + response_format: { type: "json_object" }, + }, + { + headers: { + Authorization: `Bearer ${apiKey} `, + "Content-Type": "application/json", + }, + }, + ); + addUsage(p5Resp.data); + if (!p5Resp.data.choices?.[0]?.message?.content) { + console.error( + "❌ Pass 5 failed. Response:", + JSON.stringify(p5Resp.data, null, 2), + ); + throw new Error("Pass 5: No content in response"); + } + const positionsData = JSON.parse( + cleanJson(p5Resp.data.choices[0].message.content), + ); - // 6. PASS 6: The Industrial Critic - console.log(' ↳ Pass 6: The Industrial Critic (Quality Gate)...'); - const pass6SystemPrompt = ` + // 6. PASS 6: The Industrial Critic + console.log(" ↳ Pass 6: The Industrial Critic (Quality Gate)..."); + const pass6SystemPrompt = ` You are the "Industrial Critic".Your goal is to catch quality regressions and ensure the document is bespoke, technical, and professional. Analyze the CURRENT_STATE against the BRIEFING_TRUTH. @@ -620,9 +848,10 @@ Analyze the CURRENT_STATE against the BRIEFING_TRUTH. 3. ** Implementation Fluff **: FAIL if tech - stack details are mentioned(React, etc.).Focus on Concept & Result. 4. ** Genericism Check(CRITICAL) **: FAIL if any text sounds like it could apply to ANY company.It MUST mention specific industry details(e.g., "Kabeltiefbau", "Infrastruktur-Zentrum") from the Briefing or Crawl. 6. ** Namen-Verbot (STRICT) **: FAIL if any personal names (e.g. "Danny Joseph", "Joseph", etc.) appear in 'briefingSummary' or 'designVision'. Use abstract terms like "Unternehmensführung" or "Management" if necessary. -7. ** LOGIC GUARD (CMS) **: If 'cmsSetup' is false in the DATA CONTEXT, FAIL if any 'positionDescriptions' or 'briefingSummary' mentions "CMS", "Content Management System" or "Inhaltsverwaltung". +7. ** LOGIC GUARD (CMS) **: If 'cmsSetup' is false in the DATA CONTEXT, FAIL if any 'positionDescriptions' or 'briefingSummary' mentions "CMS", "Content Management System", "Inhaltsverwaltung" or "Redaktionssystem". 8. ** AGB BAN **: FAIL if "Allgemeine Geschäftsbedingungen" or "AGB" appear anywhere. 9. ** Length Check **: Briefing (ca. 6 Sätze) und Vision (ca. 4 Sätze). Kürze Texte, die zu ausschweifend sind, auf das Wesentliche. +10. **LEGAL SAFETY**: FAIL if the term "rechtssicher" or similar absolute legal claims are used. Ensure terminology remains technical (e.g., "Standard-konform", "Best Practice"). ### MISSION: Return updated fields ONLY.Specifically focus on hardening 'positionDescriptions', 'sitemap', 'briefingSummary', and 'designVision'. @@ -630,228 +859,290 @@ Return updated fields ONLY.Specifically focus on hardening 'positionDescriptions ### DATA CONTEXT: ${JSON.stringify({ facts, strategy, ia, positionsData }, null, 2)} `; - const p6Resp = await axios.post('https://openrouter.ai/api/v1/chat/completions', { - model: 'google/gemini-3-flash-preview', - messages: [{ role: 'system', content: pass6SystemPrompt }, { role: 'user', content: `BRIEFING_TRUTH: \n${briefing} ` }], - response_format: { type: 'json_object' } - }, { headers: { 'Authorization': `Bearer ${apiKey} `, 'Content-Type': 'application/json' } }); - addUsage(p6Resp.data); - if (!p6Resp.data.choices?.[0]?.message?.content) { - console.error('❌ Pass 6 failed. Response:', JSON.stringify(p6Resp.data, null, 2)); - throw new Error('Pass 6: No content in response'); + const p6Resp = await axios.post( + "https://openrouter.ai/api/v1/chat/completions", + { + model: "google/gemini-3-flash-preview", + messages: [ + { role: "system", content: pass6SystemPrompt }, + { role: "user", content: `BRIEFING_TRUTH: \n${briefing} ` }, + ], + response_format: { type: "json_object" }, + }, + { + headers: { + Authorization: `Bearer ${apiKey} `, + "Content-Type": "application/json", + }, + }, + ); + addUsage(p6Resp.data); + if (!p6Resp.data.choices?.[0]?.message?.content) { + console.error( + "❌ Pass 6 failed. Response:", + JSON.stringify(p6Resp.data, null, 2), + ); + throw new Error("Pass 6: No content in response"); + } + const reflection = JSON.parse( + cleanJson(p6Resp.data.choices[0].message.content), + ); + + // 6. Reflection Merge Utility + const mergeReflection = (state: any, reflection: any) => { + let result = { ...state }; + const unwrap = (obj: any): any => { + if (!obj || typeof obj !== "object" || Array.isArray(obj)) return obj; + // Always unwrap "0" if it exists, regardless of other keys (AI often nests) + if (obj["0"]) return unwrap(obj["0"]); + if (obj.state && Object.keys(obj).length === 1) return unwrap(obj.state); + if (obj.facts && Object.keys(obj).length === 1) return unwrap(obj.facts); + return obj; + }; + + const cleanedReflection = unwrap(reflection); + Object.entries(cleanedReflection).forEach(([key, value]) => { + if (value && value !== "" && value !== "null") { + result[key] = value; + } + }); + return result; + }; + + let finalState = mergeReflection( + { + ...initialState, + ...facts, + ...strategy, + ...ia, + ...positionsData, + }, + reflection, + ); + + finalState.statusQuo = facts.isRelaunch ? "Relaunch" : "Neuentwicklung"; + + // Recipient Mapping + if (finalState.personName) finalState.name = finalState.personName; + if (finalState.email) finalState.email = finalState.email; + + // Normalization Layer: Map hallucinated German keys back to internal keys + const normalizationMap: Record = { + "Briefing-Zusammenfassung": "briefingSummary", + "Design-Vision": "designVision", + Zusammenfassung: "briefingSummary", + Vision: "designVision", + BRIEFING_SUMMARY: "briefingSummary", + DESIGN_VISION: "designVision", + }; + + Object.entries(normalizationMap).forEach(([gerKey, intKey]) => { + if (finalState[gerKey] && !finalState[intKey]) { + if ( + typeof finalState[gerKey] === "object" && + !Array.isArray(finalState[gerKey]) + ) { + finalState[intKey] = Object.values(finalState[gerKey]).join("\n\n"); + } else { + finalState[intKey] = finalState[gerKey]; + } } - const reflection = JSON.parse(cleanJson(p6Resp.data.choices[0].message.content)); + }); - // 6. Reflection Merge Utility - const mergeReflection = (state: any, reflection: any) => { - let result = { ...state }; - const unwrap = (obj: any): any => { - if (!obj || typeof obj !== 'object' || Array.isArray(obj)) return obj; - // Always unwrap "0" if it exists, regardless of other keys (AI often nests) - if (obj["0"]) return unwrap(obj["0"]); - if (obj.state && Object.keys(obj).length === 1) return unwrap(obj.state); - if (obj.facts && Object.keys(obj).length === 1) return unwrap(obj.facts); - return obj; - }; - - const cleanedReflection = unwrap(reflection); - Object.entries(cleanedReflection).forEach(([key, value]) => { - if (value && value !== "" && value !== "null") { - result[key] = value; - } + // Final Logic Guard: Strictly strip CMS from ALL descriptions and fields if not enabled + if (!finalState.cmsSetup) { + const stripCMS = (obj: any): any => { + if (typeof obj === "string") { + return obj.replace( + /CMS|Content-Management-System|Inhaltsverwaltung/gi, + "Plattform-Struktur", + ); + } + if (Array.isArray(obj)) { + return obj.map(stripCMS); + } + if (obj !== null && typeof obj === "object") { + const newObj: any = {}; + Object.entries(obj).forEach(([k, v]) => { + newObj[k] = stripCMS(v); }); - return result; + return newObj; + } + return obj; }; + finalState = stripCMS(finalState); + } - let finalState = mergeReflection({ - ...initialState, - ...facts, - ...strategy, - ...ia, - ...positionsData - }, reflection); + // Sitemap Normalization (German keys to internal) + if (Array.isArray(finalState.sitemap)) { + finalState.sitemap = finalState.sitemap.map((cat: any) => ({ + category: + cat.category || + cat.kategorie || + cat.Kategorie || + cat.title || + "Allgemein", + pages: (cat.pages || cat.seiten || cat.Seiten || []).map((page: any) => ({ + title: page.title || page.titel || page.Titel || "Seite", + desc: + page.desc || + page.beschreibung || + page.Beschreibung || + page.description || + "", + })), + })); + } - finalState.statusQuo = facts.isRelaunch ? 'Relaunch' : 'Neuentwicklung'; + // Position Descriptions Normalization (Strict Title Mapping + Index-based Fallback) + if (finalState.positionDescriptions) { + const normalized: Record = {}; + const rawPositions = finalState.positionDescriptions; - // Recipient Mapping - if (finalState.personName) finalState.name = finalState.personName; - if (finalState.email) finalState.email = finalState.email; - - // Normalization Layer: Map hallucinated German keys back to internal keys - const normalizationMap: Record = { - "Briefing-Zusammenfassung": "briefingSummary", - "Design-Vision": "designVision", - "Zusammenfassung": "briefingSummary", - "Vision": "designVision", - "BRIEFING_SUMMARY": "briefingSummary", - "DESIGN_VISION": "designVision" - }; - - Object.entries(normalizationMap).forEach(([gerKey, intKey]) => { - if (finalState[gerKey] && !finalState[intKey]) { - if (typeof finalState[gerKey] === 'object' && !Array.isArray(finalState[gerKey])) { - finalState[intKey] = Object.values(finalState[gerKey]).join('\n\n'); - } else { - finalState[intKey] = finalState[gerKey]; - } - } + // 1. Initial cleanup + Object.entries(rawPositions).forEach(([key, value]) => { + const normalizedValue = + typeof value === "object" + ? (value as any).beschreibung || + (value as any).description || + JSON.stringify(value) + : value; + normalized[key] = normalizedValue as string; }); - // Final Logic Guard: Strictly strip CMS from ALL descriptions and fields if not enabled - if (!finalState.cmsSetup) { - const stripCMS = (obj: any): any => { - if (typeof obj === 'string') { - return obj.replace(/CMS|Content-Management-System|Inhaltsverwaltung/gi, 'Plattform-Struktur'); - } - if (Array.isArray(obj)) { - return obj.map(stripCMS); - } - if (obj !== null && typeof obj === 'object') { - const newObj: any = {}; - Object.entries(obj).forEach(([k, v]) => { - newObj[k] = stripCMS(v); - }); - return newObj; - } - return obj; - }; - finalState = stripCMS(finalState); - } + // 2. Index-based matching (Map "10. Foo" to "10. Bar") + const standardTitles = [ + "1. Das technische Fundament", + "2. Individuelle Seiten", + "3. System-Module (Features)", + "4. Logik-Funktionen", + "5. Schnittstellen (API)", + "6. Inhalts-Verwaltung", + "7. Mehrsprachigkeit", + "8. Inhaltliche Initial-Pflege", + "9. Sorglos Betrieb", + ]; - // Sitemap Normalization (German keys to internal) - if (Array.isArray(finalState.sitemap)) { - finalState.sitemap = finalState.sitemap.map((cat: any) => ({ - category: cat.category || cat.kategorie || cat.Kategorie || cat.title || "Allgemein", - pages: (cat.pages || cat.seiten || cat.Seiten || []).map((page: any) => ({ - title: page.title || page.titel || page.Titel || "Seite", - desc: page.desc || page.beschreibung || page.Beschreibung || page.description || "" - })) + standardTitles.forEach((std) => { + const prefix = std.split(".")[0] + "."; // e.g., "10." + // Find any key in the AI output that starts with this number + const matchingKey = Object.keys(normalized).find((k) => + k.trim().startsWith(prefix), + ); + if (matchingKey && matchingKey !== std) { + normalized[std] = normalized[matchingKey]; + // Keep the old key too just in case, but prioritize the standard one + } + }); + + finalState.positionDescriptions = normalized; + } + + // Normalize final state + if (Array.isArray(finalState.positionDescriptions)) { + const normalized: Record = {}; + finalState.positionDescriptions.forEach((item: any) => { + const key = item.feature || item.id || item.title || item.pos; + if (key) normalized[key] = item.description || item.desc; + }); + finalState.positionDescriptions = normalized; + } + + if (finalState.sitemap && !Array.isArray(finalState.sitemap)) { + if (finalState.sitemap.categories) + finalState.sitemap = finalState.sitemap.categories; + else if (finalState.sitemap.sitemap) + finalState.sitemap = finalState.sitemap.sitemap; + else { + const entries = Object.entries(finalState.sitemap); + if (entries.every(([_, v]) => Array.isArray(v))) { + finalState.sitemap = entries.map(([category, pages]) => ({ + category, + pages, })); + } } + } - // Position Descriptions Normalization (Strict Title Mapping + Index-based Fallback) - if (finalState.positionDescriptions) { - const normalized: Record = {}; - const rawPositions = finalState.positionDescriptions; + // Final Post-Reflection Budget Sync (Hard Pruning if still over) + if (budget) { + const targetValue = parseInt(budget.replace(/[^0-9]/g, "")); + if (!isNaN(targetValue)) { + console.log(`⚖️ Final Budget Audit(${targetValue} € target)...`); + let currentTotals = calculateTotals(finalState, PRICING); - // 1. Initial cleanup - Object.entries(rawPositions).forEach(([key, value]) => { - const normalizedValue = typeof value === 'object' ? (value as any).beschreibung || (value as any).description || JSON.stringify(value) : value; - normalized[key] = normalizedValue as string; - }); + // Step-by-step pruning if too expensive + if (currentTotals.totalPrice > targetValue) { + console.log( + `⚠️ Budget exceeded(${currentTotals.totalPrice} €).Pruning scope to fit ${targetValue} €...`, + ); - // 2. Index-based matching (Map "10. Foo" to "10. Bar") - const standardTitles = [ - "1. Das technische Fundament", - "2. Individuelle Seiten", - "3. System-Module (Features)", - "4. Logik-Funktionen", - "5. Schnittstellen (API)", - "6. Inhaltsverwaltung (CMS)", - "7. Inszenierung & Interaktion", - "8. Mehrsprachigkeit", - "9. Inhaltliche Initial-Pflege", - "10. Sorglos-Paket (Betrieb & Pflege)" - ]; + // 1. Remove optional "other" stuff + finalState.otherFeatures = []; + finalState.otherFunctions = []; + finalState.otherTech = []; - standardTitles.forEach(std => { - const prefix = std.split('.')[0] + '.'; // e.g., "10." - // Find any key in the AI output that starts with this number - const matchingKey = Object.keys(normalized).find(k => k.trim().startsWith(prefix)); - if (matchingKey && matchingKey !== std) { - normalized[std] = normalized[matchingKey]; - // Keep the old key too just in case, but prioritize the standard one - } - }); - - finalState.positionDescriptions = normalized; - } - - // Normalize final state - if (Array.isArray(finalState.positionDescriptions)) { - const normalized: Record = {}; - finalState.positionDescriptions.forEach((item: any) => { - const key = item.feature || item.id || item.title || item.pos; - if (key) normalized[key] = item.description || item.desc; - }); - finalState.positionDescriptions = normalized; - } - - if (finalState.sitemap && !Array.isArray(finalState.sitemap)) { - if (finalState.sitemap.categories) finalState.sitemap = finalState.sitemap.categories; - else if (finalState.sitemap.sitemap) finalState.sitemap = finalState.sitemap.sitemap; - else { - const entries = Object.entries(finalState.sitemap); - if (entries.every(([_, v]) => Array.isArray(v))) { - finalState.sitemap = entries.map(([category, pages]) => ({ category, pages })); - } + // 2. Remove non-critical functions + const funcPriority = ["search", "filter", "calendar", "multilang"]; + for (const f of funcPriority) { + if (currentTotals.totalPrice <= targetValue) break; + if (finalState.functions.includes(f)) { + finalState.functions = finalState.functions.filter( + (x: string) => x !== f, + ); + currentTotals = calculateTotals(finalState, PRICING); + } } - } - // Final Post-Reflection Budget Sync (Hard Pruning if still over) - if (budget) { - const targetValue = parseInt(budget.replace(/[^0-9]/g, '')); - if (!isNaN(targetValue)) { - console.log(`⚖️ Final Budget Audit(${targetValue} € target)...`); - let currentTotals = calculateTotals(finalState, PRICING); - - // Step-by-step pruning if too expensive - if (currentTotals.totalPrice > targetValue) { - console.log(`⚠️ Budget exceeded(${currentTotals.totalPrice} €).Pruning scope to fit ${targetValue} €...`); - - // 1. Remove optional "other" stuff - finalState.otherFeatures = []; - finalState.otherFunctions = []; - finalState.otherTech = []; - - // 2. Remove non-critical functions - const funcPriority = ['search', 'filter', 'calendar', 'multilang']; - for (const f of funcPriority) { - if (currentTotals.totalPrice <= targetValue) break; - if (finalState.functions.includes(f)) { - finalState.functions = finalState.functions.filter((x: string) => x !== f); - currentTotals = calculateTotals(finalState, PRICING); - } - } - - // 3. Remove least critical features if still over - const featurePriority = ['events', 'blog_news', 'products']; - for (const p of featurePriority) { - if (currentTotals.totalPrice <= targetValue) break; - if (finalState.features.includes(p)) { - finalState.features = finalState.features.filter((f: string) => f !== p); - currentTotals = calculateTotals(finalState, PRICING); - } - } - - // 4. Reduce page count (Selected Pages AND Sitemap) - while (currentTotals.totalPrice > targetValue && (finalState.selectedPages.length > 4 || currentTotals.totalPagesCount > 5)) { - if (finalState.selectedPages.length > 4) { - finalState.selectedPages.pop(); - } - - // Prune Sitemap to match - if (finalState.sitemap && Array.isArray(finalState.sitemap)) { - const lastCat = finalState.sitemap[finalState.sitemap.length - 1]; - if (lastCat && lastCat.pages && lastCat.pages.length > 0) { - lastCat.pages.pop(); - if (lastCat.pages.length === 0) finalState.sitemap.pop(); - } - } - currentTotals = calculateTotals(finalState, PRICING); - } - - // 5. Final fallback: Remove second feature if still over - if (currentTotals.totalPrice > targetValue && finalState.features.length > 1) { - finalState.features.pop(); - currentTotals = calculateTotals(finalState, PRICING); - } - } - console.log(`✅ Final budget audit complete: ${currentTotals.totalPrice} €`); + // 3. Remove least critical features if still over + const featurePriority = ["events", "blog_news", "products"]; + for (const p of featurePriority) { + if (currentTotals.totalPrice <= targetValue) break; + if (finalState.features.includes(p)) { + finalState.features = finalState.features.filter( + (f: string) => f !== p, + ); + currentTotals = calculateTotals(finalState, PRICING); + } } - } - return { state: finalState, usage }; -} + // 4. Reduce page count (Selected Pages AND Sitemap) + while ( + currentTotals.totalPrice > targetValue && + (finalState.selectedPages.length > 4 || + currentTotals.totalPagesCount > 5) + ) { + if (finalState.selectedPages.length > 4) { + finalState.selectedPages.pop(); + } + + // Prune Sitemap to match + if (finalState.sitemap && Array.isArray(finalState.sitemap)) { + const lastCat = finalState.sitemap[finalState.sitemap.length - 1]; + if (lastCat && lastCat.pages && lastCat.pages.length > 0) { + lastCat.pages.pop(); + if (lastCat.pages.length === 0) finalState.sitemap.pop(); + } + } + currentTotals = calculateTotals(finalState, PRICING); + } + + // 5. Final fallback: Remove second feature if still over + if ( + currentTotals.totalPrice > targetValue && + finalState.features.length > 1 + ) { + finalState.features.pop(); + currentTotals = calculateTotals(finalState, PRICING); + } + } + console.log( + `✅ Final budget audit complete: ${currentTotals.totalPrice} €`, + ); + } + } + + return { state: finalState, usage }; +}; main().catch(console.error); diff --git a/apps/web/scripts/generate-estimate.ts b/apps/web/scripts/generate-estimate.ts index 8f08366..7255bce 100644 --- a/apps/web/scripts/generate-estimate.ts +++ b/apps/web/scripts/generate-estimate.ts @@ -1,131 +1,165 @@ -import * as fs from 'node:fs'; -import * as path from 'node:path'; -import * as readline from 'node:readline/promises'; -import { fileURLToPath } from 'node:url'; -import { createElement } from 'react'; -import { renderToFile } from '@react-pdf/renderer'; -import { calculatePositions, calculateTotals } from '../src/logic/pricing/calculator.js'; -import { CombinedQuotePDF } from '../src/components/CombinedQuotePDF.js'; -import { initialState, PRICING } from '../src/logic/pricing/constants.js'; -import { getTechDetails, getPrinciples } from '../src/logic/content-provider.js'; +import * as fs from "node:fs"; +import * as path from "node:path"; +import * as readline from "node:readline/promises"; +import { fileURLToPath } from "node:url"; +import { createElement } from "react"; +import { renderToFile } from "@react-pdf/renderer"; +import { + calculatePositions, + calculateTotals, +} from "../src/logic/pricing/calculator.js"; +import { CombinedQuotePDF } from "../src/components/CombinedQuotePDF.js"; +import { initialState, PRICING } from "../src/logic/pricing/constants.js"; +import { + getTechDetails, + getPrinciples, + getMaintenanceDetails, + getStandardsDetails, +} from "../src/logic/content-provider.js"; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); async function main() { - const args = process.argv.slice(2); - const isInteractive = args.includes('--interactive') || args.includes('-I'); - const isEstimationOnly = args.includes('--estimation') || args.includes('-E'); - const inputPath = args.find((_, i) => args[i - 1] === '--input' || args[i - 1] === '-i'); + const args = process.argv.slice(2); + const isInteractive = args.includes("--interactive") || args.includes("-I"); + const isEstimationOnly = args.includes("--estimation") || args.includes("-E"); + const inputPath = args.find( + (_, i) => args[i - 1] === "--input" || args[i - 1] === "-i", + ); - let state = { ...initialState }; + let state = { ...initialState }; - if (inputPath) { - const rawData = fs.readFileSync(path.resolve(process.cwd(), inputPath), 'utf8'); - const diskState = JSON.parse(rawData); - state = { ...state, ...diskState }; - } - - if (isInteractive) { - state = await runWizard(state); - } - - // Final confirmation of data needed for PDF - if (!state.name || !state.email) { - console.warn('⚠️ Missing recipient name or email. Document might look incomplete.'); - } - - const totals = calculateTotals(state, PRICING); - const { totalPrice, monthlyPrice, totalPagesCount } = totals; - - const finalOutputPath = generateDefaultPath(state); - const outputDir = path.dirname(finalOutputPath); - if (!fs.existsSync(outputDir)) { - fs.mkdirSync(outputDir, { recursive: true }); - } - - // Resolve assets for the PDF - const assetsDir = path.resolve(process.cwd(), 'src/assets'); - const headerIcon = path.join(assetsDir, 'logo/Icon White Transparent.png'); - const footerLogo = path.join(assetsDir, 'logo/Logo Black Transparent.png'); - - console.log(`🚀 Generating PDF: ${finalOutputPath}`); - - const estimationProps = { - state, - totalPrice, - monthlyPrice, - totalPagesCount, - pricing: PRICING, - headerIcon, - footerLogo - }; - - await renderToFile( - createElement(CombinedQuotePDF as any, { - estimationProps, - techDetails: getTechDetails(), - principles: getPrinciples(), - mode: isEstimationOnly ? 'estimation' : 'full', - showAgbs: !isEstimationOnly // AGBS only for full quotes - }) as any, - finalOutputPath + if (inputPath) { + const rawData = fs.readFileSync( + path.resolve(process.cwd(), inputPath), + "utf8", ); + const diskState = JSON.parse(rawData); + state = { ...state, ...diskState }; + } - console.log('✅ Done!'); + if (isInteractive) { + state = await runWizard(state); + } + + // Final confirmation of data needed for PDF + if (!state.name || !state.email) { + console.warn( + "⚠️ Missing recipient name or email. Document might look incomplete.", + ); + } + + const totals = calculateTotals(state, PRICING); + const { totalPrice, monthlyPrice, totalPagesCount } = totals; + + const finalOutputPath = generateDefaultPath(state); + const outputDir = path.dirname(finalOutputPath); + if (!fs.existsSync(outputDir)) { + fs.mkdirSync(outputDir, { recursive: true }); + } + + // Resolve assets for the PDF + const assetsDir = path.resolve(process.cwd(), "src/assets"); + const headerIcon = path.join(assetsDir, "logo/Icon White Transparent.png"); + const footerLogo = path.join(assetsDir, "logo/Logo Black Transparent.png"); + + console.log(`🚀 Generating PDF: ${finalOutputPath}`); + + const estimationProps = { + state, + totalPrice, + monthlyPrice, + totalPagesCount, + pricing: PRICING, + headerIcon, + footerLogo, + }; + + await renderToFile( + createElement(CombinedQuotePDF as any, { + estimationProps, + techDetails: getTechDetails(), + principles: getPrinciples(), + maintenanceDetails: getMaintenanceDetails(), + standardsDetails: getStandardsDetails(), + mode: isEstimationOnly ? "estimation" : "full", + showAgbs: !isEstimationOnly, // AGBS only for full quotes + }) as any, + finalOutputPath, + ); + + console.log("✅ Done!"); } async function runWizard(state: any) { - const rl = readline.createInterface({ - input: process.stdin, - output: process.stdout - }); + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + }); - console.log('\n--- Mintel Quote Generator Wizard ---\n'); + console.log("\n--- Mintel Quote Generator Wizard ---\n"); - const ask = async (q: string, def?: string) => { - const answer = await rl.question(`${q}${def ? ` [${def}]` : ''}: `); - return answer || def || ''; - }; + const ask = async (q: string, def?: string) => { + const answer = await rl.question(`${q}${def ? ` [${def}]` : ""}: `); + return answer || def || ""; + }; - const selectOne = async (q: string, options: { id: string, label: string }[]) => { - console.log(`\n${q}:`); - options.forEach((opt, i) => console.log(`${i + 1}) ${opt.label}`)); - const answer = await rl.question('Selection (number): '); - const idx = parseInt(answer) - 1; - return options[idx]?.id || options[0].id; - }; + const selectOne = async ( + q: string, + options: { id: string; label: string }[], + ) => { + console.log(`\n${q}:`); + options.forEach((opt, i) => console.log(`${i + 1}) ${opt.label}`)); + const answer = await rl.question("Selection (number): "); + const idx = parseInt(answer) - 1; + return options[idx]?.id || options[0].id; + }; - state.name = await ask('Recipient Name', state.name); - state.email = await ask('Recipient Email', state.email); - state.companyName = await ask('Company Name', state.companyName); + state.name = await ask("Recipient Name", state.name); + state.email = await ask("Recipient Email", state.email); + state.companyName = await ask("Company Name", state.companyName); - state.projectType = await selectOne('Project Type', [ - { id: 'website', label: 'Website' }, - { id: 'web-app', label: 'Web App' } - ]); + state.projectType = await selectOne("Project Type", [ + { id: "website", label: "Website" }, + { id: "web-app", label: "Web App" }, + ]); - if (state.projectType === 'website') { - state.websiteTopic = await ask('Website Topic', state.websiteTopic); - // Simplified for now, in a real tool we'd loop through all options - } + if (state.projectType === "website") { + state.websiteTopic = await ask("Website Topic", state.websiteTopic); + // Simplified for now, in a real tool we'd loop through all options + } - rl.close(); - return state; + rl.close(); + return state; } - function generateDefaultPath(state: any) { - const now = new Date(); - const month = now.toISOString().slice(0, 7); - const day = now.toISOString().slice(0, 10); - // Add seconds and minutes for 100% unique names without collision - const time = now.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit', second: '2-digit' }).replace(/:/g, '-'); - const company = (state.companyName || state.name || 'Unknown').replace(/[^a-z0-9]/gi, '_'); - return path.join(process.cwd(), 'out', 'estimations', month, `${day}_${time}_${company}_${state.projectType}.pdf`); + const now = new Date(); + const month = now.toISOString().slice(0, 7); + const day = now.toISOString().slice(0, 10); + // Add seconds and minutes for 100% unique names without collision + const time = now + .toLocaleTimeString("de-DE", { + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + }) + .replace(/:/g, "-"); + const company = (state.companyName || state.name || "Unknown").replace( + /[^a-z0-9]/gi, + "_", + ); + return path.join( + process.cwd(), + "out", + "estimations", + month, + `${day}_${time}_${company}_${state.projectType}.pdf`, + ); } -main().catch(err => { - console.error('❌ Error:', err); - process.exit(1); +main().catch((err) => { + console.error("❌ Error:", err); + process.exit(1); }); diff --git a/apps/web/src/components/CombinedQuotePDF.tsx b/apps/web/src/components/CombinedQuotePDF.tsx index c9a5dc6..e697659 100644 --- a/apps/web/src/components/CombinedQuotePDF.tsx +++ b/apps/web/src/components/CombinedQuotePDF.tsx @@ -1,24 +1,80 @@ -'use client'; +"use client"; -import * as React from 'react'; -import { Document as PDFDocument } from '@react-pdf/renderer'; -import { EstimationPDF } from './EstimationPDF'; -import { AgbsPDF } from './AgbsPDF'; +import * as React from "react"; +import { Document as PDFDocument } from "@react-pdf/renderer"; +import { EstimationPDF } from "./EstimationPDF"; +import { AgbsPDF } from "./AgbsPDF"; +import { ClosingModule } from "./pdf/modules/CommonModules"; +import { SimpleLayout } from "./pdf/SimpleLayout"; interface CombinedProps { - estimationProps: any; - showAgbs?: boolean; - techDetails?: any[]; - principles?: any[]; + estimationProps: any; + showAgbs?: boolean; + techDetails?: any[]; + principles?: any[]; + maintenanceDetails?: any[]; + standardsDetails?: any[]; } -export const CombinedQuotePDF = ({ estimationProps, showAgbs = true, techDetails, principles, mode = 'full' }: CombinedProps & { mode?: 'estimation' | 'full' }) => { - return ( - - - {showAgbs && ( - - )} - - ); +export const CombinedQuotePDF = ({ + estimationProps, + showAgbs = true, + techDetails, + principles, + maintenanceDetails, + standardsDetails, + mode = "full", +}: CombinedProps & { mode?: "estimation" | "full" }) => { + const date = new Date().toLocaleDateString("de-DE", { + year: "numeric", + month: "long", + day: "numeric", + }); + + const companyData = { + name: "Marc Mintel", + address1: "Georg-Meistermann-Straße 7", + address2: "54586 Schüller", + ustId: "DE367588065", + }; + + const bankData = { + name: "N26", + bic: "NTSBDEB1XXX", + iban: "DE50 1001 1001 2620 4328 65", + }; + + const layoutProps = { + date, + icon: estimationProps.headerIcon, + footerLogo: estimationProps.footerLogo, + companyData, + bankData, + }; + + return ( + + + {showAgbs && ( + + )} + + + + + ); }; diff --git a/apps/web/src/components/ContactForm/components/PriceCalculation.tsx b/apps/web/src/components/ContactForm/components/PriceCalculation.tsx index 89eea7f..cbe8117 100644 --- a/apps/web/src/components/ContactForm/components/PriceCalculation.tsx +++ b/apps/web/src/components/ContactForm/components/PriceCalculation.tsx @@ -1,16 +1,19 @@ -'use client'; +"use client"; -import * as React from 'react'; -import { FormState, Totals } from '../types'; -import { PRICING } from '../constants'; -import { AnimatedNumber } from './AnimatedNumber'; -import { ConceptPrice, ConceptAutomation } from '../../Landing/ConceptIllustrations'; -import { Info, Download, Share2, RefreshCw } from 'lucide-react'; -import { motion, AnimatePresence } from 'framer-motion'; -import dynamic from 'next/dynamic'; +import * as React from "react"; +import { FormState, Totals } from "../types"; +import { PRICING } from "../constants"; +import { AnimatedNumber } from "./AnimatedNumber"; +import { + ConceptPrice, + ConceptAutomation, +} from "../../Landing/ConceptIllustrations"; +import { Info, Download, Share2, RefreshCw } from "lucide-react"; +import { motion, AnimatePresence } from "framer-motion"; +import dynamic from "next/dynamic"; // EstimationPDF will be imported dynamically where used or inside the and client-side block -import IconWhite from '../../../assets/logo/Icon White Transparent.png'; -import LogoBlack from '../../../assets/logo/Logo Black Transparent.png'; +import IconWhite from "../../../assets/logo/Icon White Transparent.png"; +import LogoBlack from "../../../assets/logo/Logo Black Transparent.png"; // PDF components removed from top-level dynamic import to fix ESM resolution issues in Next.js 16/Webpack @@ -27,9 +30,17 @@ export function PriceCalculation({ totals, isClient, qrCodeData, - onShare + onShare, }: PriceCalculationProps) { - const { totalPrice, monthlyPrice, totalPagesCount, totalFeatures, totalFunctions, totalApis, languagesCount } = totals; + const { + totalPrice, + monthlyPrice, + totalPagesCount, + totalFeatures, + totalFunctions, + totalApis, + languagesCount, + } = totals; const totalPages = totalPagesCount; const [pdfLoading, setPdfLoading] = React.useState(false); @@ -40,35 +51,41 @@ export function PriceCalculation({ setPdfLoading(true); try { - const { EstimationPDF } = await import('../../EstimationPDF'); - const doc = ; + const { EstimationPDF } = await import("../../EstimationPDF"); + const doc = ( + + ); - const { pdf } = await import('@react-pdf/renderer'); + const { pdf } = await import("@react-pdf/renderer"); // Minimum loading time of 2 seconds for better UX const [blob] = await Promise.all([ pdf(doc).toBlob(), - new Promise(resolve => setTimeout(resolve, 2000)) + new Promise((resolve) => setTimeout(resolve, 2000)), ]); const url = URL.createObjectURL(blob); - const link = document.createElement('a'); + const link = document.createElement("a"); link.href = url; - link.download = `kalkulation-${state.name.toLowerCase().replace(/\s+/g, '-') || 'projekt'}.pdf`; + link.download = `kalkulation-${state.name.toLowerCase().replace(/\s+/g, "-") || "projekt"}.pdf`; document.body.appendChild(link); link.click(); document.body.removeChild(link); URL.revokeObjectURL(url); } catch (error) { - console.error('PDF generation failed:', error); + console.error("PDF generation failed:", error); } finally { setPdfLoading(false); } @@ -78,20 +95,93 @@ export function PriceCalculation({
- {state.projectType === 'website' ? ( + {state.projectType === "website" ? ( <>
- {totalPages > 0 && (
{totalPages}x Seite{(totalPages * PRICING.PAGE).toLocaleString()} €
)} - {totalFeatures > 0 && (
{totalFeatures}x System-Modul{(totalFeatures * PRICING.FEATURE).toLocaleString()} €
)} - {totalFunctions > 0 && (
{totalFunctions}x Logik-Funktion{(totalFunctions * PRICING.FUNCTION).toLocaleString()} €
)} - {totalApis > 0 && (
{totalApis}x API Sync{(totalApis * PRICING.API_INTEGRATION).toLocaleString()} €
)} - {state.cmsSetup && (
CMS Setup & Anbindung{(PRICING.CMS_SETUP + totalFeatures * PRICING.CMS_CONNECTION_PER_FEATURE).toLocaleString()} €
)} - {state.newDatasets > 0 && (
{state.newDatasets}x Inhalte einpflegen{(state.newDatasets * PRICING.NEW_DATASET).toLocaleString()} €
)} - {languagesCount > 1 && (
Mehrsprachigkeit ({languagesCount}x)+{(totalPrice - (totalPrice / (1 + (languagesCount - 1) * 0.2))).toLocaleString()} €
)} + {totalPages > 0 && ( +
+ {totalPages}x Seite + + {(totalPages * PRICING.PAGE).toLocaleString()} € + +
+ )} + {totalFeatures > 0 && ( +
+ + {totalFeatures}x System-Modul + + + {(totalFeatures * PRICING.FEATURE).toLocaleString()} € + +
+ )} + {totalFunctions > 0 && ( +
+ + {totalFunctions}x Logik-Funktion + + + {(totalFunctions * PRICING.FUNCTION).toLocaleString()} € + +
+ )} + {totalApis > 0 && ( +
+ + {totalApis}x API Sync + + + {(totalApis * PRICING.API_INTEGRATION).toLocaleString()} € + +
+ )} + {state.cmsSetup && ( +
+ Inhalts-Verwaltung + + {( + Math.max(1, totalFeatures) * + PRICING.CMS_CONNECTION_PER_FEATURE + ).toLocaleString()}{" "} + € + +
+ )} + {state.newDatasets > 0 && ( +
+ + {state.newDatasets}x Inhalte einpflegen + + + {( + state.newDatasets * PRICING.NEW_DATASET + ).toLocaleString()}{" "} + € + +
+ )} + {languagesCount > 1 && ( +
+ + Mehrsprachigkeit ({languagesCount}x) + + + + + {( + totalPrice - + totalPrice / (1 + (languagesCount - 1) * 0.2) + ).toLocaleString()}{" "} + € + +
+ )}
- Gesamt + + Gesamt +
€ @@ -101,8 +191,12 @@ export function PriceCalculation({
- Sorglos-Paket - {monthlyPrice.toLocaleString()} € / Monat + + Sorglos Betrieb (Hosting + Support) + + + {(monthlyPrice * 12).toLocaleString()} € / Jahr +
@@ -169,8 +263,15 @@ export function PriceCalculation({
-

Web Apps werden nach Aufwand abgerechnet.

-

{PRICING.APP_HOURLY} € / Std.

+

+ Web Apps werden nach Aufwand abgerechnet. +

+

+ {PRICING.APP_HOURLY} €{" "} + + / Std. + +

{onShare && ( @@ -186,7 +287,10 @@ export function PriceCalculation({
)}
-

Ein verbindliches Angebot erstelle ich nach einem persönlichen Gespräch.

+

+ Ein verbindliches Angebot erstelle ich nach einem persönlichen + Gespräch. +

); diff --git a/apps/web/src/components/EstimationPDF.tsx b/apps/web/src/components/EstimationPDF.tsx index e3a5a6b..0f2d70f 100644 --- a/apps/web/src/components/EstimationPDF.tsx +++ b/apps/web/src/components/EstimationPDF.tsx @@ -1,19 +1,24 @@ -'use client'; +"use client"; -import * as React from 'react'; -import { calculatePositions } from '../logic/pricing'; -import { Page as PDFPage } from '@react-pdf/renderer'; -import { pdfStyles } from './pdf/SharedUI'; -import { DINLayout } from './pdf/DINLayout'; -import { SimpleLayout } from './pdf/SimpleLayout'; +import * as React from "react"; +import { calculatePositions } from "../logic/pricing"; +import { Page as PDFPage } from "@react-pdf/renderer"; +import { pdfStyles } from "./pdf/SharedUI"; +import { DINLayout } from "./pdf/DINLayout"; +import { SimpleLayout } from "./pdf/SimpleLayout"; // Modules -import { FrontPageModule } from './pdf/modules/FrontPageModule'; -import { BriefingModule } from './pdf/modules/BriefingModule'; -import { SitemapModule } from './pdf/modules/SitemapModule'; -import { EstimationModule } from './pdf/modules/EstimationModule'; -import { TransparenzModule, techPageModule as TechPageModule, PrinciplesModule } from './pdf/modules/CommonModules'; -import { AboutModule, CrossSellModule } from './pdf/modules/BrandingModules'; +import { FrontPageModule } from "./pdf/modules/FrontPageModule"; +import { BriefingModule } from "./pdf/modules/BriefingModule"; +import { SitemapModule } from "./pdf/modules/SitemapModule"; +import { EstimationModule } from "./pdf/modules/EstimationModule"; +import { + TransparenzModule, + techPageModule as TechPageModule, + MaintenanceModule, + StandardsModule, +} from "./pdf/modules/CommonModules"; +import { AboutModule, CrossSellModule } from "./pdf/modules/BrandingModules"; interface PDFProps { state: any; @@ -21,28 +26,32 @@ interface PDFProps { monthlyPrice: number; totalPagesCount: number; pricing: any; - mode?: 'estimation' | 'full'; + mode?: "estimation" | "full"; headerIcon?: string; footerLogo?: string; - techDetails?: { t: string, d: string }[]; - principles?: { t: string, d: string }[]; + techDetails?: { t: string; d: string }[]; + principles?: { t: string; d: string }[]; + maintenanceDetails?: { t: string; d: string }[]; + standardsDetails?: { t: string; d: string }[]; } export const EstimationPDF = ({ state, totalPrice, pricing, - mode = 'full', + mode = "full", headerIcon, footerLogo, techDetails, principles, + maintenanceDetails, + standardsDetails, ...props }: PDFProps) => { - const date = new Date().toLocaleDateString('de-DE', { - year: 'numeric', - month: 'long', - day: 'numeric', + const date = new Date().toLocaleDateString("de-DE", { + year: "numeric", + month: "long", + day: "numeric", }); const positions = calculatePositions(state, pricing); @@ -51,13 +60,13 @@ export const EstimationPDF = ({ name: "Marc Mintel", address1: "Georg-Meistermann-Straße 7", address2: "54586 Schüller", - ustId: "DE367588065" + ustId: "DE367588065", }; const bankData = { name: "N26", bic: "NTSBDEB1XXX", - iban: "DE50 1001 1001 2620 4328 65" + iban: "DE50 1001 1001 2620 4328 65", }; const commonProps = { @@ -66,20 +75,25 @@ export const EstimationPDF = ({ icon: headerIcon, footerLogo, companyData, - bankData + bankData, }; - if (mode === 'estimation') { + if (mode === "estimation") { return ( - + ); } // Full Portfolio Mode let pageCounter = 1; - const getPageNum = () => (pageCounter++).toString().padStart(2, '0'); + const getPageNum = () => (pageCounter++).toString().padStart(2, "0"); return ( <> @@ -98,22 +112,36 @@ export const EstimationPDF = ({ )} - + + {standardsDetails && standardsDetails.length > 0 && ( + + + + )} + {techDetails && techDetails.length > 0 && ( )} - {principles && principles.length > 0 && ( + {maintenanceDetails && maintenanceDetails.length > 0 && ( - + )} diff --git a/apps/web/src/components/pdf/SharedUI.tsx b/apps/web/src/components/pdf/SharedUI.tsx index a82de9c..cf4a57d 100644 --- a/apps/web/src/components/pdf/SharedUI.tsx +++ b/apps/web/src/components/pdf/SharedUI.tsx @@ -1,304 +1,810 @@ -'use client'; +"use client"; -import * as React from 'react'; -import { View as PDFView, Text as PDFText, StyleSheet, Link as PDFLink, Image as PDFImage, Font } from '@react-pdf/renderer'; +import * as React from "react"; +import { + View as PDFView, + Text as PDFText, + StyleSheet, + Link as PDFLink, + Image as PDFImage, + Font, +} from "@react-pdf/renderer"; // INDUSTRIAL DESIGN SYSTEM TOKENS export const COLORS = { - CHARCOAL: '#0f172a', // Slate 900 - TEXT_MAIN: '#334155', // Slate 700 - TEXT_DIM: '#64748b', // Slate 500 - TEXT_LIGHT: '#94a3b8', // Slate 400 - DIVIDER: '#cbd5e1', // Slate 300 - GRID: '#f1f5f9', // Slate 100 - BLUEPRINT: '#e2e8f0', // Slate 200 - WHITE: '#ffffff' + CHARCOAL: "#0f172a", // Slate 900 + TEXT_MAIN: "#334155", // Slate 700 + TEXT_DIM: "#64748b", // Slate 500 + TEXT_LIGHT: "#94a3b8", // Slate 400 + DIVIDER: "#cbd5e1", // Slate 300 + GRID: "#f1f5f9", // Slate 100 + BLUEPRINT: "#e2e8f0", // Slate 200 + WHITE: "#ffffff", }; export const FONT_SIZES = { - H1: 28, - H2: 20, - H3: 14, - BODY: 11, - TINY: 9, - SUB: 10, - BLUEPRINT: 8 + HERO: 24, // Main Page Titles + HEADING: 14, // Section Headers + BODY: 11, // Standard Content + LABEL: 10, // Bold Labels / Keys + SMALL: 9, // Descriptions / Footnotes + TINY: 8, // Metadata / Unit prices +}; + +// Mintel Industrial Glyphs (strictly 1px stroke, 12x12px grid) +export const IndustrialGlyph = ({ + type, + color = COLORS.TEXT_LIGHT, + size = 12, +}: { + type: string; + color?: string; + size?: number; +}) => { + const stroke = 1; + const scale = size / 12; + + switch (type) { + case "base": // Skeletal cube base + return ( + + + + + ); + case "pages": // Layered rectangles + return ( + + + + + ); + case "modules": // Four small squares grid + return ( + + + + + + + ); + case "logic": // Diamond with center point + return ( + + + + + ); + case "interface": // Three horizontal lines of varying length + return ( + + + + + + ); + case "management": // Framed grid + return ( + + + + + + ); + case "reveal": // Ascending bars + return ( + + + + + + ); + case "maintenance": // Circle with vertical notch + return ( + + + + ); + default: + return ( + + ); + } }; // Register a more technical font if possible, or use Helvetica with varying weights // Note: helvetica-bold is standard in react-pdf export const pdfStyles = StyleSheet.create({ - page: { - paddingTop: 45, // DIN 5008 - paddingLeft: 70, // ~25mm - paddingRight: 57, // ~20mm - paddingBottom: 80, // Safe buffer for absolute footer - backgroundColor: COLORS.WHITE, - fontFamily: 'Helvetica', - fontSize: FONT_SIZES.BODY, - color: COLORS.CHARCOAL, - }, - titlePage: { - width: '100%', - height: '100%', - backgroundColor: COLORS.WHITE, - fontFamily: 'Helvetica', - color: COLORS.CHARCOAL, - padding: 0, // NO PADDING to prevent inner overflow page breaks - }, - header: { - flexDirection: 'row', - justifyContent: 'space-between', - alignItems: 'flex-start', - marginBottom: 20, - minHeight: 120, - }, - addressBlock: { - width: '55%', - marginTop: 45, // DIN 5008 positioning for window - }, - senderLine: { - fontSize: FONT_SIZES.TINY, - textDecoration: 'underline', - color: COLORS.TEXT_DIM, - marginBottom: 8, - }, - recipientAddress: { - fontSize: FONT_SIZES.BODY, - lineHeight: 1.4, - }, - brandLogoContainer: { - width: '40%', - alignItems: 'flex-end', - }, - brandIconContainer: { - width: 40, - height: 40, - backgroundColor: '#0f172a', - borderRadius: 8, - alignItems: 'center', - justifyContent: 'center', - marginBottom: 12, - }, - brandIconText: { - color: COLORS.WHITE, - fontSize: 20, - fontWeight: 'bold', - }, - titleInfo: { - marginBottom: 24, - }, - mainTitle: { - fontSize: FONT_SIZES.H3, - fontWeight: 'bold', - marginBottom: 4, - color: COLORS.CHARCOAL, - letterSpacing: 0.5, - }, - subTitle: { - fontSize: FONT_SIZES.BODY, - color: COLORS.TEXT_DIM, - marginTop: 2, - }, - section: { - marginBottom: 32, - }, - sectionTitle: { - fontSize: FONT_SIZES.SUB, - fontWeight: 'bold', - textTransform: 'uppercase', - letterSpacing: 1, - color: COLORS.TEXT_LIGHT, - marginBottom: 8, - }, - footer: { - position: 'absolute', - bottom: 32, - left: 70, - right: 57, - borderTopWidth: 1, - borderTopColor: COLORS.GRID, - paddingTop: 16, - flexDirection: 'row', - justifyContent: 'space-between', - alignItems: 'flex-start', - }, - footerColumn: { - flex: 1, - alignItems: 'flex-start', - }, - footerLogo: { - height: 20, - width: 'auto', - objectFit: 'contain', - marginBottom: 8, - }, - footerText: { - fontSize: FONT_SIZES.TINY, - color: COLORS.TEXT_LIGHT, - lineHeight: 1.5, - }, - footerLabel: { - fontWeight: 'bold', - color: COLORS.TEXT_DIM, - }, - pageNumber: { - fontSize: FONT_SIZES.TINY, - color: COLORS.DIVIDER, - fontWeight: 'bold', - marginTop: 8, - textAlign: 'right', - }, - foldingMark: { - position: 'absolute', - left: 20, - width: 10, - borderTopWidth: 0.5, - borderTopColor: COLORS.DIVIDER, - }, - divider: { - width: '100%', - height: 1, - backgroundColor: COLORS.DIVIDER, - marginVertical: 12, - }, - blueprintGrid: { - position: 'absolute', - top: 0, - left: 0, - right: 0, - bottom: 0, - zIndex: -10, - }, - gridLineH: { - width: '100%', - height: 0.5, - backgroundColor: COLORS.GRID, - position: 'absolute', - }, - gridLineV: { - width: 0.5, - height: '100%', - backgroundColor: COLORS.GRID, - position: 'absolute', - }, - technicalMarker: { - position: 'absolute', - fontSize: FONT_SIZES.BLUEPRINT, - color: COLORS.BLUEPRINT, - fontFamily: 'Helvetica', - letterSpacing: 1, - }, - // Atoms - industrialListItem: { - flexDirection: 'row', - alignItems: 'flex-start', - marginBottom: 6, - }, - industrialBulletBox: { - width: 6, - height: 6, - backgroundColor: COLORS.DIVIDER, - marginRight: 8, - marginTop: 5, - }, - industrialTitle: { - fontSize: FONT_SIZES.H1, - fontWeight: 'bold', - color: COLORS.CHARCOAL, - marginBottom: 6, - letterSpacing: 0, // Reset for clarity - }, - industrialSubtitle: { - fontSize: FONT_SIZES.SUB, - fontWeight: 'bold', - color: COLORS.TEXT_LIGHT, - textTransform: 'uppercase', - marginBottom: 16, - letterSpacing: 2, - }, - industrialTextLead: { - fontSize: FONT_SIZES.BODY, - color: COLORS.TEXT_MAIN, - lineHeight: 1.6, - marginBottom: 12, - }, - industrialText: { - fontSize: FONT_SIZES.BODY, - color: COLORS.TEXT_DIM, - lineHeight: 1.6, - marginBottom: 8, - }, - industrialCard: { - padding: 16, - borderWidth: 1, - borderColor: COLORS.BLUEPRINT, - marginBottom: 12, - }, - industrialCardTitle: { - fontSize: FONT_SIZES.BODY + 1, // 10 - fontWeight: 'bold', - color: COLORS.CHARCOAL, - marginBottom: 4, - textTransform: 'uppercase', - letterSpacing: 0.5, - }, - darkBox: { - marginTop: 32, - padding: 24, - backgroundColor: COLORS.CHARCOAL, - color: COLORS.WHITE, - }, - darkTitle: { - fontSize: FONT_SIZES.H2, - fontWeight: 'bold', - color: COLORS.WHITE, - marginBottom: 8, - }, - darkText: { - fontSize: FONT_SIZES.BODY, - color: COLORS.TEXT_LIGHT, - lineHeight: 1.6, - }, + page: { + paddingTop: 45, // DIN 5008 + paddingLeft: 70, // ~25mm + paddingRight: 57, // ~20mm + paddingBottom: 80, // Safe buffer for absolute footer + backgroundColor: COLORS.WHITE, + fontFamily: "Helvetica", + fontSize: FONT_SIZES.BODY, + color: COLORS.CHARCOAL, + }, + titlePage: { + width: "100%", + height: "100%", + backgroundColor: COLORS.WHITE, + fontFamily: "Helvetica", + color: COLORS.CHARCOAL, + padding: 0, // NO PADDING to prevent inner overflow page breaks + }, + header: { + flexDirection: "row", + justifyContent: "space-between", + alignItems: "flex-start", + marginBottom: 20, + minHeight: 120, + }, + addressBlock: { + width: "55%", + marginTop: 45, // DIN 5008 positioning for window + }, + senderLine: { + fontSize: FONT_SIZES.TINY, + textDecoration: "underline", + color: COLORS.TEXT_DIM, + marginBottom: 8, + }, + recipientAddress: { + fontSize: FONT_SIZES.BODY, + lineHeight: 1.4, + }, + brandLogoContainer: { + width: "40%", + alignItems: "flex-end", + }, + brandIconContainer: { + width: 40, + height: 40, + backgroundColor: "#0f172a", + borderRadius: 8, + alignItems: "center", + justifyContent: "center", + marginBottom: 12, + }, + brandIconText: { + color: COLORS.WHITE, + fontSize: 20, + fontWeight: "bold", + }, + titleInfo: { + marginBottom: 24, + }, + mainTitle: { + fontSize: FONT_SIZES.HEADING, + fontWeight: "bold", + marginBottom: 4, + color: COLORS.CHARCOAL, + letterSpacing: 0.5, + }, + subTitle: { + fontSize: FONT_SIZES.BODY, + color: COLORS.TEXT_DIM, + marginTop: 2, + lineHeight: 1.4, + }, + section: { + marginBottom: 32, + }, + sectionTitle: { + fontSize: FONT_SIZES.LABEL, + fontWeight: "bold", + textTransform: "uppercase", + letterSpacing: 1, + color: COLORS.TEXT_LIGHT, + marginBottom: 8, + }, + footer: { + position: "absolute", + bottom: 32, + left: 70, + right: 57, + borderTopWidth: 1, + borderTopColor: COLORS.GRID, + paddingTop: 16, + flexDirection: "row", + justifyContent: "space-between", + alignItems: "flex-start", + }, + footerColumn: { + flex: 1, + alignItems: "flex-start", + }, + footerLogo: { + height: 20, + width: "auto", + objectFit: "contain", + marginBottom: 8, + }, + footerText: { + fontSize: FONT_SIZES.TINY, + color: COLORS.TEXT_LIGHT, + lineHeight: 1.4, + }, + // NEW LAYOUT PRIMITIVES + asymmetryContainer: { + flexDirection: "row", + gap: 32, + }, + asymmetryLeft: { + width: "32%", + }, + asymmetryRight: { + width: "63%", + }, + specRow: { + flexDirection: "row", + justifyContent: "space-between", + paddingVertical: 6, + borderBottomWidth: 1, + borderBottomColor: COLORS.GRID, + }, + specLabel: { + fontSize: FONT_SIZES.TINY, + fontWeight: "bold", + color: COLORS.TEXT_LIGHT, + textTransform: "uppercase", + letterSpacing: 0.5, + }, + specValue: { + fontSize: FONT_SIZES.SMALL, + color: COLORS.CHARCOAL, + fontWeight: "bold", + }, + blueprintBox: { + borderWidth: 1, + borderColor: COLORS.GRID, + padding: 16, + backgroundColor: "#fafafa", + }, + footerLabel: { + fontWeight: "bold", + color: COLORS.TEXT_DIM, + }, + pageNumber: { + fontSize: FONT_SIZES.TINY, + color: COLORS.DIVIDER, + fontWeight: "bold", + marginTop: 8, + textAlign: "right", + }, + foldingMark: { + position: "absolute", + left: 20, + width: 10, + borderTopWidth: 0.5, + borderTopColor: COLORS.DIVIDER, + }, + divider: { + width: "100%", + height: 1, + backgroundColor: COLORS.DIVIDER, + marginVertical: 12, + }, + blueprintGrid: { + position: "absolute", + top: 0, + left: 0, + right: 0, + bottom: 0, + zIndex: -10, + }, + gridLineH: { + width: "100%", + height: 0.5, + backgroundColor: COLORS.GRID, + position: "absolute", + }, + gridLineV: { + width: 0.5, + height: "100%", + backgroundColor: COLORS.GRID, + position: "absolute", + }, + technicalMarker: { + position: "absolute", + fontSize: FONT_SIZES.TINY, + color: COLORS.BLUEPRINT, + fontFamily: "Helvetica", + letterSpacing: 1, + }, + // Atoms + industrialListItem: { + flexDirection: "row", + alignItems: "flex-start", + marginBottom: 6, + }, + industrialBulletBox: { + width: 6, + height: 6, + backgroundColor: COLORS.DIVIDER, + marginRight: 8, + marginTop: 5, + }, + industrialTitle: { + fontSize: FONT_SIZES.HERO, + fontWeight: "bold", + color: COLORS.CHARCOAL, + marginBottom: 6, + letterSpacing: 0, // Reset for clarity + }, + industrialSubtitle: { + fontSize: FONT_SIZES.LABEL, + fontWeight: "bold", + color: COLORS.TEXT_LIGHT, + textTransform: "uppercase", + marginBottom: 16, + letterSpacing: 2, + }, + industrialTextLead: { + fontSize: FONT_SIZES.BODY, + color: COLORS.TEXT_MAIN, + lineHeight: 1.4, + marginBottom: 12, + }, + industrialText: { + fontSize: FONT_SIZES.BODY, + color: COLORS.TEXT_DIM, + lineHeight: 1.4, + marginBottom: 8, + }, + industrialCard: { + padding: 16, + borderWidth: 1, + borderColor: COLORS.BLUEPRINT, + marginBottom: 12, + }, + industrialCardTitle: { + fontSize: FONT_SIZES.BODY + 1, // 10 + fontWeight: "bold", + color: COLORS.CHARCOAL, + marginBottom: 4, + textTransform: "uppercase", + letterSpacing: 0.5, + }, + darkBox: { + marginTop: 32, + padding: 24, + backgroundColor: COLORS.CHARCOAL, + color: COLORS.WHITE, + }, + darkTitle: { + fontSize: FONT_SIZES.HERO, + fontWeight: "bold", + color: COLORS.WHITE, + marginBottom: 8, + }, + darkText: { + fontSize: FONT_SIZES.BODY, + color: COLORS.TEXT_LIGHT, + lineHeight: 1.4, + }, }); const styles = pdfStyles; export const BlueprintBackground = () => ( - - {/* Clean background - grid lines removed per user request */} - + + {/* Clean background - grid lines removed per user request */} + ); -export const IndustrialListItem = ({ children }: { children: React.ReactNode }) => ( - - - {children} - +export const IndustrialListItem = ({ + children, +}: { + children: React.ReactNode; +}) => ( + + + {children} + ); -export const IndustrialCard = ({ title, children, style = {} }: { title: string; children: React.ReactNode; style?: any }) => ( - - {title} - {children} - +export const IndustrialCard = ({ + title, + children, + style = {}, +}: { + title: string; + children: React.ReactNode; + style?: any; +}) => ( + + {title} + {children} + ); -export const FoldingMarks = () => (<>); +export const FoldingMarks = () => ( + <> + + + + +); export const Divider = ({ style = {} }: { style?: any }) => ( - + ); -export const Footer = ({ logo, companyData, bankData, showDetails = true, showPageNumber = true }: { logo?: string; companyData: any; bankData: any; showDetails?: boolean; showPageNumber?: boolean }) => ( - {logo ? () : (marc mintel)}{showDetails && (<>{companyData.name}{"\n"}{companyData.address1}{"\n"}{companyData.address2}{"\n"}UST: {companyData.ustId}{showPageNumber && `${pageNumber} / ${totalPages}`} fixed />})}{!showDetails && ({showPageNumber && `${pageNumber} / ${totalPages}`} fixed />})} +export const Footer = ({ + logo, + companyData, + bankData, + showDetails = true, + showPageNumber = true, +}: { + logo?: string; + companyData: any; + bankData: any; + showDetails?: boolean; + showPageNumber?: boolean; +}) => ( + + + {logo ? ( + + ) : ( + + marc mintel + + )} + + {showDetails && ( + <> + + + {companyData.name} + {"\n"} + {companyData.address1} + {"\n"} + {companyData.address2} + {"\n"}UST: {companyData.ustId} + + + + {showPageNumber && ( + + `${pageNumber} / ${totalPages}` + } + fixed + /> + )} + + + )} + {!showDetails && ( + + {showPageNumber && ( + + `${pageNumber} / ${totalPages}` + } + fixed + /> + )} + + )} + ); -export const Header = ({ sender, recipient, icon, showAddress = true }: { sender?: string; recipient?: { title: string; subtitle?: string; email?: string; address?: string; phone?: string; taxId?: string }; icon?: string; showAddress?: boolean; }) => ( - {showAddress && sender && (<>{sender}{recipient && ({recipient.title}{recipient.subtitle && {recipient.subtitle}}{recipient.address && {recipient.address}}{recipient.phone && {recipient.phone}}{recipient.email && {recipient.email}}{recipient.taxId && USt-ID: {recipient.taxId}})})}{icon ? () : (M)} +export const Header = ({ + sender, + recipient, + icon, + showAddress = true, +}: { + sender?: string; + recipient?: { + title: string; + subtitle?: string; + email?: string; + address?: string; + phone?: string; + taxId?: string; + }; + icon?: string; + showAddress?: boolean; +}) => ( + + + {showAddress && sender && ( + <> + {sender} + {recipient && ( + + + {recipient.title} + + {recipient.subtitle && {recipient.subtitle}} + {recipient.address && {recipient.address}} + {recipient.phone && {recipient.phone}} + {recipient.email && {recipient.email}} + {recipient.taxId && USt-ID: {recipient.taxId}} + + )} + + )} + + + + {icon ? ( + + ) : ( + M + )} + + + ); -export const DocumentTitle = ({ title, subLines }: { title: string; subLines?: string[] }) => ( - {title}{subLines?.map((line, i) => ({line}))} +export const DocumentTitle = ({ + title, + subLines, + isHero = false, +}: { + title: string; + subLines?: string[]; + isHero?: boolean; +}) => ( + + + {title} + + {subLines?.map((line, i) => ( + + {line} + + ))} + +); + +export const TechnicalSpec = ({ + label, + value, +}: { + label: string; + value: string; +}) => ( + + {label} + {value} + +); + +export const AsymmetryView = ({ + left, + right, + style, +}: { + left: React.ReactNode; + right: React.ReactNode; + style?: any; +}) => ( + + {left} + {right} + ); diff --git a/apps/web/src/components/pdf/modules/BrandingModules.tsx b/apps/web/src/components/pdf/modules/BrandingModules.tsx index 31718c0..daf007f 100644 --- a/apps/web/src/components/pdf/modules/BrandingModules.tsx +++ b/apps/web/src/components/pdf/modules/BrandingModules.tsx @@ -1,108 +1,218 @@ -'use client'; +"use client"; -import * as React from 'react'; -import { View as PDFView, Text as PDFText, StyleSheet } from '@react-pdf/renderer'; -import { IndustrialListItem, IndustrialCard, Divider, COLORS, FONT_SIZES } from '../SharedUI'; +import * as React from "react"; +import { + View as PDFView, + Text as PDFText, + StyleSheet, +} from "@react-pdf/renderer"; +import { + DocumentTitle, + IndustrialListItem, + IndustrialCard, + Divider, + COLORS, + FONT_SIZES, +} from "../SharedUI"; const styles = StyleSheet.create({ - industrialTitle: { fontSize: FONT_SIZES.H1, fontWeight: 'bold', color: COLORS.CHARCOAL, marginBottom: 6, letterSpacing: -1 }, - industrialSubtitle: { fontSize: FONT_SIZES.SUB, fontWeight: 'bold', color: COLORS.TEXT_LIGHT, marginBottom: 16, letterSpacing: 0.5 }, - industrialTextLead: { fontSize: FONT_SIZES.H3, color: COLORS.TEXT_MAIN, lineHeight: 1.6, marginBottom: 16 }, - industrialText: { fontSize: FONT_SIZES.BODY, color: COLORS.TEXT_DIM, lineHeight: 1.6, marginBottom: 12 }, - industrialGrid2: { flexDirection: 'row' }, - industrialCol: { width: '46%' }, - industrialBulletBox: { - width: 6, - height: 6, - backgroundColor: COLORS.DIVIDER, - marginRight: 8, - marginTop: 5, - }, + industrialTextLead: { + fontSize: FONT_SIZES.BODY, + color: COLORS.TEXT_MAIN, + lineHeight: 1.4, + marginBottom: 16, + }, + industrialText: { + fontSize: FONT_SIZES.BODY, + color: COLORS.TEXT_DIM, + lineHeight: 1.4, + marginBottom: 12, + }, + industrialGrid2: { flexDirection: "row" }, + industrialCol: { width: "46%" }, }); export const AboutModule = () => ( - <> - Expertise & Profil - Entwicklung & Technischer Partner für den Mittelstand - + <> + + - - - Begleitung mittelständischer Unternehmen und Agenturen bei der Realisierung anspruchsvoller Web-Projekte. Als Senior Software Developer mit über 15 Jahren Erfahrung wird das gesamte technische Spektrum abgedeckt – von der Architektur bis zum fertigen Produkt. - + + + Begleitung mittelständischer Unternehmen und Agenturen bei der + Realisierung anspruchsvoller Web-Projekte. Als Senior Software Developer + mit over 15 Jahren Erfahrung wird das gesamte technische Spektrum + abgedeckt – von der Architektur bis zum fertigen Produkt. + - - - Erfahrung & Substanz - - Der Werdegang umfasst alle Ebenen der Webentwicklung: von der Teamleitung in Kreativagenturen bis zur Softwareentwicklung für internationale Konzerne. - - - Die Kenntnis komplexer Enterprise-Systeme wird mit der Agilität kombiniert, die im Mittelstand gefordert ist. Dieses Wissen ermöglicht den Bau von Lösungen, die technologisch auf Augenhöhe mit Konzern-Standards sind, jedoch ohne unnötigen bürokratischen Overhead auskommen. - - - - - Fokus Einzelentwicklung - - Die Umsetzung erfolgt bewusst als spezialisierter Einzelentwickler. Dies garantiert maximale Geschwindigkeit, direkte Kommunikationswege und volle technologische Verantwortung. - - - Als direkter technischer Sparringspartner bleibt die Codebasis von der ersten bis zur letzten Zeile transparent und wartbar. Diese Unmittelbarkeit stellt sicher, dass Ergebnisse sowohl technisch sauber als auch wirtschaftlich sinnvoll realisiert werden. - - - - - - Infrastruktur & Souveränität - - Es wird keine instabile Prototyp-Software geliefert, sondern produktionsreife Systeme, die technisch skalierbar bleiben. Die Codebasis folgt modernen Standards – bei wachsenden Ansprüchen oder dem Wechsel zu einer Agentur kann der Quellcode jederzeit nahtlos übernommen und weitergeführt werden. - - + + + + Erfahrung & Substanz + + + Der Werdegang umfasst alle Ebenen der Webentwicklung: von der + Teamleitung in Kreativagenturen bis zur Softwareentwicklung für + internationale Konzerne. + + + Die Kenntnis komplexer Enterprise-Systeme wird mit der Agilität + kombiniert, die im Mittelstand gefordert ist. Dieses Wissen + ermöglicht den Bau von Lösungen, die technologisch auf Augenhöhe mit + Konzern-Standards sind, jedoch ohne unnötigen bürokratischen + Overhead auskommen. + - + + + + Fokus Einzelentwicklung + + + Die Umsetzung erfolgt bewusst als spezialisierter Einzelentwickler. + Dies garantiert maximale Geschwindigkeit, direkte Kommunikationswege + und volle technologische Verantwortung. + + + Als direkter technischer Sparringspartner bleibt die Codebasis von + der ersten bis zur letzten Zeile transparent und wartbar. Diese + Unmittelbarkeit stellt sicher, dass Ergebnisse sowohl technisch + sauber als auch wirtschaftlich sinnvoll realisiert werden. + + + + + + + Infrastruktur & Souveränität + + + Es wird keine instabile Prototyp-Software geliefert, sondern + produktionsreife Systeme, die technisch skalierbar bleiben. Die + Codebasis folgt modernen Standards – bei wachsenden Ansprüchen oder + dem Wechsel zu einer Agentur kann der Quellcode jederzeit nahtlos + übernommen und weitergeführt werden. + + + + ); export const CrossSellModule = ({ state }: any) => { - const isWebsite = state.projectType === 'website'; - const title = isWebsite ? "Weitere Potenziale" : "Websites & Ökosysteme"; - const subtitle = isWebsite ? "Automatisierung und Prozessoptimierung" : "Technische Infrastruktur ohne Kompromisse"; + const isWebsite = state.projectType === "website"; + const title = isWebsite ? "Weitere Potenziale" : "Websites & Ökosysteme"; + const subtitle = isWebsite + ? "Automatisierung und Prozessoptimierung" + : "Technische Infrastruktur ohne Kompromisse"; - return ( - <> - {title} - {subtitle} - - - {isWebsite ? ( - <> - - Über die klassische Webpräsenz hinaus werden maßgeschneiderte Lösungen zur Automatisierung von Routine-Prozessen angeboten. Dies ermöglicht eine signifikante Effizienzsteigerung im Tagesgeschäft. - Keine Abos. Keine komplexen neuen Systeme. Gezielte Zeitersparnis. - - Individuelle Analyse - Spezifische Prozesse werden auf technisches Automatisierungspotenzial untersucht. Das Ergebnis liefert Klarheit über die wirtschaftliche Sinnhaftigkeit einer Umsetzung. - - - - - Erstellung von PDF-Angeboten, Berichten oder Protokollen in Sekunden statt Stunden. - - - Intelligente Tabellen und automatisierte Auswertungen bestehender Datensätze. - - - Effiziente Verarbeitung von analogen Dokumenten oder handschriftlichen Notizen mittels KI. - - - - ) : ( - - Bereitstellung einer stabilen technischen Basis ohne Abhängigkeiten von Baukasten-Systemen oder Agenturen. - Entwicklung performanter Frontends und skalierbarer Backends. Die Auslieferung erfolgt als kontrollierbarer und nachhaltiger Quellcode. - - )} + return ( + <> + + + + {isWebsite ? ( + <> + + + Über die klassische Webpräsenz hinaus werden maßgeschneiderte + Lösungen zur Automatisierung von Routine-Prozessen angeboten. + Dies ermöglicht eine signifikante Effizienzsteigerung im + Tagesgeschäft. + + + Keine Abos. Keine komplexen neuen Systeme. Gezielte + Zeitersparnis. + + + + Individuelle Analyse + + + Spezifische Prozesse werden auf technisches + Automatisierungspotenzial untersucht. Das Ergebnis liefert + Klarheit über die wirtschaftliche Sinnhaftigkeit einer + Umsetzung. + + - - ); + + + + Erstellung von PDF-Angeboten, Berichten oder Protokollen in + Sekunden statt Stunden. + + + + + Intelligente Tabellen und automatisierte Auswertungen + bestehender Datensätze. + + + + + Effiziente Verarbeitung von analogen Dokumenten oder + handschriftlichen Notizen mittels KI. + + + + + ) : ( + + + Bereitstellung einer stabilen technischen Basis ohne + Abhängigkeiten von Baukasten-Systemen oder Agenturen. + + + Entwicklung performanter Frontends und skalierbarer Backends. Die + Auslieferung erfolgt als kontrollierbarer und nachhaltiger + Quellcode. + + + )} + + + ); }; diff --git a/apps/web/src/components/pdf/modules/BriefingModule.tsx b/apps/web/src/components/pdf/modules/BriefingModule.tsx index e0671df..dce3589 100644 --- a/apps/web/src/components/pdf/modules/BriefingModule.tsx +++ b/apps/web/src/components/pdf/modules/BriefingModule.tsx @@ -1,29 +1,69 @@ -'use client'; +"use client"; -import * as React from 'react'; -import { View as PDFView, Text as PDFText, StyleSheet } from '@react-pdf/renderer'; -import { DocumentTitle, COLORS, FONT_SIZES } from '../SharedUI'; +import * as React from "react"; +import { + View as PDFView, + Text as PDFText, + StyleSheet, +} from "@react-pdf/renderer"; +import { DocumentTitle, COLORS, FONT_SIZES } from "../SharedUI"; const styles = StyleSheet.create({ - section: { marginBottom: 24 }, - sectionTitle: { fontSize: FONT_SIZES.BODY + 1, fontWeight: 'bold', marginBottom: 8, color: COLORS.CHARCOAL }, - visionText: { fontSize: FONT_SIZES.BODY, color: COLORS.TEXT_MAIN, lineHeight: 1.8, textAlign: 'justify' }, + section: { marginBottom: 24 }, + sectionTitle: { + fontSize: FONT_SIZES.LABEL, + fontWeight: "bold", + marginBottom: 8, + color: COLORS.CHARCOAL, + }, + visionText: { + fontSize: FONT_SIZES.BODY, + color: COLORS.TEXT_MAIN, + lineHeight: 1.4, + textAlign: "justify", + }, }); export const BriefingModule = ({ state }: any) => ( - <> - - {state.briefingSummary && ( - - Briefing Analyse - {state.briefingSummary} - - )} - {state.designVision && ( - - Strategische Vision - {state.designVision} - - )} - + <> + + {state.briefingSummary && ( + + Briefing Analyse + + {state.briefingSummary} + + + )} + {state.designVision && ( + + + Strategische Vision + + {state.designVision} + + )} + ); diff --git a/apps/web/src/components/pdf/modules/CommonModules.tsx b/apps/web/src/components/pdf/modules/CommonModules.tsx index 381fb7c..8238b92 100644 --- a/apps/web/src/components/pdf/modules/CommonModules.tsx +++ b/apps/web/src/components/pdf/modules/CommonModules.tsx @@ -1,106 +1,443 @@ -'use client'; +"use client"; -import * as React from 'react'; -import { View as PDFView, Text as PDFText, StyleSheet, Image as PDFImage } from '@react-pdf/renderer'; -import { DocumentTitle, Divider, COLORS, FONT_SIZES } from '../SharedUI'; +import * as React from "react"; +import { + View as PDFView, + Text as PDFText, + StyleSheet, + Image as PDFImage, +} from "@react-pdf/renderer"; +import { + DocumentTitle, + Divider, + COLORS, + FONT_SIZES, + TechnicalSpec, + AsymmetryView, +} from "../SharedUI"; const styles = StyleSheet.create({ - section: { marginBottom: 16 }, - pricingGrid: { marginTop: 12 }, - pricingRow: { flexDirection: 'row', borderBottomWidth: 1, borderBottomColor: COLORS.DIVIDER, paddingVertical: 12, alignItems: 'flex-start' }, - pricingTitle: { width: '30%', fontSize: FONT_SIZES.BODY, fontWeight: 'bold', color: COLORS.CHARCOAL, paddingRight: 15 }, - pricingDesc: { width: '55%', fontSize: FONT_SIZES.SUB, color: COLORS.TEXT_DIM, lineHeight: 1.5, paddingRight: 10 }, - pricingTag: { width: '15%', fontSize: FONT_SIZES.BODY, fontWeight: 'bold', textAlign: 'right', color: COLORS.CHARCOAL }, - configLabel: { fontSize: FONT_SIZES.BLUEPRINT, color: COLORS.TEXT_LIGHT, textTransform: 'uppercase', marginBottom: 8 }, + section: { marginBottom: 24 }, + moduleLabel: { + fontSize: FONT_SIZES.LABEL, + fontWeight: "bold", + color: COLORS.CHARCOAL, + letterSpacing: 0.5, + marginBottom: 4, + }, + moduleDesc: { + fontSize: FONT_SIZES.SMALL, + color: COLORS.TEXT_DIM, + lineHeight: 1.5, + }, + ledgerRow: { + paddingVertical: 14, + borderBottomWidth: 1, + borderBottomColor: COLORS.GRID, + flexDirection: "row", + alignItems: "flex-start", + }, + ledgerPrice: { + fontSize: FONT_SIZES.BODY, + fontWeight: "bold", + color: COLORS.CHARCOAL, + }, + ledgerUnit: { + fontSize: FONT_SIZES.TINY, + color: COLORS.TEXT_LIGHT, + marginLeft: 2, + }, }); -export const techPageModule = ({ techDetails, headerIcon }: any) => ( +export const techPageModule = ({ techDetails }: any) => { + // Focus on the first 3 items as "Featured Specs", the rest as high-density grid + const featured = techDetails?.slice(0, 3) || []; + const rest = techDetails?.slice(3) || []; + + return ( <> - - - - {techDetails?.map((item: any, i: number) => ( - - {item.t} - {item.d} - + + + {/* FEATURED SPECS - Editorial focus */} + {featured.map((item: any, i: number) => ( + + + FOKUS_{i + 1} + + + {item.t} + + + {item.d} + + + ))} + + + + {/* TECHNICAL GRID - High density */} + + {rest.map((item: any, i: number) => ( + + + {item.t.toUpperCase()} + + + {item.d} + + + ))} + + + + ); +}; + +export const MaintenanceModule = ({ maintenanceDetails }: any) => ( + <> + + + + {maintenanceDetails?.map((item: any, i: number) => ( + + + {item.t.toUpperCase()} + + + {item.d} + + + ))} + + + +); + +export const StandardsModule = ({ standardsDetails, principles }: any) => { + const independence = standardsDetails?.find( + (s: any) => s.t === "Unabhängigkeit", + ); + const others = + standardsDetails?.filter((s: any) => s.t !== "Unabhängigkeit") || []; + + return ( + <> + + + + {/* FOCUS: UNABHÄNGIGKEIT & PRINCIPLES */} + + + MODERNE PRINZIPIEN + + {principles?.map((p: any, i: number) => ( + + + {p.t.toUpperCase()} + + + {p.d} + + + ))} + + } + right={ + + {/* HERO BOX: UNABHÄNGIGKEIT */} + {independence && ( + + + {independence.t.toUpperCase()} + + + {independence.d} + + + )} + + {/* VERTICAL STACK OF OTHER STANDARDS */} + + {others.map((item: any, i: number) => ( + + {item.t} + {item.d} + ))} + - + } + /> + -); + ); +}; -export const TransparenzModule = ({ pricing }: any) => ( +export const TransparenzModule = ({ pricing }: any) => { + const sorglosPrice = (pricing.HOSTING_MONTHLY || 250) * 12; + + return ( <> - - - - - Fundament - Setup, Infrastruktur, Hosting, SEO-Basics, Staging & Live-Umgebungen. - {pricing.BASE_WEBSITE?.toLocaleString('de-DE')} € - - - Seiten - Layout & Umsetzung individueller Seiten. Responsive Design / Cross-Browser. - {pricing.PAGE?.toLocaleString('de-DE')} € / Stk - - - Features - Abgeschlossene Systeme (z. B. Blog, Jobs, Produkte) inkl. Datenstruktur. - {pricing.FEATURE?.toLocaleString('de-DE')} € / Stk - - - Funktionen - Logik-Einheiten wie Filter, Suchen oder Kontakt-Schnittstellen. - {pricing.FUNCTION?.toLocaleString('de-DE')} € / Stk - - - Schnittstellen - Anbindung externer Systeme (CRM, ERP, Payment) zur Synchronisation. - ab {pricing.API_INTEGRATION?.toLocaleString('de-DE')} € / Stk - - - CMS Setup - Konfiguration Headless CMS zur unabhängigen Datenpflege aller Module. - {pricing.CMS_SETUP?.toLocaleString('de-DE')} € - - - Inszenierung - Interaktions-Mechanismen, Konfiguratoren oder visuelles Storytelling. - ab {pricing.VISUAL_STAGING?.toLocaleString('de-DE')} € - - - Sprachen - Skalierung der System-Architektur auf zusätzliche Sprachversionen. - +20% / Sprache - - - Initial-Pflege - Manuelle Aufbereitung & Übernahme von Datensätzen in das Zielsystem. - {pricing.NEW_DATASET?.toLocaleString('de-DE')} € / Stk - - - Sorglos-Paket - Betrieb, Hosting, Updates & Monitoring gemäß AGB Punkt 7a. - Inklusive 1 Jahr - + + + + {[ + { + l: "Fundament", + d: "Bereitstellung der techn. Infrastruktur & System-Umgebung.", + p: pricing.BASE_WEBSITE, + }, + { + l: "Einzelseiten", + d: "Individuelle Gestaltung, Layout & responsive Struktur.", + p: pricing.PAGE, + unit: "/ Stk", + }, + { + l: "Core Features", + d: "Geschlossene Datensysteme mit eigener Datenstruktur.", + p: pricing.FEATURE, + unit: "/ Stk", + }, + { + l: "Logik & Funktionen", + d: "Interaktive Funktions-Bausteine & Prozess-Logik.", + p: pricing.FUNCTION, + unit: "/ Stk", + }, + { + l: "Schnittstellen", + d: "Synchronisation mit externen Zielsystemen.", + p: pricing.API_INTEGRATION, + unit: "/ Stk", + }, + { + l: "Inhalts-Verwaltung", + d: "Schnittstelle zur eigenständigen Daten-Pflege (optional).", + p: pricing.CMS_CONNECTION_PER_FEATURE, + unit: "/ Stk", + }, + { + l: "Sprachversionen", + d: "Skalierung der System-Architektur auf Zweit-Sprachen.", + p: "+20%", + isLang: true, + }, + { + l: "Initial-Pflege", + d: "Konvertierung & Aufbereitung von Bestandsdaten.", + p: pricing.NEW_DATASET, + unit: "/ Stk", + }, + { + l: "Sorglos Betrieb", + d: "Hosting, Instandhaltung, Security & techn. Support.", + p: sorglosPrice, + unit: "/ Jahr", + }, + ].map((item: any, i: number) => ( + + + + {item.l.toUpperCase()} + + + + {item.d} + + + + {typeof item.p === "number" + ? `${item.p.toLocaleString("de-DE")} €` + : item.p} + {item.unit && ( + {item.unit} + )} + + {item.sub && ( + + {item.sub} + + )} + + ))} + -); + ); +}; -export const PrinciplesModule = ({ principles }: any) => ( - <> - - - {principles?.map((item: any, i: number) => ( - - {item.t} - {item.d} - - ))} +export const ClosingModule = () => ( + <> + + + + Vielen Dank für Ihr Interesse! + + + Die aufgeführten Positionen stellen eine detaillierte Schätzung auf + Basis unseres aktuellen Stands dar. Sollten sich Anforderungen ändern + oder Sie Fragen zu einzelnen Details haben, lassen Sie uns die + Positionen gerne gemeinsam besprechen. + + + + Haben Sie Fragen? + + + Ich erkläre Ihnen gerne noch einmal persönlich, was die technische + Umsetzung für Ihr Projekt bedeutet und wie wir die nächsten Schritte + gemeinsam gehen können. + + + Kontakt: + + Marc Mintel – marc@mintel.me + - + + + ); - diff --git a/apps/web/src/components/pdf/modules/EstimationModule.tsx b/apps/web/src/components/pdf/modules/EstimationModule.tsx index 6ddea63..25a27f1 100644 --- a/apps/web/src/components/pdf/modules/EstimationModule.tsx +++ b/apps/web/src/components/pdf/modules/EstimationModule.tsx @@ -1,55 +1,159 @@ -'use client'; +"use client"; -import * as React from 'react'; -import { View as PDFView, Text as PDFText, StyleSheet } from '@react-pdf/renderer'; -import { DocumentTitle } from '../SharedUI'; +import * as React from "react"; +import { + View as PDFView, + Text as PDFText, + StyleSheet, +} from "@react-pdf/renderer"; +import { DocumentTitle, COLORS, FONT_SIZES } from "../SharedUI"; const styles = StyleSheet.create({ - table: { marginTop: 12 }, - tableHeader: { flexDirection: 'row', paddingBottom: 8, borderBottomWidth: 1, borderBottomColor: '#334155', marginBottom: 12 }, - tableRow: { flexDirection: 'row', paddingVertical: 8, borderBottomWidth: 1, borderBottomColor: '#f8fafc', alignItems: 'flex-start' }, - colPos: { width: '8%' }, - colDesc: { width: '62%' }, - colQty: { width: '10%', textAlign: 'center' }, - colPrice: { width: '20%', textAlign: 'right' }, - headerText: { fontSize: 7, fontWeight: 'bold', textTransform: 'uppercase', letterSpacing: 1 }, - posText: { fontSize: 8, color: '#999999' }, - itemTitle: { fontSize: 10, fontWeight: 'bold', marginBottom: 4 }, - itemDesc: { fontSize: 8, color: '#666666', lineHeight: 1.4 }, - priceText: { fontSize: 10, fontWeight: 'bold' }, - summaryContainer: { borderTopWidth: 1, borderTopColor: '#334155', paddingTop: 8 }, - summaryRow: { flexDirection: 'row', justifyContent: 'flex-end', paddingVertical: 4, alignItems: 'baseline' }, - summaryLabel: { fontSize: 7, color: '#64748b', textTransform: 'uppercase', letterSpacing: 1, fontWeight: 'bold', marginRight: 12 }, - summaryValue: { fontSize: 9, fontWeight: 'bold', width: 100, textAlign: 'right' }, - totalRow: { flexDirection: 'row', justifyContent: 'flex-end', paddingTop: 12, marginTop: 8, borderTopWidth: 2, borderTopColor: '#334155', alignItems: 'baseline' }, + table: { marginTop: 12 }, + tableHeader: { + flexDirection: "row", + paddingBottom: 8, + borderBottomWidth: 1, + borderBottomColor: COLORS.CHARCOAL, + marginBottom: 12, + }, + tableRow: { + flexDirection: "row", + paddingVertical: 10, + borderBottomWidth: 1, + borderBottomColor: COLORS.GRID, + alignItems: "flex-start", + }, + colPos: { width: "8%" }, + colDesc: { width: "62%" }, + colQty: { width: "10%", textAlign: "center" }, + colPrice: { width: "20%", textAlign: "right" }, + headerText: { + fontSize: FONT_SIZES.TINY, + fontWeight: "bold", + textTransform: "uppercase", + letterSpacing: 1, + color: COLORS.TEXT_DIM, + }, + posText: { fontSize: FONT_SIZES.TINY, color: COLORS.TEXT_LIGHT }, + itemTitle: { + fontSize: FONT_SIZES.LABEL, + fontWeight: "bold", + color: COLORS.CHARCOAL, + marginBottom: 4, + }, + itemDesc: { + fontSize: FONT_SIZES.SMALL, + color: COLORS.TEXT_DIM, + lineHeight: 1.4, + }, + priceText: { + fontSize: FONT_SIZES.BODY, + fontWeight: "bold", + color: COLORS.CHARCOAL, + }, + summaryContainer: { + borderTopWidth: 1, + borderTopColor: COLORS.CHARCOAL, + paddingTop: 8, + }, + summaryRow: { + flexDirection: "row", + justifyContent: "flex-end", + paddingVertical: 4, + alignItems: "baseline", + }, + summaryLabel: { + fontSize: FONT_SIZES.TINY, + color: COLORS.TEXT_DIM, + textTransform: "uppercase", + letterSpacing: 1, + fontWeight: "bold", + marginRight: 12, + }, + summaryValue: { + fontSize: FONT_SIZES.BODY, + fontWeight: "bold", + width: 100, + textAlign: "right", + color: COLORS.CHARCOAL, + }, + totalRow: { + flexDirection: "row", + justifyContent: "flex-end", + paddingTop: 12, + marginTop: 8, + borderTopWidth: 2, + borderTopColor: COLORS.CHARCOAL, + alignItems: "baseline", + }, }); -export const EstimationModule = ({ state, positions, totalPrice, date }: any) => ( - <> - - - - Pos - Beschreibung - Menge - Betrag - - {positions.map((item: any, i: number) => ( - - {item.pos.toString().padStart(2, '0')} - - {item.title} - {state.positionDescriptions?.[item.title] || item.desc} - - {item.qty} - {item.price > 0 ? `${item.price.toLocaleString('de-DE')} €` : 'n. A.'} - - ))} +export const EstimationModule = ({ + state, + positions, + totalPrice, + date, +}: any) => ( + <> + + + + Pos + + Beschreibung + + Menge + Betrag + + {positions.map((item: any, i: number) => ( + + + {item.pos.toString().padStart(2, "0")} + + + {item.title} + + {state.positionDescriptions?.[item.title] || item.desc} + + + {item.qty} + + {item.price > 0 + ? `${item.price.toLocaleString("de-DE")} €` + : "n. A."} + - - Nettobetrag{totalPrice.toLocaleString('de-DE')} € - Umsatzsteuer (19%){(totalPrice * 0.19).toLocaleString('de-DE')} € - Gesamtbetrag (Brutto){(totalPrice * 1.19).toLocaleString('de-DE')} € - - + ))} + + + + Nettobetrag + + {totalPrice.toLocaleString("de-DE")} € + + + + Umsatzsteuer (19%) + + {(totalPrice * 0.19).toLocaleString("de-DE")} € + + + + Gesamtbetrag (Brutto) + + {(totalPrice * 1.19).toLocaleString("de-DE")} € + + + + ); diff --git a/apps/web/src/components/pdf/modules/FrontPageModule.tsx b/apps/web/src/components/pdf/modules/FrontPageModule.tsx index 741a6f7..0cbaba4 100644 --- a/apps/web/src/components/pdf/modules/FrontPageModule.tsx +++ b/apps/web/src/components/pdf/modules/FrontPageModule.tsx @@ -1,81 +1,92 @@ -'use client'; +"use client"; -import * as React from 'react'; -import { View as PDFView, Text as PDFText, Image as PDFImage, StyleSheet } from '@react-pdf/renderer'; -import { COLORS, FONT_SIZES } from '../SharedUI'; +import * as React from "react"; +import { + View as PDFView, + Text as PDFText, + Image as PDFImage, + StyleSheet, +} from "@react-pdf/renderer"; +import { COLORS, FONT_SIZES } from "../SharedUI"; const styles = StyleSheet.create({ - titlePage: { - flex: 1, // Fill the whole page - padding: 60, - justifyContent: 'center', - alignItems: 'center', - backgroundColor: COLORS.WHITE, - }, - titleBrandIcon: { - width: 80, - height: 80, - backgroundColor: COLORS.CHARCOAL, - borderRadius: 16, - alignItems: 'center', - justifyContent: 'center', - marginBottom: 40, - }, - brandIconText: { - fontSize: 40, - color: COLORS.WHITE, - fontWeight: 'bold' - }, - titleProjectName: { - fontSize: FONT_SIZES.H1, - fontWeight: 'bold', - color: COLORS.CHARCOAL, - marginBottom: 16, - textAlign: 'center', - maxWidth: '85%', - lineHeight: 1.2, - }, - titleCustomerName: { - fontSize: FONT_SIZES.H3, - color: COLORS.TEXT_DIM, - marginBottom: 40, - textAlign: 'center', - maxWidth: '80%', - }, - titleDocumentType: { - fontSize: FONT_SIZES.BODY + 1, // ~10 - color: COLORS.TEXT_LIGHT, - textTransform: 'uppercase', - letterSpacing: 4, - marginBottom: 12, - }, - titleDivider: { - width: 40, - height: 2, - backgroundColor: COLORS.CHARCOAL, - marginBottom: 40, - }, - titleDate: { - fontSize: FONT_SIZES.BODY, - color: COLORS.TEXT_LIGHT, - marginTop: 40, - }, + titlePage: { + flex: 1, // Fill the whole page + padding: 60, + justifyContent: "center", + alignItems: "center", + backgroundColor: COLORS.WHITE, + }, + titleBrandIcon: { + width: 80, + height: 80, + backgroundColor: COLORS.CHARCOAL, + borderRadius: 16, + alignItems: "center", + justifyContent: "center", + marginBottom: 40, + }, + brandIconText: { + fontSize: 40, + color: COLORS.WHITE, + fontWeight: "bold", + }, + titleProjectName: { + fontSize: FONT_SIZES.HERO, + fontWeight: "bold", + color: COLORS.CHARCOAL, + marginBottom: 16, + textAlign: "center", + maxWidth: "85%", + lineHeight: 1.2, + }, + titleCustomerName: { + fontSize: FONT_SIZES.HEADING, + color: COLORS.TEXT_DIM, + marginBottom: 40, + textAlign: "center", + maxWidth: "80%", + }, + titleDocumentType: { + fontSize: FONT_SIZES.BODY + 1, // ~10 + color: COLORS.TEXT_LIGHT, + textTransform: "uppercase", + letterSpacing: 4, + marginBottom: 12, + }, + titleDivider: { + width: 40, + height: 2, + backgroundColor: COLORS.CHARCOAL, + marginBottom: 40, + }, + titleDate: { + fontSize: FONT_SIZES.BODY, + color: COLORS.TEXT_LIGHT, + marginTop: 40, + }, }); export const FrontPageModule = ({ state, headerIcon, date }: any) => { - const fullTitle = `Digitale Webpräsenz für\n${state.companyName || "Ihr Projekt"}`; + const fullTitle = `Digitale Webpräsenz für\n${state.companyName || "Ihr Projekt"}`; - // Responsive font size based on length - const fontSize = fullTitle.length > 60 ? 14 : fullTitle.length > 40 ? 18 : 22; + // Responsive font size based on length + const fontSize = fullTitle.length > 60 ? 14 : fullTitle.length > 40 ? 18 : 22; - return ( - - - {headerIcon ? : M} - - {fullTitle} - - {date} | Marc Mintel - - ); + return ( + + + {headerIcon ? ( + + ) : ( + M + )} + + + {fullTitle} + + + {date} | Marc Mintel + + ); }; diff --git a/apps/web/src/components/pdf/modules/SitemapModule.tsx b/apps/web/src/components/pdf/modules/SitemapModule.tsx index d1555ba..7f9e851 100644 --- a/apps/web/src/components/pdf/modules/SitemapModule.tsx +++ b/apps/web/src/components/pdf/modules/SitemapModule.tsx @@ -1,77 +1,125 @@ -'use client'; +"use client"; -import * as React from 'react'; -import { View as PDFView, Text as PDFText, StyleSheet } from '@react-pdf/renderer'; -import { DocumentTitle, Divider, COLORS, FONT_SIZES } from '../SharedUI'; +import * as React from "react"; +import { + View as PDFView, + Text as PDFText, + StyleSheet, +} from "@react-pdf/renderer"; +import { DocumentTitle, Divider, COLORS, FONT_SIZES } from "../SharedUI"; const styles = StyleSheet.create({ - section: { marginBottom: 32 }, - intro: { fontSize: FONT_SIZES.BODY, color: COLORS.TEXT_DIM, lineHeight: 1.6, marginBottom: 24, textAlign: 'justify' }, - sitemapTree: { marginTop: 8 }, - rootNode: { - padding: 12, - backgroundColor: COLORS.GRID, - marginBottom: 20, - borderLeftWidth: 2, - borderLeftColor: COLORS.CHARCOAL - }, - rootTitle: { fontSize: FONT_SIZES.H3, fontWeight: 'bold', color: COLORS.CHARCOAL, letterSpacing: 0.5 }, - categorySection: { marginBottom: 20 }, - categoryHeader: { - flexDirection: 'row', - alignItems: 'center', - paddingBottom: 6, - borderBottomWidth: 1, - borderBottomColor: COLORS.BLUEPRINT, - marginBottom: 10 - }, - categoryIcon: { width: 8, height: 8, backgroundColor: COLORS.GRID, borderInlineWidth: 1, borderColor: COLORS.DIVIDER, marginRight: 10 }, - categoryTitle: { fontSize: FONT_SIZES.BODY, fontWeight: 'bold', color: COLORS.CHARCOAL, textTransform: 'uppercase', letterSpacing: 1 }, - pagesGrid: { flexDirection: 'row', flexWrap: 'wrap' }, - pageCard: { - width: '48%', - marginRight: '2%', - marginBottom: 12, - padding: 10, - borderWidth: 1, - borderColor: COLORS.GRID, - backgroundColor: '#fafafa' - }, - pageTitle: { fontSize: FONT_SIZES.BODY, fontWeight: 'bold', color: COLORS.TEXT_MAIN, marginBottom: 2 }, - pageDesc: { fontSize: FONT_SIZES.TINY, color: COLORS.TEXT_DIM, lineHeight: 1.4 }, + section: { marginBottom: 32 }, + intro: { + fontSize: FONT_SIZES.BODY, + color: COLORS.TEXT_DIM, + lineHeight: 1.4, + marginBottom: 24, + textAlign: "justify", + }, + sitemapTree: { marginTop: 8 }, + rootNode: { + padding: 12, + backgroundColor: COLORS.GRID, + marginBottom: 20, + borderLeftWidth: 2, + borderLeftColor: COLORS.CHARCOAL, + }, + rootTitle: { + fontSize: FONT_SIZES.HEADING, + fontWeight: "bold", + color: COLORS.CHARCOAL, + letterSpacing: 0.5, + }, + categorySection: { marginBottom: 20 }, + categoryHeader: { + flexDirection: "row", + alignItems: "center", + paddingBottom: 6, + borderBottomWidth: 1, + borderBottomColor: COLORS.BLUEPRINT, + marginBottom: 10, + }, + categoryIcon: { + width: 8, + height: 8, + backgroundColor: COLORS.GRID, + borderInlineWidth: 1, + borderColor: COLORS.DIVIDER, + marginRight: 10, + }, + categoryTitle: { + fontSize: FONT_SIZES.BODY, + fontWeight: "bold", + color: COLORS.CHARCOAL, + textTransform: "uppercase", + letterSpacing: 1, + }, + pagesGrid: { flexDirection: "row", flexWrap: "wrap" }, + pageCard: { + width: "48%", + marginRight: "2%", + marginBottom: 12, + padding: 10, + borderWidth: 1, + borderColor: COLORS.GRID, + backgroundColor: "#fafafa", + }, + pageTitle: { + fontSize: FONT_SIZES.BODY, + fontWeight: "bold", + color: COLORS.TEXT_MAIN, + marginBottom: 4, + }, + pageDesc: { + fontSize: FONT_SIZES.TINY, + color: COLORS.TEXT_DIM, + lineHeight: 1.3, + }, }); export const SitemapModule = ({ state }: any) => ( - <> - - - - Die folgende Struktur definiert die logische Hierarchie und Benutzerführung. Sie dient als Bauplan für die technische Umsetzung und stellt sicher, dass alle relevanten Geschäftsbereiche intuitiv auffindbar sind. - + <> + + + + Die folgende Struktur definiert die logische Hierarchie und + Benutzerführung. Sie dient als Bauplan für die technische Umsetzung und + stellt sicher, dass alle relevanten Geschäftsbereiche intuitiv + auffindbar sind. + - - - Seitenstruktur - - - {state.sitemap?.map((cat: any, i: number) => ( - - - - {cat.category} - - - - {cat.pages.map((p: any, j: number) => ( - - {p.title} - {p.desc && {p.desc}} - - ))} - - - ))} - + + + Seitenstruktur - + + {state.sitemap?.map((cat: any, i: number) => ( + + + + {cat.category} + + + + {cat.pages.map((p: any, j: number) => ( + + {p.title} + {p.desc && ( + {p.desc} + )} + + ))} + + + ))} + + + ); diff --git a/apps/web/src/logic/content-provider.ts b/apps/web/src/logic/content-provider.ts index 2d4b0db..c3b7ce3 100644 --- a/apps/web/src/logic/content-provider.ts +++ b/apps/web/src/logic/content-provider.ts @@ -1,46 +1,161 @@ -import fs from 'fs'; -import path from 'path'; +import fs from "fs"; +import path from "path"; -const DOCS_DIR = path.join(process.cwd(), 'docs'); +const DOCS_DIR = path.join(process.cwd(), "docs"); export function getTechDetails() { - try { - const content = fs.readFileSync(path.join(DOCS_DIR, 'TECH.md'), 'utf-8'); - const sections = content.split('⸻').map(s => s.trim()); + try { + const content = fs.readFileSync(path.join(DOCS_DIR, "TECH.md"), "utf-8"); + const sections = content.split("⸻").map((s) => s.trim()); - // Extract items (Speed, Responsive, Stability, etc.) - // Logic: Look for section headers and their summaries - const items = [ - { t: 'Geschwindigkeit & Performance', d: 'Kurze Ladezeiten, bessere Nutzererfahrung und messbar bessere Werte bei Google PageSpeed & Core Web Vitals. Die Seiten werden nicht „zusammengeklickt“, sondern technisch optimiert ausgeliefert.' }, - { t: 'Responsives Design', d: 'Jede Website ist von Grund auf responsiv. Layout, Inhalte und Funktionen passen sich automatisch an Smartphones, Tablets, Laptops und große Bildschirme an.' }, - { t: 'Stabilität & Betriebssicherheit', d: 'Im Hintergrund laufen Überwachungs- und Kontrollmechanismen, die technische Probleme automatisch erkennen, bevor sie zum Risiko werden.' }, - { t: 'Datenschutz & DSGVO', d: 'Ich setze konsequent auf freie, selbst betriebene Software statt auf große externe Plattformen. Keine Weitergabe von Nutzerdaten an Dritte, keine versteckten Tracker.' }, - { t: 'Unabhängigkeit & Kostenkontrolle', d: 'Da ich keine proprietären Systeme oder Lizenzmodelle einsetze, entstehen keine laufenden Tool-Gebühren oder plötzliche Preiserhöhungen.' }, - { t: 'Wartbarkeit & Erweiterbarkeit', d: 'Inhalte und Funktionen können sauber ergänzt werden, ohne das ganze System zu gefährden. Das schützt Ihre Investition langfristig.' } - ]; + // Extract items (Speed, Responsive, Stability, etc.) + // Logic: Look for section headers and their summaries + const items = [ + { + t: "Maximale Ladegeschwindigkeit & SEO-Vorsprung", + d: "Durch modernste Auslieferungstechnologien laden Ihre Seiten nahezu verzögerungsfrei. Das sorgt für ein flüssiges Nutzererlebnis, reduziert Absprungraten und sichert Ihnen eine bevorzugte Platzierung bei Google.", + }, + { + t: "Investitionsschutz durch Fehlertoleranz", + d: "Eine strikte technische Prüfung des Quellcodes bereits während der Entwicklung verhindert Fehler, bevor sie entstehen. Das garantiert eine extrem stabile, sichere und über Jahre hinweg wartbare Codebasis.", + }, + { + t: "Intuitive System-Kontrolle & Flexibilität", + d: "Die saubere Trennung von Design und Inhalten erlaubt es Ihnen, alle Texte und Medien flexibel und ohne technisches Vorwissen selbst zu verwalten, während das System im Hintergrund stabil und skalierbar bleibt.", + }, + { + t: "Globale Performance & Skalierbarkeit", + d: "Fortgeschrittene Zwischenspeicher-Technologien garantieren, dass Ihre Website auch bei hohen Besucherzahlen stets blitzschnell reagiert und weltweit ohne Verzögerung verfügbar ist.", + }, + { + t: "Infrastruktur-Souveränität & Portabilität", + d: "Dank einer standardisierten technischen Umgebung ist Ihre Website vollständig portabel. Sie sind an keinen Anbieter gebunden und behalten die volle Kontrolle über Ihre digitale Infrastruktur.", + }, + { + t: "Präzision auf allen Endgeräten", + d: "Eine hocheffiziente Architektur minimiert die zu übertragenden Datenmengen und sorgt für eine perfekte, fehlerfreie Darstellung auf jedem Smartphone, Tablet oder Desktop-Rechner.", + }, + ]; - return items; - } catch (e) { - console.error('Failed to read TECH.md', e); - return []; - } + return items; + } catch (e) { + console.error("Failed to read TECH.md", e); + return []; + } } export function getPrinciples() { - try { - const content = fs.readFileSync(path.join(DOCS_DIR, 'PRINCIPLES.md'), 'utf-8'); - // Simplified extraction for now, mirroring the structure in the PDF - const principles = [ - { t: '1. Volle Preis-Transparenz', d: 'Alle Kosten sind offen und nachvollziehbar. Es gibt keine versteckten Gebühren, keine Abos, keine Lock-ins. Jeder Kunde sieht genau, wofür er bezahlt.' }, - { t: '2. Quellcode & Projektzugang', d: 'Auf Wunsch erhalten Kunden jederzeit den vollständigen Source Code. Damit kann jeder andere Entwickler problemlos weiterarbeiten.' }, - { t: '3. Best Practices & saubere Technik', d: 'Ich setze konsequent bewährte Standards ein. Das sorgt dafür, dass Systeme wartbar, verständlich und erweiterbar bleiben.' }, - { t: '4. Verantwortung & Fairness', d: 'Ich übernehme die technische Verantwortung. Ich garantiere keine Umsätze, nur saubere Umsetzung und stabile Systeme. Wenn etwas nicht sinnvoll ist, sage ich es ehrlich.' }, - { t: '5. Langfristiger Wert', d: 'Eine Website ist ein Investment. Ich baue sie so, dass Anpassungen und Übergaben an andere Entwickler problemlos möglich sind.' }, - { t: '6. Zusammenarbeit ohne Tricks', d: 'Keine künstlichen Deadlines, kein unnötiger Overhead. Kommunikation ist klar, Entscheidungen nachvollziehbar, Übergaben sauber dokumentiert.' } - ]; - return principles; - } catch (e) { - console.error('Failed to read PRINCIPLES.md', e); - return []; - } + try { + const content = fs.readFileSync( + path.join(DOCS_DIR, "PRINCIPLES.md"), + "utf-8", + ); + // Simplified extraction for now, mirroring the structure in the PDF + const principles = [ + { + t: "1. Volle Preis-Transparenz", + d: "Alle Kosten sind offen und nachvollziehbar. Es gibt keine versteckten Gebühren, keine Abos, keine Lock-ins. Jeder Kunde sieht genau, wofür er bezahlt.", + }, + { + t: "2. Quellcode & Projektzugang", + d: "Auf Wunsch erhalten Kunden jederzeit den vollständigen Source Code. Damit kann jeder andere Entwickler problemlos weiterarbeiten.", + }, + { + t: "3. Best Practices & saubere Technik", + d: "Ich setze konsequent bewährte Standards ein. Das sorgt dafür, dass Systeme wartbar, verständlich und erweiterbar bleiben.", + }, + { + t: "4. Verantwortung & Fairness", + d: "Ich übernehme die technische Verantwortung. Ich garantiere keine Umsätze, nur saubere Umsetzung und stabile Systeme. Wenn etwas nicht sinnvoll ist, sage ich es ehrlich.", + }, + { + t: "5. Langfristiger Wert", + d: "Eine Website ist ein Investment. Ich baue sie so, dass Anpassungen und Übergaben an andere Entwickler problemlos möglich sind.", + }, + { + t: "6. Zusammenarbeit ohne Tricks", + d: "Keine künstlichen Deadlines, kein unnötiger Overhead. Kommunikation ist klar, Entscheidungen nachvollziehbar, Übergaben sauber dokumentiert.", + }, + ]; + return principles; + } catch (e) { + console.error("Failed to read PRINCIPLES.md", e); + return []; + } +} + +export function getMaintenanceDetails() { + try { + const content = fs.readFileSync( + path.join(DOCS_DIR, "MAINTENANCE.md"), + "utf-8", + ); + // Extracting key points based on the structure of MAINTENANCE.md + const items = [ + { + t: "Proaktive Betreuung", + d: "Regelmäßige Checks, Updates von Systemen und Plugins sowie Sicherheitsüberprüfungen zur Vermeidung von Ausfällen.", + }, + { + t: "Schnelle Reaktion", + d: "Analyse und Behebung bei Hoster-Störungen oder unvorhergesehenen Updates von Drittanbietern ohne Eigenaufwand für Sie.", + }, + { + t: "Sicherheit & Aktualität", + d: "Bestehende Technik wird auf dem neuesten Stand gehalten, kleine Fehler werden korrigiert und Inhalte bei Bedarf angepasst.", + }, + { + t: "Betriebs- & Pflegeleistung", + d: "Fortlaufende Instandhaltung und Pflege der bestehenden Seite gemäß AGB Punkt 7a.", + }, + { + t: "Verlässlichkeit", + d: "Persönliche Verantwortung für einen stabilen Betrieb mit dem gleichen Anspruch, mit dem die Website gebaut wurde.", + }, + ]; + return items; + } catch (e) { + console.error("Failed to read MAINTENANCE.md", e); + return []; + } +} + +export function getStandardsDetails() { + try { + const content = fs.readFileSync( + path.join(DOCS_DIR, "STANDARDS.md"), + "utf-8", + ); + // Extracting key points based on the structure of STANDARDS.md + const items = [ + { + t: "Geringer CO₂-Verbrauch", + d: "Schlanke Website durch optimierten Code und Bilder – oft 70–90% unter dem Durchschnitt vergleichbarer Projekte.", + }, + { + t: "Unabhängigkeit", + d: 'Kein "Lock-in" durch Big Tech oder Baukasten-Systeme. Alles ist self-hosted oder custom-coded und bleibt dauerhaft in Ihrem Besitz für absolute Souveränität.', + }, + { + t: "Besucher-Vertrauen", + d: "Keine Cookie-Banner oder heimliches Tracking. Ein seriöser Auftritt, der Datenschutz intuitiv erlebbar macht.", + }, + { + t: "Security by Design", + d: "Schutz vor typischen Angriffsvektoren von Grund auf eingebaut, um teure Sicherheits-Updates oder Datenlecks zu vermeiden.", + }, + { + t: "Echte DSGVO-Konformität", + d: "Keine Grauzonen oder Tricks – es wird nur verarbeitet, was technisch unbedingt erforderlich ist.", + }, + { + t: "Wartungsarmut", + d: "Durch minimierte Abhängigkeiten altert die Website langsamer, was Notfall-Reparaturen reduziert und Budget für Neues lässt.", + }, + ]; + return items; + } catch (e) { + console.error("Failed to read STANDARDS.md", e); + return []; + } } diff --git a/apps/web/src/logic/pricing/calculator.ts b/apps/web/src/logic/pricing/calculator.ts index 512df8c..e165c63 100644 --- a/apps/web/src/logic/pricing/calculator.ts +++ b/apps/web/src/logic/pricing/calculator.ts @@ -1,202 +1,224 @@ -import { FormState, Position, Totals } from './types'; -import { FEATURE_LABELS, FUNCTION_LABELS, API_LABELS, PAGE_LABELS } from './constants'; +import { FormState, Position, Totals } from "./types"; +import { + FEATURE_LABELS, + FUNCTION_LABELS, + API_LABELS, + PAGE_LABELS, +} from "./constants"; export function calculateTotals(state: FormState, pricing: any): Totals { - if (state.projectType !== 'website') { - return { - totalPrice: 0, - monthlyPrice: 0, - totalPagesCount: 0, - totalFeatures: 0, - totalFunctions: 0, - totalApis: 0, - languagesCount: 0 - }; - } - - const sitemapPagesCount = state.sitemap?.reduce((acc: number, cat: any) => acc + (cat.pages?.length || 0), 0) || 0; - const totalPagesCount = Math.max( - (state.selectedPages?.length || 0) + (state.otherPages?.length || 0) + (state.otherPagesCount || 0), - sitemapPagesCount - ); - - const totalFeatures = (state.features?.length || 0) + (state.otherFeatures?.length || 0) + (state.otherFeaturesCount || 0); - const totalFunctions = (state.functions?.length || 0) + (state.otherFunctions?.length || 0) + (state.otherFunctionsCount || 0); - const totalApis = (state.apiSystems?.length || 0) + (state.otherTech?.length || 0) + (state.otherTechCount || 0); - - let total = pricing.BASE_WEBSITE; - total += totalPagesCount * pricing.PAGE; - total += totalFeatures * pricing.FEATURE; - total += totalFunctions * pricing.FUNCTION; - total += totalApis * pricing.API_INTEGRATION; - total += (state.newDatasets || 0) * pricing.NEW_DATASET; - - if (state.cmsSetup) { - total += pricing.CMS_SETUP; - total += totalFeatures * pricing.CMS_CONNECTION_PER_FEATURE; - } - - // Optional visual/complexity boosters (from calculator.ts logic) - if (state.visualStaging && !isNaN(Number(state.visualStaging)) && Number(state.visualStaging) > 0) { - total += Number(state.visualStaging) * pricing.VISUAL_STAGING; - } - if (state.complexInteractions && !isNaN(Number(state.complexInteractions)) && Number(state.complexInteractions) > 0) { - total += Number(state.complexInteractions) * pricing.COMPLEX_INTERACTION; - } - - const languagesCount = state.languagesList?.length || 1; - if (languagesCount > 1) { - total *= (1 + (languagesCount - 1) * 0.2); - } - - const monthlyPrice = pricing.HOSTING_MONTHLY + ((state.storageExpansion || 0) * pricing.STORAGE_EXPANSION_MONTHLY); - + if (state.projectType !== "website") { return { - totalPrice: Math.round(total), - monthlyPrice: Math.round(monthlyPrice), - totalPagesCount, - totalFeatures, - totalFunctions, - totalApis, - languagesCount + totalPrice: 0, + monthlyPrice: 0, + totalPagesCount: 0, + totalFeatures: 0, + totalFunctions: 0, + totalApis: 0, + languagesCount: 0, }; + } + + const sitemapPagesCount = + state.sitemap?.reduce( + (acc: number, cat: any) => acc + (cat.pages?.length || 0), + 0, + ) || 0; + const totalPagesCount = Math.max( + (state.selectedPages?.length || 0) + + (state.otherPages?.length || 0) + + (state.otherPagesCount || 0), + sitemapPagesCount, + ); + + const totalFeatures = + (state.features?.length || 0) + + (state.otherFeatures?.length || 0) + + (state.otherFeaturesCount || 0); + const totalFunctions = + (state.functions?.length || 0) + + (state.otherFunctions?.length || 0) + + (state.otherFunctionsCount || 0); + const totalApis = + (state.apiSystems?.length || 0) + + (state.otherTech?.length || 0) + + (state.otherTechCount || 0); + + let total = pricing.BASE_WEBSITE; + total += totalPagesCount * pricing.PAGE; + total += totalFeatures * pricing.FEATURE; + total += totalFunctions * pricing.FUNCTION; + total += totalApis * pricing.API_INTEGRATION; + total += (state.newDatasets || 0) * pricing.NEW_DATASET; + + if (state.cmsSetup) { + total += Math.max(1, totalFeatures) * pricing.CMS_CONNECTION_PER_FEATURE; + } + + const languagesCount = state.languagesList?.length || 1; + if (languagesCount > 1) { + total *= 1 + (languagesCount - 1) * 0.2; + } + + const monthlyPrice = + pricing.HOSTING_MONTHLY + + (state.storageExpansion || 0) * pricing.STORAGE_EXPANSION_MONTHLY; + + return { + totalPrice: Math.round(total), + monthlyPrice: Math.round(monthlyPrice), + totalPagesCount, + totalFeatures, + totalFunctions, + totalApis, + languagesCount, + }; } export function calculatePositions(state: FormState, pricing: any): Position[] { - const positions: Position[] = []; - let pos = 1; + const positions: Position[] = []; + let pos = 1; - if (state.projectType === 'website') { - positions.push({ - pos: pos++, - title: 'Das technische Fundament', - desc: 'Projekt-Setup, Infrastruktur, Hosting-Bereitstellung, Grundstruktur & Design-Vorlage, technisches SEO-Basics, Analytics.', - qty: 1, - price: pricing.BASE_WEBSITE - }); + if (state.projectType === "website") { + positions.push({ + pos: pos++, + title: "Das technische Fundament", + desc: "Projekt-Setup, Infrastruktur, Hosting-Bereitstellung, Grundstruktur & Design-Vorlage, technisches SEO-Basics, Analytics.", + qty: 1, + price: pricing.BASE_WEBSITE, + }); - const sitemapPagesCount = state.sitemap?.reduce((acc: number, cat: any) => acc + (cat.pages?.length || 0), 0) || 0; - const totalPagesCount = Math.max( - (state.selectedPages?.length || 0) + (state.otherPages?.length || 0) + (state.otherPagesCount || 0), - sitemapPagesCount - ); + const sitemapPagesCount = + state.sitemap?.reduce( + (acc: number, cat: any) => acc + (cat.pages?.length || 0), + 0, + ) || 0; + const totalPagesCount = Math.max( + (state.selectedPages?.length || 0) + + (state.otherPages?.length || 0) + + (state.otherPagesCount || 0), + sitemapPagesCount, + ); - const allPages = [ - ...(state.selectedPages || []).map((p: string) => PAGE_LABELS[p] || p), - ...(state.otherPages || []), - ...(state.sitemap?.flatMap((cat: any) => cat.pages?.map((p: any) => p.title)) || []) - ]; + const allPages = [ + ...(state.selectedPages || []).map((p: string) => PAGE_LABELS[p] || p), + ...(state.otherPages || []), + ...(state.sitemap?.flatMap((cat: any) => + cat.pages?.map((p: any) => p.title), + ) || []), + ]; - // Deduplicate labels - const uniquePages = Array.from(new Set(allPages)); + // Deduplicate labels + const uniquePages = Array.from(new Set(allPages)); - positions.push({ - pos: pos++, - title: 'Individuelle Seiten', - desc: `Gestaltung und Umsetzung von ${totalPagesCount} individuellen Seiten-Layouts (${uniquePages.join(', ')}).`, - qty: totalPagesCount, - price: totalPagesCount * pricing.PAGE - }); + positions.push({ + pos: pos++, + title: "Individuelle Seiten", + desc: `Gestaltung und Umsetzung von ${totalPagesCount} individuellen Seiten-Layouts (${uniquePages.join(", ")}).`, + qty: totalPagesCount, + price: totalPagesCount * pricing.PAGE, + }); - if (state.features.length > 0 || (state.otherFeatures?.length || 0) > 0) { - const allFeatures = [...state.features.map((f: string) => FEATURE_LABELS[f] || f), ...(state.otherFeatures || [])]; - positions.push({ - pos: pos++, - title: 'System-Module (Features)', - desc: `Implementierung funktionaler Bereiche: ${allFeatures.join(', ')}. Inklusive Datenstruktur und Darstellung.`, - qty: allFeatures.length, - price: allFeatures.length * pricing.FEATURE - }); - } - - if (state.functions.length > 0 || (state.otherFunctions?.length || 0) > 0) { - const allFunctions = [...state.functions.map((f: string) => FUNCTION_LABELS[f] || f), ...(state.otherFunctions || [])]; - positions.push({ - pos: pos++, - title: 'Logik-Funktionen', - desc: `Implementierung technischer Logik: ${allFunctions.join(', ')}.`, - qty: allFunctions.length, - price: allFunctions.length * pricing.FUNCTION - }); - } - - if (state.apiSystems.length > 0 || (state.otherTech?.length || 0) > 0) { - const allApis = [...state.apiSystems.map((a: string) => API_LABELS[a] || a), ...(state.otherTech || [])]; - positions.push({ - pos: pos++, - title: 'Schnittstellen (API)', - desc: `Anbindung externer Systeme zur Datensynchronisation: ${allApis.join(', ')}.`, - qty: allApis.length, - price: allApis.length * pricing.API_INTEGRATION - }); - } - - if (state.cmsSetup) { - const totalFeatures = state.features.length + (state.otherFeatures?.length || 0) + (state.otherFeaturesCount || 0); - positions.push({ - pos: pos++, - title: 'Inhaltsverwaltung (CMS)', - desc: 'Einrichtung eines Systems zur eigenständigen Pflege von Inhalten und Datensätzen.', - qty: 1, - price: pricing.CMS_SETUP + totalFeatures * pricing.CMS_CONNECTION_PER_FEATURE - }); - } - - if (state.newDatasets > 0) { - positions.push({ - pos: pos++, - title: 'Inhaltliche Initial-Pflege', - desc: `Manuelle Übernahme und Aufbereitung von ${state.newDatasets} Datensätzen (Produkte, Artikel) in das Zielsystem.`, - qty: state.newDatasets, - price: state.newDatasets * pricing.NEW_DATASET - }); - } - - if ((state.visualStaging && Number(state.visualStaging) > 0) || (state.complexInteractions && Number(state.complexInteractions) > 0)) { - const vsCount = Number(state.visualStaging || 0); - const ciCount = Number(state.complexInteractions || 0); - const totalCount = vsCount + ciCount; - - positions.push({ - pos: pos++, - title: 'Inszenierung & Interaktion', - desc: `Umsetzung von ${totalCount} speziellen Sektionen, Hero-Stories oder Konfiguratoren zur Steigerung der Conversion.`, - qty: totalCount, - price: (vsCount * pricing.VISUAL_STAGING) + (ciCount * pricing.COMPLEX_INTERACTION) - }); - } - - const languagesCount = state.languagesList.length || 1; - if (languagesCount > 1) { - const subtotal = positions.reduce((sum, p) => sum + p.price, 0); - const factorPrice = subtotal * ((languagesCount - 1) * 0.2); - - positions.push({ - pos: pos++, - title: 'Mehrsprachigkeit', - desc: `Erweiterung des Systems auf ${languagesCount} Sprachen (Struktur & Logik).`, - qty: languagesCount, - price: Math.round(factorPrice) - }); - } - - const monthlyRate = pricing.HOSTING_MONTHLY + (state.storageExpansion * pricing.STORAGE_EXPANSION_MONTHLY); - positions.push({ - pos: pos++, - title: 'Sorglos-Paket (Betrieb & Pflege)', - desc: `1 Jahr Sicherung des technischen Betriebs, Instandhaltung, Sicherheits-Updates und Inhalts-Aktualisierungen gemäß AGB Punkt 7a.`, - qty: 1, - price: monthlyRate * 12 - }); - } else { - positions.push({ - pos: pos++, - title: 'Web App / Software Entwicklung', - desc: 'Individuelle Software-Entwicklung nach Aufwand. Abrechnung erfolgt auf Stundenbasis.', - qty: 1, - price: 0 - }); + if (state.features.length > 0 || (state.otherFeatures?.length || 0) > 0) { + const allFeatures = [ + ...state.features.map((f: string) => FEATURE_LABELS[f] || f), + ...(state.otherFeatures || []), + ]; + positions.push({ + pos: pos++, + title: "System-Module (Features)", + desc: `Implementierung funktionaler Bereiche: ${allFeatures.join(", ")}. Inklusive Datenstruktur und Darstellung.`, + qty: allFeatures.length, + price: allFeatures.length * pricing.FEATURE, + }); } - return positions; + if (state.functions.length > 0 || (state.otherFunctions?.length || 0) > 0) { + const allFunctions = [ + ...state.functions.map((f: string) => FUNCTION_LABELS[f] || f), + ...(state.otherFunctions || []), + ]; + positions.push({ + pos: pos++, + title: "Logik-Funktionen", + desc: `Implementierung technischer Logik: ${allFunctions.join(", ")}.`, + qty: allFunctions.length, + price: allFunctions.length * pricing.FUNCTION, + }); + } + + if (state.apiSystems.length > 0 || (state.otherTech?.length || 0) > 0) { + const allApis = [ + ...state.apiSystems.map((a: string) => API_LABELS[a] || a), + ...(state.otherTech || []), + ]; + positions.push({ + pos: pos++, + title: "Schnittstellen (API)", + desc: `Anbindung externer Systeme zur Datensynchronisation: ${allApis.join(", ")}.`, + qty: allApis.length, + price: allApis.length * pricing.API_INTEGRATION, + }); + } + + if (state.cmsSetup) { + const totalFeatures = + state.features.length + + (state.otherFeatures?.length || 0) + + (state.otherFeaturesCount || 0); + const qty = Math.max(1, totalFeatures); + positions.push({ + pos: pos++, + title: "Inhalts-Verwaltung", + desc: "Anbindung der System-Module an das Redaktions-System zur eigenständigen Pflege von Inhalten.", + qty: qty, + price: qty * pricing.CMS_CONNECTION_PER_FEATURE, + }); + } + + if (state.newDatasets > 0) { + positions.push({ + pos: pos++, + title: "Inhaltliche Initial-Pflege", + desc: `Manuelle Übernahme und Aufbereitung von ${state.newDatasets} Datensätzen (Produkte, Artikel) in das Zielsystem.`, + qty: state.newDatasets, + price: state.newDatasets * pricing.NEW_DATASET, + }); + } + + const languagesCount = state.languagesList.length || 1; + if (languagesCount > 1) { + const subtotal = positions.reduce((sum, p) => sum + p.price, 0); + const factorPrice = subtotal * ((languagesCount - 1) * 0.2); + + positions.push({ + pos: pos++, + title: "Mehrsprachigkeit", + desc: `Erweiterung des Systems auf ${languagesCount} Sprachen (Struktur & Logik).`, + qty: languagesCount, + price: Math.round(factorPrice), + }); + } + + const monthlyRate = + pricing.HOSTING_MONTHLY + + state.storageExpansion * pricing.STORAGE_EXPANSION_MONTHLY; + positions.push({ + pos: pos++, + title: "Sorglos Betrieb (1 Jahr)", + desc: `Inklusive 1 Jahr Sicherung des technischen Betriebs, Hosting, Instandhaltung, Sicherheits-Updates und techn. Support gemäß AGB Punkt 7a.`, + qty: 1, + price: monthlyRate * 12, + }); + } else { + positions.push({ + pos: pos++, + title: "Web App / Software Entwicklung", + desc: "Individuelle Software-Entwicklung nach Aufwand. Abrechnung erfolgt auf Stundenbasis.", + qty: 1, + price: 0, + }); + } + + return positions; } diff --git a/apps/web/src/logic/pricing/constants.ts b/apps/web/src/logic/pricing/constants.ts index 9a1bd73..9568219 100644 --- a/apps/web/src/logic/pricing/constants.ts +++ b/apps/web/src/logic/pricing/constants.ts @@ -1,226 +1,332 @@ -import { FormState } from './types'; +import { FormState } from "./types"; export const PRICING = { - BASE_WEBSITE: 4000, - PAGE: 600, - FEATURE: 1500, - FUNCTION: 800, - NEW_DATASET: 200, - HOSTING_MONTHLY: 250, - STORAGE_EXPANSION_MONTHLY: 10, - CMS_SETUP: 1500, - CMS_CONNECTION_PER_FEATURE: 800, - API_INTEGRATION: 800, - APP_HOURLY: 120, - VISUAL_STAGING: 2000, - COMPLEX_INTERACTION: 1500, + BASE_WEBSITE: 4000, + PAGE: 600, + FEATURE: 1500, + FUNCTION: 800, + NEW_DATASET: 450, + HOSTING_MONTHLY: 250, + STORAGE_EXPANSION_MONTHLY: 10, + CMS_SETUP: 1500, + CMS_CONNECTION_PER_FEATURE: 1500, + API_INTEGRATION: 800, + APP_HOURLY: 120, }; export const initialState: FormState = { - projectType: 'website', - // Company - companyName: '', - employeeCount: '', - // Existing Presence - existingWebsite: '', - socialMedia: [], - socialMediaUrls: {}, - existingDomain: '', - wishedDomain: '', - // Project - websiteTopic: '', - selectedPages: ['Home'], - otherPages: [], - otherPagesCount: 0, - features: [], - otherFeatures: [], - otherFeaturesCount: 0, - functions: [], - otherFunctions: [], - otherFunctionsCount: 0, - apiSystems: [], - otherTech: [], - otherTechCount: 0, - assets: [], - otherAssets: [], - otherAssetsCount: 0, - newDatasets: 0, - cmsSetup: false, - storageExpansion: 0, - name: '', - email: '', - role: '', - message: '', - sitemapFile: null, - contactFiles: [], - // Design - designVibe: 'minimal', - colorScheme: ['#ffffff', '#f8fafc', '#0f172a'], - references: [], - designWishes: '', - // Maintenance - expectedAdjustments: 'low', - languagesList: ['Deutsch'], - personName: '', - // Timeline - deadline: 'flexible', - // Web App specific - targetAudience: 'internal', - userRoles: [], - dataSensitivity: 'standard', - platformType: 'web-only', - // Meta - dontKnows: [], - visualStaging: 'standard', - complexInteractions: 'standard', - // AI generated / Post-processed - briefingSummary: '', - designVision: '', - positionDescriptions: {}, - taxId: '', - sitemap: [], + projectType: "website", + // Company + companyName: "", + employeeCount: "", + // Existing Presence + existingWebsite: "", + socialMedia: [], + socialMediaUrls: {}, + existingDomain: "", + wishedDomain: "", + // Project + websiteTopic: "", + selectedPages: ["Home"], + otherPages: [], + otherPagesCount: 0, + features: [], + otherFeatures: [], + otherFeaturesCount: 0, + functions: [], + otherFunctions: [], + otherFunctionsCount: 0, + apiSystems: [], + otherTech: [], + otherTechCount: 0, + assets: [], + otherAssets: [], + otherAssetsCount: 0, + newDatasets: 0, + cmsSetup: false, + storageExpansion: 0, + name: "", + email: "", + role: "", + message: "", + sitemapFile: null, + contactFiles: [], + // Design + designVibe: "minimal", + colorScheme: ["#ffffff", "#f8fafc", "#0f172a"], + references: [], + designWishes: "", + // Maintenance + expectedAdjustments: "low", + languagesList: ["Deutsch"], + personName: "", + // Timeline + deadline: "flexible", + // Web App specific + targetAudience: "internal", + userRoles: [], + dataSensitivity: "standard", + platformType: "web-only", + // Meta + dontKnows: [], + visualStaging: "standard", + complexInteractions: "standard", + // AI generated / Post-processed + briefingSummary: "", + designVision: "", + positionDescriptions: {}, + taxId: "", + sitemap: [], }; export const PAGE_SAMPLES = [ - { id: 'Home', label: 'Startseite', desc: 'Der erste Eindruck Ihrer Marke.' }, - { id: 'About', label: 'Über uns', desc: 'Ihre Geschichte und Ihr Team.' }, - { id: 'Services', label: 'Leistungen', desc: 'Übersicht Ihres Angebots.' }, - { id: 'Contact', label: 'Kontakt', desc: 'Anlaufstelle für Ihre Kunden.' }, - { id: 'Landing', label: 'Landingpage', desc: 'Optimiert für Marketing-Kampagnen.' }, - { id: 'Legal', label: 'Rechtliches', desc: 'Impressum & Datenschutz.' }, + { id: "Home", label: "Startseite", desc: "Der erste Eindruck Ihrer Marke." }, + { id: "About", label: "Über uns", desc: "Ihre Geschichte und Ihr Team." }, + { id: "Services", label: "Leistungen", desc: "Übersicht Ihres Angebots." }, + { id: "Contact", label: "Kontakt", desc: "Anlaufstelle für Ihre Kunden." }, + { + id: "Landing", + label: "Landingpage", + desc: "Optimiert für Marketing-Kampagnen.", + }, + { id: "Legal", label: "Rechtliches", desc: "Impressum & Datenschutz." }, ]; export const FEATURE_OPTIONS = [ - { id: 'blog_news', label: 'Blog / News', desc: 'Ein Bereich für aktuelle Beiträge und Neuigkeiten.' }, - { id: 'products', label: 'Produktbereich', desc: 'Katalog Ihrer Leistungen oder Produkte.' }, - { id: 'jobs', label: 'Karriere / Jobs', desc: 'Stellenanzeigen und Bewerbungsoptionen.' }, - { id: 'refs', label: 'Referenzen / Cases', desc: 'Präsentation Ihrer Projekte.' }, - { id: 'events', label: 'Events / Termine', desc: 'Veranstaltungskalender.' }, + { + id: "blog_news", + label: "Blog / News", + desc: "Ein Bereich für aktuelle Beiträge und Neuigkeiten.", + }, + { + id: "products", + label: "Produktbereich", + desc: "Katalog Ihrer Leistungen oder Produkte.", + }, + { + id: "jobs", + label: "Karriere / Jobs", + desc: "Stellenanzeigen und Bewerbungsoptionen.", + }, + { + id: "refs", + label: "Referenzen / Cases", + desc: "Präsentation Ihrer Projekte.", + }, + { id: "events", label: "Events / Termine", desc: "Veranstaltungskalender." }, ]; export const FUNCTION_OPTIONS = [ - { id: 'search', label: 'Suche', desc: 'Volltextsuche über alle Inhalte.' }, - { id: 'filter', label: 'Filter-Systeme', desc: 'Kategorisierung und Sortierung.' }, - { id: 'pdf', label: 'PDF-Export', desc: 'Automatisierte PDF-Erstellung.' }, - { id: 'forms', label: 'Individuelle Formular-Logik', desc: 'Smarte Validierung & mehrstufige Prozesse.' }, + { id: "search", label: "Suche", desc: "Volltextsuche über alle Inhalte." }, + { + id: "filter", + label: "Filter-Systeme", + desc: "Kategorisierung und Sortierung.", + }, + { id: "pdf", label: "PDF-Export", desc: "Automatisierte PDF-Erstellung." }, + { + id: "forms", + label: "Individuelle Formular-Logik", + desc: "Smarte Validierung & mehrstufige Prozesse.", + }, ]; export const API_OPTIONS = [ - { id: 'crm', label: 'CRM System', desc: 'HubSpot, Salesforce, Pipedrive etc.' }, - { id: 'erp', label: 'ERP / Warenwirtschaft', desc: 'SAP, Microsoft Dynamics, Xentral etc.' }, - { id: 'stripe', label: 'Stripe / Payment', desc: 'Zahlungsabwicklung und Abonnements.' }, - { id: 'newsletter', label: 'Newsletter / Marketing', desc: 'Mailchimp, Brevo, ActiveCampaign etc.' }, - { id: 'ecommerce', label: 'E-Commerce / Shop', desc: 'Shopify, WooCommerce, Shopware Sync.' }, - { id: 'hr', label: 'HR / Recruiting', desc: 'Personio, Workday, Recruitee etc.' }, - { id: 'realestate', label: 'Immobilien', desc: 'OpenImmo, FlowFact, Immowelt Sync.' }, - { id: 'calendar', label: 'Termine / Booking', desc: 'Calendly, Shore, Doctolib etc.' }, - { id: 'social', label: 'Social Media Sync', desc: 'Automatisierte Posts oder Feeds.' }, - { id: 'maps', label: 'Google Maps / Places', desc: 'Standortsuche und Kartenintegration.' }, - { id: 'analytics', label: 'Custom Analytics', desc: 'Anbindung an spezialisierte Tracking-Tools.' }, + { + id: "crm", + label: "CRM System", + desc: "HubSpot, Salesforce, Pipedrive etc.", + }, + { + id: "erp", + label: "ERP / Warenwirtschaft", + desc: "SAP, Microsoft Dynamics, Xentral etc.", + }, + { + id: "stripe", + label: "Stripe / Payment", + desc: "Zahlungsabwicklung und Abonnements.", + }, + { + id: "newsletter", + label: "Newsletter / Marketing", + desc: "Mailchimp, Brevo, ActiveCampaign etc.", + }, + { + id: "ecommerce", + label: "E-Commerce / Shop", + desc: "Shopify, WooCommerce, Shopware Sync.", + }, + { + id: "hr", + label: "HR / Recruiting", + desc: "Personio, Workday, Recruitee etc.", + }, + { + id: "realestate", + label: "Immobilien", + desc: "OpenImmo, FlowFact, Immowelt Sync.", + }, + { + id: "calendar", + label: "Termine / Booking", + desc: "Calendly, Shore, Doctolib etc.", + }, + { + id: "social", + label: "Social Media Sync", + desc: "Automatisierte Posts oder Feeds.", + }, + { + id: "maps", + label: "Google Maps / Places", + desc: "Standortsuche und Kartenintegration.", + }, + { + id: "analytics", + label: "Custom Analytics", + desc: "Anbindung an spezialisierte Tracking-Tools.", + }, ]; export const ASSET_OPTIONS = [ - { id: 'existing_website', label: 'Bestehende Website', desc: 'Inhalte oder Struktur können übernommen werden.' }, - { id: 'logo', label: 'Logo', desc: 'Vektordatei Ihres Logos.' }, - { id: 'styleguide', label: 'Styleguide', desc: 'Farben, Schriften, Design-Vorgaben.' }, - { id: 'content_concept', label: 'Inhalts-Konzept', desc: 'Struktur und Texte sind bereits geplant.' }, - { id: 'media', label: 'Bild/Video-Material', desc: 'Professionelles Bildmaterial vorhanden.' }, - { id: 'icons', label: 'Icons', desc: 'Eigene Icon-Sets vorhanden.' }, - { id: 'illustrations', label: 'Illustrationen', desc: 'Eigene Illustrationen vorhanden.' }, - { id: 'fonts', label: 'Fonts', desc: 'Lizenzen für Hausschriften vorhanden.' }, + { + id: "existing_website", + label: "Bestehende Website", + desc: "Inhalte oder Struktur können übernommen werden.", + }, + { id: "logo", label: "Logo", desc: "Vektordatei Ihres Logos." }, + { + id: "styleguide", + label: "Styleguide", + desc: "Farben, Schriften, Design-Vorgaben.", + }, + { + id: "content_concept", + label: "Inhalts-Konzept", + desc: "Struktur und Texte sind bereits geplant.", + }, + { + id: "media", + label: "Bild/Video-Material", + desc: "Professionelles Bildmaterial vorhanden.", + }, + { id: "icons", label: "Icons", desc: "Eigene Icon-Sets vorhanden." }, + { + id: "illustrations", + label: "Illustrationen", + desc: "Eigene Illustrationen vorhanden.", + }, + { + id: "fonts", + label: "Fonts", + desc: "Lizenzen für Hausschriften vorhanden.", + }, ]; export const DESIGN_OPTIONS = [ - { id: 'minimal', label: 'Minimalistisch', desc: 'Viel Weißraum, klare Typografie.' }, - { id: 'bold', label: 'Mutig & Laut', desc: 'Starke Kontraste, große Schriften.' }, - { id: 'nature', label: 'Natürlich', desc: 'Sanfte Erdtöne, organische Formen.' }, - { id: 'tech', label: 'Technisch', desc: 'Präzise Linien, dunkle Akzente.' }, + { + id: "minimal", + label: "Minimalistisch", + desc: "Viel Weißraum, klare Typografie.", + }, + { + id: "bold", + label: "Mutig & Laut", + desc: "Starke Kontraste, große Schriften.", + }, + { + id: "nature", + label: "Natürlich", + desc: "Sanfte Erdtöne, organische Formen.", + }, + { id: "tech", label: "Technisch", desc: "Präzise Linien, dunkle Akzente." }, ]; export const EMPLOYEE_OPTIONS = [ - { id: '1-5', label: '1-5 Mitarbeiter' }, - { id: '6-20', label: '6-20 Mitarbeiter' }, - { id: '21-100', label: '21-100 Mitarbeiter' }, - { id: '100+', label: '100+ Mitarbeiter' }, + { id: "1-5", label: "1-5 Mitarbeiter" }, + { id: "6-20", label: "6-20 Mitarbeiter" }, + { id: "21-100", label: "21-100 Mitarbeiter" }, + { id: "100+", label: "100+ Mitarbeiter" }, ]; export const SOCIAL_MEDIA_OPTIONS = [ - { id: 'instagram', label: 'Instagram' }, - { id: 'linkedin', label: 'LinkedIn' }, - { id: 'facebook', label: 'Facebook' }, - { id: 'twitter', label: 'Twitter / X' }, - { id: 'tiktok', label: 'TikTok' }, - { id: 'youtube', label: 'YouTube' }, + { id: "instagram", label: "Instagram" }, + { id: "linkedin", label: "LinkedIn" }, + { id: "facebook", label: "Facebook" }, + { id: "twitter", label: "Twitter / X" }, + { id: "tiktok", label: "TikTok" }, + { id: "youtube", label: "YouTube" }, ]; export const VIBE_LABELS: Record = { - minimal: 'Minimalistisch', - bold: 'Mutig & Laut', - nature: 'Natürlich', - tech: 'Technisch' + minimal: "Minimalistisch", + bold: "Mutig & Laut", + nature: "Natürlich", + tech: "Technisch", }; export const DEADLINE_LABELS: Record = { - asap: 'So schnell wie möglich', - '2-3-months': 'In 2-3 Monaten', - '3-6-months': 'In 3-6 Monaten', - flexible: 'Flexibel' + asap: "So schnell wie möglich", + "2-3-months": "In 2-3 Monaten", + "3-6-months": "In 3-6 Monaten", + flexible: "Flexibel", }; export const ASSET_LABELS: Record = { - existing_website: 'Bestehende Website', - logo: 'Logo', - styleguide: 'Styleguide', - content_concept: 'Inhalts-Konzept', - media: 'Bild/Video-Material', - icons: 'Icons', - illustrations: 'Illustrationen', - fonts: 'Fonts' + existing_website: "Bestehende Website", + logo: "Logo", + styleguide: "Styleguide", + content_concept: "Inhalts-Konzept", + media: "Bild/Video-Material", + icons: "Icons", + illustrations: "Illustrationen", + fonts: "Fonts", }; export const FEATURE_LABELS: Record = { - blog_news: 'Blog / News', - products: 'Produktbereich', - jobs: 'Karriere / Jobs', - refs: 'Referenzen / Cases', - events: 'Events / Termine' + blog_news: "Blog / News", + products: "Produktbereich", + jobs: "Karriere / Jobs", + refs: "Referenzen / Cases", + events: "Events / Termine", }; export const FUNCTION_LABELS: Record = { - search: 'Suche', - filter: 'Filter-Systeme', - pdf: 'PDF-Export', - forms: 'Individuelle Formular-Logik', - members: 'Mitgliederbereich', - calendar: 'Event-Kalender', - multilang: 'Mehrsprachigkeit', - chat: 'Echtzeit-Chat' + search: "Suche", + filter: "Filter-Systeme", + pdf: "PDF-Export", + forms: "Individuelle Formular-Logik", + members: "Mitgliederbereich", + calendar: "Event-Kalender", + multilang: "Mehrsprachigkeit", + chat: "Echtzeit-Chat", }; export const API_LABELS: Record = { - crm_erp: 'CRM / ERP', - payment: 'Payment', - marketing: 'Marketing', - ecommerce: 'E-Commerce', - maps: 'Google Maps / Places', - social: 'Social Media Sync', - analytics: 'Custom Analytics' + crm_erp: "CRM / ERP", + payment: "Payment", + marketing: "Marketing", + ecommerce: "E-Commerce", + maps: "Google Maps / Places", + social: "Social Media Sync", + analytics: "Custom Analytics", }; export const SOCIAL_LABELS: Record = { - instagram: 'Instagram', - linkedin: 'LinkedIn', - facebook: 'Facebook', - twitter: 'Twitter / X', - tiktok: 'TikTok', - youtube: 'YouTube' + instagram: "Instagram", + linkedin: "LinkedIn", + facebook: "Facebook", + twitter: "Twitter / X", + tiktok: "TikTok", + youtube: "YouTube", }; export const PAGE_LABELS: Record = { - Home: 'Startseite', - About: 'Über uns', - Services: 'Leistungen', - Contact: 'Kontakt', - Landing: 'Landingpage', - Legal: 'Impressum & Datenschutz' + Home: "Startseite", + About: "Über uns", + Services: "Leistungen", + Contact: "Kontakt", + Landing: "Landingpage", + Legal: "Impressum & Datenschutz", };