chore: overhaul infrastructure and integrate @mintel packages
Some checks failed
🧪 CI (QA) / 🧪 Quality Assurance (push) Failing after 1m3s

- Restructure to pnpm monorepo (site moved to apps/web)
- Integrate @mintel/tsconfig, @mintel/eslint-config, @mintel/husky-config
- Implement Docker service architecture (Varnish, Directus, Gatekeeper)
- Setup environment-aware Gitea Actions deployment
This commit is contained in:
2026-02-05 14:18:51 +01:00
parent 190720ad92
commit 103d71851c
1029 changed files with 13242 additions and 27898 deletions

View File

@@ -0,0 +1,23 @@
'use client';
import * as React from 'react';
import { useEffect, useRef } from 'react';
import { useSpring, useTransform } from 'framer-motion';
export function AnimatedNumber({ value }: { value: number }) {
const spring = useSpring(value, { stiffness: 50, damping: 20 });
const display = useTransform(spring, (v) => Math.round(v).toLocaleString());
const ref = useRef<HTMLSpanElement>(null);
useEffect(() => {
spring.set(value);
}, [value, spring]);
useEffect(() => {
return display.on('change', (v) => {
if (ref.current) ref.current.textContent = v;
});
}, [display]);
return <span ref={ref}>{value.toLocaleString()}</span>;
}

View File

@@ -0,0 +1,48 @@
'use client';
import * as React from 'react';
import { Check } from 'lucide-react';
import { motion } from 'framer-motion';
interface CheckboxProps {
label: string;
desc: string;
checked: boolean;
onChange: () => void;
}
export function Checkbox({ label, desc, checked, onChange }: CheckboxProps) {
return (
<button
type="button"
onClick={onChange}
className={`w-full p-5 rounded-[2rem] border-2 text-left transition-all duration-300 flex items-start gap-4 focus:outline-none overflow-hidden relative ${
checked ? 'border-slate-900 bg-slate-900 text-white' : 'border-slate-100 bg-white hover:border-slate-200'
}`}
>
<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-2xl font-bold mb-1 transition-colors duration-500 ${checked ? 'text-white' : 'text-slate-900'}`}>{label}</h4>
{desc && <p className={`text-lg leading-relaxed transition-colors duration-500 ${checked ? 'text-slate-300' : 'text-slate-500'}`}>{desc}</p>}
</div>
{checked && (
<motion.div
layoutId={`check-bg-${label}`}
className="absolute inset-0 bg-gradient-to-br from-slate-800 to-slate-900 -z-10"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
/>
)}
</button>
);
}

View File

@@ -0,0 +1,36 @@
'use client';
import * as React from 'react';
import { LucideIcon } from 'lucide-react';
interface InputProps extends React.InputHTMLAttributes<HTMLInputElement | HTMLTextAreaElement> {
label?: string;
icon?: LucideIcon;
isTextArea?: boolean;
rows?: number;
}
export function Input({ label, icon: Icon, isTextArea, className = '', ...props }: InputProps) {
const InputComponent = isTextArea ? 'textarea' : 'input';
return (
<div className="space-y-4 w-full">
{label && (
<label className="block text-sm font-bold uppercase tracking-widest text-slate-400 ml-2">
{label}
</label>
)}
<div className="relative group">
{Icon && (
<div className={`absolute left-6 ${isTextArea ? 'top-10' : 'top-1/2'} -translate-y-1/2 text-black transition-colors`}>
<Icon size={24} />
</div>
)}
<InputComponent
{...(props as any)}
className={`w-full p-8 ${Icon ? 'pl-16' : 'px-10'} bg-white border border-slate-100 rounded-[2.5rem] focus:outline-none focus:border-slate-900 transition-all duration-500 text-xl focus:shadow-2xl ${isTextArea ? 'resize-none' : ''} ${className}`}
/>
</div>
</div>
);
}

View File

@@ -0,0 +1,196 @@
'use client';
import * as React from 'react';
import { FormState, Totals } from '../types';
import { PRICING } from '../constants';
import { AnimatedNumber } from './AnimatedNumber';
import { ConceptPrice, ConceptAutomation } from '../../Landing/ConceptIllustrations';
import { Info, Download, Share2, RefreshCw } from 'lucide-react';
import { motion, AnimatePresence } from 'framer-motion';
import dynamic from 'next/dynamic';
import { EstimationPDF } from '../../EstimationPDF';
import IconWhite from '../../../assets/logo/Icon White Transparent.png';
import LogoBlack from '../../../assets/logo/Logo Black Transparent.png';
// Dynamically import PDF components to avoid SSR issues
const PDFDownloadLink = dynamic(
() => import('@react-pdf/renderer').then((mod) => mod.PDFDownloadLink),
{ ssr: false }
);
interface PriceCalculationProps {
state: FormState;
totals: Totals;
isClient: boolean;
qrCodeData: string;
onShare?: () => void;
}
export function PriceCalculation({
state,
totals,
isClient,
qrCodeData,
onShare
}: PriceCalculationProps) {
const { totalPrice, monthlyPrice, totalPagesCount, totalFeatures, totalFunctions, totalApis, languagesCount } = totals;
const totalPages = totalPagesCount;
const [pdfLoading, setPdfLoading] = React.useState(false);
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}
headerIcon={typeof IconWhite === 'string' ? IconWhite : (IconWhite as any).src}
footerLogo={typeof LogoBlack === 'string' ? LogoBlack : (LogoBlack as any).src}
/>;
// Minimum loading time of 2 seconds for better UX
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">
<div className="space-y-6">
{state.projectType === 'website' ? (
<>
<div className="space-y-4 overflow-y-auto pr-2 hide-scrollbar max-h-[120px]">
{totalPages > 0 && (<div className="flex justify-between items-center text-sm"><span className="text-slate-500">{totalPages}x Seite</span><span className="font-medium text-slate-900">{(totalPages * PRICING.PAGE).toLocaleString()} </span></div>)}
{totalFeatures > 0 && (<div className="flex justify-between items-center text-sm"><span className="text-slate-500">{totalFeatures}x System-Modul</span><span className="font-medium text-slate-900">{(totalFeatures * PRICING.FEATURE).toLocaleString()} </span></div>)}
{totalFunctions > 0 && (<div className="flex justify-between items-center text-sm"><span className="text-slate-500">{totalFunctions}x Logik-Funktion</span><span className="font-medium text-slate-900">{(totalFunctions * PRICING.FUNCTION).toLocaleString()} </span></div>)}
{totalApis > 0 && (<div className="flex justify-between items-center text-sm"><span className="text-slate-500">{totalApis}x API Sync</span><span className="font-medium text-slate-900">{(totalApis * PRICING.API_INTEGRATION).toLocaleString()} </span></div>)}
{state.cmsSetup && (<div className="flex justify-between items-center text-sm"><span className="text-slate-500">CMS Setup & Anbindung</span><span className="font-medium text-slate-900">{(PRICING.CMS_SETUP + totalFeatures * PRICING.CMS_CONNECTION_PER_FEATURE).toLocaleString()} </span></div>)}
{state.newDatasets > 0 && (<div className="flex justify-between items-center text-sm"><span className="text-slate-500">{state.newDatasets}x Inhalte einpflegen</span><span className="font-medium text-slate-900">{(state.newDatasets * PRICING.NEW_DATASET).toLocaleString()} </span></div>)}
{languagesCount > 1 && (<div className="flex justify-between items-center text-sm text-slate-900 font-bold pt-2 border-t border-slate-100"><span className="text-slate-500">Mehrsprachigkeit ({languagesCount}x)</span><span>+{(totalPrice - (totalPrice / (1 + (languagesCount - 1) * 0.2))).toLocaleString()} </span></div>)}
</div>
<div className="pt-4 space-y-2">
<div className="flex justify-between items-end">
<span className="text-lg font-bold text-slate-900">Gesamt</span>
<div className="text-right">
<div className="text-2xl font-bold tracking-tighter text-slate-900">
<AnimatedNumber value={totalPrice} />
</div>
</div>
</div>
</div>
<div className="pt-4 border-t border-slate-200 space-y-4">
<div className="flex justify-between items-center">
<span className="text-slate-600 font-medium text-sm">Sorglos-Paket</span>
<span className="text-base font-bold text-slate-900">{monthlyPrice.toLocaleString()} / Monat</span>
</div>
</div>
<div className="pt-4 space-y-4">
{isClient && (
<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"
>
<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 && (
<button
type="button"
onClick={onShare}
className="w-full flex items-center justify-center gap-3 rounded-full bg-slate-900 text-white font-bold text-sm uppercase tracking-widest hover:bg-slate-800 transition-all focus:outline-none px-6 py-3"
>
<Share2 size={18} />
<span>Teilen</span>
</button>
)}
</div>
</>
) : (
<div className="space-y-6">
<div className="py-6 text-center space-y-4">
<div className="w-16 h-16 bg-white rounded-full flex items-center justify-center mx-auto border border-slate-100">
<ConceptAutomation className="w-10 h-10 text-black" />
</div>
<div className="space-y-1">
<p className="text-slate-600 text-xs leading-relaxed">Web Apps werden nach Aufwand abgerechnet.</p>
<p className="text-2xl font-bold text-slate-900">{PRICING.APP_HOURLY} <span className="text-base text-slate-400 font-normal">/ Std.</span></p>
</div>
</div>
{onShare && (
<button
type="button"
onClick={onShare}
className="w-full flex items-center justify-center gap-3 px-6 py-3 rounded-full bg-slate-900 text-white font-bold text-sm uppercase tracking-widest hover:bg-slate-800 transition-all focus:outline-none"
>
<Share2 size={18} />
<span>Teilen</span>
</button>
)}
</div>
)}
</div>
<p className="text-[10px] leading-relaxed text-slate-400 italic text-center">Ein verbindliches Angebot erstelle ich nach einem persönlichen Gespräch.</p>
</div>
</div>
);
}

View File

@@ -0,0 +1,74 @@
'use client';
import * as React from 'react';
import { useState } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { Plus, X } from 'lucide-react';
interface RepeatableListProps {
items: string[];
onAdd: (val: string) => void;
onRemove: (index: number) => void;
placeholder: string;
}
export function RepeatableList({
items,
onAdd,
onRemove,
placeholder
}: RepeatableListProps) {
const [input, setInput] = useState('');
return (
<div className="space-y-6">
<div className="flex gap-3">
<input
type="text"
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
if (input.trim()) {
onAdd(input.trim());
setInput('');
}
}
}}
placeholder={placeholder}
className="flex-1 p-8 bg-white border border-slate-100 rounded-[2rem] focus:outline-none focus:border-slate-900 transition-colors text-lg"
/>
<button
type="button"
onClick={() => {
if (input.trim()) {
onAdd(input.trim());
setInput('');
}
}}
className="w-20 h-20 rounded-full bg-slate-900 text-white flex items-center justify-center hover:bg-slate-800 transition-colors shrink-0 focus:outline-none"
>
<Plus size={32} />
</button>
</div>
<div className="flex flex-wrap gap-3">
<AnimatePresence>
{items.map((item, i) => (
<motion.div
key={i}
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.8 }}
className="flex items-center gap-3 px-6 py-3 bg-slate-100 rounded-full text-base font-bold text-slate-700"
>
<span className="truncate max-w-[300px]">{item}</span>
<button type="button" onClick={() => onRemove(i)} className="text-slate-400 hover:text-slate-900 transition-colors focus:outline-none">
<X size={18} />
</button>
</motion.div>
))}
</AnimatePresence>
</div>
</div>
);
}

View File

@@ -0,0 +1,72 @@
import * as React from 'react';
import { FormState } from './types';
import {
PRICING as LOGIC_PRICING,
PAGE_SAMPLES as LOGIC_PAGE_SAMPLES,
FEATURE_OPTIONS as LOGIC_FEATURE_OPTIONS,
FUNCTION_OPTIONS as LOGIC_FUNCTION_OPTIONS,
API_OPTIONS as LOGIC_API_OPTIONS,
ASSET_OPTIONS as LOGIC_ASSET_OPTIONS,
EMPLOYEE_OPTIONS as LOGIC_EMPLOYEE_OPTIONS,
SOCIAL_MEDIA_OPTIONS as LOGIC_SOCIAL_MEDIA_OPTIONS,
VIBE_LABELS as LOGIC_VIBE_LABELS,
DEADLINE_LABELS as LOGIC_DEADLINE_LABELS,
ASSET_LABELS as LOGIC_ASSET_LABELS,
FEATURE_LABELS as LOGIC_FEATURE_LABELS,
FUNCTION_LABELS as LOGIC_FUNCTION_LABELS,
API_LABELS as LOGIC_API_LABELS,
SOCIAL_LABELS as LOGIC_SOCIAL_LABELS,
initialState as LOGIC_INITIAL_STATE,
DESIGN_OPTIONS
} from '../../logic/pricing';
export const PRICING = LOGIC_PRICING;
export const PAGE_SAMPLES = LOGIC_PAGE_SAMPLES;
export const FEATURE_OPTIONS = LOGIC_FEATURE_OPTIONS;
export const FUNCTION_OPTIONS = LOGIC_FUNCTION_OPTIONS;
export const API_OPTIONS = LOGIC_API_OPTIONS;
export const ASSET_OPTIONS = LOGIC_ASSET_OPTIONS;
export const EMPLOYEE_OPTIONS = LOGIC_EMPLOYEE_OPTIONS;
export const SOCIAL_MEDIA_OPTIONS = LOGIC_SOCIAL_MEDIA_OPTIONS;
export const VIBE_LABELS = LOGIC_VIBE_LABELS;
export const DEADLINE_LABELS = LOGIC_DEADLINE_LABELS;
export const ASSET_LABELS = LOGIC_ASSET_LABELS;
export const FEATURE_LABELS = LOGIC_FEATURE_LABELS;
export const FUNCTION_LABELS = LOGIC_FUNCTION_LABELS;
export const API_LABELS = LOGIC_API_LABELS;
export const SOCIAL_LABELS = LOGIC_SOCIAL_LABELS;
export const initialState = LOGIC_INITIAL_STATE;
const VIBE_ILLUSTRATIONS: Record<string, React.ReactNode> = {
minimal: (
<svg viewBox="0 0 100 60" className="w-full h-full opacity-40">
<rect x="10" y="10" width="80" height="2" rx="1" className="fill-current" />
<rect x="10" y="20" width="50" height="2" rx="1" className="fill-current" />
<rect x="10" y="40" width="30" height="10" rx="1" className="fill-current" />
</svg>
),
bold: (
<svg viewBox="0 0 100 60" className="w-full h-full opacity-40">
<rect x="10" y="10" width="80" height="15" rx="2" className="fill-current" />
<rect x="10" y="35" width="80" height="15" rx="2" className="fill-current" />
</svg>
),
nature: (
<svg viewBox="0 0 100 60" className="w-full h-full opacity-40">
<circle cx="30" cy="30" r="20" className="fill-current" />
<circle cx="70" cy="30" r="15" className="fill-current" />
</svg>
),
tech: (
<svg viewBox="0 0 100 60" className="w-full h-full opacity-40">
<path d="M10 10 L90 10 L90 50 L10 50 Z" fill="none" stroke="currentColor" strokeWidth="2" />
<path d="M10 30 L90 30" stroke="currentColor" strokeWidth="1" strokeDasharray="4 2" />
<path d="M50 10 L50 50" stroke="currentColor" strokeWidth="1" strokeDasharray="4 2" />
</svg>
),
};
export const DESIGN_VIBES = DESIGN_OPTIONS.map(opt => ({
...opt,
illustration: VIBE_ILLUSTRATIONS[opt.id]
}));

View File

@@ -0,0 +1,111 @@
'use client';
import * as React from 'react';
import { FormState } from '../types';
import { Checkbox } from '../components/Checkbox';
import { RepeatableList } from '../components/RepeatableList';
import { Share2, ListPlus } from 'lucide-react';
import { motion } from 'framer-motion';
import { Reveal } from '../../Reveal';
interface ApiStepProps {
state: FormState;
updateState: (updates: Partial<FormState>) => void;
toggleItem: (list: string[], id: string) => string[];
}
export function ApiStep({ state, updateState, toggleItem }: ApiStepProps) {
const isWebApp = state.projectType === 'web-app';
const toggleDontKnow = (id: string) => {
const current = state.dontKnows || [];
if (current.includes(id)) {
updateState({ dontKnows: current.filter(i => i !== id) });
} else {
updateState({ dontKnows: [...current, id] });
}
};
return (
<div className="space-y-12">
<Reveal width="100%" delay={0.1}>
<div className="space-y-8">
<div className="flex justify-between items-center">
<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>
<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 whitespace-nowrap shrink-0 ${
state.dontKnows?.includes('api') ? 'bg-slate-900 text-white' : 'bg-slate-100 text-slate-500 hover:bg-slate-200'
}`}
>
Ich weiß es nicht
</motion.button>
</div>
<p className="text-lg text-slate-500 leading-relaxed ml-2">
{isWebApp
? 'Mit welchen Systemen soll die Web App kommunizieren?'
: 'Datenaustausch mit Drittsystemen zur Automatisierung.'}
</p>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{[
{ id: 'crm_erp', label: 'CRM / ERP', desc: 'HubSpot, Salesforce, SAP, Xentral etc.' },
{ id: 'payment', label: 'Payment', desc: 'Stripe, PayPal, Klarna Integration.' },
{ 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.' },
].map((opt, index) => (
<motion.div
key={opt.id}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: index * 0.05 }}
>
<Checkbox
label={opt.label} desc={opt.desc}
checked={state.apiSystems.includes(opt.id)}
onChange={() => updateState({ apiSystems: toggleItem(state.apiSystems, opt.id) })}
/>
</motion.div>
))}
</div>
</div>
</Reveal>
<Reveal width="100%" delay={0.2}>
<div className="space-y-12">
<div className="space-y-8">
<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">
<ListPlus size={24} />
</div>
<h4 className="text-2xl font-bold text-slate-900">Weitere Systeme oder eigene APIs?</h4>
</div>
<RepeatableList
items={state.otherTech}
onAdd={(v) => updateState({ otherTech: [...state.otherTech, v] })}
onRemove={(i) => updateTech(i)}
placeholder="z.B. Microsoft Graph, Google Search Console, Custom REST API..."
/>
</div>
</div>
</Reveal>
</div>
);
function updateTech(index: number) {
updateState({ otherTech: state.otherTech.filter((_, idx) => idx !== index) });
}
}

View File

@@ -0,0 +1,98 @@
'use client';
import * as React from 'react';
import { FormState } from '../types';
import { ASSET_OPTIONS } from '../constants';
import { Checkbox } from '../components/Checkbox';
import { RepeatableList } from '../components/RepeatableList';
import { motion, AnimatePresence } from 'framer-motion';
import { Minus, Plus, Briefcase, ListPlus } from 'lucide-react';
import { Reveal } from '../../Reveal';
interface AssetsStepProps {
state: FormState;
updateState: (updates: Partial<FormState>) => void;
toggleItem: (list: string[], id: string) => string[];
}
export function AssetsStep({ state, updateState, toggleItem }: AssetsStepProps) {
const toggleDontKnow = (id: string) => {
const current = state.dontKnows || [];
if (current.includes(id)) {
updateState({ dontKnows: current.filter(i => i !== id) });
} else {
updateState({ dontKnows: [...current, id] });
}
};
return (
<div className="space-y-12">
<Reveal width="100%" delay={0.1}>
<div className="space-y-8">
<div className="flex justify-between items-center">
<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">
<Briefcase size={24} />
</div>
<h4 className="text-2xl font-bold text-slate-900">Vorhandene Assets</h4>
</div>
<motion.button
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
type="button"
onClick={() => toggleDontKnow('assets')}
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'
}`}
>
Ich weiß es nicht
</motion.button>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{ASSET_OPTIONS.map((opt, index) => (
<motion.div
key={opt.id}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: index * 0.05 }}
>
<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>
</div>
</Reveal>
<Reveal width="100%" delay={0.2}>
<div className="space-y-12">
<div className="space-y-8">
<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">
<ListPlus size={24} />
</div>
<h4 className="text-2xl font-bold text-slate-900">Weitere vorhandene Unterlagen?</h4>
</div>
<RepeatableList
items={state.otherAssets}
onAdd={(v) => updateState({ otherAssets: [...state.otherAssets, v] })}
onRemove={(i) => updateState({ otherAssets: state.otherAssets.filter((_, idx) => idx !== i) })}
placeholder="z.B. Lastenheft, Wireframes, Bilddatenbank..."
/>
</div>
</div>
</Reveal>
</div>
);
}

View File

@@ -0,0 +1,168 @@
'use client';
import * as React from 'react';
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, ArrowRight } from 'lucide-react';
import { Input } from '../components/Input';
interface BaseStepProps {
state: FormState;
updateState: (updates: Partial<FormState>) => void;
toggleItem: (list: string[], id: string) => string[];
}
export function BaseStep({ state, updateState, toggleItem }: BaseStepProps) {
const toggleDontKnow = (id: string) => {
const current = state.dontKnows || [];
if (current.includes(id)) {
updateState({ dontKnows: current.filter(i => i !== id) });
} else {
updateState({ dontKnows: [...current, id] });
}
};
return (
<div className="space-y-16">
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
className="space-y-8"
>
<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 flex-col md:flex-row justify-between items-start md:items-center gap-6">
<div className="flex items-center gap-4">
<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>
</div>
<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 whitespace-nowrap shrink-0 ${
state.dontKnows?.includes('pages') ? 'bg-slate-900 text-white' : 'bg-slate-100 text-slate-500 hover:bg-slate-200'
}`}
>
Ich weiß es nicht
</motion.button>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{[
{ id: 'Home', label: 'Startseite', desc: 'Der erste Eindruck Ihrer Marke.' },
{ id: 'About', label: 'Über uns', desc: 'Ihre Geschichte und Ihr Team.' },
{ id: 'Services', label: 'Leistungen', desc: 'Übersicht Ihres Angebots.' },
{ id: 'Contact', label: 'Kontakt', desc: 'Anlaufstelle für Ihre Kunden.' },
{ id: 'Landing', label: 'Landingpage', desc: 'Optimiert für Marketing-Kampagnen.' },
{ id: 'Legal', label: 'Rechtliches', desc: 'Impressum & Datenschutz.' },
].map((opt, index) => (
<motion.div
key={opt.id}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: index * 0.05 }}
>
<Checkbox
label={opt.label} desc={opt.desc}
checked={state.selectedPages.includes(opt.id)}
onChange={() => updateState({ selectedPages: toggleItem(state.selectedPages, opt.id) })}
/>
</motion.div>
))}
</div>
</div>
<div className="space-y-12">
<div className="space-y-8">
<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">
<ListPlus size={24} />
</div>
<h4 className="text-2xl font-bold text-slate-900">Weitere individuelle Seiten?</h4>
</div>
<RepeatableList
items={state.otherPages}
onAdd={(v) => updateState({ otherPages: [...state.otherPages, v] })}
onRemove={(i) => updateState({ otherPages: state.otherPages.filter((_, idx) => idx !== i) })}
placeholder="z.B. Karriere, FAQ, Team-Detail..."
/>
</div>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.4 }}
className="p-10 bg-slate-900 text-white rounded-[3rem] space-y-6 shadow-2xl shadow-slate-200 relative overflow-hidden group"
>
<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-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-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={28} />
</motion.button>
<AnimatePresence mode="wait">
<motion.span
key={state.otherPagesCount}
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
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.9 }}
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={28} />
</motion.button>
</div>
</div>
</motion.div>
</div>
</div>
);
}

View File

@@ -0,0 +1,64 @@
'use client';
import * as React from 'react';
import { FormState } from '../types';
import { EMPLOYEE_OPTIONS } from '../constants';
import { motion } from 'framer-motion';
import { Building2, Users } from 'lucide-react';
import { Reveal } from '../../Reveal';
import { Input } from '../components/Input';
interface CompanyStepProps {
state: FormState;
updateState: (updates: Partial<FormState>) => void;
}
export function CompanyStep({ state, updateState }: CompanyStepProps) {
return (
<div className="space-y-16">
<Reveal width="100%" delay={0.1}>
<div className="space-y-8" id="focus-target-company">
<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">
<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"
placeholder="z.B. Muster GmbH"
value={state.companyName}
onChange={(e) => updateState({ companyName: e.target.value })}
/>
</div>
</Reveal>
<Reveal width="100%" delay={0.2}>
<div className="space-y-8">
<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">
<Users size={24} />
</div>
<h4 className="text-2xl font-bold text-slate-900">Mitarbeiteranzahl</h4>
</div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-6">
{EMPLOYEE_OPTIONS.map((option, index) => (
<motion.button
key={option.id}
whileHover={{ y: -5 }}
whileTap={{ scale: 0.95 }}
type="button"
onClick={() => updateState({ employeeCount: option.id })}
className={`p-6 rounded-[2rem] border-2 transition-all duration-300 font-bold text-lg ${state.employeeCount === option.id ? 'border-slate-900 bg-slate-900 text-white' : 'border-slate-100 bg-white hover:border-slate-300 text-slate-600'
}`}
>
{option.label}
</motion.button>
))}
</div>
</div>
</Reveal>
</div>
);
}

View File

@@ -0,0 +1,170 @@
'use client';
import * as React from 'react';
import { FormState } from '../types';
import { FileText, Upload, X, User, Mail, Briefcase, MessageSquare } from 'lucide-react';
import { motion, AnimatePresence } from 'framer-motion';
import { Reveal } from '../../Reveal';
import { Input } from '../components/Input';
interface ContactStepProps {
state: FormState;
updateState: (updates: Partial<FormState>) => void;
}
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}>
<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}>
<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>
<Reveal width="100%" delay={0.2}>
<Input
label="Ihre Rolle"
icon={Briefcase}
placeholder="z.B. CEO, Marketing Manager..."
value={state.role}
onChange={(e) => updateState({ role: e.target.value })}
/>
</Reveal>
<Reveal width="100%" delay={0.3}>
<Input
label="Nachricht"
icon={MessageSquare}
isTextArea
rows={5}
placeholder="Erzählen Sie mir kurz von Ihrem Projekt..."
value={state.message}
onChange={(e) => updateState({ message: e.target.value })}
/>
</Reveal>
<Reveal width="100%" delay={0.4}>
<div className="space-y-6">
<p className="text-lg font-bold text-slate-900 ml-2">Dateien hochladen (optional)</p>
<div
className={`relative group border-2 border-dashed rounded-[3rem] p-12 transition-all duration-500 flex flex-col items-center justify-center gap-6 cursor-pointer min-h-[250px] ${
state.contactFiles.length > 0 ? 'border-slate-900 bg-slate-50' : 'border-slate-200 hover:border-slate-400 bg-white hover:shadow-xl'
}`}
onDragOver={(e) => { e.preventDefault(); e.stopPropagation(); }}
onDrop={(e) => {
e.preventDefault();
e.stopPropagation();
const files = Array.from(e.dataTransfer.files);
if (files.length > 0) updateState({ contactFiles: [...state.contactFiles, ...files] });
}}
onClick={() => document.getElementById('contact-upload')?.click()}
>
<input id="contact-upload" type="file" multiple className="hidden" onChange={(e) => {
const files = e.target.files ? Array.from(e.target.files) : [];
if (files.length > 0) updateState({ contactFiles: [...state.contactFiles, ...files] });
}} />
<AnimatePresence mode="wait">
{state.contactFiles.length > 0 ? (
<motion.div
key="files"
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.95 }}
className="w-full space-y-4"
>
{state.contactFiles.map((file, idx) => (
<motion.div
key={`${file.name}-${idx}`}
initial={{ opacity: 0, x: -10 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: idx * 0.05 }}
className="flex items-center justify-between p-5 bg-white border border-slate-100 rounded-2xl group/file"
>
<div className="flex items-center gap-4 text-slate-900">
<div className="w-10 h-10 bg-slate-50 rounded-xl flex items-center justify-center text-slate-400 group-hover/file:text-slate-900 transition-colors">
<FileText size={20} />
</div>
<div className="flex flex-col">
<span className="font-bold text-base truncate max-w-[250px]">{file.name}</span>
<span className="text-[10px] text-slate-400 uppercase font-bold">{(file.size / 1024 / 1024).toFixed(2)} MB</span>
</div>
</div>
<motion.button
whileHover={{ scale: 1.1, backgroundColor: '#fee2e2', color: '#ef4444' }}
whileTap={{ scale: 0.9 }}
type="button"
onClick={(e) => {
e.stopPropagation();
updateState({ contactFiles: state.contactFiles.filter((_, i) => i !== idx) });
}}
className="p-2 bg-slate-50 text-slate-400 rounded-full transition-colors focus:outline-none"
>
<X size={20} />
</motion.button>
</motion.div>
))}
<p className="text-xs text-slate-400 text-center mt-8 font-medium">Klicken oder ziehen, um weitere Dateien hinzuzufügen</p>
</motion.div>
) : (
<motion.div
key="empty"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="flex flex-col items-center gap-6"
>
<div className="w-20 h-20 bg-slate-50 rounded-[2rem] flex items-center justify-center text-slate-400 group-hover:text-slate-900 group-hover:scale-110 transition-all duration-500">
<Upload size={32} />
</div>
<div className="text-center">
<p className="text-xl font-bold text-slate-900">Dateien hierher ziehen</p>
<p className="text-lg text-slate-500 mt-1">oder klicken zum Auswählen</p>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
</div>
</Reveal>
</div>
);
}

View File

@@ -0,0 +1,184 @@
'use client';
import * as React from 'react';
import { FormState } from '../types';
import { Zap, AlertCircle, Minus, Plus, Settings2, BarChart3 } from 'lucide-react';
import { motion, AnimatePresence } from 'framer-motion';
import { Reveal } from '../../Reveal';
interface ContentStepProps {
state: FormState;
updateState: (updates: Partial<FormState>) => void;
}
export function ContentStep({ state, updateState }: ContentStepProps) {
const toggleDontKnow = (id: string) => {
const current = state.dontKnows || [];
if (current.includes(id)) {
updateState({ dontKnows: current.filter(i => i !== id) });
} else {
updateState({ dontKnows: [...current, id] });
}
};
return (
<div className="space-y-16">
<Reveal width="100%" delay={0.1}>
<div className="flex flex-col md:flex-row items-center justify-between p-10 bg-white border border-slate-100 rounded-[3rem] gap-8">
<div className="max-w-2xl space-y-4">
<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">
<Settings2 size={24} />
</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">
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
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
type="button"
onClick={() => toggleDontKnow('cms')}
className={`px-6 py-3 rounded-full text-sm font-bold transition-all ${
state.dontKnows?.includes('cms') ? 'bg-slate-900 text-white' : 'bg-slate-100 text-slate-500 hover:bg-slate-200'
}`}
>
Ich weiß es nicht
</motion.button>
<button
type="button"
onClick={() => updateState({ cmsSetup: !state.cmsSetup })}
className={`w-24 h-12 rounded-full transition-all duration-500 relative focus:outline-none ${state.cmsSetup ? 'bg-slate-900' : 'bg-slate-200'}`}
>
<motion.div
animate={{ x: state.cmsSetup ? 48 : 0 }}
transition={{ type: "spring", stiffness: 500, damping: 30 }}
className="absolute top-1.5 left-1.5 w-9 h-9 bg-white rounded-full"
/>
</button>
</div>
</div>
</Reveal>
<Reveal width="100%" delay={0.2}>
<div className="p-10 bg-slate-50 rounded-[3rem] border border-slate-100 space-y-10">
<div className="flex items-center gap-4">
<div className="w-12 h-12 bg-white rounded-2xl flex items-center justify-center text-black">
<BarChart3 size={24} />
</div>
<p className="text-xl font-bold text-slate-900">Wie oft ändern sich Ihre Inhalte?</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
{[
{ id: 'low', label: 'Selten', desc: 'Wenige Male im Jahr.' },
{ id: 'medium', label: 'Regelmäßig', desc: 'Monatliche Updates.' },
{ id: 'high', label: 'Häufig', desc: 'Wöchentlich oder täglich.' },
].map((opt, index) => (
<motion.button
key={opt.id}
whileHover={{ y: -5 }}
whileTap={{ scale: 0.98 }}
type="button"
onClick={() => updateState({ expectedAdjustments: opt.id })}
className={`p-6 rounded-[2rem] border-2 text-left transition-all duration-300 focus:outline-none ${
state.expectedAdjustments === opt.id ? 'border-slate-900 bg-slate-900 text-white' : 'border-slate-200 bg-white hover:border-slate-400'
}`}
>
<p className={`font-bold text-lg ${state.expectedAdjustments === opt.id ? 'text-white' : 'text-slate-900'}`}>{opt.label}</p>
<p className={`text-sm mt-2 leading-relaxed ${state.expectedAdjustments === opt.id ? 'text-slate-200' : 'text-slate-500'}`}>{opt.desc}</p>
</motion.button>
))}
</div>
<AnimatePresence>
{state.expectedAdjustments === 'high' && !state.cmsSetup && (
<motion.div
initial={{ opacity: 0, height: 0, y: 20 }}
animate={{ opacity: 1, height: 'auto', y: 0 }}
exit={{ opacity: 0, height: 0, y: 20 }}
className="p-8 bg-amber-50 rounded-[2.5rem] 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">Empfehlung: CMS nutzen</p>
<p className="text-amber-800 text-base leading-relaxed max-w-3xl">
Bei täglichen oder wöchentlichen Änderungen sparen Sie mit einem CMS langfristig viel Geld, da Sie keine externen Entwickler für Inhalts-Updates benötigen.
</p>
</div>
</motion.div>
)}
</AnimatePresence>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 mt-8">
<div className="p-8 bg-white rounded-[2.5rem] border border-slate-100 space-y-4">
<div className="flex items-center gap-3 text-slate-900 font-bold text-sm uppercase tracking-[0.2em]">
<Zap size={18} /> Vorteil CMS
</div>
<p className="text-base text-slate-500 leading-relaxed">
Volle Kontrolle über Ihre Inhalte und keine laufenden Kosten für kleine Textänderungen oder neue Blog-Beiträge.
</p>
</div>
<div className="p-8 bg-white rounded-[2.5rem] border border-slate-100 space-y-4">
<div className="flex items-center gap-3 text-slate-900 font-bold text-sm uppercase tracking-[0.2em]">
<AlertCircle size={18} /> Fokus Design
</div>
<p className="text-base text-slate-500 leading-relaxed">
Ohne CMS bleibt die technische Komplexität geringer und das Design ist maximal geschützt vor ungewollten Änderungen.
</p>
</div>
</div>
</div>
</Reveal>
<Reveal width="100%" delay={0.3}>
<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">
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
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.9 }}
type="button"
onClick={() => updateState({ newDatasets: Math.max(0, state.newDatasets - 1) })}
className="w-16 h-16 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={28} />
</motion.button>
<AnimatePresence mode="wait">
<motion.span
key={state.newDatasets}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
className="text-7xl font-bold w-20 text-center tabular-nums"
>
{state.newDatasets}
</motion.span>
</AnimatePresence>
<motion.button
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.9 }}
type="button"
onClick={() => updateState({ newDatasets: state.newDatasets + 1 })}
className="w-16 h-16 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={28} />
</motion.button>
</div>
</div>
</Reveal>
</div>
);
}

View File

@@ -0,0 +1,242 @@
'use client';
import * as React from 'react';
import { FormState } from '../types';
import { DESIGN_VIBES } from '../constants';
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;
updateState: (updates: Partial<FormState>) => void;
}
export function DesignStep({ state, updateState }: DesignStepProps) {
const addColor = () => {
if (state.colorScheme.length < 5) {
updateState({ colorScheme: [...state.colorScheme, '#000000'] });
}
};
const removeColor = (index: number) => {
if (state.colorScheme.length > 1) {
const newScheme = [...state.colorScheme];
newScheme.splice(index, 1);
updateState({ colorScheme: newScheme });
}
};
const updateColor = (index: number, value: string) => {
const newScheme = [...state.colorScheme];
newScheme[index] = value;
updateState({ colorScheme: newScheme });
};
const toggleDontKnow = (id: string) => {
const current = state.dontKnows || [];
if (current.includes(id)) {
updateState({ dontKnows: current.filter(i => i !== id) });
} else {
updateState({ dontKnows: [...current, id] });
}
};
const generateHarmonicPalette = () => {
const hue = Math.floor(Math.random() * 360);
const saturation = 40 + Math.floor(Math.random() * 40);
const lightness = 40 + Math.floor(Math.random() * 40);
const hslToHex = (h: number, s: number, l: number) => {
l /= 100;
const a = s * Math.min(l, 1 - l) / 100;
const f = (n: number) => {
const k = (n + h / 30) % 12;
const color = l - a * Math.max(Math.min(k - 3, 9 - k, 1), -1);
return Math.round(255 * color).toString(16).padStart(2, '0');
};
return `#${f(0)}${f(8)}${f(4)}`;
};
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 });
};
return (
<div className="space-y-16">
{/* Design Vibe */}
<Reveal width="100%" delay={0.1}>
<div className="space-y-8">
<div className="flex justify-between items-center">
<div className="space-y-1">
<h4 className="text-2xl font-bold text-slate-900">Design-Richtung</h4>
<p className="text-slate-500">Welche Ästhetik passt zu Ihrer Marke?</p>
</div>
<motion.button
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
type="button"
onClick={() => toggleDontKnow('design_vibe')}
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'
}`}
>
Ich weiß es nicht
</motion.button>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{DESIGN_VIBES.map((vibe, index) => (
<motion.button
key={vibe.id}
whileHover={{ y: -5 }}
type="button"
onClick={() => updateState({ designVibe: vibe.id })}
className={`p-8 rounded-[2.5rem] border-2 text-left transition-all duration-500 focus:outline-none overflow-hidden relative group ${
state.designVibe === vibe.id ? 'border-slate-900 bg-slate-900 text-white' : 'border-slate-100 bg-white hover:border-slate-300'
}`}
>
<div className={`w-16 h-10 mb-6 transition-transform duration-500 group-hover:scale-110 ${state.designVibe === vibe.id ? 'text-white' : 'text-black'}`}>{vibe.illustration}</div>
<h4 className={`text-2xl font-bold mb-3 ${state.designVibe === vibe.id ? 'text-white' : 'text-slate-900'}`}>{vibe.label}</h4>
<p className={`text-lg leading-relaxed ${state.designVibe === vibe.id ? 'text-slate-200' : 'text-slate-500'}`}>{vibe.desc}</p>
{state.designVibe === vibe.id && (
<motion.div layoutId="activeVibe" className="absolute top-4 right-4 w-3 h-3 bg-white rounded-full" />
)}
</motion.button>
))}
</div>
</div>
</Reveal>
{/* Color Scheme */}
<Reveal width="100%" delay={0.2}>
<div className="space-y-12">
<div className="flex justify-between items-center">
<div className="space-y-1">
<h4 className="text-2xl font-bold text-slate-900">Farbschema</h4>
<p className="text-slate-500">Definieren Sie Ihre Markenfarben oder lassen Sie sich inspirieren.</p>
</div>
<div className="flex items-center gap-4">
<motion.button
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
type="button"
onClick={generateHarmonicPalette}
className="flex items-center gap-2 px-6 py-3 rounded-full text-sm font-bold bg-white border border-slate-200 text-slate-900 hover:border-slate-900 transition-all"
>
<RefreshCw size={16} />
Zufall
</motion.button>
<motion.button
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
type="button"
onClick={() => toggleDontKnow('color_scheme')}
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'
}`}
>
Ich weiß es nicht
</motion.button>
</div>
</div>
{/* Custom Picker */}
<div className="space-y-8 p-10 bg-slate-50 rounded-[3rem] border border-slate-100">
<div className="flex items-center gap-3 text-slate-400 font-bold uppercase tracking-widest text-xs">
<Pipette size={16} />
Individuelle Farben
</div>
<div className="flex flex-wrap gap-6">
<AnimatePresence mode="popLayout">
{state.colorScheme.map((color, i) => (
<motion.div
key={`${i}-${color}`}
layout
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.8 }}
className="relative group"
>
<div className="relative w-24 h-24 rounded-3xl overflow-hidden border-2 border-white group-hover:scale-105 transition-transform duration-300">
<input
type="color"
value={color}
onChange={(e) => updateColor(i, e.target.value)}
className="absolute inset-[-100%] w-[300%] h-[300%] cursor-pointer outline-none border-none appearance-none bg-transparent"
/>
<div className="absolute inset-0 pointer-events-none border border-black/5 rounded-3xl" />
</div>
<div className="mt-2 text-center font-mono text-[10px] text-slate-400 uppercase">{color}</div>
{state.colorScheme.length > 1 && (
<motion.button
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.9 }}
type="button"
onClick={() => removeColor(i)}
className="absolute -top-3 -right-3 w-8 h-8 bg-white text-red-500 rounded-full flex items-center justify-center border border-slate-100 opacity-0 group-hover:opacity-100 transition-all duration-300 z-10"
>
<X size={16} strokeWidth={3} />
</motion.button>
)}
</motion.div>
))}
</AnimatePresence>
{state.colorScheme.length < 5 && (
<motion.button
layout
whileHover={{ scale: 1.05, borderColor: '#0f172a', color: '#0f172a' }}
whileTap={{ scale: 0.95 }}
type="button"
onClick={addColor}
className="w-24 h-24 rounded-3xl border-2 border-dashed border-slate-300 flex flex-col items-center justify-center text-slate-400 transition-all duration-300 bg-white/50 hover:bg-white"
>
<Plus size={32} />
<span className="text-[10px] font-bold uppercase mt-1">Add</span>
</motion.button>
)}
</div>
<p className="text-sm text-slate-400 font-medium">Klicken Sie auf eine Farbe, um sie anzupassen. Sie können bis zu 5 Farben definieren.</p>
</div>
</div>
</Reveal>
{/* 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 weitere konkrete Vorstellungen?"
value={state.designWishes}
onChange={(e) => updateState({ designWishes: e.target.value })}
/>
</Reveal>
</div>
);
}

View File

@@ -0,0 +1,95 @@
'use client';
import * as React from 'react';
import { FormState } from '../types';
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, HelpCircle } from 'lucide-react';
interface FeaturesStepProps {
state: FormState;
updateState: (updates: Partial<FormState>) => void;
toggleItem: (list: string[], id: string) => string[];
}
export function FeaturesStep({ state, updateState, toggleItem }: FeaturesStepProps) {
const toggleDontKnow = (id: string) => {
const current = state.dontKnows || [];
if (current.includes(id)) {
updateState({ dontKnows: current.filter(i => i !== id) });
} else {
updateState({ dontKnows: [...current, id] });
}
};
return (
<div className="space-y-16">
<div className="space-y-8">
<div className="flex justify-between items-center">
<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">
<LayoutGrid size={24} />
</div>
<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 whitespace-nowrap shrink-0 ${
state.dontKnows?.includes('features') ? 'bg-slate-900 text-white' : 'bg-slate-100 text-slate-500 hover:bg-slate-200'
}`}
>
Ich weiß es nicht
</motion.button>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{FEATURE_OPTIONS.map((opt, index) => (
<motion.div
key={opt.id}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: index * 0.05 }}
>
<Checkbox
label={opt.label} desc={opt.desc}
checked={state.features.includes(opt.id)}
onChange={() => updateState({ features: toggleItem(state.features, opt.id) })}
/>
</motion.div>
))}
</div>
</div>
<div className="space-y-12">
<div className="space-y-8">
<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">
<ListPlus size={24} />
</div>
<h4 className="text-2xl font-bold text-slate-900">Weitere inhaltliche Module?</h4>
</div>
<RepeatableList
items={state.otherFeatures}
onAdd={(v) => updateState({ otherFeatures: [...state.otherFeatures, v] })}
onRemove={(i) => updateState({ otherFeatures: state.otherFeatures.filter((_, idx) => idx !== i) })}
placeholder="z.B. Glossar, Download-Center, Partner-Bereich..."
/>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,142 @@
'use client';
import * as React from 'react';
import { FormState } from '../types';
import { Checkbox } from '../components/Checkbox';
import { RepeatableList } from '../components/RepeatableList';
import { motion, AnimatePresence } from 'framer-motion';
import { Minus, Plus, Cpu, ListPlus } from 'lucide-react';
import { Reveal } from '../../Reveal';
interface FunctionsStepProps {
state: FormState;
updateState: (updates: Partial<FormState>) => void;
toggleItem: (list: string[], id: string) => string[];
}
export function FunctionsStep({ state, updateState, toggleItem }: FunctionsStepProps) {
const isWebApp = state.projectType === 'web-app';
const toggleDontKnow = (id: string) => {
const current = state.dontKnows || [];
if (current.includes(id)) {
updateState({ dontKnows: current.filter(i => i !== id) });
} else {
updateState({ dontKnows: [...current, id] });
}
};
return (
<div className="space-y-12">
<Reveal width="100%" delay={0.1}>
<div className="space-y-8">
<div className="flex justify-between items-center">
<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">
<Cpu size={24} />
</div>
<h4 className="text-2xl font-bold text-slate-900">
{isWebApp ? 'Funktionale Anforderungen' : 'Erweiterte Funktionen'}
</h4>
</div>
<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 whitespace-nowrap shrink-0 ${
state.dontKnows?.includes('functions') ? 'bg-slate-900 text-white' : 'bg-slate-100 text-slate-500 hover:bg-slate-200'
}`}
>
Ich weiß es nicht
</motion.button>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{isWebApp ? (
<>
<Checkbox
label="Dashboard & Analytics" desc="Visualisierung von Daten und Kennzahlen."
checked={state.functions.includes('dashboard')}
onChange={() => updateState({ functions: toggleItem(state.functions, 'dashboard') })}
/>
<Checkbox
label="Dateiverwaltung" desc="Upload, Download und Organisation von Dokumenten."
checked={state.functions.includes('files')}
onChange={() => updateState({ functions: toggleItem(state.functions, 'files') })}
/>
<Checkbox
label="Benachrichtigungen" desc="E-Mail, Push oder In-App Alerts."
checked={state.functions.includes('notifications')}
onChange={() => updateState({ functions: toggleItem(state.functions, 'notifications') })}
/>
<Checkbox
label="Export-Funktionen" desc="CSV, Excel oder PDF Generierung."
checked={state.functions.includes('export')}
onChange={() => updateState({ functions: toggleItem(state.functions, 'export') })}
/>
</>
) : (
<>
<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="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="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') })}
/>
</>
)}
</div>
</div>
</Reveal>
<Reveal width="100%" delay={0.2}>
<div className="space-y-12">
<div className="space-y-8">
<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">
<ListPlus size={24} />
</div>
<h4 className="text-2xl font-bold text-slate-900">Weitere spezifische Wünsche?</h4>
</div>
<RepeatableList
items={state.otherFunctions}
onAdd={(v) => updateState({ otherFunctions: [...state.otherFunctions, v] })}
onRemove={(i) => updateState({ otherFunctions: state.otherFunctions.filter((_, idx) => idx !== i) })}
placeholder={isWebApp ? "z.B. Echtzeit-Chat, KI-Anbindung, Offline-Modus..." : "z.B. Mitgliederbereich, Event-Kalender, geschützte Downloads..."}
/>
</div>
</div>
</Reveal>
</div>
);
}

View File

@@ -0,0 +1,168 @@
'use client';
import * as React from 'react';
import { FormState } from '../types';
import { Globe, Info, Plus, X } from 'lucide-react';
import { motion, AnimatePresence } from 'framer-motion';
import { Reveal } from '../../Reveal';
interface LanguageStepProps {
state: FormState;
updateState: (updates: Partial<FormState>) => void;
}
const COMMON_LANGUAGES = [
{ id: 'de', label: 'Deutsch' },
{ id: 'en', label: 'Englisch' },
{ id: 'fr', label: 'Französisch' },
{ id: 'es', label: 'Spanisch' },
{ id: 'it', label: 'Italienisch' },
];
export function LanguageStep({ state, updateState }: LanguageStepProps) {
const basePriceExplanation = "Jede zusätzliche Sprache erhöht den Gesamtaufwand für Design, Entwicklung und Qualitätssicherung um ca. 20%. Dies deckt die technische Implementierung der Übersetzungsschicht sowie die Anpassung von Layouts für unterschiedliche Textlängen ab.";
const toggleLanguage = (lang: string) => {
const current = state.languagesList || [];
if (current.includes(lang)) {
updateState({ languagesList: current.filter(l => l !== lang) });
} else {
updateState({ languagesList: [...current, lang] });
}
};
const toggleDontKnow = (id: string) => {
const current = state.dontKnows || [];
if (current.includes(id)) {
updateState({ dontKnows: current.filter(i => i !== id) });
} else {
updateState({ dontKnows: [...current, id] });
}
};
const languagesCount = state.languagesList.length || 1;
return (
<div className="space-y-12">
<Reveal width="100%" delay={0.1}>
<div className="space-y-8">
<div className="flex justify-between items-center">
<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">
<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 whitespace-nowrap shrink-0 ${
state.dontKnows?.includes('languages') ? 'bg-slate-900 text-white' : 'bg-slate-100 text-slate-500 hover:bg-slate-200'
}`}
>
Ich weiß es nicht
</motion.button>
</div>
<div className="space-y-8">
<p className="text-lg text-slate-500 leading-relaxed ml-2">
Welche Sprachen soll Ihre Website unterstützen?
</p>
<div className="flex flex-wrap gap-4">
{COMMON_LANGUAGES.map((lang) => (
<motion.button
key={lang.id}
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
type="button"
onClick={() => toggleLanguage(lang.label)}
className={`px-8 py-4 rounded-2xl font-bold transition-all border-2 ${
state.languagesList.includes(lang.label)
? 'bg-slate-900 border-slate-900 text-white'
: 'bg-white border-slate-100 text-slate-600 hover:border-slate-300'
}`}
>
{lang.label}
</motion.button>
))}
</div>
<div className="pt-4">
<div className="flex gap-3">
<input
type="text"
placeholder="Weitere Sprache hinzufügen..."
className="flex-1 p-6 bg-white border border-slate-100 rounded-2xl focus:outline-none focus:border-slate-900 transition-colors text-lg"
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
const val = e.currentTarget.value.trim();
if (val && !state.languagesList.includes(val)) {
updateState({ languagesList: [...state.languagesList, val] });
e.currentTarget.value = '';
}
}
}}
/>
</div>
</div>
<div className="flex flex-wrap gap-3">
<AnimatePresence>
{state.languagesList.filter(l => !COMMON_LANGUAGES.find(cl => cl.label === l)).map((lang, i) => (
<motion.div
key={lang}
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.8 }}
className="flex items-center gap-3 px-6 py-3 bg-slate-100 rounded-full text-base font-bold text-slate-700"
>
<span>{lang}</span>
<button
type="button"
onClick={() => updateState({ languagesList: state.languagesList.filter(l => l !== lang) })}
className="text-slate-400 hover:text-slate-900 transition-colors"
>
<X size={18} />
</button>
</motion.div>
))}
</AnimatePresence>
</div>
</div>
</div>
</Reveal>
<Reveal width="100%" delay={0.3}>
<div className="p-10 bg-slate-900 text-white rounded-[3rem] space-y-8 relative overflow-hidden">
<div className="absolute top-0 right-0 w-64 h-64 bg-white/5 rounded-full -mr-32 -mt-32 blur-3xl" />
<div className="flex items-center gap-4 text-slate-400 relative z-10">
<Info size={24} />
<span className="text-sm font-bold uppercase tracking-widest">Warum dieser Faktor?</span>
</div>
<p className="text-lg leading-relaxed text-slate-300 relative z-10 max-w-3xl">
{basePriceExplanation}
</p>
{languagesCount > 1 && (
<div className="pt-8 border-t border-white/10 relative z-10">
<div className="flex justify-between items-center">
<span className="text-lg font-medium text-slate-400">Aktueller Aufschlagsfaktor:</span>
<motion.span
initial={{ scale: 0.8 }}
animate={{ scale: 1 }}
className="text-4xl font-bold text-white"
>
+{((languagesCount - 1) * 20)}%
</motion.span>
</div>
</div>
)}
</div>
</Reveal>
</div>
);
}

View File

@@ -0,0 +1,150 @@
'use client';
import * as React from 'react';
import { FormState } from '../types';
import { Checkbox } from '../components/Checkbox';
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';
interface PresenceStepProps {
state: FormState;
updateState: (updates: Partial<FormState>) => void;
toggleItem: (list: string[], id: string) => string[];
}
export function PresenceStep({ state, updateState, toggleItem }: PresenceStepProps) {
const updateUrl = (id: string, url: string) => {
updateState({
socialMediaUrls: {
...state.socialMediaUrls,
[id]: url
}
});
};
const SOCIAL_PLATFORMS = [
{ 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 (
<div className="space-y-16">
<Reveal width="100%" delay={0.1}>
<div className="space-y-8">
<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">
<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)"
type="url"
icon={Link2}
placeholder="https://www.beispiel.de"
value={state.existingWebsite}
onChange={(e) => updateState({ existingWebsite: e.target.value })}
/>
</div>
</Reveal>
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
<Reveal width="100%" delay={0.2}>
<Input
label="Bestehende Domain"
placeholder="z.B. beispiel.de"
value={state.existingDomain}
onChange={(e) => updateState({ existingDomain: e.target.value })}
/>
</Reveal>
<Reveal width="100%" delay={0.2}>
<Input
label="Wunsch-Domain"
placeholder="z.B. neue-marke.de"
value={state.wishedDomain}
onChange={(e) => updateState({ wishedDomain: e.target.value })}
/>
</Reveal>
</div>
<Reveal width="100%" delay={0.3}>
<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>
<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: -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={`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>
);
})}
</div>
<div className="space-y-4">
<AnimatePresence mode="popLayout">
{state.socialMedia.map((id) => {
const platform = SOCIAL_PLATFORMS.find(p => p.id === id);
if (!platform) return null;
return (
<motion.div
key={id}
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: 20 }}
layout
className="relative group"
>
<div className="absolute left-6 top-1/2 -translate-y-1/2 flex items-center gap-3 text-slate-400 group-focus-within:text-slate-900 transition-colors">
<span className="font-bold text-xs uppercase tracking-widest w-20">{platform.label}</span>
<div className="w-[1px] h-4 bg-slate-200" />
<Link2 size={18} />
</div>
<input
type="url"
placeholder={`https://${platform.id}.com/ihr-profil`}
value={state.socialMediaUrls[id] || ''}
onChange={(e) => updateUrl(id, e.target.value)}
className="w-full p-6 pl-40 bg-white border border-slate-100 rounded-[2rem] focus:outline-none focus:border-slate-900 transition-all duration-500 text-lg focus:shadow-xl"
/>
</motion.div>
);
})}
</AnimatePresence>
{state.socialMedia.length === 0 && (
<div className="p-12 border-2 border-dashed border-slate-100 rounded-[3rem] text-center">
<p className="text-slate-400 font-medium">Wählen Sie oben Ihre Kanäle aus, um die Links hinzuzufügen.</p>
</div>
)}
</div>
</div>
</Reveal>
</div>
);
}

View File

@@ -0,0 +1,85 @@
'use client';
import * as React from 'react';
import { FormState } from '../types';
import { AlertCircle } from 'lucide-react';
interface TimelineStepProps {
state: FormState;
updateState: (updates: Partial<FormState>) => void;
}
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)) {
updateState({ dontKnows: current.filter(i => i !== id) });
} else {
updateState({ dontKnows: [...current, id] });
}
};
return (
<div className="space-y-12">
<div className="space-y-6">
<div className="flex justify-between items-center">
<h4 className="text-2xl font-bold text-slate-900">Zeitplan</h4>
<button
type="button"
onClick={() => toggleDontKnow('timeline')}
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'
}`}
>
Ich weiß es nicht
</button>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{[
{ id: 'asap', label: 'So schnell wie möglich', desc: 'Priorisierter Start gewünscht.' },
{ id: '2-3-months', label: 'In 2-3 Monaten', desc: 'Normaler Projektvorlauf.' },
{ id: '3-6-months', label: 'In 3-6 Monaten', desc: 'Langfristige Planung.' },
{ id: 'flexible', label: 'Flexibel', desc: 'Kein fester Termindruck.' },
].map(opt => (
<button
key={opt.id}
type="button"
onClick={() => updateState({ deadline: opt.id })}
className={`p-10 rounded-[2.5rem] border-2 text-left transition-all focus:outline-none overflow-hidden relative ${
state.deadline === opt.id ? 'border-slate-900 bg-slate-900 text-white' : 'border-slate-100 bg-white hover:border-slate-200'
}`}
>
<h4 className={`text-2xl font-bold mb-2 ${state.deadline === opt.id ? 'text-white' : 'text-slate-900'}`}>{opt.label}</h4>
<p className={`text-lg ${state.deadline === opt.id ? 'text-slate-200' : 'text-slate-500'}`}>{opt.desc}</p>
</button>
))}
</div>
</div>
{state.deadline === 'asap' && (
<div className="p-8 bg-slate-50 rounded-[2rem] border border-slate-100 flex gap-6 items-start">
<AlertCircle className="text-slate-900 shrink-0 mt-1" size={28} />
<p className="text-base text-slate-600 leading-relaxed">
<strong>Hinweis:</strong> Bei sehr kurzfristigen Deadlines kann ein Express-Zuschlag anfallen, um die Kapazitäten entsprechend zu priorisieren.
</p>
</div>
)}
{(isMissingAssets || isMissingPages) && (
<div className="p-8 bg-amber-50 rounded-[2rem] border border-amber-100 flex gap-6 items-start">
<div className="w-12 h-12 bg-white rounded-xl flex items-center justify-center text-amber-600 shrink-0">
<AlertCircle size={24} />
</div>
<div className="space-y-2">
<p className="text-amber-900 text-xl font-bold">Mögliche Verzögerungen</p>
<p className="text-amber-800 text-base leading-relaxed">
Für einen reibungslosen Projektstart benötigen wir noch einige Details (z.B. {isMissingAssets ? 'Logo/Inhaltskonzept' : ''} {isMissingAssets && isMissingPages ? 'und' : ''} {isMissingPages ? 'Seitenstruktur' : ''}). Ohne diese kann sich der Beginn verzögern.
</p>
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,51 @@
'use client';
import * as React from 'react';
import { FormState, ProjectType } from '../types';
import { ConceptWebsite, ConceptSystem } from '../../Landing/ConceptIllustrations';
import { motion } from 'framer-motion';
import { Reveal } from '../../Reveal';
interface TypeStepProps {
state: FormState;
updateState: (updates: Partial<FormState>) => void;
}
export function TypeStep({ state, updateState }: TypeStepProps) {
return (
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
{[
{ id: 'website', label: 'Website', desc: 'Klassische Webpräsenz, Portfolio oder Blog.', illustration: <ConceptWebsite className="w-20 h-20 mb-6" /> },
{ id: 'web-app', label: 'Web App', desc: 'Internes Tool, Dashboard oder Prozess-Logik.', illustration: <ConceptSystem className="w-20 h-20 mb-6" /> },
].map((type, index) => (
<Reveal key={type.id} width="100%" delay={index * 0.1}>
<motion.button
id={`focus-target-${type.id}`}
whileHover={{ y: -8 }}
whileTap={{ scale: 0.98 }}
type="button"
onClick={() => updateState({ projectType: type.id as ProjectType })}
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-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
layoutId="activeType"
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>
))}
</div>
);
}

View File

@@ -0,0 +1,144 @@
'use client';
import * as React from 'react';
import { FormState } from '../types';
import { Users, Shield, Monitor, Smartphone, Globe, Lock } from 'lucide-react';
interface WebAppStepProps {
state: FormState;
updateState: (updates: Partial<FormState>) => void;
}
export function WebAppStep({ state, updateState }: WebAppStepProps) {
const toggleUserRole = (role: string) => {
const current = state.userRoles || [];
const next = current.includes(role)
? current.filter(r => r !== role)
: [...current, role];
updateState({ userRoles: next });
};
return (
<div className="space-y-12">
{/* Target Audience */}
<div className="space-y-6">
<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.' },
{ id: 'external', label: 'Kunden-Portal', desc: 'Für Ihre Endnutzer (B2B/B2C).' },
].map(opt => (
<button
key={opt.id}
type="button"
onClick={() => updateState({ targetAudience: opt.id })}
className={`p-8 rounded-[2rem] border-2 text-left transition-all ${
state.targetAudience === opt.id ? 'border-slate-900 bg-slate-900 text-white' : 'border-slate-100 bg-white hover:border-slate-200'
}`}
>
<p className="text-xl font-bold">{opt.label}</p>
<p className={`text-base mt-2 ${state.targetAudience === opt.id ? 'text-slate-200' : 'text-slate-500'}`}>{opt.desc}</p>
</button>
))}
</div>
</div>
{/* User Roles */}
<div className="space-y-6">
<h4 className="text-2xl font-bold text-slate-900">Benutzerrollen</h4>
<p className="text-lg text-slate-500">Wer wird das System nutzen?</p>
<div className="flex flex-wrap gap-4">
{['Administratoren', 'Manager', 'Standard-Nutzer', 'Gäste', 'Read-Only'].map(role => (
<button
key={role}
type="button"
onClick={() => toggleUserRole(role)}
className={`px-8 py-4 rounded-full border-2 font-bold text-base transition-all ${
(state.userRoles || []).includes(role) ? 'border-slate-900 bg-slate-900 text-white' : 'border-slate-100 bg-white hover:border-slate-200'
}`}
>
{role}
</button>
))}
</div>
</div>
{/* Platform Type */}
<div className="space-y-6">
<h4 className="text-2xl font-bold text-slate-900 flex items-center gap-3">
<Monitor size={24} className="text-black" /> Plattform-Fokus
</h4>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
{[
{ id: 'desktop', label: 'Desktop First', icon: <Monitor size={24} /> },
{ id: 'mobile', label: 'Mobile First', icon: <Smartphone size={24} /> },
{ id: 'pwa', label: 'PWA (Installierbar)', icon: <Globe size={24} /> },
].map(opt => (
<button
key={opt.id}
type="button"
onClick={() => updateState({ platformType: opt.id })}
className={`p-8 rounded-[2rem] border-2 flex flex-col items-center gap-4 transition-all ${
state.platformType === opt.id ? 'border-slate-900 bg-slate-900 text-white' : 'border-slate-100 bg-white hover:border-slate-200'
}`}
>
<div className={state.platformType === opt.id ? 'text-white' : 'text-black'}>
{opt.icon}
</div>
<span className="font-bold text-lg">{opt.label}</span>
</button>
))}
</div>
</div>
{/* Data Sensitivity */}
<div className="space-y-6">
<h4 className="text-2xl font-bold text-slate-900 flex items-center gap-3">
<Shield size={24} className="text-black" /> Datensicherheit
</h4>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{[
{ id: 'standard', label: 'Standard', desc: 'Normale Nutzerdaten & Profile.' },
{ id: 'high', label: 'Sensibel', desc: 'Finanzdaten, Gesundheitsdaten oder DSGVO-kritisch.' },
].map(opt => (
<button
key={opt.id}
type="button"
onClick={() => updateState({ dataSensitivity: opt.id })}
className={`p-8 rounded-[2rem] border-2 text-left transition-all ${
state.dataSensitivity === opt.id ? 'border-slate-900 bg-slate-900 text-white' : 'border-slate-100 bg-white hover:border-slate-200'
}`}
>
<p className="text-xl font-bold">{opt.label}</p>
<p className={`text-base mt-2 ${state.dataSensitivity === opt.id ? 'text-slate-200' : 'text-slate-500'}`}>{opt.desc}</p>
</button>
))}
</div>
</div>
{/* Authentication */}
<div className="p-10 bg-slate-50 rounded-[3rem] border border-slate-100 space-y-6">
<h4 className="text-2xl font-bold text-slate-900 flex items-center gap-3">
<Lock size={24} className="text-black" /> Authentifizierung
</h4>
<p className="text-lg text-slate-500">Wie sollen sich Nutzer anmelden?</p>
<div className="flex flex-wrap gap-4">
{['E-Mail / Passwort', 'Social Login', 'SSO / SAML', '2FA / MFA', 'Magic Links'].map(method => (
<div
key={method}
className="px-8 py-4 rounded-full border-2 border-white bg-white font-bold text-base text-slate-400"
>
{method}
</div>
))}
</div>
<p className="text-xs text-slate-400 italic">Details zur Authentifizierung besprechen wir im Erstgespräch.</p>
</div>
</div>
);
}

View File

@@ -0,0 +1,85 @@
import * as React from 'react';
export type ProjectType = 'website' | 'web-app';
export interface FormState {
projectType: ProjectType;
// Company
companyName: string;
employeeCount: string;
// Existing Presence
existingWebsite: string;
socialMedia: string[];
socialMediaUrls: Record<string, string>;
existingDomain: string;
wishedDomain: string;
// Project
websiteTopic: string;
selectedPages: string[];
otherPages: string[];
otherPagesCount: number;
features: string[];
otherFeatures: string[];
otherFeaturesCount: number;
functions: string[];
otherFunctions: string[];
otherFunctionsCount: number;
apiSystems: string[];
otherTech: string[];
otherTechCount: number;
assets: string[];
otherAssets: string[];
otherAssetsCount: number;
newDatasets: number;
cmsSetup: boolean;
storageExpansion: number;
name: string;
email: string;
role: string;
message: string;
sitemapFile: File | null;
contactFiles: File[];
// Design
designVibe: string;
colorScheme: string[];
references: string[];
designWishes: string;
// Maintenance
expectedAdjustments: string;
languagesList: string[];
// Timeline
deadline: string;
// Web App specific
targetAudience: string;
userRoles: string[];
dataSensitivity: string;
platformType: string;
// Meta
dontKnows: string[];
visualStaging: string;
complexInteractions: string;
}
export interface Totals {
totalPrice: number;
monthlyPrice: number;
totalPagesCount: number;
totalFeatures: number;
totalFunctions: number;
totalApis: number;
languagesCount: number;
}
export interface Step {
id: string;
title: string;
description: string;
illustration: React.ReactNode;
chapter?: string;
}
export interface Chapter {
id: string;
title: string;
steps: string[];
}

View File

@@ -0,0 +1,6 @@
import { calculatePositions as logicCalculatePositions } from '../../logic/pricing';
import { FormState } from './types';
export type { Position } from '../../logic/pricing';
export const calculatePositions = (state: FormState, pricing: any) => logicCalculatePositions(state as any, pricing);