4 Commits

Author SHA1 Message Date
668af74c2a form
Some checks failed
Build & Deploy Mintel Blog / build-and-deploy (push) Failing after 1m27s
2026-01-30 22:19:40 +01:00
1cfc92a922 form 2026-01-30 20:43:34 +01:00
047c21278e form 2026-01-30 19:46:36 +01:00
d2d4f3be14 form 2026-01-30 19:15:39 +01:00
25 changed files with 1407 additions and 826 deletions

136
docs/WEBSITES.md Normal file
View File

@@ -0,0 +1,136 @@
Wie ich Websites baue und warum Sie damit Ruhe haben
Die meisten Websites funktionieren.
Bis jemand sie anfasst.
Oder Google etwas ändert.
Oder ein Plugin ein Update macht.
Oder die Agentur nicht mehr antwortet.
Ich baue Websites so, dass das alles egal ist.
Ich baue Websites wie Systeme nicht wie Broschüren
Eine Website ist kein Flyer.
Sie ist ein System, das jeden Tag arbeitet.
Deshalb baue ich sie auch so:
• stabil
• schnell
• vorhersehbar
• ohne Überraschungen
Sie müssen nichts warten.
Sie müssen nichts lernen.
Sie müssen nichts pflegen, wenn Sie nicht wollen.
Geschwindigkeit ist kein Extra. Sie ist Standard.
Viele Websites sind langsam, weil sie zusammengeklickt sind.
Meine sind schnell, weil sie gebaut sind.
Das bedeutet für Sie:
• Seiten laden sofort
• Google mag sie
• Besucher bleiben
• weniger Absprünge
• bessere Sichtbarkeit
90+ Pagespeed ist bei mir kein Ziel.
Es ist der Normalzustand.
Keine Plugins. Keine Updates. Keine Wartungshölle.
Ich nutze keine Baukästen.
Keine Plugin-Sammlungen.
Keine Systeme, die sich selbst zerstören.
Ihre Website besteht aus:
• sauberem Code
• klarer Struktur
• festen Bausteinen
Das heißt:
Wenn etwas geändert wird, geht nichts kaputt.
Inhalte und Technik sind getrennt (absichtlich)
Wenn Sie Inhalte selbst pflegen wollen, können Sie das.
Aber nur Inhalte.
Kein Design.
Keine Struktur.
Keine Technik.
Sie können nichts kaputt machen.
Ich verspreche es.
Und wenn Sie nichts selbst pflegen wollen:
Dann schreiben Sie mir einfach.
Ich erledige das.
Änderungen sind einfach. Wirklich.
Neue Seite?
Neue Funktion?
Neue Idee?
Kein Ticket.
Kein Formular.
Kein Projektplan.
Sie schreiben mir, was Sie brauchen.
Ich setze es um.
Fertig.
Warum das alles so gebaut ist
Weil ich 15 Jahre Agenturen gesehen habe.
Zu viele Meetings.
Zu viele Konzepte.
Zu viele Übergaben.
Zu viele „eigentlich müsste man mal“.
Meine Websites sind dafür gebaut,
dass Dinge einfach passieren.
Das Ergebnis für Sie
• schnelle Website
• keine Pflegepflicht
• keine Überraschungen
• keine Abhängigkeit
• keine Agentur
• kein Stress
Oder anders gesagt:
Eine Website, die sich wie eine erledigte Aufgabe anfühlt.
Und technisch?
Technisch ist das alles sehr modern.
Aber das ist mein Problem, nicht Ihres.
Wenn Sie wollen, erkläre ich Ihnen das gerne.
Wenn nicht, funktioniert es trotzdem.

18
package-lock.json generated
View File

@@ -9,10 +9,12 @@
"version": "0.1.0",
"dependencies": {
"@react-pdf/renderer": "^4.3.2",
"@types/canvas-confetti": "^1.9.0",
"@types/ioredis": "^4.28.10",
"@types/react": "^19.2.8",
"@types/react-dom": "^19.2.3",
"@vercel/og": "^0.8.6",
"canvas-confetti": "^1.9.4",
"framer-motion": "^12.29.2",
"ioredis": "^5.9.1",
"lucide-react": "^0.468.0",
@@ -1129,6 +1131,12 @@
"tailwindcss": ">=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1"
}
},
"node_modules/@types/canvas-confetti": {
"version": "1.9.0",
"resolved": "https://registry.npmjs.org/@types/canvas-confetti/-/canvas-confetti-1.9.0.tgz",
"integrity": "sha512-aBGj/dULrimR1XDZLtG9JwxX1b4HPRF6CX9Yfwh3NvstZEm1ZL7RBnel4keCPSqs1ANRu1u2Aoz9R+VmtjYuTg==",
"license": "MIT"
},
"node_modules/@types/d3": {
"version": "7.4.3",
"resolved": "https://registry.npmjs.org/@types/d3/-/d3-7.4.3.tgz",
@@ -1783,6 +1791,16 @@
],
"license": "CC-BY-4.0"
},
"node_modules/canvas-confetti": {
"version": "1.9.4",
"resolved": "https://registry.npmjs.org/canvas-confetti/-/canvas-confetti-1.9.4.tgz",
"integrity": "sha512-yxQbJkAVrFXWNbTUjPqjF7G+g6pDotOUHGbkZq2NELZUMDpiJ85rIEazVb8GTaAptNW2miJAXbs1BtioA251Pw==",
"license": "ISC",
"funding": {
"type": "donate",
"url": "https://www.paypal.me/kirilvatev"
}
},
"node_modules/ccount": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz",

View File

@@ -15,10 +15,12 @@
},
"dependencies": {
"@react-pdf/renderer": "^4.3.2",
"@types/canvas-confetti": "^1.9.0",
"@types/ioredis": "^4.28.10",
"@types/react": "^19.2.8",
"@types/react-dom": "^19.2.3",
"@vercel/og": "^0.8.6",
"canvas-confetti": "^1.9.4",
"framer-motion": "^12.29.2",
"ioredis": "^5.9.1",
"lucide-react": "^0.468.0",

View File

@@ -4,8 +4,9 @@ import * as React from 'react';
import { useState, useMemo, useEffect, useRef } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import { motion, AnimatePresence } from 'framer-motion';
import { ChevronRight, ChevronLeft, Send, Check } from 'lucide-react';
import { ChevronRight, ChevronLeft, Send, Check, Sparkles, Info, ArrowRight } from 'lucide-react';
import * as QRCode from 'qrcode';
import * as confetti from 'canvas-confetti';
import { FormState, Step } from './ContactForm/types';
import { PRICING, initialState } from './ContactForm/constants';
@@ -50,8 +51,23 @@ export function ContactForm() {
const [qrCodeData, setQrCodeData] = useState<string>('');
const [isShareModalOpen, setIsShareModalOpen] = useState(false);
const [hoveredStep, setHoveredStep] = useState<number | null>(null);
const [isSticky, setIsSticky] = useState(false);
const formContainerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const handleScroll = () => {
if (formContainerRef.current) {
const rect = formContainerRef.current.getBoundingClientRect();
// Stick when the container top reaches the sticky position
setIsSticky(rect.top <= 80);
}
};
window.addEventListener('scroll', handleScroll);
// Initial check
handleScroll();
return () => window.removeEventListener('scroll', handleScroll);
}, []);
useEffect(() => {
setIsClient(true);
}, []);
@@ -200,20 +216,28 @@ export function ContactForm() {
};
const steps: Step[] = [
{ id: 'type', title: 'Das Ziel', description: 'Was möchten Sie realisieren?', illustration: <ConceptTarget className="w-full h-full" /> },
{ id: 'company', title: 'Unternehmen', description: 'Wer sind Sie?', illustration: <ConceptCommunication className="w-full h-full" /> },
{ id: 'presence', title: 'Präsenz', description: 'Bestehende Kanäle.', illustration: <ConceptSystem className="w-full h-full" /> },
{ id: 'features', title: 'Die Systeme', description: 'Welche inhaltlichen Bereiche planen wir?', illustration: <ConceptPrototyping className="w-full h-full" /> },
{ id: 'base', title: 'Die Seiten', description: 'Welche Seiten benötigen wir?', illustration: <ConceptWebsite className="w-full h-full" /> },
{ id: 'design', title: 'Design-Wünsche', description: 'Wie soll die Seite wirken?', illustration: <ConceptCommunication className="w-full h-full" /> },
{ id: 'assets', title: 'Ihre Assets', description: 'Was bringen Sie bereits mit?', illustration: <ConceptSystem className="w-full h-full" /> },
{ id: 'functions', title: 'Die Logik', description: 'Welche Funktionen werden benötigt?', illustration: <ConceptCode className="w-full h-full" /> },
{ id: 'api', title: 'Schnittstellen', description: 'Datenaustausch mit Drittsystemen.', illustration: <ConceptAutomation className="w-full h-full" /> },
{ id: 'content', title: 'Die Pflege', description: 'Wer kümmert sich um die Daten?', illustration: <ConceptPrice className="w-full h-full" /> },
{ id: 'language', title: 'Sprachen', description: 'Globale Reichweite planen.', illustration: <ConceptCommunication className="w-full h-full" /> },
{ id: 'timeline', title: 'Zeitplan', description: 'Wann soll das Projekt live gehen?', illustration: <HeroArchitecture className="w-full h-full" /> },
{ id: 'contact', title: 'Abschluss', description: 'Erzählen Sie mir mehr über Ihr Vorhaben.', illustration: <ConceptCommunication className="w-full h-full" /> },
{ id: 'webapp', title: 'Web App Details', description: 'Spezifische Anforderungen für Ihre Anwendung.', illustration: <ConceptSystem className="w-full h-full" /> },
{ id: 'type', title: 'Das Ziel', description: 'Was möchten Sie realisieren?', illustration: <ConceptTarget className="w-full h-full" />, chapter: 'strategy' },
{ id: 'company', title: 'Unternehmen', description: 'Wer sind Sie?', illustration: <ConceptCommunication className="w-full h-full" />, chapter: 'strategy' },
{ id: 'presence', title: 'Präsenz', description: 'Bestehende Kanäle von {company}.', illustration: <ConceptSystem className="w-full h-full" />, chapter: 'strategy' },
{ id: 'features', title: 'Die Systeme', description: 'Welche inhaltlichen Bereiche planen wir für {company}?', illustration: <ConceptPrototyping className="w-full h-full" />, chapter: 'scope' },
{ id: 'base', title: 'Die Seiten', description: 'Welche Seiten benötigen wir?', illustration: <ConceptWebsite className="w-full h-full" />, chapter: 'scope' },
{ id: 'design', title: 'Design-Wünsche', description: 'Wie soll die neue Präsenz von {company} wirken?', illustration: <ConceptCommunication className="w-full h-full" />, chapter: 'creative' },
{ id: 'assets', title: 'Ihre Assets', description: 'Was bringen Sie bereits mit?', illustration: <ConceptSystem className="w-full h-full" />, chapter: 'creative' },
{ id: 'functions', title: 'Die Logik', description: 'Welche Funktionen werden benötigt?', illustration: <ConceptCode className="w-full h-full" />, chapter: 'tech' },
{ id: 'api', title: 'Schnittstellen', description: 'Datenaustausch mit Drittsystemen.', illustration: <ConceptAutomation className="w-full h-full" />, chapter: 'tech' },
{ id: 'content', title: 'Die Pflege', description: 'Wer kümmert sich um die Daten?', illustration: <ConceptPrice className="w-full h-full" />, chapter: 'tech' },
{ id: 'language', title: 'Sprachen', description: 'Globale Reichweite planen.', illustration: <ConceptCommunication className="w-full h-full" />, chapter: 'tech' },
{ id: 'timeline', title: 'Zeitplan', description: 'Wann soll das Projekt live gehen?', illustration: <HeroArchitecture className="w-full h-full" />, chapter: 'final' },
{ id: 'contact', title: 'Abschluss', description: 'Erzählen Sie mir mehr über Ihr Vorhaben.', illustration: <ConceptCommunication className="w-full h-full" />, chapter: 'final' },
{ id: 'webapp', title: 'Web App Details', description: 'Spezifische Anforderungen für {company}.', illustration: <ConceptSystem className="w-full h-full" />, chapter: 'scope' },
];
const chapters = [
{ id: 'strategy', title: 'Strategie' },
{ id: 'scope', title: 'Umfang' },
{ id: 'creative', title: 'Design' },
{ id: 'tech', title: 'Technik' },
{ id: 'final', title: 'Start' },
];
const activeSteps = useMemo(() => {
@@ -231,7 +255,7 @@ export function ContactForm() {
steps.find(s => s.id === 'timeline')!,
steps.find(s => s.id === 'contact')!,
];
}, [state.projectType]);
}, [state.projectType, state.companyName]);
useEffect(() => {
if (stepIndex >= activeSteps.length) setStepIndex(activeSteps.length - 1);
@@ -287,6 +311,26 @@ export function ContactForm() {
${state.message}
`;
window.location.href = `mailto:marc@mintel.me?subject=Projektanfrage: ${state.name}&body=${encodeURIComponent(mailBody)}`;
// Celebration!
const duration = 5 * 1000;
const animationEnd = Date.now() + duration;
const defaults = { startVelocity: 30, spread: 360, ticks: 60, zIndex: 0 };
const randomInRange = (min: number, max: number) => Math.random() * (max - min) + min;
const interval: any = setInterval(function() {
const timeLeft = animationEnd - Date.now();
if (timeLeft <= 0) {
return clearInterval(interval);
}
const particleCount = 50 * (timeLeft / duration);
confetti({ ...defaults, particleCount, origin: { x: randomInRange(0.1, 0.3), y: Math.random() - 0.2 } });
confetti({ ...defaults, particleCount, origin: { x: randomInRange(0.7, 0.9), y: Math.random() - 0.2 } });
}, 250);
setIsSubmitted(true);
} else {
nextStep();
@@ -310,70 +354,209 @@ export function ContactForm() {
return (
<div ref={formContainerRef} className="grid grid-cols-1 lg:grid-cols-12 gap-16 items-start">
<div className="lg:col-span-8 space-y-12">
<div className="flex flex-col gap-10">
<div className="flex flex-col md:flex-row md:items-center gap-8">
<div className="w-32 h-32 shrink-0 bg-slate-50 rounded-[2.5rem] p-4 flex items-center justify-center">{activeSteps[stepIndex].illustration}</div>
<div className="space-y-2">
<span className="text-base font-bold uppercase tracking-[0.2em] text-slate-400">Schritt {stepIndex + 1} von {activeSteps.length}</span>
<h3 className="text-4xl font-bold tracking-tight text-slate-900">{activeSteps[stepIndex].title}</h3>
<p className="text-xl text-slate-500 leading-relaxed">{activeSteps[stepIndex].description}</p>
<div
className={`sticky top-[80px] z-40 transition-all duration-500 ${isSticky ? 'bg-white/80 backdrop-blur-md py-4 -mx-12 px-12 -mt-px border-b border-slate-50' : 'bg-transparent py-6 border-none'}`}
>
<div className={`flex flex-col ${isSticky ? 'gap-4' : 'gap-8'}`}>
<div className="flex flex-row items-center justify-between gap-8">
<div className="flex items-center gap-6">
<motion.div
animate={{
scale: isSticky ? 0.7 : 1,
width: isSticky ? 80 : 128,
height: isSticky ? 80 : 128,
borderRadius: isSticky ? '1.75rem' : '2.5rem'
}}
className="shrink-0 bg-slate-50 flex items-center justify-center relative shadow-inner z-10"
>
<div className="p-3 w-full h-full flex items-center justify-center overflow-hidden rounded-[inherit]">
{activeSteps[stepIndex].illustration}
</div>
<AnimatePresence>
{!isSticky && (
<motion.div
initial={{ scale: 0, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
exit={{ scale: 0, opacity: 0 }}
transition={{ type: 'spring', stiffness: 300, damping: 20 }}
className="absolute -bottom-2 -right-2 w-10 h-10 bg-slate-900 text-white rounded-full flex items-center justify-center font-bold text-sm border-4 border-white shadow-xl z-20"
>
{stepIndex + 1}
</motion.div>
)}
</AnimatePresence>
</motion.div>
<div className="space-y-1 min-w-0">
<motion.div
animate={{ opacity: isSticky ? 0 : 1, height: isSticky ? 0 : 'auto', marginBottom: isSticky ? 0 : 4 }}
className="flex items-center gap-3 overflow-hidden"
>
<span className="text-xs font-bold uppercase tracking-[0.2em] text-slate-400 flex items-center gap-2">
<Sparkles size={12} className="text-slate-300" />
Schritt {stepIndex + 1} / {activeSteps.length}
</span>
</motion.div>
<motion.h3
animate={{
fontSize: isSticky ? '1.5rem' : '2.25rem',
lineHeight: isSticky ? '2rem' : '2.5rem',
color: isSticky ? '#0f172a' : '#0f172a'
}}
className="font-black tracking-tight truncate"
>
{activeSteps[stepIndex].title.replace('{company}', state.companyName || 'Ihr Unternehmen')}
</motion.h3>
<motion.p
animate={{
fontSize: isSticky ? '0.875rem' : '1.125rem',
lineHeight: isSticky ? '1.25rem' : '1.75rem'
}}
className="text-slate-500 leading-relaxed max-w-2xl truncate overflow-hidden"
>
{activeSteps[stepIndex].description.replace('{company}', state.companyName || 'Ihr Unternehmen')}
</motion.p>
</div>
</div>
<div className="flex items-center gap-3 shrink-0">
{stepIndex > 0 ? (
<motion.button
whileHover={{ x: -3, backgroundColor: '#f8fafc' }}
whileTap={{ scale: 0.95 }}
type="button"
onClick={prevStep}
className={`flex items-center justify-center gap-2 rounded-full border-2 border-slate-100 transition-all font-bold focus:outline-none whitespace-nowrap ${isSticky ? 'px-5 py-2 text-sm' : 'px-8 py-4 text-lg'}`}
>
<ChevronLeft size={isSticky ? 16 : 20} /> Zurück
</motion.button>
) : <div className={isSticky ? 'w-0' : 'w-32'} />}
{stepIndex < activeSteps.length - 1 ? (
<motion.button
whileHover={{ x: 3, scale: 1.02 }}
whileTap={{ scale: 0.95 }}
type="button"
onClick={nextStep}
className={`flex items-center justify-center gap-2 rounded-full bg-slate-900 text-white hover:bg-slate-800 transition-all font-bold shadow-xl shadow-slate-200 focus:outline-none group whitespace-nowrap ${isSticky ? 'px-6 py-2 text-sm' : 'px-10 py-4 text-lg'}`}
>
Weiter <ChevronRight size={isSticky ? 16 : 20} className="transition-transform group-hover:translate-x-1" />
</motion.button>
) : (
<motion.button
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
type="submit"
form="contact-form"
disabled={!state.email || !state.name}
className={`flex items-center justify-center gap-2 rounded-full bg-slate-900 text-white hover:bg-slate-800 transition-all font-bold disabled:opacity-50 disabled:cursor-not-allowed shadow-xl shadow-slate-200 focus:outline-none group whitespace-nowrap ${isSticky ? 'px-6 py-2 text-sm' : 'px-10 py-4 text-lg'}`}
>
Senden <Send size={isSticky ? 16 : 20} className="transition-transform group-hover:translate-x-1 group-hover:-translate-y-1" />
</motion.button>
)}
</div>
</div>
<div className="relative">
<div className={`flex gap-1.5 transition-all duration-500 ${isSticky ? 'h-1' : 'h-2.5'}`}>
{activeSteps.map((step, i) => (
<div
key={i}
className="flex-1 h-full flex items-center relative"
onMouseEnter={() => setHoveredStep(i)}
onMouseLeave={() => setHoveredStep(null)}
>
<button
type="button"
onClick={() => {
setStepIndex(i);
setTimeout(scrollToTop, 50);
}}
className={`w-full h-full rounded-full transition-all duration-700 ${
i === stepIndex ? 'bg-slate-900 scale-y-150 shadow-lg shadow-slate-200' :
i < stepIndex ? 'bg-slate-400' : 'bg-slate-100'
} cursor-pointer focus:outline-none p-0 border-none relative group`}
>
<AnimatePresence>
{hoveredStep === i && (
<motion.div
initial={{ opacity: 0, y: 5, x: "-50%", scale: 0.9 }}
animate={{ opacity: 1, y: isSticky ? -35 : -40, x: "-50%", scale: 1 }}
exit={{ opacity: 0, y: 5, x: "-50%", scale: 0.9 }}
className="absolute left-1/2 px-3 py-1.5 bg-white text-slate-900 text-[10px] font-black uppercase tracking-[0.15em] rounded-md whitespace-nowrap pointer-events-none z-50 shadow-[0_10px_30px_rgba(0,0,0,0.1)] border border-slate-100"
>
{step.title.replace('{company}', state.companyName || 'Unternehmen')}
</motion.div>
)}
</AnimatePresence>
</button>
</div>
))}
</div>
{!isSticky && (
<div className="flex justify-between mt-4 px-1">
{chapters.map((chapter, idx) => {
const chapterSteps = activeSteps.filter(s => s.chapter === chapter.id);
if (chapterSteps.length === 0) return null;
const firstStepIdx = activeSteps.indexOf(chapterSteps[0]);
const lastStepIdx = activeSteps.indexOf(chapterSteps[chapterSteps.length - 1]);
const isActive = stepIndex >= firstStepIdx && stepIndex <= lastStepIdx;
return (
<div
key={chapter.id}
className={`text-[10px] font-bold uppercase tracking-[0.2em] transition-colors duration-500 ${
isActive ? 'text-slate-900' : 'text-slate-300'
}`}
>
{chapter.title}
</div>
);
})}
</div>
)}
</div>
</div>
<div className="flex gap-3 h-4">
{activeSteps.map((step, i) => (
<div
key={i}
className="flex-1 h-full flex items-center relative"
onMouseEnter={() => setHoveredStep(i)}
onMouseLeave={() => setHoveredStep(null)}
>
<button
type="button"
onClick={() => {
setStepIndex(i);
setTimeout(scrollToTop, 50);
}}
className={`w-full h-full rounded-full transition-all duration-700 ${i <= stepIndex ? 'bg-slate-900' : 'bg-slate-100'} cursor-pointer focus:outline-none p-0 border-none`}
/>
<AnimatePresence>
{hoveredStep === i && (
<motion.div
initial={{ opacity: 0, y: 5, x: '-50%' }}
animate={{ opacity: 1, y: 0, x: '-50%' }}
exit={{ opacity: 0, y: 5, x: '-50%' }}
className="absolute bottom-full left-1/2 mb-3 px-4 py-2 bg-slate-900 text-white text-sm font-bold uppercase tracking-wider rounded-lg whitespace-nowrap pointer-events-none z-50 shadow-xl"
>
{step.title}
<div className="absolute top-full left-1/2 -translate-x-1/2 -mt-1 border-4 border-transparent border-t-slate-900" />
</motion.div>
)}
</AnimatePresence>
</div>
))}
</div>
</div>
<div className="flex justify-between items-center py-6 border-y border-slate-50">
{stepIndex > 0 ? (
<button type="button" onClick={prevStep} className="text-base font-bold uppercase tracking-widest text-slate-400 hover:text-slate-900 transition-colors flex items-center gap-2 focus:outline-none relative overflow-hidden rounded-full">
<ChevronLeft size={18} /> Zurück
</button>
) : <div />}
<div className="flex items-center gap-4">
{stepIndex < activeSteps.length - 1 && (
<button type="button" onClick={nextStep} className="text-base font-bold uppercase tracking-widest text-slate-400 hover:text-slate-900 transition-colors flex items-center gap-2 focus:outline-none relative overflow-hidden rounded-full">
Weiter <ChevronRight size={18} />
</button>
)}
</div>
</div>
<form id="contact-form" onSubmit={handleSubmit} className="min-h-[450px] relative pt-12">
<form onSubmit={handleSubmit} className="min-h-[450px]">
<AnimatePresence mode="wait"><motion.div key={activeSteps[stepIndex].id} initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: -20 }} transition={{ duration: 0.4, ease: [0.23, 1, 0.32, 1] }}>{renderStepContent()}</motion.div></AnimatePresence>
<div className="flex justify-between mt-16">
{stepIndex > 0 ? (<button type="button" onClick={prevStep} className="flex items-center gap-3 px-10 py-5 rounded-full border border-slate-200 hover:border-slate-900 transition-all font-bold text-xl focus:outline-none overflow-hidden relative rounded-full"><ChevronLeft size={24} /> Zurück</button>) : <div />}
{stepIndex < activeSteps.length - 1 ? (<button type="button" onClick={nextStep} className="flex items-center gap-3 px-10 py-5 rounded-full bg-slate-900 text-white hover:bg-slate-800 transition-all font-bold text-xl shadow-xl shadow-slate-200 focus:outline-none overflow-hidden relative rounded-full">Weiter <ChevronRight size={24} /></button>) : (<button type="submit" disabled={!state.email || !state.name} className="flex items-center gap-3 px-10 py-5 rounded-full bg-slate-900 text-white hover:bg-slate-800 transition-all font-bold text-xl disabled:opacity-50 disabled:cursor-not-allowed shadow-xl shadow-slate-200 focus:outline-none overflow-hidden relative rounded-full">Anfrage senden <Send size={24} /></button>)}
</div>
<AnimatePresence mode="wait">
<motion.div
key={activeSteps[stepIndex].id}
initial={{ opacity: 0, x: 20 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -20 }}
transition={{ duration: 0.5, ease: [0.23, 1, 0.32, 1] }}
>
{renderStepContent()}
</motion.div>
</AnimatePresence>
{/* Contextual Help / Why this matters */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.8 }}
className="mt-24 p-10 bg-slate-50 rounded-[3rem] border border-slate-100 flex gap-8 items-start relative overflow-hidden"
>
<div className="absolute top-0 right-0 p-12 opacity-[0.03] pointer-events-none">
<Sparkles size={160} />
</div>
<div className="w-14 h-14 shrink-0 bg-white rounded-2xl flex items-center justify-center text-slate-900 shadow-sm relative z-10">
<Info size={28} />
</div>
<div className="space-y-2 relative z-10">
<h4 className="text-xl font-bold text-slate-900">Warum das wichtig ist</h4>
<p className="text-lg text-slate-500 leading-relaxed max-w-2xl">
{stepIndex === 0 && "Die Wahl zwischen Website und Web App bestimmt die technologische Basis. Web Apps nutzen oft Frameworks wie React oder Next.js für hochgradig interaktive Prozesse, während Websites auf Content-Präsentation optimiert sind."}
{stepIndex === 1 && "Ihr Unternehmen und dessen Größe helfen mir, die Skalierbarkeit und die notwendige Infrastruktur von Beginn an richtig zu dimensionieren."}
{stepIndex === 2 && "Bestehende Kanäle geben Aufschluss über Ihre aktuelle Markenidentität. Wir können diese entweder konsequent weiterführen oder einen bewussten Neuanfang wagen."}
{stepIndex > 2 && "Jede Auswahl beeinflusst die Komplexität und damit auch die Entwicklungszeit. Mein Ziel ist es, für Sie das beste Preis-Leistungs-Verhältnis zu finden."}
</p>
</div>
</motion.div>
</form>
</div>
<PriceCalculation

View File

@@ -2,6 +2,7 @@
import * as React from 'react';
import { Check } from 'lucide-react';
import { motion } from 'framer-motion';
interface CheckboxProps {
label: string;
@@ -19,13 +20,29 @@ export function Checkbox({ label, desc, checked, onChange }: CheckboxProps) {
checked ? 'border-slate-900 bg-slate-900 text-white' : 'border-slate-100 bg-white hover:border-slate-200'
}`}
>
<div className={`mt-1 w-6 h-6 rounded-full border-2 flex items-center justify-center shrink-0 ${checked ? 'border-white bg-white text-slate-900' : 'border-slate-200'}`}>
{checked && <Check size={14} strokeWidth={4} />}
<div className={`mt-1 w-8 h-8 rounded-full border-2 flex items-center justify-center shrink-0 transition-all duration-500 ${checked ? 'border-white bg-white text-slate-900 scale-110 shadow-lg' : 'border-slate-200'}`}>
{checked && (
<motion.div
initial={{ scale: 0, rotate: -45 }}
animate={{ scale: 1, rotate: 0 }}
transition={{ type: 'spring', stiffness: 300, damping: 20 }}
>
<Check size={18} strokeWidth={4} />
</motion.div>
)}
</div>
<div className="flex-grow">
<h4 className={`text-xl font-bold mb-1 ${checked ? 'text-white' : 'text-slate-900'}`}>{label}</h4>
{desc && <p className={`text-base leading-relaxed ${checked ? 'text-slate-300' : 'text-slate-500'}`}>{desc}</p>}
<h4 className={`text-2xl font-bold mb-1 transition-colors duration-500 ${checked ? 'text-white' : 'text-slate-900'}`}>{label}</h4>
{desc && <p className={`text-lg leading-relaxed transition-colors duration-500 ${checked ? 'text-slate-300' : 'text-slate-500'}`}>{desc}</p>}
</div>
{checked && (
<motion.div
layoutId={`check-bg-${label}`}
className="absolute inset-0 bg-gradient-to-br from-slate-800 to-slate-900 -z-10"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
/>
)}
</button>
);
}

View File

@@ -22,7 +22,7 @@ export function Input({ label, icon: Icon, isTextArea, className = '', ...props
)}
<div className="relative group">
{Icon && (
<div className="absolute left-6 top-[1.8rem] -translate-y-1/2 text-black transition-colors">
<div className={`absolute left-6 ${isTextArea ? 'top-10' : 'top-1/2'} -translate-y-1/2 text-black transition-colors`}>
<Icon size={24} />
</div>
)}

View File

@@ -5,7 +5,8 @@ import { FormState } from '../types';
import { PRICING } from '../constants';
import { AnimatedNumber } from './AnimatedNumber';
import { ConceptPrice, ConceptAutomation } from '../../Landing/ConceptIllustrations';
import { Info, Download, Share2 } from 'lucide-react';
import { Info, Download, Share2, RefreshCw } from 'lucide-react';
import { motion, AnimatePresence } from 'framer-motion';
import dynamic from 'next/dynamic';
import { EstimationPDF } from '../../EstimationPDF';
@@ -42,6 +43,43 @@ export function PriceCalculation({
const [pdfLoading, setPdfLoading] = React.useState(false);
const languagesCount = state.languagesList.length || 1;
const handleDownload = async () => {
if (pdfLoading) return;
setPdfLoading(true);
try {
const { pdf } = await import('@react-pdf/renderer');
const doc = <EstimationPDF
state={state}
totalPrice={totalPrice}
monthlyPrice={monthlyPrice}
totalPagesCount={totalPagesCount}
pricing={PRICING}
qrCodeData={qrCodeData}
/>;
// Minimum loading time of 2 seconds for better UX
const [blob] = await Promise.all([
pdf(doc).toBlob(),
new Promise(resolve => setTimeout(resolve, 2000))
]);
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = `kalkulation-${state.name.toLowerCase().replace(/\s+/g, '-') || 'projekt'}.pdf`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
} catch (error) {
console.error('PDF generation failed:', error);
} finally {
setPdfLoading(false);
}
};
return (
<div className="lg:col-span-4 lg:sticky lg:top-32 self-start z-30">
<div className="bg-slate-50 border border-slate-100 rounded-[3rem] p-6 space-y-6">
@@ -76,22 +114,46 @@ export function PriceCalculation({
<div className="pt-4 space-y-4">
{isClient && (
<PDFDownloadLink
document={<EstimationPDF state={state} totalPrice={totalPrice} monthlyPrice={monthlyPrice} totalPagesCount={totalPagesCount} pricing={PRICING} qrCodeData={qrCodeData} />}
fileName={`kalkulation-${state.name.toLowerCase().replace(/\s+/g, '-') || 'projekt'}.pdf`}
className="w-full flex items-center justify-center gap-3 px-8 py-4 rounded-full border border-slate-200 text-slate-900 font-bold text-sm uppercase tracking-widest hover:bg-white hover:border-slate-900 transition-all focus:outline-none overflow-hidden relative"
onClick={() => {
setPdfLoading(true);
setTimeout(() => setPdfLoading(false), 2000);
}}
<button
type="button"
disabled={pdfLoading}
onClick={handleDownload}
className="w-full h-14 rounded-full border border-slate-200 text-slate-900 font-bold text-sm uppercase tracking-widest hover:bg-white hover:border-slate-900 transition-all focus:outline-none overflow-hidden relative"
>
{({ loading }) => (
<div className="flex items-center gap-3">
<Download size={18} />
<span>{loading || pdfLoading ? 'PDF wird erstellt...' : 'Als PDF speichern'}</span>
</div>
)}
</PDFDownloadLink>
<AnimatePresence mode="wait">
{pdfLoading ? (
<motion.div
key="loading"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="absolute inset-0 flex items-center justify-center bg-white"
>
<motion.div
className="absolute bottom-0 left-0 h-1 bg-slate-900"
initial={{ width: "0%" }}
animate={{ width: "100%" }}
transition={{ duration: 2, ease: "easeInOut" }}
/>
<span className="flex items-center gap-2">
<RefreshCw className="animate-spin" size={16} />
PDF wird erstellt...
</span>
</motion.div>
) : (
<motion.div
key="idle"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="flex items-center justify-center gap-3"
>
<Download size={18} />
<span>Als PDF speichern</span>
</motion.div>
)}
</AnimatePresence>
</button>
)}
{onShare && (

View File

@@ -69,6 +69,8 @@ export const initialState: FormState = {
platformType: 'web-only',
// Meta
dontKnows: [],
visualStaging: 'standard',
complexInteractions: 'standard',
};
export const PAGE_SAMPLES = [
@@ -107,7 +109,6 @@ export const API_OPTIONS = [
{ id: 'social', label: 'Social Media Sync', desc: 'Automatisierte Posts oder Feeds.' },
{ id: 'maps', label: 'Google Maps / Places', desc: 'Standortsuche und Kartenintegration.' },
{ id: 'analytics', label: 'Custom Analytics', desc: 'Anbindung an spezialisierte Tracking-Tools.' },
{ id: 'auth', label: 'Auth-Provider', desc: 'NextAuth, Clerk, Auth0 Integration.' },
];
export const ASSET_OPTIONS = [
@@ -185,3 +186,65 @@ export const SOCIAL_MEDIA_OPTIONS = [
{ 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

@@ -35,16 +35,19 @@ export function ApiStep({ state, updateState, toggleItem }: ApiStepProps) {
<div className="w-12 h-12 bg-slate-50 rounded-2xl flex items-center justify-center text-black">
<Share2 size={24} />
</div>
<h4 className="text-2xl font-bold text-slate-900">
{isWebApp ? 'Integrationen & Datenquellen' : 'Schnittstellen (API)'}
</h4>
<div className="flex items-center gap-3">
<h4 className="text-2xl font-bold text-slate-900">
{isWebApp ? 'Integrationen & Datenquellen' : 'Schnittstellen (API)'}
</h4>
<span className="px-2 py-1 bg-slate-50 text-slate-400 text-[10px] font-bold uppercase tracking-wider rounded">Optional</span>
</div>
</div>
<motion.button
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
type="button"
onClick={() => toggleDontKnow('api')}
className={`px-6 py-3 rounded-full text-sm font-bold transition-all ${
className={`px-6 py-3 rounded-full text-sm font-bold transition-all whitespace-nowrap shrink-0 ${
state.dontKnows?.includes('api') ? 'bg-slate-900 text-white' : 'bg-slate-100 text-slate-500 hover:bg-slate-200'
}`}
>
@@ -63,7 +66,6 @@ export function ApiStep({ state, updateState, toggleItem }: ApiStepProps) {
{ id: 'marketing', label: 'Marketing', desc: 'Newsletter (Mailchimp), Social Media Sync.' },
{ id: 'ecommerce', label: 'E-Commerce', desc: 'Shopify, WooCommerce, Lagerbestand-Sync.' },
{ id: 'maps', label: 'Google Maps / Places', desc: 'Standortsuche und Kartenintegration.' },
{ id: 'auth', label: 'Auth-Provider', desc: 'NextAuth, Clerk, Auth0 Integration.' },
].map((opt, index) => (
<motion.div
key={opt.id}

View File

@@ -41,7 +41,7 @@ export function AssetsStep({ state, updateState, toggleItem }: AssetsStepProps)
whileTap={{ scale: 0.95 }}
type="button"
onClick={() => toggleDontKnow('assets')}
className={`px-6 py-3 rounded-full text-sm font-bold transition-all ${
className={`px-6 py-3 rounded-full text-sm font-bold transition-all whitespace-nowrap shrink-0 ${
state.dontKnows?.includes('assets') ? 'bg-slate-900 text-white' : 'bg-slate-100 text-slate-500 hover:bg-slate-200'
}`}
>
@@ -56,11 +56,18 @@ export function AssetsStep({ state, updateState, toggleItem }: AssetsStepProps)
animate={{ opacity: 1, y: 0 }}
transition={{ delay: index * 0.05 }}
>
<Checkbox
key={opt.id} label={opt.label} desc={opt.desc}
checked={state.assets.includes(opt.id)}
onChange={() => updateState({ assets: toggleItem(state.assets, opt.id) })}
/>
<div className="relative">
<Checkbox
key={opt.id} label={opt.label} desc={opt.desc}
checked={state.assets.includes(opt.id)}
onChange={() => updateState({ assets: toggleItem(state.assets, opt.id) })}
/>
{['logo', 'styleguide', 'content_concept'].includes(opt.id) && (
<div className="absolute top-4 right-4 px-2 py-1 bg-slate-100 text-slate-500 text-[10px] font-bold uppercase tracking-wider rounded">
Empfohlen
</div>
)}
</div>
</motion.div>
))}
</div>
@@ -84,50 +91,6 @@ export function AssetsStep({ state, updateState, toggleItem }: AssetsStepProps)
/>
</div>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.4 }}
className="p-10 bg-white border border-slate-100 rounded-[3rem] space-y-6 hover:shadow-xl transition-all duration-500"
>
<div className="flex flex-col md:flex-row justify-between items-center gap-8">
<div>
<h4 className="text-xl font-bold text-slate-900">Anzahl weiterer Assets</h4>
<p className="text-base text-slate-500 mt-1">Falls Sie weitere Unterlagen haben, diese aber noch nicht benennen können.</p>
</div>
<div className="flex items-center gap-8">
<motion.button
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.9 }}
type="button"
onClick={() => updateState({ otherAssetsCount: Math.max(0, state.otherAssetsCount - 1) })}
className="w-14 h-14 rounded-full bg-slate-50 border border-slate-100 flex items-center justify-center hover:border-slate-900 transition-colors focus:outline-none"
>
<Minus size={24} />
</motion.button>
<AnimatePresence mode="wait">
<motion.span
key={state.otherAssetsCount}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
className="text-5xl font-bold w-12 text-center"
>
{state.otherAssetsCount}
</motion.span>
</AnimatePresence>
<motion.button
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.9 }}
type="button"
onClick={() => updateState({ otherAssetsCount: state.otherAssetsCount + 1 })}
className="w-14 h-14 rounded-full bg-slate-50 border border-slate-100 flex items-center justify-center hover:border-slate-900 transition-colors focus:outline-none"
>
<Plus size={24} />
</motion.button>
</div>
</div>
</motion.div>
</div>
</Reveal>
</div>

View File

@@ -5,7 +5,7 @@ import { FormState } from '../types';
import { Checkbox } from '../components/Checkbox';
import { RepeatableList } from '../components/RepeatableList';
import { motion, AnimatePresence } from 'framer-motion';
import { Minus, Plus, FileText, ListPlus } from 'lucide-react';
import { Minus, Plus, FileText, ListPlus, HelpCircle, ArrowRight } from 'lucide-react';
import { Input } from '../components/Input';
interface BaseStepProps {
@@ -31,28 +31,42 @@ export function BaseStep({ state, updateState, toggleItem }: BaseStepProps) {
animate={{ opacity: 1, y: 0 }}
className="space-y-8"
>
<Input
label="Thema der Website"
placeholder="z.B. Portfolio für Architektur, Onlineshop für Bio-Tee..."
value={state.websiteTopic}
onChange={(e) => updateState({ websiteTopic: e.target.value })}
/>
<div className="relative">
<Input
label="Thema der Website"
placeholder="z.B. Portfolio für Architektur, Onlineshop für Bio-Tee..."
value={state.websiteTopic}
onChange={(e) => updateState({ websiteTopic: e.target.value })}
/>
<div className="absolute top-0 right-4 px-2 py-1 bg-slate-100 text-slate-500 text-[10px] font-bold uppercase tracking-wider rounded">
Essenziell
</div>
</div>
</motion.div>
<div className="space-y-8">
<div className="flex justify-between items-center">
<div className="flex flex-col md:flex-row justify-between items-start md:items-center gap-6">
<div className="flex items-center gap-4">
<div className="w-12 h-12 bg-slate-50 rounded-2xl flex items-center justify-center text-black">
<FileText size={24} />
<div className="w-14 h-14 bg-slate-900 text-white rounded-2xl flex items-center justify-center shadow-lg shadow-slate-200">
<FileText size={28} />
</div>
<div>
<div className="flex items-center gap-3">
<h4 className="text-3xl font-bold text-slate-900 tracking-tight">Die Seitenstruktur</h4>
<span className="px-2 py-1 bg-slate-100 text-slate-500 text-[10px] font-bold uppercase tracking-wider rounded">Essenziell</span>
</div>
<div className="flex items-center gap-2 text-slate-400 mt-1">
<HelpCircle size={14} className="shrink-0" />
<span className="text-base">Wählen Sie die Bausteine Ihrer neuen Website.</span>
</div>
</div>
<h4 className="text-2xl font-bold text-slate-900">Die Seiten</h4>
</div>
<motion.button
<motion.button
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
type="button"
onClick={() => toggleDontKnow('pages')}
className={`px-6 py-3 rounded-full text-sm font-bold transition-all ${
className={`px-6 py-3 rounded-full text-sm font-bold transition-all whitespace-nowrap shrink-0 ${
state.dontKnows?.includes('pages') ? 'bg-slate-900 text-white' : 'bg-slate-100 text-slate-500 hover:bg-slate-200'
}`}
>
@@ -104,42 +118,46 @@ export function BaseStep({ state, updateState, toggleItem }: BaseStepProps) {
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.4 }}
className="p-10 bg-white border border-slate-100 rounded-[3rem] space-y-6 hover:shadow-xl transition-all duration-500"
className="p-10 bg-slate-900 text-white rounded-[3rem] space-y-6 shadow-2xl shadow-slate-200 relative overflow-hidden group"
>
<div className="flex flex-col md:flex-row justify-between items-center gap-8">
<div className="absolute top-0 right-0 p-8 opacity-10 group-hover:opacity-20 transition-opacity">
<ListPlus size={120} />
</div>
<div className="flex flex-col md:flex-row justify-between items-center gap-8 relative z-10">
<div>
<h4 className="text-xl font-bold text-slate-900">Anzahl weiterer Seiten</h4>
<p className="text-base text-slate-500 mt-1">Falls Sie die Namen noch nicht wissen, aber die Menge schätzen können.</p>
<h4 className="text-2xl font-bold text-white">Noch mehr Seiten?</h4>
<p className="text-lg text-slate-400 mt-1">Falls Sie die Namen noch nicht wissen, aber die Menge schätzen können.</p>
</div>
<div className="flex items-center gap-8">
<motion.button
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.9 }}
type="button"
onClick={() => updateState({ otherPagesCount: Math.max(0, state.otherPagesCount - 1) })}
className="w-14 h-14 rounded-full bg-slate-50 border border-slate-100 flex items-center justify-center hover:border-slate-900 transition-colors focus:outline-none"
onClick={() => updateState({ otherPagesCount: Math.max(0, state.otherPagesCount - 1) })}
className="w-16 h-16 rounded-full bg-white/10 border border-white/10 flex items-center justify-center hover:bg-white/20 transition-colors focus:outline-none"
>
<Minus size={24} />
<Minus size={28} />
</motion.button>
<AnimatePresence mode="wait">
<motion.span
<motion.span
key={state.otherPagesCount}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
className="text-5xl font-bold w-12 text-center"
initial={{ opacity: 0, scale: 0.5 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 1.5 }}
className="text-6xl font-bold w-16 text-center"
>
{state.otherPagesCount}
</motion.span>
</AnimatePresence>
<motion.button
<motion.button
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.9 }}
type="button"
onClick={() => updateState({ otherPagesCount: state.otherPagesCount + 1 })}
className="w-14 h-14 rounded-full bg-slate-50 border border-slate-100 flex items-center justify-center hover:border-slate-900 transition-colors focus:outline-none"
type="button"
onClick={() => updateState({ otherPagesCount: state.otherPagesCount + 1 })}
className="w-16 h-16 rounded-full bg-white/10 border border-white/10 flex items-center justify-center hover:bg-white/20 transition-colors focus:outline-none"
>
<Plus size={24} />
<Plus size={28} />
</motion.button>
</div>
</div>

View File

@@ -23,6 +23,7 @@ export function CompanyStep({ state, updateState }: CompanyStepProps) {
<Building2 size={24} />
</div>
<h4 className="text-2xl font-bold text-slate-900">Unternehmen</h4>
<span className="px-2 py-1 bg-slate-100 text-slate-500 text-[10px] font-bold uppercase tracking-wider rounded">Erforderlich</span>
</div>
<Input
label="Name des Unternehmens"

View File

@@ -15,28 +15,48 @@ interface ContactStepProps {
export function ContactStep({ state, updateState }: ContactStepProps) {
return (
<div className="space-y-12">
<Reveal width="100%" delay={0.05}>
<div className="p-8 bg-slate-50 text-slate-900 rounded-[2.5rem] mb-8 border border-slate-100">
<h4 className="text-2xl font-bold mb-2">Fast geschafft! 🚀</h4>
<p className="text-slate-500 text-lg">
Ich habe alle Details für das Projekt von <span className="text-slate-900 font-bold">{state.companyName || 'Ihrem Unternehmen'}</span> erhalten.
Hinterlassen Sie mir noch Ihre Kontaktdaten, damit ich Ihnen ein konkretes Angebot erstellen kann.
</p>
</div>
</Reveal>
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
<Reveal width="100%" delay={0.1}>
<Input
label="Ihr Name"
icon={User}
placeholder="Max Mustermann"
required
value={state.name}
onChange={(e) => updateState({ name: e.target.value })}
/>
<div className="relative">
<Input
label="Ihr Name"
icon={User}
placeholder="Max Mustermann"
required
value={state.name}
onChange={(e) => updateState({ name: e.target.value })}
/>
<div className="absolute top-0 right-4 px-2 py-1 bg-slate-100 text-slate-500 text-[10px] font-bold uppercase tracking-wider rounded">
Erforderlich
</div>
</div>
</Reveal>
<Reveal width="100%" delay={0.1}>
<Input
label="Ihre Email"
icon={Mail}
type="email"
placeholder="max@beispiel.de"
required
value={state.email}
onChange={(e) => updateState({ email: e.target.value })}
/>
<div className="relative">
<Input
label="Ihre Email"
icon={Mail}
type="email"
placeholder="max@beispiel.de"
required
value={state.email}
onChange={(e) => updateState({ email: e.target.value })}
/>
<div className="absolute top-0 right-4 px-2 py-1 bg-slate-100 text-slate-500 text-[10px] font-bold uppercase tracking-wider rounded">
Erforderlich
</div>
</div>
</Reveal>
</div>

View File

@@ -32,7 +32,10 @@ export function ContentStep({ state, updateState }: ContentStepProps) {
</div>
<h4 className="text-2xl font-bold text-slate-900">Inhalte selbst verwalten (CMS)</h4>
</div>
<p className="text-lg text-slate-500 leading-relaxed">Möchten Sie Datensätze (z.B. Blogartikel, Produkte) selbst über eine einfache Oberfläche pflegen?</p>
<p className="text-lg text-slate-500 leading-relaxed">
Ein CMS (Content Management System) ermöglicht es Ihnen, Texte, Bilder und Blogartikel selbst zu ändern, ohne programmieren zu müssen.
Ideal, wenn Sie Ihre Website aktuell halten möchten.
</p>
</div>
<div className="flex flex-col items-center md:items-end gap-6">
<motion.button
@@ -138,7 +141,10 @@ export function ContentStep({ state, updateState }: ContentStepProps) {
<div className="flex flex-col gap-8 p-10 bg-white border border-slate-100 rounded-[3rem]">
<div className="space-y-2">
<h4 className="text-2xl font-bold text-slate-900">Inhalte einpflegen</h4>
<p className="text-lg text-slate-500 leading-relaxed">Für wie viele Datensätze soll ich die initiale Befüllung übernehmen?</p>
<p className="text-lg text-slate-500 leading-relaxed">
Wer kümmert sich um die erste Befüllung? Wenn ich das übernehmen soll, geben Sie hier die Anzahl der Datensätze (z.B. fertige Blogartikel oder Produkte) an.
Ansonsten übergeben wir Ihnen eine leere, aber einsatzbereite Struktur.
</p>
</div>
<div className="flex items-center gap-12 py-2">
<motion.button

View File

@@ -7,6 +7,7 @@ import { motion, AnimatePresence } from 'framer-motion';
import { Plus, X, Palette, Pipette, RefreshCw } from 'lucide-react';
import { Reveal } from '../../Reveal';
import { Input } from '../components/Input';
import { RepeatableList } from '../components/RepeatableList';
interface DesignStepProps {
state: FormState;
@@ -59,12 +60,13 @@ export function DesignStep({ state, updateState }: DesignStepProps) {
return `#${f(0)}${f(8)}${f(4)}`;
};
const palette = [
hslToHex(hue, saturation, 95), // Light
hslToHex(hue, saturation, lightness), // Main
hslToHex((hue + 30) % 360, saturation, lightness - 10), // Analogous
hslToHex((hue + 180) % 360, saturation - 10, 20), // Complementary Dark
];
const count = state.colorScheme.length;
const palette = [];
for (let i = 0; i < count; i++) {
const h = (hue + (i * (360 / count))) % 360;
const l = i === 0 ? 95 : i === count - 1 ? 20 : lightness;
palette.push(hslToHex(h, saturation, l));
}
updateState({ colorScheme: palette });
};
@@ -83,7 +85,7 @@ export function DesignStep({ state, updateState }: DesignStepProps) {
whileTap={{ scale: 0.95 }}
type="button"
onClick={() => toggleDontKnow('design_vibe')}
className={`px-6 py-3 rounded-full text-sm font-bold transition-all ${
className={`px-6 py-3 rounded-full text-sm font-bold transition-all whitespace-nowrap shrink-0 ${
state.dontKnows?.includes('design_vibe') ? 'bg-slate-900 text-white' : 'bg-slate-100 text-slate-500 hover:bg-slate-200'
}`}
>
@@ -137,7 +139,7 @@ export function DesignStep({ state, updateState }: DesignStepProps) {
whileTap={{ scale: 0.95 }}
type="button"
onClick={() => toggleDontKnow('color_scheme')}
className={`px-6 py-3 rounded-full text-sm font-bold transition-all ${
className={`px-6 py-3 rounded-full text-sm font-bold transition-all whitespace-nowrap shrink-0 ${
state.dontKnows?.includes('color_scheme') ? 'bg-slate-900 text-white' : 'bg-slate-100 text-slate-500 hover:bg-slate-200'
}`}
>
@@ -206,15 +208,33 @@ export function DesignStep({ state, updateState }: DesignStepProps) {
</div>
</Reveal>
{/* Wishes */}
{/* References */}
<Reveal width="100%" delay={0.3}>
<div className="space-y-8">
<div className="space-y-1">
<h4 className="text-2xl font-bold text-slate-900">Referenz-Websites</h4>
<p className="text-slate-500">Gibt es Websites, die Ihnen besonders gut gefallen?</p>
</div>
<div className="p-10 bg-white border border-slate-100 rounded-[3rem]">
<RepeatableList
items={state.references || []}
onAdd={(v) => updateState({ references: [...(state.references || []), v] })}
onRemove={(i) => updateState({ references: (state.references || []).filter((_, idx) => idx !== i) })}
placeholder="https://beispiel.de"
/>
</div>
</div>
</Reveal>
{/* Wishes */}
<Reveal width="100%" delay={0.4}>
<Input
label="Individuelle Wünsche"
isTextArea
rows={4}
placeholder="Haben Sie bereits konkrete Vorstellungen oder Referenzen?"
value={state.designWishes}
onChange={(e) => updateState({ designWishes: e.target.value })}
placeholder="Haben Sie weitere konkrete Vorstellungen?"
value={state.designWishes}
onChange={(e) => updateState({ designWishes: e.target.value })}
/>
</Reveal>
</div>

View File

@@ -6,7 +6,7 @@ import { FEATURE_OPTIONS } from '../constants';
import { Checkbox } from '../components/Checkbox';
import { RepeatableList } from '../components/RepeatableList';
import { motion, AnimatePresence } from 'framer-motion';
import { Minus, Plus, LayoutGrid, ListPlus } from 'lucide-react';
import { Minus, Plus, LayoutGrid, ListPlus, HelpCircle } from 'lucide-react';
interface FeaturesStepProps {
state: FormState;
@@ -32,14 +32,23 @@ export function FeaturesStep({ state, updateState, toggleItem }: FeaturesStepPro
<div className="w-12 h-12 bg-slate-50 rounded-2xl flex items-center justify-center text-black">
<LayoutGrid size={24} />
</div>
<h4 className="text-2xl font-bold text-slate-900">System-Module</h4>
<div>
<div className="flex items-center gap-3">
<h4 className="text-2xl font-bold text-slate-900">System-Module</h4>
<span className="px-2 py-1 bg-slate-50 text-slate-400 text-[10px] font-bold uppercase tracking-wider rounded">Optional</span>
</div>
<div className="flex items-center gap-2 text-slate-400 mt-1">
<HelpCircle size={14} />
<span className="text-sm">Module sind funktionale Einheiten, die über einfache Textseiten hinausgehen.</span>
</div>
</div>
</div>
<motion.button
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
type="button"
onClick={() => toggleDontKnow('features')}
className={`px-6 py-3 rounded-full text-sm font-bold transition-all ${
className={`px-6 py-3 rounded-full text-sm font-bold transition-all whitespace-nowrap shrink-0 ${
state.dontKnows?.includes('features') ? 'bg-slate-900 text-white' : 'bg-slate-100 text-slate-500 hover:bg-slate-200'
}`}
>
@@ -80,50 +89,6 @@ export function FeaturesStep({ state, updateState, toggleItem }: FeaturesStepPro
/>
</div>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.4 }}
className="p-10 bg-white border border-slate-100 rounded-[3rem] space-y-6 hover:shadow-xl transition-all duration-500"
>
<div className="flex flex-col md:flex-row justify-between items-center gap-8">
<div>
<h4 className="text-xl font-bold text-slate-900">Anzahl weiterer Module</h4>
<p className="text-base text-slate-500 mt-1">Falls Sie weitere Systeme planen, diese aber noch nicht benennen können.</p>
</div>
<div className="flex items-center gap-8">
<motion.button
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.9 }}
type="button"
onClick={() => updateState({ otherFeaturesCount: Math.max(0, state.otherFeaturesCount - 1) })}
className="w-14 h-14 rounded-full bg-slate-50 border border-slate-100 flex items-center justify-center hover:border-slate-900 transition-colors focus:outline-none"
>
<Minus size={24} />
</motion.button>
<AnimatePresence mode="wait">
<motion.span
key={state.otherFeaturesCount}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
className="text-5xl font-bold w-12 text-center"
>
{state.otherFeaturesCount}
</motion.span>
</AnimatePresence>
<motion.button
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.9 }}
type="button"
onClick={() => updateState({ otherFeaturesCount: state.otherFeaturesCount + 1 })}
className="w-14 h-14 rounded-full bg-slate-50 border border-slate-100 flex items-center justify-center hover:border-slate-900 transition-colors focus:outline-none"
>
<Plus size={24} />
</motion.button>
</div>
</div>
</motion.div>
</div>
</div>
);

View File

@@ -39,12 +39,12 @@ export function FunctionsStep({ state, updateState, toggleItem }: FunctionsStepP
{isWebApp ? 'Funktionale Anforderungen' : 'Erweiterte Funktionen'}
</h4>
</div>
<motion.button
<motion.button
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
type="button"
onClick={() => toggleDontKnow('functions')}
className={`px-6 py-3 rounded-full text-sm font-bold transition-all ${
className={`px-6 py-3 rounded-full text-sm font-bold transition-all whitespace-nowrap shrink-0 ${
state.dontKnows?.includes('functions') ? 'bg-slate-900 text-white' : 'bg-slate-100 text-slate-500 hover:bg-slate-200'
}`}
>
@@ -77,25 +77,40 @@ export function FunctionsStep({ state, updateState, toggleItem }: FunctionsStepP
</>
) : (
<>
<Checkbox
label="Suche" desc="Volltextsuche über alle Inhalte."
checked={state.functions.includes('search')}
onChange={() => updateState({ functions: toggleItem(state.functions, 'search') })}
<Checkbox
label="Suche" desc="Volltextsuche über alle Inhalte."
checked={state.functions.includes('search')}
onChange={() => updateState({ functions: toggleItem(state.functions, 'search') })}
/>
<Checkbox
label="Filter-Systeme" desc="Kategorisierung und Sortierung."
checked={state.functions.includes('filter')}
onChange={() => updateState({ functions: toggleItem(state.functions, 'filter') })}
<Checkbox
label="Filter-Systeme" desc="Kategorisierung und Sortierung."
checked={state.functions.includes('filter')}
onChange={() => updateState({ functions: toggleItem(state.functions, 'filter') })}
/>
<Checkbox
label="PDF-Export" desc="Automatisierte PDF-Erstellung."
checked={state.functions.includes('pdf')}
onChange={() => updateState({ functions: toggleItem(state.functions, 'pdf') })}
<Checkbox
label="PDF-Export" desc="Automatisierte PDF-Erstellung."
checked={state.functions.includes('pdf')}
onChange={() => updateState({ functions: toggleItem(state.functions, 'pdf') })}
/>
<Checkbox
label="Erweiterte Formulare" desc="Komplexe Abfragen & Logik."
checked={state.functions.includes('forms')}
onChange={() => updateState({ functions: toggleItem(state.functions, 'forms') })}
<Checkbox
label="Erweiterte Formulare" desc="Komplexe Abfragen & Logik."
checked={state.functions.includes('forms')}
onChange={() => updateState({ functions: toggleItem(state.functions, 'forms') })}
/>
<Checkbox
label="Mitgliederbereich" desc="Login-Bereich für exklusive Inhalte."
checked={state.functions.includes('members')}
onChange={() => updateState({ functions: toggleItem(state.functions, 'members') })}
/>
<Checkbox
label="Event-Kalender" desc="Verwaltung und Anzeige von Terminen."
checked={state.functions.includes('calendar')}
onChange={() => updateState({ functions: toggleItem(state.functions, 'calendar') })}
/>
<Checkbox
label="Echtzeit-Chat" desc="Direkte Kommunikation mit Besuchern."
checked={state.functions.includes('chat')}
onChange={() => updateState({ functions: toggleItem(state.functions, 'chat') })}
/>
</>
)}
@@ -120,50 +135,6 @@ export function FunctionsStep({ state, updateState, toggleItem }: FunctionsStepP
/>
</div>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.4 }}
className="p-10 bg-white border border-slate-100 rounded-[3rem] space-y-6 hover:shadow-xl transition-all duration-500"
>
<div className="flex flex-col md:flex-row justify-between items-center gap-8">
<div>
<h4 className="text-xl font-bold text-slate-900">Anzahl weiterer Funktionen</h4>
<p className="text-base text-slate-500 mt-1">Falls Sie weitere Logik-Bausteine planen, diese aber noch nicht benennen können.</p>
</div>
<div className="flex items-center gap-8">
<motion.button
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.9 }}
type="button"
onClick={() => updateState({ otherFunctionsCount: Math.max(0, state.otherFunctionsCount - 1) })}
className="w-14 h-14 rounded-full bg-slate-50 border border-slate-100 flex items-center justify-center hover:border-slate-900 transition-colors focus:outline-none"
>
<Minus size={24} />
</motion.button>
<AnimatePresence mode="wait">
<motion.span
key={state.otherFunctionsCount}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
className="text-5xl font-bold w-12 text-center"
>
{state.otherFunctionsCount}
</motion.span>
</AnimatePresence>
<motion.button
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.9 }}
type="button"
onClick={() => updateState({ otherFunctionsCount: state.otherFunctionsCount + 1 })}
className="w-14 h-14 rounded-full bg-slate-50 border border-slate-100 flex items-center justify-center hover:border-slate-900 transition-colors focus:outline-none"
>
<Plus size={24} />
</motion.button>
</div>
</div>
</motion.div>
</div>
</Reveal>
</div>

View File

@@ -52,13 +52,14 @@ export function LanguageStep({ state, updateState }: LanguageStepProps) {
<Globe size={24} />
</div>
<h4 className="text-2xl font-bold text-slate-900">Sprachen</h4>
<span className="px-2 py-1 bg-slate-50 text-slate-400 text-[10px] font-bold uppercase tracking-wider rounded">Optional</span>
</div>
<motion.button
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
type="button"
onClick={() => toggleDontKnow('languages')}
className={`px-6 py-3 rounded-full text-sm font-bold transition-all ${
className={`px-6 py-3 rounded-full text-sm font-bold transition-all whitespace-nowrap shrink-0 ${
state.dontKnows?.includes('languages') ? 'bg-slate-900 text-white' : 'bg-slate-100 text-slate-500 hover:bg-slate-200'
}`}
>

View File

@@ -3,7 +3,7 @@
import * as React from 'react';
import { FormState } from '../types';
import { Checkbox } from '../components/Checkbox';
import { Link2, Globe, Share2 } from 'lucide-react';
import { Link2, Globe, Share2, Instagram, Linkedin, Facebook, Twitter, Youtube } from 'lucide-react';
import { motion, AnimatePresence } from 'framer-motion';
import { Reveal } from '../../Reveal';
import { Input } from '../components/Input';
@@ -25,12 +25,11 @@ export function PresenceStep({ state, updateState, toggleItem }: PresenceStepPro
};
const SOCIAL_PLATFORMS = [
{ id: 'instagram', label: 'Instagram' },
{ id: 'linkedin', label: 'LinkedIn' },
{ id: 'facebook', label: 'Facebook' },
{ id: 'twitter', label: 'Twitter / X' },
{ id: 'tiktok', label: 'TikTok' },
{ id: 'youtube', label: 'YouTube' },
{ id: 'instagram', label: 'Instagram', icon: Instagram },
{ id: 'linkedin', label: 'LinkedIn', icon: Linkedin },
{ id: 'facebook', label: 'Facebook', icon: Facebook },
{ id: 'twitter', label: 'Twitter / X', icon: Twitter },
{ id: 'youtube', label: 'YouTube', icon: Youtube },
];
return (
@@ -42,6 +41,7 @@ export function PresenceStep({ state, updateState, toggleItem }: PresenceStepPro
<Globe size={24} />
</div>
<h4 className="text-2xl font-bold text-slate-900">Bestehende Website</h4>
<span className="px-2 py-1 bg-slate-50 text-slate-400 text-[10px] font-bold uppercase tracking-wider rounded">Optional</span>
</div>
<Input
label="URL (falls vorhanden)"
@@ -74,61 +74,75 @@ export function PresenceStep({ state, updateState, toggleItem }: PresenceStepPro
</div>
<Reveal width="100%" delay={0.3}>
<div className="space-y-8">
<div className="space-y-10">
<div className="flex items-center gap-4">
<div className="w-12 h-12 bg-slate-50 rounded-2xl flex items-center justify-center text-black">
<Share2 size={24} />
</div>
<h4 className="text-2xl font-bold text-slate-900">Social Media Accounts</h4>
</div>
<p className="text-lg text-slate-500 ml-2">Welche Kanäle nutzen Sie bereits? Bitte geben Sie die URLs an.</p>
<div className="grid grid-cols-1 gap-4">
{SOCIAL_PLATFORMS.map((option, index) => {
const isSelected = state.socialMedia.includes(option.id);
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-6">
{SOCIAL_PLATFORMS.map((platform) => {
const isSelected = state.socialMedia.includes(platform.id);
const Icon = platform.icon;
return (
<motion.div
key={option.id}
className={`p-6 rounded-[2.5rem] border-2 transition-all duration-500 ${
isSelected ? 'border-slate-900 bg-slate-50' : 'border-slate-100 bg-white hover:border-slate-300'
<motion.button
key={platform.id}
whileHover={{ y: -8, scale: 1.02 }}
whileTap={{ scale: 0.95 }}
type="button"
onClick={() => updateState({ socialMedia: toggleItem(state.socialMedia, platform.id) })}
className={`flex flex-col items-center gap-4 p-8 rounded-[2.5rem] border-2 transition-all duration-500 ${
isSelected ? 'border-slate-900 bg-slate-900 text-white shadow-2xl shadow-slate-400' : 'border-slate-100 bg-white text-slate-400 hover:border-slate-300 hover:shadow-xl'
}`}
>
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between">
<Checkbox
label={option.label}
desc=""
checked={isSelected}
onChange={() => updateState({ socialMedia: toggleItem(state.socialMedia, option.id) })}
/>
</div>
<AnimatePresence>
{isSelected && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
exit={{ opacity: 0, height: 0 }}
className="overflow-hidden"
>
<div className="relative group/input pt-2">
<div className="absolute left-5 top-[calc(50%+4px)] -translate-y-1/2 text-black transition-colors">
<Link2 size={18} />
</div>
<input
type="url"
placeholder={`URL zu Ihrem ${option.label} Profil`}
value={state.socialMediaUrls[option.id] || ''}
onChange={(e) => updateUrl(option.id, e.target.value)}
className="w-full p-4 pl-14 bg-white border border-slate-200 rounded-2xl focus:outline-none focus:border-slate-900 transition-all duration-300 text-base"
/>
</div>
</motion.div>
)}
</AnimatePresence>
<div className={`p-4 rounded-2xl transition-colors duration-500 ${isSelected ? 'bg-white/10 text-white' : 'bg-slate-50 text-slate-400'}`}>
<Icon size={32} />
</div>
</motion.div>
<span className="font-bold text-base tracking-tight">{platform.label}</span>
</motion.button>
);
})}
</div>
<div className="space-y-4">
<AnimatePresence mode="popLayout">
{state.socialMedia.map((id) => {
const platform = SOCIAL_PLATFORMS.find(p => p.id === id);
if (!platform) return null;
return (
<motion.div
key={id}
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: 20 }}
layout
className="relative group"
>
<div className="absolute left-6 top-1/2 -translate-y-1/2 flex items-center gap-3 text-slate-400 group-focus-within:text-slate-900 transition-colors">
<span className="font-bold text-xs uppercase tracking-widest w-20">{platform.label}</span>
<div className="w-[1px] h-4 bg-slate-200" />
<Link2 size={18} />
</div>
<input
type="url"
placeholder={`https://${platform.id}.com/ihr-profil`}
value={state.socialMediaUrls[id] || ''}
onChange={(e) => updateUrl(id, e.target.value)}
className="w-full p-6 pl-40 bg-white border border-slate-100 rounded-[2rem] focus:outline-none focus:border-slate-900 transition-all duration-500 text-lg focus:shadow-xl"
/>
</motion.div>
);
})}
</AnimatePresence>
{state.socialMedia.length === 0 && (
<div className="p-12 border-2 border-dashed border-slate-100 rounded-[3rem] text-center">
<p className="text-slate-400 font-medium">Wählen Sie oben Ihre Kanäle aus, um die Links hinzuzufügen.</p>
</div>
)}
</div>
</div>
</Reveal>
</div>

View File

@@ -10,6 +10,9 @@ interface TimelineStepProps {
}
export function TimelineStep({ state, updateState }: TimelineStepProps) {
const isMissingAssets = !state.assets.includes('logo') || !state.assets.includes('content_concept');
const isMissingPages = state.selectedPages.length === 0 && state.otherPages.length === 0 && state.otherPagesCount === 0;
const toggleDontKnow = (id: string) => {
const current = state.dontKnows || [];
if (current.includes(id)) {
@@ -24,10 +27,10 @@ export function TimelineStep({ state, updateState }: TimelineStepProps) {
<div className="space-y-6">
<div className="flex justify-between items-center">
<h4 className="text-2xl font-bold text-slate-900">Zeitplan</h4>
<button
<button
type="button"
onClick={() => toggleDontKnow('timeline')}
className={`px-4 py-2 rounded-full text-sm font-bold transition-all ${
className={`px-4 py-2 rounded-full text-sm font-bold transition-all whitespace-nowrap shrink-0 ${
state.dontKnows?.includes('timeline') ? 'bg-slate-900 text-white' : 'bg-slate-100 text-slate-500 hover:bg-slate-200'
}`}
>
@@ -63,6 +66,20 @@ export function TimelineStep({ state, updateState }: TimelineStepProps) {
</p>
</div>
)}
{(isMissingAssets || isMissingPages) && (
<div className="p-8 bg-amber-50 rounded-[2rem] border border-amber-100 flex gap-6 items-start">
<div className="w-12 h-12 bg-white rounded-xl flex items-center justify-center text-amber-600 shrink-0">
<AlertCircle size={24} />
</div>
<div className="space-y-2">
<p className="text-amber-900 text-xl font-bold">Mögliche Verzögerungen</p>
<p className="text-amber-800 text-base leading-relaxed">
Für einen reibungslosen Projektstart benötigen wir noch einige Details (z.B. {isMissingAssets ? 'Logo/Inhaltskonzept' : ''} {isMissingAssets && isMissingPages ? 'und' : ''} {isMissingPages ? 'Seitenstruktur' : ''}). Ohne diese kann sich der Beginn verzögern.
</p>
</div>
</div>
)}
</div>
);
}

View File

@@ -24,19 +24,24 @@ export function TypeStep({ state, updateState }: TypeStepProps) {
whileTap={{ scale: 0.98 }}
type="button"
onClick={() => updateState({ projectType: type.id as ProjectType })}
className={`w-full p-10 rounded-[3rem] border-2 text-left transition-all duration-500 focus:outline-none overflow-hidden relative group ${
state.projectType === type.id ? 'border-slate-900 bg-slate-900 text-white' : 'border-slate-100 bg-white hover:border-slate-300'
className={`w-full p-12 rounded-[4rem] border-2 text-left transition-all duration-700 focus:outline-none overflow-hidden relative group ${
state.projectType === type.id ? 'border-slate-900 bg-slate-900 text-white shadow-2xl shadow-slate-400' : 'border-slate-100 bg-white hover:border-slate-300 hover:shadow-xl'
}`}
>
<div className={`transition-transform duration-500 group-hover:scale-110 ${state.projectType === type.id ? 'text-white' : 'text-slate-900'}`}>{type.illustration}</div>
<h4 className={`text-4xl font-bold mb-4 ${state.projectType === type.id ? 'text-white' : 'text-slate-900'}`}>{type.label}</h4>
<p className={`text-xl leading-relaxed ${state.projectType === type.id ? 'text-slate-200' : 'text-slate-500'}`}>{type.desc}</p>
<div className={`transition-all duration-700 group-hover:scale-110 group-hover:rotate-3 ${state.projectType === type.id ? 'text-white' : 'text-slate-900'}`}>{type.illustration}</div>
<div className="flex items-center gap-4 mb-6">
<h4 className={`text-5xl font-bold tracking-tight ${state.projectType === type.id ? 'text-white' : 'text-slate-900'}`}>{type.label}</h4>
<span className={`px-3 py-1 rounded-full text-[10px] font-bold uppercase tracking-widest ${state.projectType === type.id ? 'bg-white/20 text-white' : 'bg-slate-100 text-slate-500'}`}>Grundlage</span>
</div>
<p className={`text-2xl leading-relaxed ${state.projectType === type.id ? 'text-slate-200' : 'text-slate-500'}`}>{type.desc}</p>
{state.projectType === type.id && (
<motion.div
<motion.div
layoutId="activeType"
className="absolute top-6 right-6 w-4 h-4 bg-white rounded-full"
/>
className="absolute top-8 right-8 w-6 h-6 bg-white rounded-full shadow-lg flex items-center justify-center"
>
<div className="w-2 h-2 bg-slate-900 rounded-full" />
</motion.div>
)}
</motion.button>
</Reveal>

View File

@@ -22,9 +22,12 @@ export function WebAppStep({ state, updateState }: WebAppStepProps) {
<div className="space-y-12">
{/* Target Audience */}
<div className="space-y-6">
<h4 className="text-2xl font-bold text-slate-900 flex items-center gap-3">
<Users size={24} className="text-black" /> Zielgruppe
</h4>
<div className="flex items-center gap-4">
<h4 className="text-2xl font-bold text-slate-900 flex items-center gap-3">
<Users size={24} className="text-black" /> Zielgruppe
</h4>
<span className="px-2 py-1 bg-slate-100 text-slate-500 text-[10px] font-bold uppercase tracking-wider rounded">Fokus</span>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{[
{ id: 'internal', label: 'Internes Tool', desc: 'Für Mitarbeiter & Prozesse.' },

View File

@@ -56,6 +56,8 @@ export interface FormState {
platformType: string;
// Meta
dontKnows: string[];
visualStaging: string;
complexInteractions: string;
}
export interface Step {
@@ -63,4 +65,11 @@ export interface Step {
title: string;
description: string;
illustration: React.ReactNode;
chapter?: string;
}
export interface Chapter {
id: string;
title: string;
steps: string[];
}

View File

@@ -0,0 +1,139 @@
import { FormState } from './types';
import { FEATURE_LABELS, FUNCTION_LABELS, API_LABELS } from './constants';
export interface Position {
pos: number;
title: string;
desc: string;
qty: number;
price: number;
}
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 + (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,178 +1,280 @@
'use client';
import * as React from 'react';
import { Document, Page, Text, View, StyleSheet, Image } from '@react-pdf/renderer';
import {
Document as PDFDocument,
Page as PDFPage,
Text as PDFText,
View as PDFView,
StyleSheet as PDFStyleSheet,
Image as PDFImage
} from '@react-pdf/renderer';
import {
VIBE_LABELS,
DEADLINE_LABELS,
ASSET_LABELS,
SOCIAL_LABELS
} from './ContactForm/constants';
import { calculatePositions } from './ContactForm/utils';
const styles = StyleSheet.create({
const styles = PDFStyleSheet.create({
page: {
padding: 40,
padding: 48,
backgroundColor: '#ffffff',
fontFamily: 'Helvetica',
fontSize: 10,
color: '#1a1a1a',
color: '#000000',
},
header: {
flexDirection: 'row',
justifyContent: 'space-between',
marginBottom: 40,
borderBottom: 2,
alignItems: 'flex-start',
marginBottom: 64,
borderBottomWidth: 1,
borderBottomColor: '#000000',
paddingBottom: 20,
paddingBottom: 24,
},
brand: {
fontSize: 18,
brandIconContainer: {
width: 40,
height: 40,
backgroundColor: '#000000',
borderRadius: 8,
alignItems: 'center',
justifyContent: 'center',
},
brandIconText: {
color: '#ffffff',
fontSize: 20,
fontWeight: 'bold',
letterSpacing: -0.5,
},
quoteInfo: {
textAlign: 'right',
},
quoteTitle: {
fontSize: 14,
fontSize: 10,
fontWeight: 'bold',
marginBottom: 4,
color: '#000000',
textTransform: 'uppercase',
letterSpacing: 1,
},
quoteDate: {
fontSize: 9,
color: '#666666',
},
recipientSection: {
marginBottom: 30,
section: {
marginBottom: 32,
},
sectionTitle: {
fontSize: 8,
fontWeight: 'bold',
textTransform: 'uppercase',
letterSpacing: 1,
color: '#999999',
marginBottom: 12,
},
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: 8,
fontSize: 7,
color: '#999999',
textTransform: 'uppercase',
marginBottom: 4,
},
recipientName: {
fontSize: 12,
fontWeight: 'bold',
},
recipientRole: {
recipientValue: {
fontSize: 10,
color: '#666666',
fontWeight: 'bold',
color: '#000000',
},
table: {
display: 'flex',
width: 'auto',
marginBottom: 30,
marginTop: 16,
},
tableHeader: {
flexDirection: 'row',
backgroundColor: '#f8fafc',
borderBottom: 1,
borderBottomColor: '#e2e8f0',
paddingVertical: 8,
paddingHorizontal: 12,
paddingBottom: 8,
borderBottomWidth: 1,
borderBottomColor: '#000000',
marginBottom: 12,
},
tableRow: {
flexDirection: 'row',
borderBottom: 1,
borderBottomColor: '#f1f5f9',
paddingVertical: 10,
paddingHorizontal: 12,
paddingVertical: 12,
borderBottomWidth: 1,
borderBottomColor: '#eeeeee',
alignItems: 'flex-start',
},
colPos: { width: '8%' },
colDesc: { width: '62%' },
colPos: { width: '6%' },
colDesc: { width: '64%' },
colQty: { width: '10%', textAlign: 'center' },
colPrice: { width: '20%', textAlign: 'right' },
headerText: {
fontSize: 8,
fontSize: 7,
fontWeight: 'bold',
color: '#64748b',
color: '#000000',
textTransform: 'uppercase',
letterSpacing: 1,
},
posText: { fontSize: 9, color: '#94a3b8' },
itemTitle: { fontSize: 10, fontWeight: 'bold', marginBottom: 2 },
itemDesc: { fontSize: 8, color: '#64748b', lineHeight: 1.4 },
priceText: { fontSize: 10, fontWeight: 'bold' },
posText: { fontSize: 8, color: '#999999' },
itemTitle: { fontSize: 10, fontWeight: 'bold', marginBottom: 4, color: '#000000' },
itemDesc: { fontSize: 8, color: '#666666', lineHeight: 1.4 },
priceText: { fontSize: 10, fontWeight: 'bold', color: '#000000' },
summarySection: {
summaryContainer: {
flexDirection: 'row',
justifyContent: 'flex-end',
marginTop: 10,
marginTop: 32,
},
summaryTable: {
summaryCard: {
width: '40%',
backgroundColor: '#ffffff',
borderRadius: 12,
padding: 20,
borderWidth: 1,
borderColor: '#000000',
},
summaryRow: {
flexDirection: 'row',
justifyContent: 'space-between',
paddingVertical: 4,
},
summaryLabel: { fontSize: 8, color: '#666666' },
summaryValue: { fontSize: 9, fontWeight: 'bold', color: '#000000' },
totalRow: {
flexDirection: 'row',
justifyContent: 'space-between',
paddingVertical: 12,
borderTop: 1,
borderTopColor: '#000000',
paddingTop: 12,
marginTop: 8,
borderTopWidth: 1,
borderTopColor: '#eeeeee',
},
totalLabel: { fontSize: 10, fontWeight: 'bold', color: '#000000' },
totalValue: { fontSize: 14, fontWeight: 'bold', color: '#000000' },
hostingBox: {
marginTop: 24,
padding: 16,
borderWidth: 1,
borderColor: '#eeeeee',
borderRadius: 12,
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
},
totalLabel: { fontSize: 12, fontWeight: 'bold' },
totalValue: { fontSize: 16, fontWeight: 'bold' },
configSection: {
marginTop: 20,
padding: 20,
backgroundColor: '#f8fafc',
borderRadius: 8,
},
configTitle: {
fontSize: 10,
fontWeight: 'bold',
marginBottom: 10,
textTransform: 'uppercase',
color: '#475569',
},
configGrid: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: 20,
gap: 24,
},
configItem: {
width: '45%',
marginBottom: 10,
width: '30%',
marginBottom: 16,
},
configLabel: { fontSize: 8, color: '#94a3b8', marginBottom: 2 },
configValue: { fontSize: 9, color: '#1e293b' },
configLabel: { fontSize: 7, color: '#999999', marginBottom: 4, textTransform: 'uppercase', fontWeight: 'bold' },
configValue: { fontSize: 8, color: '#000000', fontWeight: 'bold' },
qrSection: {
marginTop: 30,
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: {
position: 'absolute',
bottom: 120,
right: 48,
alignItems: 'center',
justifyContent: 'center',
},
qrImage: {
width: 80,
height: 80,
width: 60,
height: 60,
},
qrText: {
fontSize: 7,
color: '#94a3b8',
marginTop: 5,
fontSize: 6,
color: '#999999',
marginTop: 8,
fontWeight: 'bold',
textTransform: 'uppercase',
textAlign: 'center',
},
footer: {
position: 'absolute',
bottom: 30,
left: 40,
right: 40,
borderTop: 1,
bottom: 48,
left: 48,
right: 48,
borderTopWidth: 1,
borderTopColor: '#f1f5f9',
paddingTop: 20,
paddingTop: 24,
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'flex-end',
},
footerBrand: {
fontSize: 16,
fontWeight: 'bold',
letterSpacing: -1,
color: '#000000',
textTransform: 'lowercase',
},
footerRight: {
alignItems: 'flex-end',
},
footerContact: {
fontSize: 8,
color: '#94a3b8',
fontWeight: 'bold',
textTransform: 'uppercase',
letterSpacing: 1,
marginBottom: 4,
},
pageNumber: {
position: 'absolute',
bottom: 30,
right: 40,
fontSize: 8,
color: '#94a3b8',
fontSize: 7,
color: '#cbd5e1',
fontWeight: 'bold',
}
});
@@ -192,408 +294,252 @@ export const EstimationPDF = ({ state, totalPrice, monthlyPrice, totalPagesCount
day: 'numeric',
});
const vibeLabels: Record<string, string> = {
minimal: 'Minimalistisch',
bold: 'Mutig & Laut',
nature: 'Natürlich',
tech: 'Technisch'
};
const deadlineLabels: 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'
};
const assetLabels: Record<string, string> = {
logo: 'Logo',
styleguide: 'Styleguide',
content_concept: 'Inhalts-Konzept',
media: 'Bild/Video-Material',
icons: 'Icons',
illustrations: 'Illustrationen',
fonts: 'Fonts'
};
const featureLabels: Record<string, string> = {
blog_news: 'Blog / News',
products: 'Produktbereich',
jobs: 'Karriere / Jobs',
refs: 'Referenzen / Cases',
events: 'Events / Termine'
};
const functionLabels: Record<string, string> = {
search: 'Suche',
filter: 'Filter-Systeme',
pdf: 'PDF-Export',
forms: 'Erweiterte Formulare'
};
const apiLabels: Record<string, string> = {
crm: 'CRM System',
erp: 'ERP / Warenwirtschaft',
stripe: 'Stripe / Payment',
newsletter: 'Newsletter / Marketing',
ecommerce: 'E-Commerce / Shop',
hr: 'HR / Recruiting',
realestate: 'Immobilien',
calendar: 'Termine / Booking',
social: 'Social Media Sync',
maps: 'Google Maps / Places',
auth: 'Auth-Provider'
};
const socialLabels: Record<string, string> = {
instagram: 'Instagram',
linkedin: 'LinkedIn',
facebook: 'Facebook',
twitter: 'Twitter / X',
tiktok: 'TikTok',
youtube: 'YouTube'
};
const positions = [];
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 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) => featureLabels[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) => functionLabels[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) => apiLabels[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) {
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 + (state.features.length + state.otherFeatures.length) * 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 > 0) {
positions.push({
pos: pos++,
title: 'Visuelle Inszenierung',
desc: `Umsetzung von ${state.visualStaging} Hero-Stories, Scroll-Effekten oder speziell inszenierten Sektionen.`,
qty: state.visualStaging,
price: state.visualStaging * pricing.VISUAL_STAGING
});
}
if (state.complexInteractions > 0) {
positions.push({
pos: pos++,
title: 'Komplexe Interaktion',
desc: `Umsetzung von ${state.complexInteractions} Konfiguratoren, Live-Previews oder mehrstufigen Auswahlprozessen.`,
qty: state.complexInteractions,
price: state.complexInteractions * pricing.COMPLEX_INTERACTION
});
}
if (state.languagesCount > 1) {
const factorPrice = totalPrice - (totalPrice / (1 + (state.languagesCount - 1) * 0.2));
positions.push({
pos: pos++,
title: 'Mehrsprachigkeit',
desc: `Erweiterung des Systems auf ${state.languagesCount} Sprachen (Struktur & Logik).`,
qty: state.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
});
}
const positions = calculatePositions(state, pricing);
return (
<Document>
<Page size="A4" style={styles.page}>
<View style={styles.header}>
<View>
<Text style={styles.brand}>marc mintel</Text>
<Text style={{ fontSize: 8, color: '#64748b', marginTop: 4 }}>Digital Systems & Design</Text>
</View>
<View style={styles.quoteInfo}>
<Text style={styles.quoteTitle}>Kostenschätzung</Text>
<Text style={styles.quoteDate}>{date}</Text>
<Text style={[styles.quoteDate, { marginTop: 2 }]}>Projekt: {state.projectType === 'website' ? 'Website' : 'Web App'}</Text>
</View>
</View>
<PDFDocument>
<PDFPage size="A4" style={styles.page}>
<PDFView style={styles.header}>
<PDFView style={styles.brandIconContainer}>
<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>
<View style={styles.recipientSection}>
<Text style={styles.recipientLabel}>Ansprechpartner</Text>
<Text style={styles.recipientName}>{state.name || 'Interessent'}</Text>
{state.companyName && <Text style={styles.recipientRole}>{state.companyName}</Text>}
{state.role && <Text style={styles.recipientRole}>{state.role}</Text>}
<Text style={styles.recipientRole}>{state.email}</Text>
</View>
<PDFView style={styles.section}>
<PDFText style={styles.sectionTitle}>Ansprechpartner</PDFText>
<PDFView style={styles.recipientGrid}>
<PDFView style={styles.recipientItem}>
<PDFText style={styles.recipientLabel}>Name</PDFText>
<PDFText style={styles.recipientValue}>{state.name || 'Interessent'}</PDFText>
</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>
<View style={styles.table}>
<View style={styles.tableHeader}>
<Text style={[styles.headerText, styles.colPos]}>Pos</Text>
<Text style={[styles.headerText, styles.colDesc]}>Beschreibung</Text>
<Text style={[styles.headerText, styles.colQty]}>Menge</Text>
<Text style={[styles.headerText, styles.colPrice]}>Betrag</Text>
</View>
<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, i) => (
<View key={i} style={styles.tableRow}>
<Text style={[styles.posText, styles.colPos]}>{item.pos}</Text>
<View style={styles.colDesc}>
<Text style={styles.itemTitle}>{item.title}</Text>
<Text style={styles.itemDesc}>{item.desc}</Text>
</View>
<Text style={[styles.posText, styles.colQty]}>{item.qty}</Text>
<Text style={[styles.priceText, styles.colPrice]}>
<PDFView key={i} style={styles.tableRow}>
<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}>{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()}` : 'n. A.'}
</Text>
</View>
</PDFText>
</PDFView>
))}
</View>
</PDFView>
<View style={styles.summarySection}>
<View style={styles.summaryTable}>
<View style={styles.summaryRow}>
<Text style={{ color: '#64748b' }}>Zwischensumme (Netto)</Text>
<Text>{totalPrice.toLocaleString()} </Text>
</View>
<View style={styles.summaryRow}>
<Text style={{ color: '#64748b' }}>Umsatzsteuer (0%)*</Text>
<Text>0,00 </Text>
</View>
<View style={styles.totalRow}>
<Text style={styles.totalLabel}>Gesamtsumme</Text>
<Text style={styles.totalValue}>{totalPrice.toLocaleString()} </Text>
</View>
<Text style={{ fontSize: 7, color: '#94a3b8', textAlign: 'right', marginTop: -4 }}>
*Gemäß § 19 UStG wird keine Umsatzsteuer berechnet.
</Text>
{state.projectType === 'website' && (
<View style={[styles.summaryRow, { marginTop: 15, borderTop: 1, borderTopColor: '#f1f5f9', paddingTop: 10 }]}>
<Text style={{ color: '#64748b', fontSize: 9 }}>Betrieb & Hosting</Text>
<Text style={{ fontSize: 9, fontWeight: 'bold' }}>{monthlyPrice.toLocaleString()} / Monat</Text>
</View>
)}
</View>
</View>
<PDFView style={styles.summaryContainer}>
<PDFView style={styles.summaryCard}>
<PDFView style={styles.summaryRow}>
<PDFText style={styles.summaryLabel}>Zwischensumme (Netto)</PDFText>
<PDFText style={styles.summaryValue}>{totalPrice.toLocaleString()} </PDFText>
</PDFView>
<PDFView style={styles.totalRow}>
<PDFText style={styles.totalLabel}>Gesamtsumme</PDFText>
<PDFText style={styles.totalValue}>{totalPrice.toLocaleString()} </PDFText>
</PDFView>
</PDFView>
</PDFView>
<View style={styles.footer}>
<Text>marc@mintel.me</Text>
<Text>mintel.me</Text>
<Text>Digital Systems & Design</Text>
</View>
<Text style={styles.pageNumber} render={({ pageNumber, totalPages }) => `Seite ${pageNumber} von ${totalPages}`} fixed />
</Page>
{state.projectType === 'website' && (
<PDFView style={styles.hostingBox}>
<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>
</PDFView>
)}
<Page size="A4" style={styles.page}>
<View style={styles.header}>
<View>
<Text style={styles.brand}>marc mintel</Text>
</View>
<View style={styles.quoteInfo}>
<Text style={styles.quoteTitle}>Projektdetails</Text>
</View>
</View>
<PDFView style={styles.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>
<View style={styles.configSection}>
<Text style={styles.configTitle}>Konfiguration & Wünsche</Text>
<View style={styles.configGrid}>
<PDFPage size="A4" style={styles.page}>
<PDFView style={styles.header}>
<PDFView style={styles.brandIconContainer}>
<PDFText style={styles.brandIconText}>M</PDFText>
</PDFView>
<PDFView style={styles.quoteInfo}>
<PDFText style={styles.quoteTitle}>Projektdetails</PDFText>
</PDFView>
</PDFView>
<PDFView style={styles.section}>
<PDFText style={styles.sectionTitle}>Konfiguration & Wünsche</PDFText>
<PDFView style={styles.configGrid}>
{state.projectType === 'website' ? (
<>
<View style={styles.configItem}>
<Text style={styles.configLabel}>Thema</Text>
<Text style={styles.configValue}>{state.websiteTopic || 'Nicht angegeben'}</Text>
</View>
<View style={styles.configItem}>
<Text style={styles.configLabel}>Design-Vibe</Text>
<Text style={styles.configValue}>{vibeLabels[state.designVibe] || state.designVibe}</Text>
</View>
<View style={styles.configItem}>
<Text style={styles.configLabel}>Farbschema</Text>
<Text style={styles.configValue}>{state.colorScheme.join(', ')}</Text>
</View>
<PDFView style={styles.configItem}>
<PDFText style={styles.configLabel}>Thema</PDFText>
<PDFText style={styles.configValue}>{state.websiteTopic || 'Nicht angegeben'}</PDFText>
</PDFView>
<PDFView style={styles.configItem}>
<PDFText style={styles.configLabel}>Design-Vibe</PDFText>
<PDFText style={styles.configValue}>{VIBE_LABELS[state.designVibe] || state.designVibe}</PDFText>
</PDFView>
<PDFView style={[styles.configItem, { width: '100%' }]}>
<PDFText style={styles.configLabel}>Farbschema</PDFText>
<PDFView style={styles.colorGrid}>
{state.colorScheme.map((color: string, i: number) => (
<PDFView key={i} style={{ alignItems: 'center' }}>
<PDFView style={[styles.colorSwatch, { backgroundColor: color }]} />
<PDFText style={styles.colorHex}>{color.toUpperCase()}</PDFText>
</PDFView>
))}
</PDFView>
</PDFView>
</>
) : (
<>
<View style={styles.configItem}>
<Text style={styles.configLabel}>Zielgruppe</Text>
<Text style={styles.configValue}>{state.targetAudience === 'internal' ? 'Internes Tool' : 'Kunden-Portal'}</Text>
</View>
<View style={styles.configItem}>
<Text style={styles.configLabel}>Plattform</Text>
<Text style={styles.configValue}>{state.platformType.toUpperCase()}</Text>
</View>
<View style={styles.configItem}>
<Text style={styles.configLabel}>Sicherheit</Text>
<Text style={styles.configValue}>{state.dataSensitivity === 'high' ? 'Sensibel' : 'Standard'}</Text>
</View>
<View style={styles.configItem}>
<Text style={styles.configLabel}>Rollen</Text>
<Text style={styles.configValue}>{state.userRoles.join(', ') || 'Keine'}</Text>
</View>
<PDFView style={styles.configItem}>
<PDFText style={styles.configLabel}>Zielgruppe</PDFText>
<PDFText style={styles.configValue}>{state.targetAudience === 'internal' ? 'Internes Tool' : 'Kunden-Portal'}</PDFText>
</PDFView>
<PDFView style={styles.configItem}>
<PDFText style={styles.configLabel}>Plattform</PDFText>
<PDFText style={styles.configValue}>{state.platformType.toUpperCase()}</PDFText>
</PDFView>
<PDFView style={styles.configItem}>
<PDFText style={styles.configLabel}>Sicherheit</PDFText>
<PDFText style={styles.configValue}>{state.dataSensitivity === 'high' ? 'Sensibel' : 'Standard'}</PDFText>
</PDFView>
<PDFView style={styles.configItem}>
<PDFText style={styles.configLabel}>Rollen</PDFText>
<PDFText style={styles.configValue}>{state.userRoles.join(', ') || 'Keine'}</PDFText>
</PDFView>
</>
)}
<View style={styles.configItem}>
<Text style={styles.configLabel}>Mitarbeiter</Text>
<Text style={styles.configValue}>{state.employeeCount || 'Nicht angegeben'}</Text>
</View>
<View style={styles.configItem}>
<Text style={styles.configLabel}>Bestehende Website</Text>
<Text style={styles.configValue}>{state.existingWebsite || 'Keine'}</Text>
</View>
<View style={styles.configItem}>
<Text style={styles.configLabel}>Bestehende Domain</Text>
<Text style={styles.configValue}>{state.existingDomain || 'Keine'}</Text>
</View>
<View style={styles.configItem}>
<Text style={styles.configLabel}>Wunsch-Domain</Text>
<Text style={styles.configValue}>{state.wishedDomain || 'Keine'}</Text>
</View>
<View style={styles.configItem}>
<Text style={styles.configLabel}>Zeitplan</Text>
<Text style={styles.configValue}>{deadlineLabels[state.deadline] || state.deadline}</Text>
</View>
<View style={styles.configItem}>
<Text style={styles.configLabel}>Assets vorhanden</Text>
<Text style={styles.configValue}>{state.assets.map((a: string) => assetLabels[a] || a).join(', ') || 'Keine angegeben'}</Text>
</View>
<PDFView style={styles.configItem}>
<PDFText style={styles.configLabel}>Mitarbeiter</PDFText>
<PDFText style={styles.configValue}>{state.employeeCount || 'Nicht angegeben'}</PDFText>
</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}>
<PDFText style={styles.configLabel}>Zeitplan</PDFText>
<PDFText style={styles.configValue}>{DEADLINE_LABELS[state.deadline] || state.deadline}</PDFText>
</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 && (
<View style={styles.configItem}>
<Text style={styles.configLabel}>Weitere Assets</Text>
<Text style={styles.configValue}>{state.otherAssets.join(', ')}</Text>
</View>
<PDFView style={styles.configItem}>
<PDFText style={styles.configLabel}>Weitere Assets</PDFText>
<PDFText style={styles.configValue}>{state.otherAssets.join(', ')}</PDFText>
</PDFView>
)}
<View style={styles.configItem}>
<Text style={styles.configLabel}>Sprachen</Text>
<Text style={styles.configValue}>{state.languagesCount} ({state.languagesList.join(', ')})</Text>
</View>
<PDFView style={styles.configItem}>
<PDFText style={styles.configLabel}>Sprachen</PDFText>
<PDFText style={styles.configValue}>{state.languagesList.length} ({state.languagesList.join(', ')})</PDFText>
</PDFView>
{state.projectType === 'website' && (
<>
<View style={styles.configItem}>
<Text style={styles.configLabel}>CMS (Inhaltsverwaltung)</Text>
<Text style={styles.configValue}>{state.cmsSetup ? 'Ja' : 'Nein'}</Text>
</View>
<View style={styles.configItem}>
<Text style={styles.configLabel}>Änderungsfrequenz</Text>
<Text style={styles.configValue}>
<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'}
</Text>
</View>
</PDFText>
</PDFView>
</>
)}
</View>
</PDFView>
{state.socialMedia.length > 0 && (
<View style={{ marginTop: 15 }}>
<Text style={styles.configLabel}>Social Media Accounts</Text>
<PDFView style={{ marginTop: 24, paddingTop: 16, borderTopWidth: 1, borderTopColor: '#eeeeee' }}>
<PDFText style={styles.configLabel}>Social Media Accounts</PDFText>
{state.socialMedia.map((id: string) => (
<Text key={id} style={[styles.configValue, { lineHeight: 1.4 }]}>
{socialLabels[id] || id}: {state.socialMediaUrls[id] || 'Keine URL angegeben'}
</Text>
<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>
))}
</View>
</PDFView>
)}
{state.designWishes && (
<View style={{ marginTop: 15 }}>
<Text style={styles.configLabel}>Design-Vorstellungen</Text>
<Text style={[styles.configValue, { lineHeight: 1.4 }]}>{state.designWishes}</Text>
</View>
<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 && (
<View style={{ marginTop: 15 }}>
<Text style={styles.configLabel}>Referenzen</Text>
<Text style={[styles.configValue, { lineHeight: 1.4 }]}>{state.references.join('\n')}</Text>
</View>
<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 && (
<View style={{ marginTop: 15 }}>
<Text style={styles.configLabel}>Nachricht / Anmerkungen</Text>
<Text style={[styles.configValue, { lineHeight: 1.4 }]}>{state.message}</Text>
</View>
<PDFView style={{ marginTop: 24, paddingTop: 16, borderTopWidth: 1, borderTopColor: '#eeeeee' }}>
<PDFText style={styles.configLabel}>Nachricht / Anmerkungen</PDFText>
<PDFText style={[styles.configValue, { lineHeight: 1.6, fontWeight: 'normal', color: '#666666' }]}>{state.message}</PDFText>
</PDFView>
)}
</View>
</PDFView>
{qrCodeData && (
<View style={styles.qrSection}>
<Image src={qrCodeData} style={styles.qrImage} />
<Text style={styles.qrText}>QR-Code scannen, um Konfiguration online zu öffnen</Text>
</View>
<PDFView style={styles.qrContainer}>
<PDFImage src={qrCodeData} style={styles.qrImage} />
<PDFText style={styles.qrText}>Online öffnen</PDFText>
</PDFView>
)}
<View style={styles.footer}>
<Text>marc@mintel.me</Text>
<Text>mintel.me</Text>
<Text>Digital Systems & Design</Text>
</View>
<Text style={styles.pageNumber} render={({ pageNumber, totalPages }) => `Seite ${pageNumber} von ${totalPages}`} fixed />
</Page>
</Document>
<PDFView style={styles.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>
</PDFDocument>
);
};