feat: adds aquisition extension to cms
Some checks failed
Monorepo Pipeline / 🧪 Quality Assurance (push) Failing after 11s
Monorepo Pipeline / 🚀 Release (push) Has been skipped
Monorepo Pipeline / 🐳 Build Directus (Base) (push) Has been skipped
Monorepo Pipeline / 🐳 Build Gatekeeper (Product) (push) Has been skipped
Monorepo Pipeline / 🐳 Build Build-Base (push) Has been skipped
Monorepo Pipeline / 🐳 Build Production Runtime (push) Has been skipped

This commit is contained in:
2026-02-10 21:30:23 +01:00
parent f2c0a4581c
commit 9e4e296e3b
58 changed files with 7215 additions and 1782 deletions

View File

@@ -0,0 +1,32 @@
{
"name": "@mintel/acquisition",
"version": "1.0.0",
"private": true,
"type": "module",
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"scripts": {
"build": "tsup src/index.ts --format esm --dts --clean",
"dev": "tsup src/index.ts --format esm --watch --dts",
"lint": "eslint src",
"test": "vitest run",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"axios": "^1.7.9",
"crawlee": "^3.12.2",
"cheerio": "^1.0.0",
"react": "^19.0.0",
"@react-pdf/renderer": "^4.3.0",
"framer-motion": "^12.4.2"
},
"devDependencies": {
"@mintel/tsconfig": "workspace:*",
"@mintel/eslint-config": "workspace:*",
"tsup": "^8.3.5",
"typescript": "^5.0.0",
"vitest": "^3.0.4",
"@types/node": "^20.17.16"
}
}

View File

@@ -0,0 +1,93 @@
"use client";
import * as React from "react";
import { Page as PDFPage, Document as PDFDocument } from "@react-pdf/renderer";
import { pdfStyles } from "./pdf/SharedUI.js";
import { SimpleLayout } from "./pdf/SimpleLayout.js";
// Modules
import { FrontPageModule } from "./pdf/modules/FrontPageModule.js";
import { BriefingModule } from "./pdf/modules/BriefingModule.js";
import { SitemapModule } from "./pdf/modules/SitemapModule.js";
import { EstimationModule } from "./pdf/modules/EstimationModule.js";
import { TransparenzModule } from "./pdf/modules/TransparenzModule.js";
import { ClosingModule } from "./pdf/modules/CommonModules.js";
import { calculatePositions } from "../logic/pricing/calculator.js";
interface PDFProps {
state: any;
totalPrice: number;
pricing: any;
headerIcon?: string;
footerLogo?: string;
}
export const EstimationPDF = ({
state,
totalPrice,
pricing,
headerIcon,
footerLogo,
}: PDFProps) => {
const date = new Date().toLocaleDateString("de-DE", {
year: "numeric",
month: "long",
day: "numeric",
});
const positions = calculatePositions(state, pricing);
const companyData = {
name: "Marc Mintel",
address1: "Georg-Meistermann-Straße 7",
address2: "54586 Schüller",
ustId: "DE367588065",
};
const commonProps = {
state,
date,
icon: headerIcon,
footerLogo,
companyData,
};
let pageCounter = 1;
const getPageNum = () => (pageCounter++).toString().padStart(2, "0");
return (
<PDFDocument title={`Angebot - ${state.companyName || "Projekt"}`}>
<PDFPage size="A4" style={pdfStyles.titlePage}>
<FrontPageModule state={state} headerIcon={headerIcon} date={date} />
</PDFPage>
<SimpleLayout {...commonProps} pageNumber={getPageNum()}>
<BriefingModule state={state} />
</SimpleLayout>
{state.sitemap && state.sitemap.length > 0 && (
<SimpleLayout {...commonProps} pageNumber={getPageNum()}>
<SitemapModule state={state} />
</SimpleLayout>
)}
<SimpleLayout {...commonProps} pageNumber={getPageNum()}>
<EstimationModule
state={state}
positions={positions}
totalPrice={totalPrice}
date={date}
/>
</SimpleLayout>
<SimpleLayout {...commonProps} pageNumber={getPageNum()}>
<TransparenzModule pricing={pricing} />
</SimpleLayout>
<SimpleLayout {...commonProps} pageNumber={getPageNum()}>
<ClosingModule />
</SimpleLayout>
</PDFDocument>
);
};

View File

@@ -0,0 +1,401 @@
"use client";
import * as React from "react";
import {
View as PDFView,
Text as PDFText,
StyleSheet,
Image as PDFImage,
} 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",
};
export const FONT_SIZES = {
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
};
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,
},
header: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "flex-start",
marginBottom: 20,
minHeight: 120,
},
addressBlock: {
width: "55%",
marginTop: 45,
},
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,
},
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,
},
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,
},
});
export const IndustrialListItem = ({
children,
}: {
children: React.ReactNode;
}) => (
<PDFView style={pdfStyles.industrialListItem}>
<PDFView style={pdfStyles.industrialBulletBox} />
{children}
</PDFView>
);
export const Divider = ({ style = {} }: { style?: any }) => (
<PDFView style={[pdfStyles.divider, style]} />
);
export const Footer = ({
logo,
companyData,
showDetails = true,
showPageNumber = true,
}: {
logo?: string;
companyData: any;
showDetails?: boolean;
showPageNumber?: boolean;
}) => (
<PDFView style={pdfStyles.footer}>
<PDFView style={pdfStyles.footerColumn}>
{logo ? (
<PDFImage src={logo} style={pdfStyles.footerLogo} />
) : (
<PDFText style={{ fontSize: 12, fontWeight: "bold", marginBottom: 8 }}>
marc mintel
</PDFText>
)}
</PDFView>
{showDetails && (
<>
<PDFView style={pdfStyles.footerColumn}>
<PDFText style={pdfStyles.footerText}>
<PDFText style={pdfStyles.footerLabel}>{companyData.name}</PDFText>
{"\n"}
{companyData.address1}
{"\n"}
{companyData.address2}
{"\n"}UST: {companyData.ustId}
</PDFText>
</PDFView>
<PDFView style={[pdfStyles.footerColumn, { alignItems: "flex-end" }]}>
{showPageNumber && (
<PDFText
style={pdfStyles.pageNumber}
render={({ pageNumber, totalPages }) =>
`${pageNumber} / ${totalPages}`
}
fixed
/>
)}
</PDFView>
</>
)}
</PDFView>
);
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;
}) => (
<PDFView
style={[
pdfStyles.header,
showAddress ? {} : { minHeight: 40, marginBottom: 0 },
]}
>
<PDFView style={pdfStyles.addressBlock}>
{showAddress && sender && (
<>
<PDFText style={pdfStyles.senderLine}>{sender}</PDFText>
{recipient && (
<PDFView style={pdfStyles.recipientAddress}>
<PDFText style={{ fontWeight: "bold" }}>
{recipient.title}
</PDFText>
{recipient.subtitle && <PDFText>{recipient.subtitle}</PDFText>}
{recipient.address && <PDFText>{recipient.address}</PDFText>}
{recipient.phone && <PDFText>{recipient.phone}</PDFText>}
{recipient.email && <PDFText>{recipient.email}</PDFText>}
{recipient.taxId && <PDFText>USt-ID: {recipient.taxId}</PDFText>}
</PDFView>
)}
</>
)}
</PDFView>
<PDFView style={pdfStyles.brandLogoContainer}>
<PDFView style={pdfStyles.brandIconContainer}>
{icon ? (
<PDFImage src={icon} style={{ width: 24, height: 24 }} />
) : (
<PDFText style={pdfStyles.brandIconText}>M</PDFText>
)}
</PDFView>
</PDFView>
</PDFView>
);
export const DocumentTitle = ({
title,
subLines,
isHero = false,
}: {
title: string;
subLines?: string[];
isHero?: boolean;
}) => (
<PDFView style={pdfStyles.titleInfo}>
<PDFText
style={[
pdfStyles.mainTitle,
{ fontSize: isHero ? FONT_SIZES.HERO : FONT_SIZES.HEADING },
]}
>
{title}
</PDFText>
{subLines?.map((line, i) => (
<PDFText
key={i}
style={[
pdfStyles.subTitle,
i === 1 ? { fontWeight: "bold", color: COLORS.CHARCOAL } : {},
]}
>
{line}
</PDFText>
))}
</PDFView>
);
export const TechnicalSpec = ({
label,
value,
}: {
label: string;
value: string;
}) => (
<PDFView style={pdfStyles.specRow}>
<PDFText style={pdfStyles.specLabel}>{label}</PDFText>
<PDFText style={pdfStyles.specValue}>{value}</PDFText>
</PDFView>
);
export const AsymmetryView = ({
left,
right,
style = {},
}: {
left: React.ReactNode;
right: React.ReactNode;
style?: any;
}) => (
<PDFView style={[pdfStyles.asymmetryContainer, style]}>
<PDFView style={pdfStyles.asymmetryLeft}>{left}</PDFView>
<PDFView style={pdfStyles.asymmetryRight}>{right}</PDFView>
</PDFView>
);

View File

@@ -0,0 +1,64 @@
'use client';
import * as React from 'react';
import { Page as PDFPage, View as PDFView, Text as PDFText, StyleSheet } from '@react-pdf/renderer';
import { Header, Footer, pdfStyles } from './SharedUI.js';
const simpleStyles = StyleSheet.create({
industrialPage: {
padding: 30,
paddingTop: 20,
backgroundColor: '#ffffff',
},
industrialNumber: {
fontSize: 60,
fontWeight: 'bold',
color: '#f1f5f9',
position: 'absolute',
top: -10,
right: 0,
zIndex: -1,
},
industrialSection: {
marginTop: 16,
paddingTop: 12,
flexDirection: 'row',
position: 'relative',
},
});
interface SimpleLayoutProps {
children: React.ReactNode;
pageNumber?: string;
icon?: string;
footerLogo?: string;
companyData: any;
showPageNumber?: boolean;
}
export const SimpleLayout = ({
children,
pageNumber,
icon,
footerLogo,
companyData,
showPageNumber = true
}: SimpleLayoutProps) => {
return (
<PDFPage size="A4" style={[pdfStyles.page, simpleStyles.industrialPage]}>
<Header icon={icon} showAddress={false} />
{pageNumber && <PDFText style={simpleStyles.industrialNumber}>{pageNumber}</PDFText>}
<PDFView style={simpleStyles.industrialSection}>
<PDFView style={{ width: '100%' }}>
{children}
</PDFView>
</PDFView>
<Footer
logo={footerLogo}
companyData={companyData}
showDetails={false}
showPageNumber={showPageNumber}
/>
</PDFPage>
);
};

View File

@@ -0,0 +1,69 @@
"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.js";
const styles = StyleSheet.create({
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) => (
<>
<DocumentTitle title="Projektdetails" isHero={true} />
{state.briefingSummary && (
<PDFView style={styles.section}>
<PDFText style={styles.sectionTitle}>Briefing Analyse</PDFText>
<PDFText
style={{
fontSize: FONT_SIZES.BODY,
color: COLORS.TEXT_MAIN,
lineHeight: 1.6,
textAlign: "justify",
}}
>
{state.briefingSummary}
</PDFText>
</PDFView>
)}
{state.designVision && (
<PDFView
style={[
styles.section,
{
padding: 12,
borderLeftWidth: 2,
borderLeftColor: COLORS.DIVIDER,
backgroundColor: COLORS.GRID,
},
]}
>
<PDFText
style={[
styles.sectionTitle,
{ color: COLORS.CHARCOAL, marginBottom: 4 },
]}
>
Strategische Vision
</PDFText>
<PDFText style={styles.visionText}>{state.designVision}</PDFText>
</PDFView>
)}
</>
);

View File

@@ -0,0 +1,97 @@
"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.js";
const styles = StyleSheet.create({
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 ClosingModule = () => (
<>
<DocumentTitle title="Abschluss & Kontakt" isHero={true} />
<PDFView style={styles.section}>
<PDFText
style={[
styles.moduleLabel,
{ fontSize: FONT_SIZES.HEADING, marginBottom: 12 },
]}
>
Vielen Dank für Ihr Interesse!
</PDFText>
<PDFText style={styles.moduleDesc}>
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.
</PDFText>
<PDFView
style={{
marginTop: 24,
padding: 16,
backgroundColor: COLORS.GRID,
borderLeftWidth: 2,
borderLeftColor: COLORS.DIVIDER,
}}
>
<PDFText style={[styles.moduleLabel, { marginBottom: 8 }]}>
Haben Sie Fragen?
</PDFText>
<PDFText style={styles.moduleDesc}>
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.
</PDFText>
<PDFView style={{ marginTop: 16 }}>
<PDFText style={styles.moduleLabel}>Kontakt:</PDFText>
<PDFText
style={[
styles.moduleDesc,
{ color: COLORS.CHARCOAL, fontWeight: "bold" },
]}
>
Marc Mintel marc@mintel.me
</PDFText>
</PDFView>
</PDFView>
</PDFView>
</>
);

View File

@@ -0,0 +1,159 @@
"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.js";
const styles = StyleSheet.create({
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) => (
<>
<DocumentTitle
title="Kostenschätzung"
subLines={[
`Datum: ${date}`,
`Projekt: ${state.projectType === "website" ? "Website" : "Web App"}`,
]}
isHero={true}
/>
<PDFView style={styles.table}>
<PDFView style={styles.tableHeader}>
<PDFText style={[styles.headerText, styles.colPos]}>Pos</PDFText>
<PDFText style={[styles.headerText, styles.colDesc]}>
Beschreibung
</PDFText>
<PDFText style={[styles.headerText, styles.colQty]}>Menge</PDFText>
<PDFText style={[styles.headerText, styles.colPrice]}>Betrag</PDFText>
</PDFView>
{positions.map((item: any, i: number) => (
<PDFView key={i} style={styles.tableRow} wrap={false}>
<PDFText style={[styles.posText, styles.colPos]}>
{item.pos.toString().padStart(2, "0")}
</PDFText>
<PDFView style={styles.colDesc}>
<PDFText style={styles.itemTitle}>{item.title}</PDFText>
<PDFText style={styles.itemDesc}>
{state.positionDescriptions?.[item.title] || item.desc}
</PDFText>
</PDFView>
<PDFText style={[styles.posText, styles.colQty]}>{item.qty}</PDFText>
<PDFText style={[styles.priceText, styles.colPrice]}>
{item.price > 0
? `${item.price.toLocaleString("de-DE")}`
: "n. A."}
</PDFText>
</PDFView>
))}
</PDFView>
<PDFView style={styles.summaryContainer} wrap={false}>
<PDFView style={styles.summaryRow}>
<PDFText style={styles.summaryLabel}>Nettobetrag</PDFText>
<PDFText style={styles.summaryValue}>
{totalPrice.toLocaleString("de-DE")}
</PDFText>
</PDFView>
<PDFView style={styles.summaryRow}>
<PDFText style={styles.summaryLabel}>Umsatzsteuer (19%)</PDFText>
<PDFText style={styles.summaryValue}>
{(totalPrice * 0.19).toLocaleString("de-DE")}
</PDFText>
</PDFView>
<PDFView style={styles.totalRow}>
<PDFText style={styles.summaryLabel}>Gesamtbetrag (Brutto)</PDFText>
<PDFText
style={[styles.summaryValue, { fontSize: FONT_SIZES.HEADING }]}
>
{(totalPrice * 1.19).toLocaleString("de-DE")}
</PDFText>
</PDFView>
</PDFView>
</>
);

View File

@@ -0,0 +1,70 @@
"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.js";
const styles = StyleSheet.create({
titlePage: {
flex: 1,
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,
},
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 fontSize = fullTitle.length > 60 ? 14 : fullTitle.length > 40 ? 18 : 22;
return (
<PDFView style={styles.titlePage}>
<PDFView style={styles.titleBrandIcon}>
{headerIcon ? (
<PDFImage src={headerIcon} style={{ width: 40, height: 40 }} />
) : (
<PDFText style={styles.brandIconText}>M</PDFText>
)}
</PDFView>
<PDFText style={[styles.titleProjectName, { fontSize }]}>
{fullTitle}
</PDFText>
<PDFView style={{ marginBottom: 40 }} />
<PDFText style={styles.titleDate}>{date} | Marc Mintel</PDFText>
</PDFView>
);
};

View File

@@ -0,0 +1,56 @@
"use client";
import * as React from "react";
import { View as PDFView, Text as PDFText, StyleSheet } from "@react-pdf/renderer";
import { DocumentTitle, COLORS, FONT_SIZES, IndustrialListItem } from "../SharedUI.js";
const styles = StyleSheet.create({
section: { marginBottom: 24 },
categoryBox: {
marginBottom: 20,
padding: 12,
backgroundColor: COLORS.GRID,
borderLeftWidth: 2,
borderLeftColor: COLORS.DIVIDER,
},
categoryTitle: {
fontSize: FONT_SIZES.TINY,
fontWeight: "bold",
color: COLORS.TEXT_LIGHT,
textTransform: "uppercase",
marginBottom: 10,
letterSpacing: 1,
},
pageTitle: {
fontSize: FONT_SIZES.LABEL,
fontWeight: "bold",
color: COLORS.CHARCOAL,
marginBottom: 2,
},
pageDesc: {
fontSize: FONT_SIZES.TINY,
color: COLORS.TEXT_DIM,
lineHeight: 1.4,
},
});
export const SitemapModule = ({ state }: any) => (
<>
<DocumentTitle title="Informations-Architektur" isHero={true} />
<PDFView style={styles.section}>
{state.sitemap?.map((cat: any, i: number) => (
<PDFView key={i} style={styles.categoryBox}>
<PDFText style={styles.categoryTitle}>{cat.category}</PDFText>
{cat.pages?.map((p: any, j: number) => (
<IndustrialListItem key={j}>
<PDFView style={{ marginBottom: 8 }}>
<PDFText style={styles.pageTitle}>{p.title}</PDFText>
<PDFText style={styles.pageDesc}>{p.desc}</PDFText>
</PDFView>
</IndustrialListItem>
))}
</PDFView>
))}
</PDFView>
</>
);

View File

@@ -0,0 +1,125 @@
"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.js";
const styles = StyleSheet.create({
section: { marginBottom: 24 },
ledgerRow: {
paddingVertical: 14,
borderBottomWidth: 1,
borderBottomColor: COLORS.GRID,
flexDirection: "row",
alignItems: "flex-start",
},
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,
},
ledgerPrice: {
fontSize: FONT_SIZES.BODY,
fontWeight: "bold",
color: COLORS.CHARCOAL,
},
ledgerUnit: {
fontSize: FONT_SIZES.TINY,
color: COLORS.TEXT_LIGHT,
marginLeft: 2,
},
});
export const TransparenzModule = ({ pricing }: any) => {
const sorglosPrice = (pricing.HOSTING_MONTHLY || 250) * 12;
return (
<>
<DocumentTitle title="Preis-Transparenz & Modell" isHero={true} />
<PDFView style={styles.section}>
<PDFView style={{ borderTopWidth: 1, borderTopColor: COLORS.CHARCOAL }}>
{[
{
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: "Sprachversionen",
d: "Skalierung der System-Architektur auf Zweit-Sprachen.",
p: "+20%",
},
{
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) => (
<PDFView key={i} style={styles.ledgerRow}>
<PDFView style={{ width: "25%" }}>
<PDFText style={styles.moduleLabel}>
{item.l.toUpperCase()}
</PDFText>
</PDFView>
<PDFView style={{ width: "50%" }}>
<PDFText style={styles.moduleDesc}>{item.d}</PDFText>
</PDFView>
<PDFView style={{ width: "25%", alignItems: "flex-end" }}>
<PDFText style={styles.ledgerPrice}>
{typeof item.p === "number"
? `${item.p.toLocaleString("de-DE")}`
: item.p}
{item.unit && (
<PDFText style={styles.ledgerUnit}> {item.unit}</PDFText>
)}
</PDFText>
</PDFView>
</PDFView>
))}
</PDFView>
</PDFView>
</>
);
};

View File

@@ -0,0 +1,6 @@
export * from "./logic/pricing/types.js";
export * from "./logic/pricing/constants.js";
export * from "./logic/pricing/calculator.js";
export * from "./services/AcquisitionService.js";
export * from "./services/PdfEngine.js";
export * from "./components/EstimationPDF.js";

View File

@@ -0,0 +1,224 @@
import { FormState, Position, Totals } from "./types.js";
import {
FEATURE_LABELS,
FUNCTION_LABELS,
API_LABELS,
PAGE_LABELS,
} from "./constants.js";
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 += 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;
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 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));
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) > 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) > 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) > 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 || 0) +
(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;
}

View File

@@ -0,0 +1,332 @@
import { FormState } from "./types.js";
export const PRICING = {
BASE_WEBSITE: 5440, // Updated to match AI prompt requirement in Pass 1
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: [],
};
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." },
];
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." },
];
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.",
},
];
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.",
},
];
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.",
},
];
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." },
];
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" },
];
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" },
];
export const VIBE_LABELS: Record<string, string> = {
minimal: "Minimalistisch",
bold: "Mutig & Laut",
nature: "Natürlich",
tech: "Technisch",
};
export const DEADLINE_LABELS: Record<string, string> = {
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<string, string> = {
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<string, string> = {
blog_news: "Blog / News",
products: "Produktbereich",
jobs: "Karriere / Jobs",
refs: "Referenzen / Cases",
events: "Events / Termine",
};
export const FUNCTION_LABELS: Record<string, string> = {
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<string, string> = {
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<string, string> = {
instagram: "Instagram",
linkedin: "LinkedIn",
facebook: "Facebook",
twitter: "Twitter / X",
tiktok: "TikTok",
youtube: "YouTube",
};
export const PAGE_LABELS: Record<string, string> = {
Home: "Startseite",
About: "Über uns",
Services: "Leistungen",
Contact: "Kontakt",
Landing: "Landingpage",
Legal: "Impressum & Datenschutz",
};

View File

@@ -0,0 +1,89 @@
export type ProjectType = 'website' | 'web-app';
export interface FormState {
projectType: ProjectType;
// Company
companyName: string;
employeeCount: string;
// Existing Presence
existingWebsite: string;
socialMedia: string[];
socialMediaUrls: Record<string, string>;
existingDomain: string;
wishedDomain: string;
// Project
websiteTopic: string;
selectedPages: string[];
otherPages: string[];
otherPagesCount: number;
features: string[];
otherFeatures: string[];
otherFeaturesCount: number;
functions: string[];
otherFunctions: string[];
otherFunctionsCount: number;
apiSystems: string[];
otherTech: string[];
otherTechCount: number;
assets: string[];
otherAssets: string[];
otherAssetsCount: number;
newDatasets: number;
cmsSetup: boolean;
storageExpansion: number;
name: string;
email: string;
role: string;
message: string;
sitemapFile: any;
contactFiles: any[];
// Design
designVibe: string;
colorScheme: string[];
references: string[];
designWishes: string;
// Maintenance
expectedAdjustments: string;
languagesList: string[];
// Timeline
deadline: string;
// Web App specific
targetAudience: string;
userRoles: string[];
dataSensitivity: string;
platformType: string;
// Meta
dontKnows: string[];
visualStaging: string;
complexInteractions: string;
gridDontKnows?: Record<string, string>;
briefingSummary?: string;
companyAddress?: string;
companyPhone?: string;
personName?: string;
taxId?: string;
designVision?: string;
positionDescriptions?: Record<string, string>;
sitemap?: {
category: string;
pages: { title: string; desc: string }[];
}[];
}
export interface Position {
pos: number;
title: string;
desc: string;
qty: number;
price: number;
isRecurring?: boolean;
}
export interface Totals {
totalPrice: number;
monthlyPrice: number;
totalPagesCount: number;
totalFeatures: number;
totalFunctions: number;
totalApis: number;
languagesCount: number;
}

View File

@@ -0,0 +1,153 @@
import { CheerioCrawler } from "crawlee";
import axios from "axios";
import { FileCacheAdapter } from "../utils/cache/FileCacheAdapter.js";
import { initialState } from "../logic/pricing/constants.js";
import { FormState } from "../logic/pricing/types.js";
export interface AcquisitionResult {
state: FormState;
usage: {
prompt: number;
completion: number;
cost: number;
};
}
export class AcquisitionService {
private cache: FileCacheAdapter;
private openRouterKey: string;
constructor(openRouterKey: string) {
this.openRouterKey = openRouterKey;
this.cache = new FileCacheAdapter({ prefix: "acq_" });
}
async runFullSequence(url: string, briefing: string, comments?: string): Promise<AcquisitionResult> {
console.log(`🚀 Starting Acquisition Sequence for: ${url}`);
// 1. Crawl
const crawlData = await this.performCrawl(url);
// 2. Distill
const distilledContext = await this.distillCrawlContext(crawlData);
// 3. AI Estimation (using parts of the original ai-estimate logic)
// For brevity in this initial port, I'll implement a combined prompt strategy
// or keep the multi-pass if needed.
const result = await this.getAiEstimation(briefing, distilledContext, comments || null);
return result;
}
private async performCrawl(url: string): Promise<string> {
const pages: any[] = [];
const origin = new URL(url).origin;
const crawler = new CheerioCrawler({
maxRequestsPerCrawl: 15,
async requestHandler({ $, request, enqueueLinks }) {
const title = $("title").text();
const bodyText = $("body").text().replace(/\s+/g, " ").substring(0, 10000);
pages.push({
url: request.url,
content: `Title: ${title}\nText: ${bodyText}`,
});
await enqueueLinks({
limit: 10,
transformRequestFunction: (req) => {
try {
const reqUrl = new URL(req.url);
if (reqUrl.origin !== origin) return false;
if (reqUrl.pathname.match(/\.(pdf|zip|jpg|png|svg|webp)$/i)) return false;
return req;
} catch (_error) {
// Ignored - malformed URL in enqueueLinks
return false;
}
},
});
},
});
await crawler.run([url]);
return pages.map((p) => `--- PAGE: ${p.url} ---\n${p.content}`).join("\n\n");
}
private async distillCrawlContext(rawCrawl: string): Promise<string> {
const systemPrompt = `
You are a context distiller. Extract the "Company DNA" in 5-8 bullet points (GERMAN).
Focus on: Services, USPs, Target Audience, Tone.
`;
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, 20000)}` },
],
},
{
headers: { Authorization: `Bearer ${this.openRouterKey}` },
}
);
return resp.data.choices[0].message.content;
}
private async getAiEstimation(briefing: string, context: string, comments: string | null): Promise<AcquisitionResult> {
// Porting a simplified version of Pass 1 and Pass 3 together for the "Audit"
const systemPrompt = `
You are a Digital Architect. Analyze the briefing and crawl context.
Generate a JSON state for a project estimation.
Language: GERMAN.
Format: ROOT LEVEL JSON matching FormState interface.
### PRICING RULES:
- Base: 5440 €
- Page: 600 €
- Feature: 1500 €
- Function/API: 800 €
Return ONLY the JSON.
`;
const userPrompt = `BRIEFING: ${briefing}\n\nCONTEXT: ${context}\n\nCOMMENTS: ${comments}`;
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: userPrompt },
],
response_format: { type: "json_object" },
},
{
headers: { Authorization: `Bearer ${this.openRouterKey}` },
}
);
let state: FormState;
try {
state = JSON.parse(resp.data.choices[0].message.content);
} catch (_error) {
console.error("Failed to parse AI estimation JSON, returning initial state.");
state = initialState;
}
// Ensure it matches FormState defaults
const finalState = { ...initialState, ...state };
return {
state: finalState,
usage: {
prompt: resp.data.usage?.prompt_tokens || 0,
completion: resp.data.usage?.completion_tokens || 0,
cost: resp.data.usage?.cost || 0,
},
};
}
}

View File

@@ -0,0 +1,24 @@
import { renderToFile } from "@react-pdf/renderer";
import { createElement } from "react";
import { EstimationPDF } from "../components/EstimationPDF.js";
import { PRICING } from "../logic/pricing/constants.js";
import { calculateTotals } from "../logic/pricing/calculator.js";
export class PdfEngine {
constructor() { }
async generateEstimatePdf(state: any, outputPath: string): Promise<string> {
const totals = calculateTotals(state, PRICING);
await renderToFile(
createElement(EstimationPDF as any, {
state,
totalPrice: totals.totalPrice,
pricing: PRICING,
} as any) as any,
outputPath
);
return outputPath;
}
}

View File

@@ -0,0 +1,78 @@
import * as fs from 'node:fs/promises';
import * as path from 'node:path';
import { existsSync } from 'node:fs';
import * as crypto from 'node:crypto';
export class FileCacheAdapter {
private cacheDir: string;
private prefix: string;
private defaultTTL: number;
constructor(config?: { cacheDir?: string; prefix?: string; defaultTTL?: number }) {
this.cacheDir = config?.cacheDir || path.resolve(process.cwd(), '.cache');
this.prefix = config?.prefix || '';
this.defaultTTL = config?.defaultTTL || 3600;
if (!existsSync(this.cacheDir)) {
fs.mkdir(this.cacheDir, { recursive: true }).catch(() => { });
}
}
private sanitize(key: string): string {
const clean = key.replace(/[^a-z0-9]/gi, '_');
if (clean.length > 64) {
return crypto.createHash('md5').update(key).digest('hex');
}
return clean;
}
private getFilePath(key: string): string {
const safeKey = this.sanitize(`${this.prefix}${key}`).toLowerCase();
return path.join(this.cacheDir, `${safeKey}.json`);
}
async get<T>(key: string): Promise<T | null> {
const filePath = this.getFilePath(key);
try {
if (!existsSync(filePath)) return null;
const content = await fs.readFile(filePath, 'utf8');
const data = JSON.parse(content);
if (data.expiry && Date.now() > data.expiry) {
await this.del(key);
return null;
}
return data.value;
} catch (_error) {
return null; // Keeping original return type Promise<T | null>
}
}
async set<T>(key: string, value: T, ttl?: number): Promise<void> {
const filePath = this.getFilePath(key);
const effectiveTTL = ttl !== undefined ? ttl : this.defaultTTL;
const data = {
value,
expiry: effectiveTTL > 0 ? Date.now() + effectiveTTL * 1000 : null,
updatedAt: new Date().toISOString()
};
try {
await fs.writeFile(filePath, JSON.stringify(data, null, 2), 'utf8');
} catch (_error) {
// Ignore
}
}
async del(key: string): Promise<void> {
const filePath = this.getFilePath(key);
try {
if (existsSync(filePath)) {
await fs.unlink(filePath);
}
} catch (_error) {
// Ignored - best effort cleanup
}
}
}

View File

@@ -0,0 +1,59 @@
import { describe, it, expect } from "vitest";
import { calculateTotals, calculatePositions } from "../src/logic/pricing/calculator.js";
import { PRICING, initialState } from "../src/logic/pricing/constants.js";
import { FormState } from "../src/logic/pricing/types.js";
describe("Pricing Logic", () => {
it("should calculate base website price correctly", () => {
const state: FormState = {
...initialState,
projectType: "website",
selectedPages: [] // Clear for base test
};
const totals = calculateTotals(state, PRICING);
expect(totals.totalPrice).toBe(PRICING.BASE_WEBSITE);
});
it("should add page costs correctly", () => {
const state: FormState = {
...initialState,
projectType: "website",
selectedPages: [], // Clear for clean test
otherPagesCount: 5
};
const totals = calculateTotals(state, PRICING);
expect(totals.totalPrice).toBe(PRICING.BASE_WEBSITE + (5 * PRICING.PAGE));
});
it("should apply multi-language multiplier", () => {
const state: FormState = {
...initialState,
projectType: "website",
selectedPages: [], // Clear for clean test
languagesList: ["Deutsch", "Englisch"]
};
const totals = calculateTotals(state, PRICING);
expect(totals.totalPrice).toBe(Math.round(PRICING.BASE_WEBSITE * 1.2));
});
it("should generate correct positions for a website", () => {
const state: FormState = {
...initialState,
projectType: "website",
selectedPages: ["Home"],
otherPagesCount: 2
};
const positions = calculatePositions(state, PRICING);
// Find "Fundament" position (Das technische Fundament)
const basePos = positions.find(p => p.title.includes("Fundament"));
expect(basePos).toBeDefined();
expect(basePos?.price).toBe(PRICING.BASE_WEBSITE);
// Find "Individuelle Seiten" position
const pagesPos = positions.find(p => p.title.includes("Seiten"));
expect(pagesPos).toBeDefined();
expect(pagesPos?.qty).toBe(3); // 1 selected + 2 other
expect(pagesPos?.price).toBe(3 * PRICING.PAGE);
});
});

View File

@@ -0,0 +1,15 @@
{
"extends": "@mintel/tsconfig/base.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src",
"jsx": "react-jsx"
},
"include": [
"src/**/*"
],
"exclude": [
"node_modules",
"dist"
]
}