chore: overhaul infrastructure and integrate @mintel packages
Some checks failed
🧪 CI (QA) / 🧪 Quality Assurance (push) Failing after 1m3s
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:
@@ -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>;
|
||||
}
|
||||
48
apps/web/src/components/ContactForm/components/Checkbox.tsx
Normal file
48
apps/web/src/components/ContactForm/components/Checkbox.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
36
apps/web/src/components/ContactForm/components/Input.tsx
Normal file
36
apps/web/src/components/ContactForm/components/Input.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
72
apps/web/src/components/ContactForm/constants.tsx
Normal file
72
apps/web/src/components/ContactForm/constants.tsx
Normal 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]
|
||||
}));
|
||||
111
apps/web/src/components/ContactForm/steps/ApiStep.tsx
Normal file
111
apps/web/src/components/ContactForm/steps/ApiStep.tsx
Normal 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) });
|
||||
}
|
||||
}
|
||||
98
apps/web/src/components/ContactForm/steps/AssetsStep.tsx
Normal file
98
apps/web/src/components/ContactForm/steps/AssetsStep.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
168
apps/web/src/components/ContactForm/steps/BaseStep.tsx
Normal file
168
apps/web/src/components/ContactForm/steps/BaseStep.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
64
apps/web/src/components/ContactForm/steps/CompanyStep.tsx
Normal file
64
apps/web/src/components/ContactForm/steps/CompanyStep.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
170
apps/web/src/components/ContactForm/steps/ContactStep.tsx
Normal file
170
apps/web/src/components/ContactForm/steps/ContactStep.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
184
apps/web/src/components/ContactForm/steps/ContentStep.tsx
Normal file
184
apps/web/src/components/ContactForm/steps/ContentStep.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
242
apps/web/src/components/ContactForm/steps/DesignStep.tsx
Normal file
242
apps/web/src/components/ContactForm/steps/DesignStep.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
95
apps/web/src/components/ContactForm/steps/FeaturesStep.tsx
Normal file
95
apps/web/src/components/ContactForm/steps/FeaturesStep.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
142
apps/web/src/components/ContactForm/steps/FunctionsStep.tsx
Normal file
142
apps/web/src/components/ContactForm/steps/FunctionsStep.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
168
apps/web/src/components/ContactForm/steps/LanguageStep.tsx
Normal file
168
apps/web/src/components/ContactForm/steps/LanguageStep.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
150
apps/web/src/components/ContactForm/steps/PresenceStep.tsx
Normal file
150
apps/web/src/components/ContactForm/steps/PresenceStep.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
85
apps/web/src/components/ContactForm/steps/TimelineStep.tsx
Normal file
85
apps/web/src/components/ContactForm/steps/TimelineStep.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
51
apps/web/src/components/ContactForm/steps/TypeStep.tsx
Normal file
51
apps/web/src/components/ContactForm/steps/TypeStep.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
144
apps/web/src/components/ContactForm/steps/WebAppStep.tsx
Normal file
144
apps/web/src/components/ContactForm/steps/WebAppStep.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
85
apps/web/src/components/ContactForm/types.ts
Normal file
85
apps/web/src/components/ContactForm/types.ts
Normal 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[];
|
||||
}
|
||||
6
apps/web/src/components/ContactForm/utils.ts
Normal file
6
apps/web/src/components/ContactForm/utils.ts
Normal 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);
|
||||
Reference in New Issue
Block a user