This commit is contained in:
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);
|
||||
}, []);
|
||||
@@ -295,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();
|
||||
@@ -318,30 +354,110 @@ 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 relative">
|
||||
<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 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">
|
||||
</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}
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-base font-bold uppercase tracking-[0.2em] text-slate-400">
|
||||
Schritt {stepIndex + 1} von {activeSteps.length}
|
||||
</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>
|
||||
</div>
|
||||
<h3 className="text-4xl font-bold tracking-tight text-slate-900">
|
||||
</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')}
|
||||
</h3>
|
||||
<p className="text-xl text-slate-500 leading-relaxed">
|
||||
</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')}
|
||||
</p>
|
||||
</motion.p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 h-2">
|
||||
<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}
|
||||
@@ -356,36 +472,91 @@ export function ContactForm() {
|
||||
setTimeout(scrollToTop, 50);
|
||||
}}
|
||||
className={`w-full h-full rounded-full transition-all duration-700 ${
|
||||
i === stepIndex ? 'bg-slate-900 scale-y-150' :
|
||||
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`}
|
||||
/>
|
||||
} 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>
|
||||
</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>
|
||||
{!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>
|
||||
|
||||
<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>
|
||||
|
||||
<form id="contact-form" onSubmit={handleSubmit} className="min-h-[450px] relative pt-12">
|
||||
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -91,51 +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 relative overflow-hidden"
|
||||
>
|
||||
<div className="absolute top-0 left-0 w-1 h-full bg-slate-900" />
|
||||
<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, HelpCircle } from 'lucide-react';
|
||||
import { Minus, Plus, FileText, ListPlus, HelpCircle, ArrowRight } from 'lucide-react';
|
||||
import { Input } from '../components/Input';
|
||||
|
||||
interface BaseStepProps {
|
||||
@@ -45,19 +45,19 @@ export function BaseStep({ state, updateState, toggleItem }: BaseStepProps) {
|
||||
</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-2xl font-bold text-slate-900">Die Seiten</h4>
|
||||
<span className="px-2 py-1 bg-slate-100 text-slate-500 text-[10px] font-bold uppercase tracking-wider rounded">Planungsbasis</span>
|
||||
<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-sm">Die Seitenstruktur hilft uns, den Umfang und die Komplexität Ihres Projekts präzise einzuschätzen.</span>
|
||||
<span className="text-base">Wählen Sie die Bausteine Ihrer neuen Website.</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -118,12 +118,16 @@ 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
|
||||
@@ -131,17 +135,17 @@ export function BaseStep({ state, updateState, toggleItem }: BaseStepProps) {
|
||||
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"
|
||||
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
|
||||
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>
|
||||
@@ -151,9 +155,9 @@ export function BaseStep({ state, updateState, toggleItem }: BaseStepProps) {
|
||||
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"
|
||||
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>
|
||||
|
||||
@@ -107,11 +107,6 @@ export function FunctionsStep({ state, updateState, toggleItem }: FunctionsStepP
|
||||
checked={state.functions.includes('calendar')}
|
||||
onChange={() => updateState({ functions: toggleItem(state.functions, 'calendar') })}
|
||||
/>
|
||||
<Checkbox
|
||||
label="Mehrsprachigkeit" desc="Inhalte in mehreren Sprachen."
|
||||
checked={state.functions.includes('multilang')}
|
||||
onChange={() => updateState({ functions: toggleItem(state.functions, 'multilang') })}
|
||||
/>
|
||||
<Checkbox
|
||||
label="Echtzeit-Chat" desc="Direkte Kommunikation mit Besuchern."
|
||||
checked={state.functions.includes('chat')}
|
||||
|
||||
@@ -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 (
|
||||
@@ -83,21 +82,25 @@ export function PresenceStep({ state, updateState, toggleItem }: PresenceStepPro
|
||||
<h4 className="text-2xl font-bold text-slate-900">Social Media Accounts</h4>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-4">
|
||||
<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.button
|
||||
key={platform.id}
|
||||
whileHover={{ y: -4 }}
|
||||
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-3 p-6 rounded-[2rem] border-2 transition-all duration-300 ${
|
||||
isSelected ? 'border-slate-900 bg-slate-900 text-white shadow-lg' : 'border-slate-100 bg-white text-slate-400 hover:border-slate-200'
|
||||
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'
|
||||
}`}
|
||||
>
|
||||
<span className="font-bold text-sm">{platform.label}</span>
|
||||
<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>
|
||||
<span className="font-bold text-base tracking-tight">{platform.label}</span>
|
||||
</motion.button>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -24,22 +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>
|
||||
<div className="flex items-center gap-4 mb-4">
|
||||
<h4 className={`text-4xl font-bold ${state.projectType === type.id ? 'text-white' : 'text-slate-900'}`}>{type.label}</h4>
|
||||
<span className={`px-2 py-1 rounded text-[10px] font-bold uppercase tracking-wider ${state.projectType === type.id ? 'bg-white/20 text-white' : 'bg-slate-100 text-slate-500'}`}>Grundlage</span>
|
||||
<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-xl leading-relaxed ${state.projectType === type.id ? 'text-slate-200' : 'text-slate-500'}`}>{type.desc}</p>
|
||||
<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
|
||||
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>
|
||||
|
||||
Reference in New Issue
Block a user