Compare commits
4 Commits
8124e9cc95
...
668af74c2a
| Author | SHA1 | Date | |
|---|---|---|---|
| 668af74c2a | |||
| 1cfc92a922 | |||
| 047c21278e | |||
| d2d4f3be14 |
136
docs/WEBSITES.md
Normal file
136
docs/WEBSITES.md
Normal 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
18
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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'
|
||||
};
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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'
|
||||
}`}
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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.' },
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
139
src/components/ContactForm/utils.ts
Normal file
139
src/components/ContactForm/utils.ts
Normal 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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user