feat: Centralize pricing calculation logic into a new module and add a script for generating quotes.

This commit is contained in:
2026-02-02 22:52:35 +01:00
parent aa4374a664
commit 083be92c5b
13 changed files with 845 additions and 630 deletions

BIN
din_test_final.pdf Normal file

Binary file not shown.

BIN
din_test_final_v2.pdf Normal file

Binary file not shown.

BIN
din_test_v2.pdf Normal file

Binary file not shown.

View File

@@ -14,6 +14,7 @@
"test:file-examples": "tsx ./scripts/test-file-examples-comprehensive.ts", "test:file-examples": "tsx ./scripts/test-file-examples-comprehensive.ts",
"clone-website": "tsx ./scripts/clone-recursive.ts", "clone-website": "tsx ./scripts/clone-recursive.ts",
"clone-page": "tsx ./scripts/clone-page.ts", "clone-page": "tsx ./scripts/clone-page.ts",
"generate-quote": "tsx ./scripts/generate-quote.ts",
"video:preview": "remotion preview video/index.ts", "video:preview": "remotion preview video/index.ts",
"video:render": "remotion render video/index.ts ButtonShowcase out/button-showcase.mp4", "video:render": "remotion render video/index.ts ButtonShowcase out/button-showcase.mp4",
"video:render:contact": "remotion render video/index.ts ContactFormShowcase out/contact-showcase.mp4 --concurrency=1 --codec=h264 --crf=16 --pixel-format=yuv420p --overwrite", "video:render:contact": "remotion render video/index.ts ContactFormShowcase out/contact-showcase.mp4 --concurrency=1 --codec=h264 --crf=16 --pixel-format=yuv420p --overwrite",
@@ -65,4 +66,4 @@
"tsx": "^4.21.0", "tsx": "^4.21.0",
"typescript": "5.9.3" "typescript": "5.9.3"
} }
} }

167
scripts/generate-quote.ts Normal file
View File

@@ -0,0 +1,167 @@
import * as React from 'react';
import { renderToFile } from '@react-pdf/renderer';
import { EstimationPDF } from '../src/components/EstimationPDF';
import {
PRICING,
initialState,
PAGE_SAMPLES,
FEATURE_OPTIONS,
FUNCTION_OPTIONS,
API_OPTIONS,
DESIGN_OPTIONS,
DEADLINE_LABELS,
ASSET_OPTIONS,
EMPLOYEE_OPTIONS,
calculatePositions,
FormState
} from '../src/logic/pricing';
import * as fs from 'fs';
import * as path from 'path';
import { parseArgs } from 'util';
import * as readline from 'readline/promises';
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
async function ask(question: string, defaultValue?: string): Promise<string> {
const answer = await rl.question(`${question}${defaultValue ? ` [${defaultValue}]` : ''}: `);
return answer.trim() || defaultValue || '';
}
async function selectOne(question: string, options: { id: string; label: string }[], defaultValue?: string): Promise<string> {
console.log(`\n${question}`);
options.forEach((opt, i) => console.log(`${i + 1}. ${opt.label}`));
const answer = await ask('Select number');
const index = parseInt(answer) - 1;
return options[index]?.id || defaultValue || options[0].id;
}
async function selectMany(question: string, options: { id: string; label: string }[]): Promise<string[]> {
console.log(`\n${question} (comma separated numbers, e.g. 1,2,4)`);
options.forEach((opt, i) => console.log(`${i + 1}. ${opt.label}`));
const answer = await ask('Selection');
if (!answer) return [];
return answer.split(',')
.map(n => parseInt(n.trim()) - 1)
.filter(i => options[i])
.map(i => options[i].id);
}
async function generate() {
const { values } = parseArgs({
options: {
input: { type: 'string', short: 'i' },
output: { type: 'string', short: 'o' },
interactive: { type: 'boolean', short: 'I', default: false },
name: { type: 'string' },
company: { type: 'string' },
email: { type: 'string' },
'project-type': { type: 'string' },
},
});
let state: FormState = { ...initialState };
// Apply command line flags first
if (values.name) state.name = values.name as string;
if (values.company) state.companyName = values.company as string;
if (values.email) state.email = values.email as string;
if (values['project-type']) state.projectType = values['project-type'] as 'website' | 'web-app';
if (values.input) {
const inputPath = path.resolve(process.cwd(), values.input);
if (fs.existsSync(inputPath)) {
const fileData = JSON.parse(fs.readFileSync(inputPath, 'utf-8'));
state = { ...state, ...fileData };
}
}
if (values.interactive) {
console.log('--- Mintel Quote Generator Wizard ---');
state.name = await ask('Client Name', state.name);
state.companyName = await ask('Company Name', state.companyName);
state.email = await ask('Client Email', state.email);
state.projectType = (await selectOne('Project Type', [
{ id: 'website', label: 'Website' },
{ id: 'web-app', label: 'Web App' }
], state.projectType)) as 'website' | 'web-app';
if (state.projectType === 'website') {
state.websiteTopic = await ask('Website Topic', state.websiteTopic);
state.selectedPages = await selectMany('Pages', PAGE_SAMPLES);
state.features = await selectMany('Features', FEATURE_OPTIONS);
state.functions = await selectMany('Functions', FUNCTION_OPTIONS);
state.apiSystems = await selectMany('APIs', API_OPTIONS);
const cms = await ask('CMS Setup? (y/n)', state.cmsSetup ? 'y' : 'n');
state.cmsSetup = cms.toLowerCase() === 'y';
const langs = await ask('Languages (comma separated)', state.languagesList.join(', '));
state.languagesList = langs.split(',').map(l => l.trim()).filter(Boolean);
} else {
state.targetAudience = await selectOne('Target Audience', [
{ id: 'internal', label: 'Internal Tool' },
{ id: 'customers', label: 'Customer Portal' }
], state.targetAudience);
state.platformType = await selectOne('Platform', [
{ id: 'web-only', label: 'Web Only' },
{ id: 'cross-platform', label: 'Cross Platform' }
], state.platformType);
state.dataSensitivity = await selectOne('Data Sensitivity', [
{ id: 'standard', label: 'Standard' },
{ id: 'high', label: 'High / Sensitive' }
], state.dataSensitivity);
}
state.employeeCount = await selectOne('Company Size', EMPLOYEE_OPTIONS, state.employeeCount);
state.designVibe = await selectOne('Design Vibe', DESIGN_OPTIONS, state.designVibe);
state.deadline = await selectOne('Timeline', Object.entries(DEADLINE_LABELS).map(([id, label]) => ({ id, label })), state.deadline);
state.assets = await selectMany('Available Assets', ASSET_OPTIONS);
state.message = await ask('Additional Message / Remarks', state.message);
}
rl.close();
const totalPagesCount = state.selectedPages.length + state.otherPages.length + (state.otherPagesCount || 0);
const positions = calculatePositions(state, PRICING);
const totalPrice = positions.reduce((sum, p) => sum + p.price, 0);
const monthlyPrice = PRICING.HOSTING_MONTHLY + (state.storageExpansion * PRICING.STORAGE_EXPANSION_MONTHLY);
const outputPath = values.output
? path.resolve(process.cwd(), values.output)
: path.resolve(process.cwd(), `quote_${state.companyName.replace(/\s+/g, '_') || 'client'}_${new Date().toISOString().split('T')[0]}.pdf`);
console.log(`\nGenerating PDF for ${state.companyName || state.name || 'Unknown Client'}...`);
const headerIcon = path.resolve(process.cwd(), 'src/assets/logo/Icon White Transparent.png');
const footerLogo = path.resolve(process.cwd(), 'src/assets/logo/Logo Black Transparent.png');
try {
// @ts-ignore
await renderToFile(
React.createElement(EstimationPDF, {
state,
totalPrice,
monthlyPrice,
totalPagesCount,
pricing: PRICING,
headerIcon,
footerLogo,
}),
outputPath
);
console.log(`Successfully generated: ${outputPath}`);
} catch (error) {
console.error('Error generating PDF:', error);
process.exit(1);
}
}
generate();

View File

@@ -9,6 +9,8 @@ import { Info, Download, Share2, RefreshCw } from 'lucide-react';
import { motion, AnimatePresence } from 'framer-motion'; import { motion, AnimatePresence } from 'framer-motion';
import dynamic from 'next/dynamic'; import dynamic from 'next/dynamic';
import { EstimationPDF } from '../../EstimationPDF'; import { EstimationPDF } from '../../EstimationPDF';
import IconWhite from '../../../assets/logo/Icon White Transparent.png';
import LogoBlack from '../../../assets/logo/Logo Black Transparent.png';
// Dynamically import PDF components to avoid SSR issues // Dynamically import PDF components to avoid SSR issues
const PDFDownloadLink = dynamic( const PDFDownloadLink = dynamic(
@@ -26,12 +28,12 @@ interface PriceCalculationProps {
onShare?: () => void; onShare?: () => void;
} }
export function PriceCalculation({ export function PriceCalculation({
state, state,
totalPrice, totalPrice,
monthlyPrice, monthlyPrice,
totalPagesCount, totalPagesCount,
isClient, isClient,
qrCodeData, qrCodeData,
onShare onShare
}: PriceCalculationProps) { }: PriceCalculationProps) {
@@ -45,11 +47,12 @@ export function PriceCalculation({
const handleDownload = async () => { const handleDownload = async () => {
if (pdfLoading) return; if (pdfLoading) return;
setPdfLoading(true); setPdfLoading(true);
try { try {
const { pdf } = await import('@react-pdf/renderer'); const { pdf } = await import('@react-pdf/renderer');
const doc = <EstimationPDF const doc = <EstimationPDF
state={state} state={state}
totalPrice={totalPrice} totalPrice={totalPrice}
@@ -57,14 +60,16 @@ export function PriceCalculation({
totalPagesCount={totalPagesCount} totalPagesCount={totalPagesCount}
pricing={PRICING} pricing={PRICING}
qrCodeData={qrCodeData} qrCodeData={qrCodeData}
headerIcon={typeof IconWhite === 'string' ? IconWhite : (IconWhite as any).src}
footerLogo={typeof LogoBlack === 'string' ? LogoBlack : (LogoBlack as any).src}
/>; />;
// Minimum loading time of 2 seconds for better UX // Minimum loading time of 2 seconds for better UX
const [blob] = await Promise.all([ const [blob] = await Promise.all([
pdf(doc).toBlob(), pdf(doc).toBlob(),
new Promise(resolve => setTimeout(resolve, 2000)) new Promise(resolve => setTimeout(resolve, 2000))
]); ]);
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
const link = document.createElement('a'); const link = document.createElement('a');
link.href = url; link.href = url;
@@ -111,7 +116,7 @@ export function PriceCalculation({
<span className="text-base font-bold text-slate-900">{monthlyPrice.toLocaleString()} / Monat</span> <span className="text-base font-bold text-slate-900">{monthlyPrice.toLocaleString()} / Monat</span>
</div> </div>
</div> </div>
<div className="pt-4 space-y-4"> <div className="pt-4 space-y-4">
{isClient && ( {isClient && (
<button <button

View File

@@ -1,250 +1,72 @@
import * as React from 'react'; import * as React from 'react';
import { FormState } from './types'; import { FormState } from './types';
import {
PRICING as LOGIC_PRICING,
PAGE_SAMPLES as LOGIC_PAGE_SAMPLES,
FEATURE_OPTIONS as LOGIC_FEATURE_OPTIONS,
FUNCTION_OPTIONS as LOGIC_FUNCTION_OPTIONS,
API_OPTIONS as LOGIC_API_OPTIONS,
ASSET_OPTIONS as LOGIC_ASSET_OPTIONS,
EMPLOYEE_OPTIONS as LOGIC_EMPLOYEE_OPTIONS,
SOCIAL_MEDIA_OPTIONS as LOGIC_SOCIAL_MEDIA_OPTIONS,
VIBE_LABELS as LOGIC_VIBE_LABELS,
DEADLINE_LABELS as LOGIC_DEADLINE_LABELS,
ASSET_LABELS as LOGIC_ASSET_LABELS,
FEATURE_LABELS as LOGIC_FEATURE_LABELS,
FUNCTION_LABELS as LOGIC_FUNCTION_LABELS,
API_LABELS as LOGIC_API_LABELS,
SOCIAL_LABELS as LOGIC_SOCIAL_LABELS,
initialState as LOGIC_INITIAL_STATE,
DESIGN_OPTIONS
} from '../../logic/pricing';
export const PRICING = { export const PRICING = LOGIC_PRICING;
BASE_WEBSITE: 6000, export const PAGE_SAMPLES = LOGIC_PAGE_SAMPLES;
PAGE: 800, export const FEATURE_OPTIONS = LOGIC_FEATURE_OPTIONS;
FEATURE: 2000, export const FUNCTION_OPTIONS = LOGIC_FUNCTION_OPTIONS;
FUNCTION: 1000, export const API_OPTIONS = LOGIC_API_OPTIONS;
NEW_DATASET: 400, export const ASSET_OPTIONS = LOGIC_ASSET_OPTIONS;
HOSTING_MONTHLY: 120, export const EMPLOYEE_OPTIONS = LOGIC_EMPLOYEE_OPTIONS;
STORAGE_EXPANSION_MONTHLY: 10, export const SOCIAL_MEDIA_OPTIONS = LOGIC_SOCIAL_MEDIA_OPTIONS;
CMS_SETUP: 1500, export const VIBE_LABELS = LOGIC_VIBE_LABELS;
CMS_CONNECTION_PER_FEATURE: 800, export const DEADLINE_LABELS = LOGIC_DEADLINE_LABELS;
API_INTEGRATION: 1000, export const ASSET_LABELS = LOGIC_ASSET_LABELS;
APP_HOURLY: 120, export const FEATURE_LABELS = LOGIC_FEATURE_LABELS;
export const FUNCTION_LABELS = LOGIC_FUNCTION_LABELS;
export const API_LABELS = LOGIC_API_LABELS;
export const SOCIAL_LABELS = LOGIC_SOCIAL_LABELS;
export const initialState = LOGIC_INITIAL_STATE;
const VIBE_ILLUSTRATIONS: Record<string, React.ReactNode> = {
minimal: (
<svg viewBox="0 0 100 60" className="w-full h-full opacity-40">
<rect x="10" y="10" width="80" height="2" rx="1" className="fill-current" />
<rect x="10" y="20" width="50" height="2" rx="1" className="fill-current" />
<rect x="10" y="40" width="30" height="10" rx="1" className="fill-current" />
</svg>
),
bold: (
<svg viewBox="0 0 100 60" className="w-full h-full opacity-40">
<rect x="10" y="10" width="80" height="15" rx="2" className="fill-current" />
<rect x="10" y="35" width="80" height="15" rx="2" className="fill-current" />
</svg>
),
nature: (
<svg viewBox="0 0 100 60" className="w-full h-full opacity-40">
<circle cx="30" cy="30" r="20" className="fill-current" />
<circle cx="70" cy="30" r="15" className="fill-current" />
</svg>
),
tech: (
<svg viewBox="0 0 100 60" className="w-full h-full opacity-40">
<path d="M10 10 L90 10 L90 50 L10 50 Z" fill="none" stroke="currentColor" strokeWidth="2" />
<path d="M10 30 L90 30" stroke="currentColor" strokeWidth="1" strokeDasharray="4 2" />
<path d="M50 10 L50 50" stroke="currentColor" strokeWidth="1" strokeDasharray="4 2" />
</svg>
),
}; };
export const initialState: FormState = { export const DESIGN_VIBES = DESIGN_OPTIONS.map(opt => ({
projectType: 'website', ...opt,
// Company illustration: VIBE_ILLUSTRATIONS[opt.id]
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'],
// Timeline
deadline: 'flexible',
// Web App specific
targetAudience: 'internal',
userRoles: [],
dataSensitivity: 'standard',
platformType: 'web-only',
// Meta
dontKnows: [],
visualStaging: 'standard',
complexInteractions: 'standard',
};
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: 'Erweiterte Formulare', desc: 'Komplexe Abfragen & Logik.' },
];
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_VIBES = [
{
id: 'minimal',
label: 'Minimalistisch',
desc: 'Viel Weißraum, klare Typografie.',
illustration: (
<svg viewBox="0 0 100 60" className="w-full h-full opacity-40">
<rect x="10" y="10" width="80" height="2" rx="1" className="fill-current" />
<rect x="10" y="20" width="50" height="2" rx="1" className="fill-current" />
<rect x="10" y="40" width="30" height="10" rx="1" className="fill-current" />
</svg>
)
},
{
id: 'bold',
label: 'Mutig & Laut',
desc: 'Starke Kontraste, große Schriften.',
illustration: (
<svg viewBox="0 0 100 60" className="w-full h-full opacity-40">
<rect x="10" y="10" width="80" height="15" rx="2" className="fill-current" />
<rect x="10" y="35" width="80" height="15" rx="2" className="fill-current" />
</svg>
)
},
{
id: 'nature',
label: 'Natürlich',
desc: 'Sanfte Erdtöne, organische Formen.',
illustration: (
<svg viewBox="0 0 100 60" className="w-full h-full opacity-40">
<circle cx="30" cy="30" r="20" className="fill-current" />
<circle cx="70" cy="30" r="15" className="fill-current" />
</svg>
)
},
{
id: 'tech',
label: 'Technisch',
desc: 'Präzise Linien, dunkle Akzente.',
illustration: (
<svg viewBox="0 0 100 60" className="w-full h-full opacity-40">
<path d="M10 10 L90 10 L90 50 L10 50 Z" fill="none" stroke="currentColor" strokeWidth="2" />
<path d="M10 30 L90 30" stroke="currentColor" strokeWidth="1" strokeDasharray="4 2" />
<path d="M50 10 L50 50" stroke="currentColor" strokeWidth="1" strokeDasharray="4 2" />
</svg>
)
},
];
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> = {
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: 'Erweiterte Formulare',
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'
};

View File

@@ -1,139 +1,6 @@
import { calculatePositions as logicCalculatePositions } from '../../logic/pricing';
import { FormState } from './types'; import { FormState } from './types';
import { FEATURE_LABELS, FUNCTION_LABELS, API_LABELS } from './constants';
export interface Position { export type { Position } from '../../logic/pricing';
pos: number;
title: string;
desc: string;
qty: number;
price: number;
}
export function calculatePositions(state: FormState, pricing: any): Position[] { export const calculatePositions = (state: FormState, pricing: any) => logicCalculatePositions(state as any, pricing);
const positions: Position[] = [];
let pos = 1;
if (state.projectType === 'website') {
positions.push({
pos: pos++,
title: 'Basis Website Setup',
desc: 'Projekt-Setup, Infrastruktur, Hosting-Bereitstellung, Grundstruktur & Design-Vorlage, technisches SEO-Basics, Analytics.',
qty: 1,
price: pricing.BASE_WEBSITE
});
const totalPagesCount = state.selectedPages.length + state.otherPages.length + (state.otherPagesCount || 0);
const allPages = [...state.selectedPages.map((p: string) => p === 'Home' ? 'Startseite' : p), ...state.otherPages];
positions.push({
pos: pos++,
title: 'Individuelle Seiten',
desc: `Gestaltung und Umsetzung von ${totalPagesCount} individuellen Seiten-Layouts (${allPages.join(', ')}).`,
qty: totalPagesCount,
price: totalPagesCount * pricing.PAGE
});
if (state.features.length > 0 || state.otherFeatures.length > 0) {
const allFeatures = [...state.features.map((f: string) => FEATURE_LABELS[f] || f), ...state.otherFeatures];
positions.push({
pos: pos++,
title: 'System-Module',
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) {
const allFunctions = [...state.functions.map((f: string) => FUNCTION_LABELS[f] || f), ...state.otherFunctions];
positions.push({
pos: pos++,
title: 'Logik-Funktionen',
desc: `Erweiterte Funktionen: ${allFunctions.join(', ')}.`,
qty: allFunctions.length,
price: allFunctions.length * pricing.FUNCTION
});
}
if (state.apiSystems.length > 0 || state.otherTech.length > 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 + (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: 'Inhaltspflege (Initial)',
desc: `Manuelle Einpflege von ${state.newDatasets} Datensätzen (z.B. Produkte, Blogartikel).`,
qty: state.newDatasets,
price: state.newDatasets * pricing.NEW_DATASET
});
}
if (state.visualStaging && Number(state.visualStaging) > 0) {
const count = Number(state.visualStaging);
positions.push({
pos: pos++,
title: 'Visuelle Inszenierung',
desc: `Umsetzung von ${count} Hero-Stories, Scroll-Effekten oder speziell inszenierten Sektionen.`,
qty: count,
price: count * (pricing.VISUAL_STAGING || 1500)
});
}
if (state.complexInteractions && Number(state.complexInteractions) > 0) {
const count = Number(state.complexInteractions);
positions.push({
pos: pos++,
title: 'Komplexe Interaktion',
desc: `Umsetzung von ${count} Konfiguratoren, Live-Previews oder mehrstufigen Auswahlprozessen.`,
qty: count,
price: count * (pricing.COMPLEX_INTERACTION || 2500)
});
}
const languagesCount = state.languagesList.length || 1;
if (languagesCount > 1) {
// This is a bit tricky because the factor applies to the total.
// For the PDF we show it as a separate position.
// We calculate the subtotal first.
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)
});
}
} 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

@@ -1,25 +1,28 @@
'use client'; 'use client';
import * as React from 'react'; import * as React from 'react';
import { import {
Document as PDFDocument, Document as PDFDocument,
Page as PDFPage, Page as PDFPage,
Text as PDFText, Text as PDFText,
View as PDFView, View as PDFView,
StyleSheet as PDFStyleSheet, StyleSheet as PDFStyleSheet,
Image as PDFImage Image as PDFImage
} from '@react-pdf/renderer'; } from '@react-pdf/renderer';
import { import {
VIBE_LABELS, VIBE_LABELS,
DEADLINE_LABELS, DEADLINE_LABELS,
ASSET_LABELS, ASSET_LABELS,
SOCIAL_LABELS SOCIAL_LABELS,
} from './ContactForm/constants'; calculatePositions
import { calculatePositions } from './ContactForm/utils'; } from '../logic/pricing';
const styles = PDFStyleSheet.create({ const styles = PDFStyleSheet.create({
page: { page: {
padding: 48, paddingTop: 45, // DIN 5008
paddingLeft: 70, // ~25mm
paddingRight: 57, // ~20mm
paddingBottom: 48,
backgroundColor: '#ffffff', backgroundColor: '#ffffff',
fontFamily: 'Helvetica', fontFamily: 'Helvetica',
fontSize: 10, fontSize: 10,
@@ -29,10 +32,26 @@ const styles = PDFStyleSheet.create({
flexDirection: 'row', flexDirection: 'row',
justifyContent: 'space-between', justifyContent: 'space-between',
alignItems: 'flex-start', alignItems: 'flex-start',
marginBottom: 64, marginBottom: 40,
borderBottomWidth: 1, minHeight: 120,
borderBottomColor: '#000000', },
paddingBottom: 24, addressBlock: {
width: '55%',
marginTop: 45, // DIN 5008 positioning for window
},
senderLine: {
fontSize: 7,
textDecoration: 'underline',
color: '#666666',
marginBottom: 8,
},
recipientAddress: {
fontSize: 10,
lineHeight: 1.4,
},
brandLogoContainer: {
width: '40%',
alignItems: 'flex-end',
}, },
brandIconContainer: { brandIconContainer: {
width: 40, width: 40,
@@ -41,6 +60,7 @@ const styles = PDFStyleSheet.create({
borderRadius: 8, borderRadius: 8,
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
marginBottom: 12,
}, },
brandIconText: { brandIconText: {
color: '#ffffff', color: '#ffffff',
@@ -48,21 +68,24 @@ const styles = PDFStyleSheet.create({
fontWeight: 'bold', fontWeight: 'bold',
}, },
quoteInfo: { quoteInfo: {
textAlign: 'right', alignItems: 'flex-end',
}, },
quoteTitle: { quoteTitle: {
fontSize: 10, fontSize: 14, // Slightly smaller to prevent wrapping
fontWeight: 'bold', fontWeight: 'bold',
marginBottom: 4, marginBottom: 4,
color: '#000000', color: '#000000',
textTransform: 'uppercase', textTransform: 'uppercase',
letterSpacing: 1, letterSpacing: 1,
textAlign: 'right',
}, },
quoteDate: { quoteDate: {
fontSize: 9, fontSize: 9,
color: '#666666', color: '#666666',
marginTop: 2,
textAlign: 'right',
}, },
section: { section: {
marginBottom: 32, marginBottom: 32,
}, },
@@ -72,49 +95,12 @@ const styles = PDFStyleSheet.create({
textTransform: 'uppercase', textTransform: 'uppercase',
letterSpacing: 1, letterSpacing: 1,
color: '#999999', color: '#999999',
marginBottom: 12, marginBottom: 8,
},
card: {
backgroundColor: '#ffffff',
borderRadius: 12,
padding: 24,
marginBottom: 24,
borderWidth: 1,
borderColor: '#eeeeee',
},
cardTitle: {
fontSize: 10,
fontWeight: 'bold',
textTransform: 'uppercase',
letterSpacing: 1,
color: '#64748b',
marginBottom: 16,
},
recipientGrid: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: 32,
},
recipientItem: {
flexDirection: 'column',
},
recipientLabel: {
fontSize: 7,
color: '#999999',
textTransform: 'uppercase',
marginBottom: 4,
fontWeight: 'bold',
},
recipientValue: {
fontSize: 10,
fontWeight: 'bold',
color: '#000000',
}, },
table: { table: {
marginTop: 16, marginTop: 16,
minHeight: 300,
}, },
tableHeader: { tableHeader: {
flexDirection: 'row', flexDirection: 'row',
@@ -130,11 +116,11 @@ const styles = PDFStyleSheet.create({
borderBottomColor: '#eeeeee', borderBottomColor: '#eeeeee',
alignItems: 'flex-start', alignItems: 'flex-start',
}, },
colPos: { width: '6%' }, colPos: { width: '8%' },
colDesc: { width: '64%' }, colDesc: { width: '62%' },
colQty: { width: '10%', textAlign: 'center' }, colQty: { width: '10%', textAlign: 'center' },
colPrice: { width: '20%', textAlign: 'right' }, colPrice: { width: '20%', textAlign: 'right' },
headerText: { headerText: {
fontSize: 7, fontSize: 7,
fontWeight: 'bold', fontWeight: 'bold',
@@ -153,7 +139,7 @@ const styles = PDFStyleSheet.create({
marginTop: 32, marginTop: 32,
}, },
summaryCard: { summaryCard: {
width: '40%', width: '45%',
backgroundColor: '#ffffff', backgroundColor: '#ffffff',
borderRadius: 12, borderRadius: 12,
padding: 20, padding: 20,
@@ -167,7 +153,7 @@ const styles = PDFStyleSheet.create({
}, },
summaryLabel: { fontSize: 8, color: '#666666' }, summaryLabel: { fontSize: 8, color: '#666666' },
summaryValue: { fontSize: 9, fontWeight: 'bold', color: '#000000' }, summaryValue: { fontSize: 9, fontWeight: 'bold', color: '#000000' },
totalRow: { totalRow: {
flexDirection: 'row', flexDirection: 'row',
justifyContent: 'space-between', justifyContent: 'space-between',
@@ -178,7 +164,7 @@ const styles = PDFStyleSheet.create({
}, },
totalLabel: { fontSize: 10, fontWeight: 'bold', color: '#000000' }, totalLabel: { fontSize: 10, fontWeight: 'bold', color: '#000000' },
totalValue: { fontSize: 14, fontWeight: 'bold', color: '#000000' }, totalValue: { fontSize: 14, fontWeight: 'bold', color: '#000000' },
hostingBox: { hostingBox: {
marginTop: 24, marginTop: 24,
padding: 16, padding: 16,
@@ -193,39 +179,19 @@ const styles = PDFStyleSheet.create({
configGrid: { configGrid: {
flexDirection: 'row', flexDirection: 'row',
flexWrap: 'wrap', flexWrap: 'wrap',
gap: 24, gap: 20,
}, },
configItem: { configItem: {
width: '30%', width: '30%',
marginBottom: 16, marginBottom: 12,
}, },
configLabel: { fontSize: 7, color: '#999999', marginBottom: 4, textTransform: 'uppercase', fontWeight: 'bold' }, configLabel: { fontSize: 7, color: '#999999', marginBottom: 4, textTransform: 'uppercase', fontWeight: 'bold' },
configValue: { fontSize: 8, color: '#000000', fontWeight: 'bold' }, configValue: { fontSize: 8, color: '#000000', fontWeight: 'bold' },
colorGrid: {
flexDirection: 'row',
gap: 12,
marginTop: 8,
},
colorSwatch: {
width: 24,
height: 24,
borderRadius: 4,
borderWidth: 1,
borderColor: '#eeeeee',
},
colorHex: {
fontSize: 6,
color: '#999999',
marginTop: 4,
textAlign: 'center',
fontWeight: 'bold',
},
qrContainer: { qrContainer: {
position: 'absolute', position: 'absolute',
bottom: 120, bottom: 110,
right: 48, right: 57,
alignItems: 'center', alignItems: 'center',
}, },
qrImage: { qrImage: {
@@ -243,38 +209,48 @@ const styles = PDFStyleSheet.create({
footer: { footer: {
position: 'absolute', position: 'absolute',
bottom: 48, bottom: 32,
left: 48, left: 70,
right: 48, right: 57,
borderTopWidth: 1, borderTopWidth: 1,
borderTopColor: '#f1f5f9', borderTopColor: '#f1f5f9',
paddingTop: 24, paddingTop: 16,
flexDirection: 'row', flexDirection: 'row',
justifyContent: 'space-between', justifyContent: 'space-between',
alignItems: 'flex-end', alignItems: 'flex-start',
}, },
footerBrand: { footerColumn: {
fontSize: 16, flex: 1,
fontWeight: 'bold', alignItems: 'flex-start',
letterSpacing: -1,
color: '#000000',
textTransform: 'lowercase',
}, },
footerRight: { footerLogo: {
alignItems: 'flex-end', height: 20,
width: 'auto',
objectFit: 'contain',
marginBottom: 8,
}, },
footerContact: { footerText: {
fontSize: 8, fontSize: 7,
color: '#94a3b8', color: '#94a3b8',
lineHeight: 1.5,
},
footerLabel: {
fontWeight: 'bold', fontWeight: 'bold',
textTransform: 'uppercase', color: '#64748b',
letterSpacing: 1,
marginBottom: 4,
}, },
pageNumber: { pageNumber: {
fontSize: 7, fontSize: 7,
color: '#cbd5e1', color: '#cbd5e1',
fontWeight: 'bold', fontWeight: 'bold',
marginTop: 8,
textAlign: 'right',
},
foldingMark: {
position: 'absolute',
left: 20,
width: 10,
borderTopWidth: 0.5,
borderTopColor: '#cbd5e1',
} }
}); });
@@ -285,9 +261,11 @@ interface PDFProps {
totalPagesCount: number; totalPagesCount: number;
pricing: any; pricing: any;
qrCodeData?: string; qrCodeData?: string;
headerIcon?: string;
footerLogo?: string;
} }
export const EstimationPDF = ({ state, totalPrice, monthlyPrice, totalPagesCount, pricing, qrCodeData }: PDFProps) => { export const EstimationPDF = ({ state, totalPrice, monthlyPrice, totalPagesCount, pricing, qrCodeData, headerIcon, footerLogo }: PDFProps) => {
const date = new Date().toLocaleDateString('de-DE', { const date = new Date().toLocaleDateString('de-DE', {
year: 'numeric', year: 'numeric',
month: 'long', month: 'long',
@@ -296,41 +274,73 @@ export const EstimationPDF = ({ state, totalPrice, monthlyPrice, totalPagesCount
const positions = calculatePositions(state, pricing); const positions = calculatePositions(state, pricing);
return ( const FoldingMarks = () => (
<PDFDocument> <>
<PDFPage size="A4" style={styles.page}> <PDFView style={[styles.foldingMark, { top: 297.6 }]} fixed />
<PDFView style={styles.header}> <PDFView style={[styles.foldingMark, { top: 420.9, width: 15 }]} fixed />
<PDFView style={styles.brandIconContainer}> <PDFView style={[styles.foldingMark, { top: 595.3 }]} fixed />
<PDFText style={styles.brandIconText}>M</PDFText> </>
</PDFView> );
<PDFView style={styles.quoteInfo}>
<PDFText style={styles.quoteTitle}>Kostenschätzung</PDFText>
<PDFText style={styles.quoteDate}>{date}</PDFText>
<PDFText style={[styles.quoteDate, { marginTop: 4, fontWeight: 'bold', color: '#000000' }]}>
Projekt: {state.projectType === 'website' ? 'Website' : 'Web App'}
</PDFText>
</PDFView>
</PDFView>
<PDFView style={styles.section}> const Footer = () => (
<PDFText style={styles.sectionTitle}>Ansprechpartner</PDFText> <PDFView style={styles.footer} fixed>
<PDFView style={styles.recipientGrid}> <PDFView style={styles.footerColumn}>
<PDFView style={styles.recipientItem}> {footerLogo ? (
<PDFText style={styles.recipientLabel}>Name</PDFText> <PDFImage src={footerLogo} style={styles.footerLogo} />
<PDFText style={styles.recipientValue}>{state.name || 'Interessent'}</PDFText> ) : (
<PDFText style={{ fontSize: 12, fontWeight: 'bold', marginBottom: 8 }}>marc mintel</PDFText>
)}
</PDFView>
<PDFView style={styles.footerColumn}>
<PDFText style={styles.footerText}>
<PDFText style={styles.footerLabel}>Marc Mintel</PDFText>{"\n"}
Georg-Meistermann-Straße 7{"\n"}
54586 Schüller{"\n"}
UST: DE367588065
</PDFText>
</PDFView>
<PDFView style={[styles.footerColumn, { alignItems: 'flex-end' }]}>
<PDFText style={[styles.footerText, { textAlign: 'right' }]}>
<PDFText style={styles.footerLabel}>N26</PDFText>{"\n"}
NTSBDEB1XXX{"\n"}
DE50100110012620432865
</PDFText>
<PDFText style={styles.pageNumber} render={({ pageNumber, totalPages }) => `${pageNumber} / ${totalPages}`} fixed />
</PDFView>
</PDFView>
);
return (
<PDFDocument title={`Kostenschätzung - ${state.companyName || state.name}`}>
<PDFPage size="A4" style={styles.page}>
<FoldingMarks />
<PDFView style={styles.header}>
<PDFView style={styles.addressBlock}>
<PDFText style={styles.senderLine}>Marc Mintel | Georg-Meistermann-Straße 7 | 54586 Schüller</PDFText>
<PDFView style={styles.recipientAddress}>
<PDFText style={{ fontWeight: 'bold' }}>{state.companyName || state.name}</PDFText>
{state.companyName && <PDFText>{state.name}</PDFText>}
<PDFText>{state.email}</PDFText>
</PDFView>
</PDFView>
<PDFView style={styles.brandLogoContainer}>
<PDFView style={styles.brandIconContainer}>
{headerIcon ? (
<PDFImage src={headerIcon} style={{ width: 24, height: 24 }} />
) : (
<PDFText style={styles.brandIconText}>M</PDFText>
)}
</PDFView>
<PDFView style={styles.quoteInfo}>
<PDFText style={styles.quoteTitle}>Kostenschätzung</PDFText>
<PDFText style={styles.quoteDate}>Datum: {date}</PDFText>
<PDFText style={[styles.quoteDate, { fontWeight: 'bold', color: '#000000' }]}>
Projekt: {state.projectType === 'website' ? 'Website' : 'Web App'}
</PDFText>
</PDFView> </PDFView>
{state.companyName && (
<PDFView style={styles.recipientItem}>
<PDFText style={styles.recipientLabel}>Unternehmen</PDFText>
<PDFText style={styles.recipientValue}>{state.companyName}</PDFText>
</PDFView>
)}
{state.email && (
<PDFView style={styles.recipientItem}>
<PDFText style={styles.recipientLabel}>E-Mail</PDFText>
<PDFText style={styles.recipientValue}>{state.email}</PDFText>
</PDFView>
)}
</PDFView> </PDFView>
</PDFView> </PDFView>
@@ -343,7 +353,7 @@ export const EstimationPDF = ({ state, totalPrice, monthlyPrice, totalPagesCount
</PDFView> </PDFView>
{positions.map((item, i) => ( {positions.map((item, i) => (
<PDFView key={i} style={styles.tableRow}> <PDFView key={i} style={styles.tableRow} wrap={false}>
<PDFText style={[styles.posText, styles.colPos]}>{item.pos.toString().padStart(2, '0')}</PDFText> <PDFText style={[styles.posText, styles.colPos]}>{item.pos.toString().padStart(2, '0')}</PDFText>
<PDFView style={styles.colDesc}> <PDFView style={styles.colDesc}>
<PDFText style={styles.itemTitle}>{item.title}</PDFText> <PDFText style={styles.itemTitle}>{item.title}</PDFText>
@@ -351,7 +361,7 @@ export const EstimationPDF = ({ state, totalPrice, monthlyPrice, totalPagesCount
</PDFView> </PDFView>
<PDFText style={[styles.posText, styles.colQty]}>{item.qty}</PDFText> <PDFText style={[styles.posText, styles.colQty]}>{item.qty}</PDFText>
<PDFText style={[styles.priceText, styles.colPrice]}> <PDFText style={[styles.priceText, styles.colPrice]}>
{item.price > 0 ? `${item.price.toLocaleString()}` : 'n. A.'} {item.price > 0 ? `${item.price.toLocaleString('de-DE')}` : 'n. A.'}
</PDFText> </PDFText>
</PDFView> </PDFView>
))} ))}
@@ -361,11 +371,11 @@ export const EstimationPDF = ({ state, totalPrice, monthlyPrice, totalPagesCount
<PDFView style={styles.summaryCard}> <PDFView style={styles.summaryCard}>
<PDFView style={styles.summaryRow}> <PDFView style={styles.summaryRow}>
<PDFText style={styles.summaryLabel}>Zwischensumme (Netto)</PDFText> <PDFText style={styles.summaryLabel}>Zwischensumme (Netto)</PDFText>
<PDFText style={styles.summaryValue}>{totalPrice.toLocaleString()} </PDFText> <PDFText style={styles.summaryValue}>{totalPrice.toLocaleString('de-DE')} </PDFText>
</PDFView> </PDFView>
<PDFView style={styles.totalRow}> <PDFView style={styles.totalRow}>
<PDFText style={styles.totalLabel}>Gesamtsumme</PDFText> <PDFText style={styles.totalLabel}>Gesamtsumme</PDFText>
<PDFText style={styles.totalValue}>{totalPrice.toLocaleString()} </PDFText> <PDFText style={styles.totalValue}>{totalPrice.toLocaleString('de-DE')} </PDFText>
</PDFView> </PDFView>
</PDFView> </PDFView>
</PDFView> </PDFView>
@@ -373,26 +383,34 @@ export const EstimationPDF = ({ state, totalPrice, monthlyPrice, totalPagesCount
{state.projectType === 'website' && ( {state.projectType === 'website' && (
<PDFView style={styles.hostingBox}> <PDFView style={styles.hostingBox}>
<PDFText style={{ color: '#666666', fontSize: 8, fontWeight: 'bold', textTransform: 'uppercase' }}>Betrieb & Hosting</PDFText> <PDFText style={{ color: '#666666', fontSize: 8, fontWeight: 'bold', textTransform: 'uppercase' }}>Betrieb & Hosting</PDFText>
<PDFText style={{ fontSize: 10, fontWeight: 'bold', color: '#000000' }}>{monthlyPrice.toLocaleString()} / Monat</PDFText> <PDFText style={{ fontSize: 10, fontWeight: 'bold', color: '#000000' }}>{monthlyPrice.toLocaleString('de-DE')} / Monat</PDFText>
</PDFView> </PDFView>
)} )}
<PDFView style={styles.footer}> <Footer />
<PDFText style={styles.footerBrand}>marc mintel</PDFText>
<PDFView style={styles.footerRight}>
<PDFText style={styles.footerContact}>marc@mintel.me</PDFText>
<PDFText style={styles.pageNumber} render={({ pageNumber, totalPages }) => `${pageNumber} / ${totalPages}`} fixed />
</PDFView>
</PDFView>
</PDFPage> </PDFPage>
<PDFPage size="A4" style={styles.page}> <PDFPage size="A4" style={styles.page}>
<FoldingMarks />
<PDFView style={styles.header}> <PDFView style={styles.header}>
<PDFView style={styles.brandIconContainer}> <PDFView style={styles.addressBlock}>
<PDFText style={styles.brandIconText}>M</PDFText> <PDFText style={styles.senderLine}>Marc Mintel | Georg-Meistermann-Straße 7 | 54586 Schüller</PDFText>
<PDFView style={styles.recipientAddress}>
<PDFText style={{ fontWeight: 'bold' }}>{state.companyName || state.name}</PDFText>
</PDFView>
</PDFView> </PDFView>
<PDFView style={styles.quoteInfo}>
<PDFText style={styles.quoteTitle}>Projektdetails</PDFText> <PDFView style={styles.brandLogoContainer}>
<PDFView style={styles.brandIconContainer}>
{headerIcon ? (
<PDFImage src={headerIcon} style={{ width: 24, height: 24 }} />
) : (
<PDFText style={styles.brandIconText}>M</PDFText>
)}
</PDFView>
<PDFView style={styles.quoteInfo}>
<PDFText style={styles.quoteTitle}>Projektdetails</PDFText>
</PDFView>
</PDFView> </PDFView>
</PDFView> </PDFView>
@@ -411,11 +429,11 @@ export const EstimationPDF = ({ state, totalPrice, monthlyPrice, totalPagesCount
</PDFView> </PDFView>
<PDFView style={[styles.configItem, { width: '100%' }]}> <PDFView style={[styles.configItem, { width: '100%' }]}>
<PDFText style={styles.configLabel}>Farbschema</PDFText> <PDFText style={styles.configLabel}>Farbschema</PDFText>
<PDFView style={styles.colorGrid}> <PDFView style={{ flexDirection: 'row', gap: 12, marginTop: 8 }}>
{state.colorScheme.map((color: string, i: number) => ( {state.colorScheme.map((color: string, i: number) => (
<PDFView key={i} style={{ alignItems: 'center' }}> <PDFView key={i} style={{ alignItems: 'center' }}>
<PDFView style={[styles.colorSwatch, { backgroundColor: color }]} /> <PDFView style={{ width: 24, height: 24, borderRadius: 4, backgroundColor: color, borderWidth: 1, borderColor: '#eeeeee' }} />
<PDFText style={styles.colorHex}>{color.toUpperCase()}</PDFText> <PDFText style={{ fontSize: 6, color: '#999999', marginTop: 4, fontWeight: 'bold' }}>{color.toUpperCase()}</PDFText>
</PDFView> </PDFView>
))} ))}
</PDFView> </PDFView>
@@ -435,92 +453,26 @@ export const EstimationPDF = ({ state, totalPrice, monthlyPrice, totalPagesCount
<PDFText style={styles.configLabel}>Sicherheit</PDFText> <PDFText style={styles.configLabel}>Sicherheit</PDFText>
<PDFText style={styles.configValue}>{state.dataSensitivity === 'high' ? 'Sensibel' : 'Standard'}</PDFText> <PDFText style={styles.configValue}>{state.dataSensitivity === 'high' ? 'Sensibel' : 'Standard'}</PDFText>
</PDFView> </PDFView>
<PDFView style={styles.configItem}>
<PDFText style={styles.configLabel}>Rollen</PDFText>
<PDFText style={styles.configValue}>{state.userRoles.join(', ') || 'Keine'}</PDFText>
</PDFView>
</> </>
)} )}
<PDFView style={styles.configItem}> <PDFView style={styles.configItem}>
<PDFText style={styles.configLabel}>Mitarbeiter</PDFText> <PDFText style={styles.configLabel}>Mitarbeiter</PDFText>
<PDFText style={styles.configValue}>{state.employeeCount || 'Nicht angegeben'}</PDFText> <PDFText style={styles.configValue}>{state.employeeCount || 'Nicht angegeben'}</PDFText>
</PDFView> </PDFView>
<PDFView style={styles.configItem}>
<PDFText style={styles.configLabel}>Bestehende Website</PDFText>
<PDFText style={styles.configValue}>{state.existingWebsite || 'Keine'}</PDFText>
</PDFView>
<PDFView style={styles.configItem}>
<PDFText style={styles.configLabel}>Bestehende Domain</PDFText>
<PDFText style={styles.configValue}>{state.existingDomain || 'Keine'}</PDFText>
</PDFView>
<PDFView style={styles.configItem}>
<PDFText style={styles.configLabel}>Wunsch-Domain</PDFText>
<PDFText style={styles.configValue}>{state.wishedDomain || 'Keine'}</PDFText>
</PDFView>
<PDFView style={styles.configItem}> <PDFView style={styles.configItem}>
<PDFText style={styles.configLabel}>Zeitplan</PDFText> <PDFText style={styles.configLabel}>Zeitplan</PDFText>
<PDFText style={styles.configValue}>{DEADLINE_LABELS[state.deadline] || state.deadline}</PDFText> <PDFText style={styles.configValue}>{DEADLINE_LABELS[state.deadline] || state.deadline}</PDFText>
</PDFView> </PDFView>
<PDFView style={styles.configItem}>
<PDFText style={styles.configLabel}>Assets vorhanden</PDFText>
<PDFText style={styles.configValue}>{state.assets.map((a: string) => ASSET_LABELS[a] || a).join(', ') || 'Keine angegeben'}</PDFText>
</PDFView>
{state.otherAssets.length > 0 && (
<PDFView style={styles.configItem}>
<PDFText style={styles.configLabel}>Weitere Assets</PDFText>
<PDFText style={styles.configValue}>{state.otherAssets.join(', ')}</PDFText>
</PDFView>
)}
<PDFView style={styles.configItem}> <PDFView style={styles.configItem}>
<PDFText style={styles.configLabel}>Sprachen</PDFText> <PDFText style={styles.configLabel}>Sprachen</PDFText>
<PDFText style={styles.configValue}>{state.languagesList.length} ({state.languagesList.join(', ')})</PDFText> <PDFText style={styles.configValue}>{state.languagesList.length} ({state.languagesList.join(', ')})</PDFText>
</PDFView> </PDFView>
{state.projectType === 'website' && (
<>
<PDFView style={styles.configItem}>
<PDFText style={styles.configLabel}>CMS (Inhaltsverwaltung)</PDFText>
<PDFText style={styles.configValue}>{state.cmsSetup ? 'Ja' : 'Nein'}</PDFText>
</PDFView>
<PDFView style={styles.configItem}>
<PDFText style={styles.configLabel}>Änderungsfrequenz</PDFText>
<PDFText style={styles.configValue}>
{state.expectedAdjustments === 'low' ? 'Selten' :
state.expectedAdjustments === 'medium' ? 'Regelmäßig' : 'Häufig'}
</PDFText>
</PDFView>
</>
)}
</PDFView> </PDFView>
{state.socialMedia.length > 0 && (
<PDFView style={{ marginTop: 24, paddingTop: 16, borderTopWidth: 1, borderTopColor: '#eeeeee' }}>
<PDFText style={styles.configLabel}>Social Media Accounts</PDFText>
{state.socialMedia.map((id: string) => (
<PDFText key={id} style={[styles.configValue, { lineHeight: 1.6, color: '#666666', fontWeight: 'normal' }]}>
<PDFText style={{ color: '#000000', fontWeight: 'bold' }}>{SOCIAL_LABELS[id] || id}:</PDFText> {state.socialMediaUrls[id] || 'Keine URL angegeben'}
</PDFText>
))}
</PDFView>
)}
{state.designWishes && (
<PDFView style={{ marginTop: 24, paddingTop: 16, borderTopWidth: 1, borderTopColor: '#eeeeee' }}>
<PDFText style={styles.configLabel}>Design-Vorstellungen</PDFText>
<PDFText style={[styles.configValue, { lineHeight: 1.6, fontWeight: 'normal', color: '#666666' }]}>{state.designWishes}</PDFText>
</PDFView>
)}
{state.references.length > 0 && (
<PDFView style={{ marginTop: 24, paddingTop: 16, borderTopWidth: 1, borderTopColor: '#eeeeee' }}>
<PDFText style={styles.configLabel}>Referenzen</PDFText>
<PDFText style={[styles.configValue, { lineHeight: 1.6, fontWeight: 'normal', color: '#666666' }]}>{state.references.join('\n')}</PDFText>
</PDFView>
)}
{state.message && ( {state.message && (
<PDFView style={{ marginTop: 24, paddingTop: 16, borderTopWidth: 1, borderTopColor: '#eeeeee' }}> <PDFView style={{ marginTop: 24, paddingTop: 16, borderTopWidth: 1, borderTopColor: '#eeeeee' }}>
<PDFText style={styles.configLabel}>Nachricht / Anmerkungen</PDFText> <PDFText style={styles.configLabel}>Nachricht / Anmerkungen</PDFText>
<PDFText style={[styles.configValue, { lineHeight: 1.6, fontWeight: 'normal', color: '#666666' }]}>{state.message}</PDFText> <PDFText style={{ fontSize: 8, color: '#666666', lineHeight: 1.6 }}>{state.message}</PDFText>
</PDFView> </PDFView>
)} )}
</PDFView> </PDFView>
@@ -528,17 +480,11 @@ export const EstimationPDF = ({ state, totalPrice, monthlyPrice, totalPagesCount
{qrCodeData && ( {qrCodeData && (
<PDFView style={styles.qrContainer}> <PDFView style={styles.qrContainer}>
<PDFImage src={qrCodeData} style={styles.qrImage} /> <PDFImage src={qrCodeData} style={styles.qrImage} />
<PDFText style={styles.qrText}>Online öffnen</PDFText> <PDFText style={styles.qrText}>Kalkulation online öffnen</PDFText>
</PDFView> </PDFView>
)} )}
<PDFView style={styles.footer}> <Footer />
<PDFText style={styles.footerBrand}>marc mintel</PDFText>
<PDFView style={styles.footerRight}>
<PDFText style={styles.footerContact}>marc@mintel.me</PDFText>
<PDFText style={styles.pageNumber} render={({ pageNumber, totalPages }) => `${pageNumber} / ${totalPages}`} fixed />
</PDFView>
</PDFView>
</PDFPage> </PDFPage>
</PDFDocument> </PDFDocument>
); );

View File

@@ -0,0 +1,128 @@
import { FormState, Position } from './types';
import { FEATURE_LABELS, FUNCTION_LABELS, API_LABELS } from './constants';
export function calculatePositions(state: FormState, pricing: any): Position[] {
const positions: Position[] = [];
let pos = 1;
if (state.projectType === 'website') {
positions.push({
pos: pos++,
title: 'Basis Website Setup',
desc: 'Projekt-Setup, Infrastruktur, Hosting-Bereitstellung, Grundstruktur & Design-Vorlage, technisches SEO-Basics, Analytics.',
qty: 1,
price: pricing.BASE_WEBSITE
});
const totalPagesCount = state.selectedPages.length + (state.otherPages?.length || 0) + (state.otherPagesCount || 0);
const allPages = [...state.selectedPages.map((p: string) => p === 'Home' ? 'Startseite' : p), ...(state.otherPages || [])];
positions.push({
pos: pos++,
title: 'Individuelle Seiten',
desc: `Gestaltung und Umsetzung von ${totalPagesCount} individuellen Seiten-Layouts (${allPages.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',
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: `Erweiterte Funktionen: ${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: 'Inhaltspflege (Initial)',
desc: `Manuelle Einpflege von ${state.newDatasets} Datensätzen (z.B. Produkte, Blogartikel).`,
qty: state.newDatasets,
price: state.newDatasets * pricing.NEW_DATASET
});
}
if (state.visualStaging && Number(state.visualStaging) > 0) {
const count = Number(state.visualStaging);
positions.push({
pos: pos++,
title: 'Visuelle Inszenierung',
desc: `Umsetzung von ${count} Hero-Stories, Scroll-Effekten oder speziell inszenierten Sektionen.`,
qty: count,
price: count * pricing.VISUAL_STAGING
});
}
if (state.complexInteractions && Number(state.complexInteractions) > 0) {
const count = Number(state.complexInteractions);
positions.push({
pos: pos++,
title: 'Komplexe Interaktion',
desc: `Umsetzung von ${count} Konfiguratoren, Live-Previews oder mehrstufigen Auswahlprozessen.`,
qty: count,
price: count * 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)
});
}
} 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,209 @@
import { FormState } from './types';
export const PRICING = {
BASE_WEBSITE: 6000,
PAGE: 800,
FEATURE: 2000,
FUNCTION: 1000,
NEW_DATASET: 400,
HOSTING_MONTHLY: 120,
STORAGE_EXPANSION_MONTHLY: 10,
CMS_SETUP: 1500,
CMS_CONNECTION_PER_FEATURE: 800,
API_INTEGRATION: 1000,
APP_HOURLY: 120,
VISUAL_STAGING: 1500,
COMPLEX_INTERACTION: 2500,
};
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'],
// Timeline
deadline: 'flexible',
// Web App specific
targetAudience: 'internal',
userRoles: [],
dataSensitivity: 'standard',
platformType: 'web-only',
// Meta
dontKnows: [],
visualStaging: 'standard',
complexInteractions: 'standard',
};
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: 'Erweiterte Formulare', desc: 'Komplexe Abfragen & Logik.' },
];
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> = {
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: 'Erweiterte Formulare',
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'
};

View File

@@ -0,0 +1,3 @@
export * from './constants';
export * from './calculator';
export * from './types';

View File

@@ -0,0 +1,67 @@
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; // Using any for File/null to be CLI-compatible
contactFiles: any[]; // Using any[] for File[]
// 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;
}
export interface Position {
pos: number;
title: string;
desc: string;
qty: number;
price: number;
}