Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 11s
Build & Deploy / 🧪 QA (push) Successful in 4m5s
Build & Deploy / 🏗️ Build (push) Successful in 9m26s
Build & Deploy / 🚀 Deploy (push) Successful in 25s
Build & Deploy / 🩺 Health Check (push) Failing after 12s
Build & Deploy / 🔔 Notify (push) Successful in 2s
844 lines
30 KiB
TypeScript
844 lines
30 KiB
TypeScript
"use client";
|
|
|
|
import * as React from "react";
|
|
import { useState, useMemo, useEffect, useRef } from "react";
|
|
import { useRouter, useSearchParams } from "next/navigation";
|
|
import { motion, AnimatePresence } from "framer-motion";
|
|
import {
|
|
ChevronRight,
|
|
ChevronLeft,
|
|
Send,
|
|
Check,
|
|
Sparkles,
|
|
Info,
|
|
} from "lucide-react";
|
|
import * as QRCode from "qrcode";
|
|
import * as confetti from "canvas-confetti";
|
|
|
|
import { FormState, Step } from "./ContactForm/types";
|
|
import { PRICING, initialState } from "./ContactForm/constants";
|
|
import { calculateTotals } from "@mintel/pdf";
|
|
import { PriceCalculation } from "./ContactForm/components/PriceCalculation";
|
|
import { ShareModal } from "./ShareModal";
|
|
|
|
// Steps
|
|
import { TypeStep } from "./ContactForm/steps/TypeStep";
|
|
import { CompanyStep } from "./ContactForm/steps/CompanyStep";
|
|
import { PresenceStep } from "./ContactForm/steps/PresenceStep";
|
|
import { BaseStep } from "./ContactForm/steps/BaseStep";
|
|
/* eslint-disable no-unused-vars */
|
|
|
|
import { FeaturesStep } from "./ContactForm/steps/FeaturesStep";
|
|
import { DesignStep } from "./ContactForm/steps/DesignStep";
|
|
import { AssetsStep } from "./ContactForm/steps/AssetsStep";
|
|
import { FunctionsStep } from "./ContactForm/steps/FunctionsStep";
|
|
import { ApiStep } from "./ContactForm/steps/ApiStep";
|
|
import { ContentStep } from "./ContactForm/steps/ContentStep";
|
|
import { LanguageStep } from "./ContactForm/steps/LanguageStep";
|
|
import { TimelineStep } from "./ContactForm/steps/TimelineStep";
|
|
import { ContactStep } from "./ContactForm/steps/ContactStep";
|
|
import { WebAppStep } from "./ContactForm/steps/WebAppStep";
|
|
|
|
import {
|
|
ConceptTarget,
|
|
ConceptWebsite,
|
|
ConceptPrototyping,
|
|
ConceptCommunication,
|
|
ConceptSystem,
|
|
ConceptCode,
|
|
ConceptAutomation,
|
|
ConceptPrice,
|
|
HeroArchitecture,
|
|
} from "./Landing/ConceptIllustrations";
|
|
|
|
export interface ContactFormProps {
|
|
initialStepIndex?: number;
|
|
initialState?: FormState;
|
|
onStepChange?: (_index: number) => void;
|
|
onStateChange?: (_state: FormState) => void;
|
|
}
|
|
|
|
export function ContactForm({
|
|
initialStepIndex,
|
|
initialState: propState,
|
|
onStepChange,
|
|
onStateChange,
|
|
}: ContactFormProps = {}) {
|
|
// Use a safe version of useRouter/useSearchParams that doesn't crash if not in a router context
|
|
let router: any = null;
|
|
let searchParams: any = null;
|
|
try {
|
|
router = useRouter();
|
|
} catch (_e) {
|
|
/* ignore */
|
|
}
|
|
try {
|
|
searchParams = useSearchParams();
|
|
} catch (_e) {
|
|
/* ignore */
|
|
}
|
|
|
|
const [internalStepIndex, setInternalStepIndex] = useState(0);
|
|
const [internalState, setInternalState] = useState<FormState>(initialState);
|
|
|
|
// Sync with props if provided
|
|
const stepIndex =
|
|
initialStepIndex !== undefined ? initialStepIndex : internalStepIndex;
|
|
const state = propState !== undefined ? propState : internalState;
|
|
|
|
const setStepIndex = (val: number) => {
|
|
setInternalStepIndex(val);
|
|
onStepChange?.(val);
|
|
};
|
|
|
|
const setState = (val: any) => {
|
|
if (typeof val === "function") {
|
|
setInternalState((prev) => {
|
|
const next = val(prev);
|
|
onStateChange?.(next);
|
|
return next;
|
|
});
|
|
} else {
|
|
setInternalState(val);
|
|
onStateChange?.(val);
|
|
}
|
|
};
|
|
|
|
const [isSubmitted, setIsSubmitted] = useState(false);
|
|
const [qrCodeData, setQrCodeData] = useState<string>("");
|
|
const [isShareModalOpen, setIsShareModalOpen] = useState(false);
|
|
const [hoveredStep, setHoveredStep] = useState<number | null>(null);
|
|
const [isSticky, setIsSticky] = useState(false);
|
|
const formContainerRef = useRef<HTMLDivElement>(null);
|
|
|
|
const isRemotion =
|
|
typeof window !== "undefined" && (window as any).isRemotion;
|
|
const [isClient, setIsClient] = useState(isRemotion);
|
|
|
|
useEffect(() => {
|
|
if (isRemotion) return;
|
|
const handleScroll = () => {
|
|
if (formContainerRef.current) {
|
|
const rect = formContainerRef.current.getBoundingClientRect();
|
|
setIsSticky(rect.top <= 80);
|
|
}
|
|
};
|
|
window.addEventListener("scroll", handleScroll);
|
|
handleScroll();
|
|
return () => window.removeEventListener("scroll", handleScroll);
|
|
}, [isRemotion]);
|
|
|
|
useEffect(() => {
|
|
if (!isRemotion) setIsClient(true);
|
|
}, [isRemotion]);
|
|
|
|
// URL Binding
|
|
useEffect(() => {
|
|
if (!searchParams) return;
|
|
const step = searchParams.get("step");
|
|
if (step) setStepIndex(parseInt(step));
|
|
|
|
const config = searchParams.get("config");
|
|
if (config) {
|
|
try {
|
|
const decoded = JSON.parse(decodeURIComponent(escape(atob(config))));
|
|
setInternalState((s: FormState) => ({ ...s, ...decoded }));
|
|
} catch (e) {
|
|
console.error("Failed to decode config", e);
|
|
}
|
|
}
|
|
}, [searchParams]);
|
|
|
|
const currentUrl = useMemo(() => {
|
|
if (!isClient) return "";
|
|
const params = new URLSearchParams();
|
|
params.set("step", stepIndex.toString());
|
|
|
|
const configData = {
|
|
projectType: state.projectType,
|
|
companyName: state.companyName,
|
|
employeeCount: state.employeeCount,
|
|
existingWebsite: state.existingWebsite,
|
|
socialMedia: state.socialMedia,
|
|
socialMediaUrls: state.socialMediaUrls,
|
|
existingDomain: state.existingDomain,
|
|
wishedDomain: state.wishedDomain,
|
|
websiteTopic: state.websiteTopic,
|
|
selectedPages: state.selectedPages,
|
|
otherPages: state.otherPages,
|
|
otherPagesCount: state.otherPagesCount,
|
|
features: state.features,
|
|
otherFeatures: state.otherFeatures,
|
|
otherFeaturesCount: state.otherFeaturesCount,
|
|
functions: state.functions,
|
|
otherFunctions: state.otherFunctions,
|
|
otherFunctionsCount: state.otherFunctionsCount,
|
|
apiSystems: state.apiSystems,
|
|
otherTech: state.otherTech,
|
|
otherTechCount: state.otherTechCount,
|
|
assets: state.assets,
|
|
otherAssets: state.otherAssets,
|
|
otherAssetsCount: state.otherAssetsCount,
|
|
cmsSetup: state.cmsSetup,
|
|
languagesList: state.languagesList,
|
|
deadline: state.deadline,
|
|
designVibe: state.designVibe,
|
|
colorScheme: state.colorScheme,
|
|
targetAudience: state.targetAudience,
|
|
userRoles: state.userRoles,
|
|
dataSensitivity: state.dataSensitivity,
|
|
platformType: state.platformType,
|
|
dontKnows: state.dontKnows,
|
|
visualStaging: state.visualStaging,
|
|
complexInteractions: state.complexInteractions,
|
|
};
|
|
|
|
const stateString = btoa(
|
|
unescape(encodeURIComponent(JSON.stringify(configData))),
|
|
);
|
|
params.set("config", stateString);
|
|
|
|
return `${window.location.origin}${window.location.pathname}?${params.toString()}`;
|
|
}, [state, stepIndex, isClient]);
|
|
|
|
useEffect(() => {
|
|
if (isRemotion) return;
|
|
if (currentUrl && router) {
|
|
router.replace(currentUrl, { scroll: false });
|
|
QRCode.toDataURL(currentUrl, { margin: 1, width: 200 }).then(
|
|
setQrCodeData,
|
|
);
|
|
}
|
|
}, [currentUrl, router, isRemotion]);
|
|
|
|
const totals = useMemo(() => {
|
|
return calculateTotals(state, PRICING);
|
|
}, [state]);
|
|
|
|
// Destructuring moved to PriceCalculation if only used there
|
|
// const { totalPrice, monthlyPrice, totalPagesCount } = totals;
|
|
|
|
const updateState = (updates: Partial<FormState>) => {
|
|
setState((s: FormState) => ({ ...s, ...updates }));
|
|
};
|
|
|
|
const toggleItem = (list: string[], id: string) => {
|
|
return list.includes(id) ? list.filter((i) => i !== id) : [...list, id];
|
|
};
|
|
|
|
const scrollToTop = () => {
|
|
if (isRemotion) return;
|
|
if (formContainerRef.current) {
|
|
const offset = 120;
|
|
const bodyRect = document.body.getBoundingClientRect().top;
|
|
const elementRect = formContainerRef.current.getBoundingClientRect().top;
|
|
const elementPosition = elementRect - bodyRect;
|
|
const offsetPosition = elementPosition - offset;
|
|
|
|
window.scrollTo({
|
|
top: offsetPosition,
|
|
behavior: "smooth",
|
|
});
|
|
}
|
|
};
|
|
|
|
const nextStep = () => {
|
|
if (stepIndex < activeSteps.length - 1) {
|
|
setStepIndex(stepIndex + 1);
|
|
if (!isRemotion) setTimeout(scrollToTop, 50);
|
|
}
|
|
};
|
|
|
|
const prevStep = () => {
|
|
if (stepIndex > 0) {
|
|
setStepIndex(stepIndex - 1);
|
|
if (!isRemotion) setTimeout(scrollToTop, 50);
|
|
}
|
|
};
|
|
|
|
const steps: Step[] = [
|
|
{
|
|
id: "type",
|
|
title: "Das Ziel",
|
|
description: "Was möchten Sie realisieren?",
|
|
illustration: <ConceptTarget className="w-full h-full" />,
|
|
chapter: "strategy",
|
|
},
|
|
{
|
|
id: "company",
|
|
title: "Unternehmen",
|
|
description: "Wer sind Sie?",
|
|
illustration: <ConceptCommunication className="w-full h-full" />,
|
|
chapter: "strategy",
|
|
},
|
|
{
|
|
id: "presence",
|
|
title: "Präsenz",
|
|
description: "Bestehende Kanäle von {company}.",
|
|
illustration: <ConceptSystem className="w-full h-full" />,
|
|
chapter: "strategy",
|
|
},
|
|
{
|
|
id: "features",
|
|
title: "Die Systeme",
|
|
description: "Welche inhaltlichen Bereiche planen wir für {company}?",
|
|
illustration: <ConceptPrototyping className="w-full h-full" />,
|
|
chapter: "scope",
|
|
},
|
|
{
|
|
id: "base",
|
|
title: "Die Seiten",
|
|
description: "Welche Seiten benötigen wir?",
|
|
illustration: <ConceptWebsite className="w-full h-full" />,
|
|
chapter: "scope",
|
|
},
|
|
{
|
|
id: "design",
|
|
title: "Design-Wünsche",
|
|
description: "Wie soll die neue Präsenz von {company} wirken?",
|
|
illustration: <ConceptCommunication className="w-full h-full" />,
|
|
chapter: "creative",
|
|
},
|
|
{
|
|
id: "assets",
|
|
title: "Ihre Assets",
|
|
description: "Was bringen Sie bereits mit?",
|
|
illustration: <ConceptSystem className="w-full h-full" />,
|
|
chapter: "creative",
|
|
},
|
|
{
|
|
id: "functions",
|
|
title: "Die Logik",
|
|
description: "Welche Funktionen werden benötigt?",
|
|
illustration: <ConceptCode className="w-full h-full" />,
|
|
chapter: "tech",
|
|
},
|
|
{
|
|
id: "api",
|
|
title: "Schnittstellen",
|
|
description: "Datenaustausch mit Drittsystemen.",
|
|
illustration: <ConceptAutomation className="w-full h-full" />,
|
|
chapter: "tech",
|
|
},
|
|
{
|
|
id: "content",
|
|
title: "Die Pflege",
|
|
description: "Wer kümmert sich um die Daten?",
|
|
illustration: <ConceptPrice className="w-full h-full" />,
|
|
chapter: "tech",
|
|
},
|
|
{
|
|
id: "language",
|
|
title: "Sprachen",
|
|
description: "Globale Reichweite planen.",
|
|
illustration: <ConceptCommunication className="w-full h-full" />,
|
|
chapter: "tech",
|
|
},
|
|
{
|
|
id: "timeline",
|
|
title: "Zeitplan",
|
|
description: "Wann soll das Projekt live gehen?",
|
|
illustration: <HeroArchitecture className="w-full h-full" />,
|
|
chapter: "final",
|
|
},
|
|
{
|
|
id: "contact",
|
|
title: "Abschluss",
|
|
description: "Erzählen Sie mir mehr über Ihr Vorhaben.",
|
|
illustration: <ConceptCommunication className="w-full h-full" />,
|
|
chapter: "final",
|
|
},
|
|
{
|
|
id: "webapp",
|
|
title: "Web App Details",
|
|
description: "Spezifische Anforderungen für {company}.",
|
|
illustration: <ConceptSystem className="w-full h-full" />,
|
|
chapter: "scope",
|
|
},
|
|
];
|
|
|
|
const chapters = [
|
|
{ id: "strategy", title: "Strategie" },
|
|
{ id: "scope", title: "Umfang" },
|
|
{ id: "creative", title: "Design" },
|
|
{ id: "tech", title: "Technik" },
|
|
{ id: "final", title: "Start" },
|
|
];
|
|
|
|
const activeSteps = useMemo(() => {
|
|
if (state.projectType === "website") {
|
|
return steps.filter((s) => s.id !== "webapp");
|
|
}
|
|
// Web App flow
|
|
return [
|
|
steps.find((s) => s.id === "type")!,
|
|
steps.find((s) => s.id === "company")!,
|
|
steps.find((s) => s.id === "presence")!,
|
|
steps.find((s) => s.id === "webapp")!,
|
|
{
|
|
...steps.find((s) => s.id === "functions")!,
|
|
title: "Funktionen",
|
|
description: "Kern-Features Ihrer Anwendung.",
|
|
},
|
|
{
|
|
...steps.find((s) => s.id === "api")!,
|
|
title: "Integrationen",
|
|
description: "Anbindung an bestehende Systeme.",
|
|
},
|
|
steps.find((s) => s.id === "timeline")!,
|
|
steps.find((s) => s.id === "contact")!,
|
|
];
|
|
}, [state.projectType, state.companyName]);
|
|
|
|
useEffect(() => {
|
|
if (stepIndex >= activeSteps.length) setStepIndex(activeSteps.length - 1);
|
|
}, [activeSteps, stepIndex]);
|
|
|
|
const renderStepContent = () => {
|
|
const currentStep = activeSteps[stepIndex];
|
|
switch (currentStep.id) {
|
|
case "type":
|
|
return <TypeStep state={state} updateState={updateState} />;
|
|
case "company":
|
|
return <CompanyStep state={state} updateState={updateState} />;
|
|
case "presence":
|
|
return (
|
|
<PresenceStep
|
|
state={state}
|
|
updateState={updateState}
|
|
toggleItem={toggleItem}
|
|
/>
|
|
);
|
|
case "base":
|
|
return (
|
|
<BaseStep
|
|
state={state}
|
|
updateState={updateState}
|
|
toggleItem={toggleItem}
|
|
/>
|
|
);
|
|
case "features":
|
|
return (
|
|
<FeaturesStep
|
|
state={state}
|
|
updateState={updateState}
|
|
toggleItem={toggleItem}
|
|
/>
|
|
);
|
|
case "design":
|
|
return <DesignStep state={state} updateState={updateState} />;
|
|
case "assets":
|
|
return (
|
|
<AssetsStep
|
|
state={state}
|
|
updateState={updateState}
|
|
toggleItem={toggleItem}
|
|
/>
|
|
);
|
|
case "functions":
|
|
return (
|
|
<FunctionsStep
|
|
state={state}
|
|
updateState={updateState}
|
|
toggleItem={toggleItem}
|
|
/>
|
|
);
|
|
case "api":
|
|
return (
|
|
<ApiStep
|
|
state={state}
|
|
updateState={updateState}
|
|
toggleItem={toggleItem}
|
|
/>
|
|
);
|
|
case "content":
|
|
return <ContentStep state={state} updateState={updateState} />;
|
|
case "language":
|
|
return <LanguageStep state={state} updateState={updateState} />;
|
|
case "timeline":
|
|
return <TimelineStep state={state} updateState={updateState} />;
|
|
case "contact":
|
|
return <ContactStep state={state} updateState={updateState} />;
|
|
case "webapp":
|
|
return <WebAppStep state={state} updateState={updateState} />;
|
|
default:
|
|
return null;
|
|
}
|
|
};
|
|
|
|
const handleSubmit = (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
if (stepIndex === activeSteps.length - 1) {
|
|
// Handle submission
|
|
const mailBody = `
|
|
Name: ${state.name}
|
|
Email: ${state.email}
|
|
Rolle: ${state.role}
|
|
Projekt: ${state.projectType}
|
|
Konfiguration: ${currentUrl}
|
|
|
|
Nachricht:
|
|
${state.message}
|
|
`;
|
|
window.location.href = `mailto:marc@mintel.me?subject=Projektanfrage: ${state.name}&body=${encodeURIComponent(mailBody)}`;
|
|
|
|
// Celebration!
|
|
const duration = 5 * 1000;
|
|
const animationEnd = Date.now() + duration;
|
|
const defaults = { startVelocity: 30, spread: 360, ticks: 60, zIndex: 0 };
|
|
|
|
const randomInRange = (min: number, max: number) =>
|
|
Math.random() * (max - min) + min;
|
|
|
|
const interval: any = !isRemotion
|
|
? setInterval(function () {
|
|
const timeLeft = animationEnd - Date.now();
|
|
|
|
if (timeLeft <= 0) {
|
|
return clearInterval(interval);
|
|
}
|
|
|
|
const particleCount = 50 * (timeLeft / duration);
|
|
(confetti as any)({
|
|
...defaults,
|
|
particleCount,
|
|
origin: { x: randomInRange(0.1, 0.3), y: Math.random() - 0.2 },
|
|
});
|
|
(confetti as any)({
|
|
...defaults,
|
|
particleCount,
|
|
origin: { x: randomInRange(0.7, 0.9), y: Math.random() - 0.2 },
|
|
});
|
|
}, 250)
|
|
: null;
|
|
|
|
setIsSubmitted(true);
|
|
} else {
|
|
nextStep();
|
|
}
|
|
};
|
|
|
|
const handleShare = () => {
|
|
setIsShareModalOpen(true);
|
|
};
|
|
|
|
if (isSubmitted) {
|
|
return (
|
|
<motion.div
|
|
initial={{ opacity: 0, scale: 0.95 }}
|
|
animate={{ opacity: 1, scale: 1 }}
|
|
className="p-12 md:p-24 bg-slate-900 text-white rounded-[4rem] text-center space-y-12"
|
|
>
|
|
<div className="w-24 h-24 bg-white text-slate-900 rounded-full flex items-center justify-center mx-auto">
|
|
<Check size={48} strokeWidth={3} />
|
|
</div>
|
|
<div className="space-y-6">
|
|
<h2 className="text-5xl font-bold tracking-tight">
|
|
Anfrage gesendet!
|
|
</h2>
|
|
<p className="text-slate-400 text-2xl max-w-2xl mx-auto leading-relaxed">
|
|
Vielen Dank, {state.name.split(" ")[0]}. Ich melde mich zeitnah bei
|
|
Ihnen.
|
|
</p>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
setIsSubmitted(false);
|
|
setStepIndex(0);
|
|
setState(initialState);
|
|
}}
|
|
className="text-slate-400 hover:text-white transition-colors underline underline-offset-8 text-lg focus:outline-none overflow-hidden relative rounded-full"
|
|
>
|
|
Neue Anfrage starten
|
|
</button>
|
|
</motion.div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div
|
|
ref={formContainerRef}
|
|
className="grid grid-cols-1 lg:grid-cols-12 gap-16 items-start"
|
|
>
|
|
<div className="lg:col-span-8 space-y-12">
|
|
<div
|
|
className={`sticky top-[80px] z-40 transition-all duration-500 ${isSticky ? "bg-white/80 backdrop-blur-md py-4 -mx-12 px-12 -mt-px border-b border-slate-50" : "bg-transparent py-6 border-none"}`}
|
|
>
|
|
<div className={`flex flex-col ${isSticky ? "gap-4" : "gap-8"}`}>
|
|
<div className="flex flex-row items-center justify-between gap-8">
|
|
<div className="flex items-center gap-6">
|
|
<motion.div
|
|
animate={{
|
|
scale: isSticky ? 0.7 : 1,
|
|
width: isSticky ? 80 : 128,
|
|
height: isSticky ? 80 : 128,
|
|
borderRadius: isSticky ? "1.75rem" : "2.5rem",
|
|
}}
|
|
className="shrink-0 bg-slate-50 flex items-center justify-center relative shadow-inner z-10"
|
|
>
|
|
<div className="p-3 w-full h-full flex items-center justify-center overflow-hidden rounded-[inherit]">
|
|
{activeSteps[stepIndex].illustration}
|
|
</div>
|
|
<AnimatePresence>
|
|
{!isSticky && (
|
|
<motion.div
|
|
initial={{ scale: 0, opacity: 0 }}
|
|
animate={{ scale: 1, opacity: 1 }}
|
|
exit={{ scale: 0, opacity: 0 }}
|
|
transition={{
|
|
type: "spring",
|
|
stiffness: 300,
|
|
damping: 20,
|
|
}}
|
|
className="absolute -bottom-2 -right-2 w-10 h-10 bg-slate-900 text-white rounded-full flex items-center justify-center font-bold text-sm border-4 border-white shadow-xl z-20"
|
|
>
|
|
{stepIndex + 1}
|
|
</motion.div>
|
|
)}
|
|
</AnimatePresence>
|
|
</motion.div>
|
|
<div className="space-y-1 min-w-0">
|
|
<motion.div
|
|
animate={{
|
|
opacity: isSticky ? 0 : 1,
|
|
height: isSticky ? 0 : "auto",
|
|
marginBottom: isSticky ? 0 : 4,
|
|
}}
|
|
className="flex items-center gap-3 overflow-hidden"
|
|
>
|
|
<span className="text-xs font-bold uppercase tracking-[0.2em] text-slate-400 flex items-center gap-2">
|
|
<Sparkles size={12} className="text-slate-300" />
|
|
Schritt {stepIndex + 1} / {activeSteps.length}
|
|
</span>
|
|
</motion.div>
|
|
<motion.h3
|
|
animate={{
|
|
fontSize: isSticky ? "1.5rem" : "2.25rem",
|
|
lineHeight: isSticky ? "2rem" : "2.5rem",
|
|
color: isSticky ? "#0f172a" : "#0f172a",
|
|
}}
|
|
className="font-black tracking-tight truncate"
|
|
>
|
|
{activeSteps[stepIndex].title.replace(
|
|
"{company}",
|
|
state.companyName || "Ihr Unternehmen",
|
|
)}
|
|
</motion.h3>
|
|
<motion.p
|
|
animate={{
|
|
fontSize: isSticky ? "0.875rem" : "1.125rem",
|
|
lineHeight: isSticky ? "1.25rem" : "1.75rem",
|
|
}}
|
|
className="text-slate-500 leading-relaxed max-w-2xl truncate overflow-hidden"
|
|
>
|
|
{activeSteps[stepIndex].description.replace(
|
|
"{company}",
|
|
state.companyName || "Ihr Unternehmen",
|
|
)}
|
|
</motion.p>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-3 shrink-0">
|
|
{stepIndex > 0 ? (
|
|
<motion.button
|
|
whileHover={{ x: -3, backgroundColor: "#f8fafc" }}
|
|
whileTap={{ scale: 0.95 }}
|
|
type="button"
|
|
onClick={prevStep}
|
|
className={`flex items-center justify-center gap-2 rounded-full border-2 border-slate-100 transition-all font-bold focus:outline-none whitespace-nowrap ${isSticky ? "px-5 py-2 text-sm" : "px-8 py-4 text-lg"}`}
|
|
>
|
|
<ChevronLeft size={isSticky ? 16 : 20} /> Zurück
|
|
</motion.button>
|
|
) : (
|
|
<div className={isSticky ? "w-0" : "w-32"} />
|
|
)}
|
|
|
|
{stepIndex < activeSteps.length - 1 ? (
|
|
<motion.button
|
|
id="focus-target-next"
|
|
whileHover={{ x: 3, scale: 1.02 }}
|
|
whileTap={{ scale: 0.95 }}
|
|
type="button"
|
|
onClick={nextStep}
|
|
className={`flex items-center justify-center gap-2 rounded-full bg-slate-900 text-white hover:bg-slate-800 transition-all font-bold shadow-xl shadow-slate-200 focus:outline-none group whitespace-nowrap ${isSticky ? "px-6 py-2 text-sm" : "px-10 py-4 text-lg"}`}
|
|
>
|
|
Weiter{" "}
|
|
<ChevronRight
|
|
size={isSticky ? 16 : 20}
|
|
className="transition-transform group-hover:translate-x-1"
|
|
/>
|
|
</motion.button>
|
|
) : (
|
|
<motion.button
|
|
whileHover={{ scale: 1.05 }}
|
|
whileTap={{ scale: 0.95 }}
|
|
type="submit"
|
|
form="contact-form"
|
|
disabled={!state.email || !state.name}
|
|
className={`flex items-center justify-center gap-2 rounded-full bg-slate-900 text-white hover:bg-slate-800 transition-all font-bold disabled:opacity-50 disabled:cursor-not-allowed shadow-xl shadow-slate-200 focus:outline-none group whitespace-nowrap ${isSticky ? "px-6 py-2 text-sm" : "px-10 py-4 text-lg"}`}
|
|
>
|
|
Senden{" "}
|
|
<Send
|
|
size={isSticky ? 16 : 20}
|
|
className="transition-transform group-hover:translate-x-1 group-hover:-translate-y-1"
|
|
/>
|
|
</motion.button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="relative">
|
|
<div
|
|
className={`flex gap-1.5 transition-all duration-500 ${isSticky ? "h-1" : "h-2.5"}`}
|
|
>
|
|
{activeSteps.map((step, i) => (
|
|
<div
|
|
key={i}
|
|
className="flex-1 h-full flex items-center relative"
|
|
onMouseEnter={() => setHoveredStep(i)}
|
|
onMouseLeave={() => setHoveredStep(null)}
|
|
>
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
setStepIndex(i);
|
|
setTimeout(scrollToTop, 50);
|
|
}}
|
|
className={`w-full h-full rounded-full transition-all duration-700 ${
|
|
i === stepIndex
|
|
? "bg-slate-900 scale-y-150 shadow-lg shadow-slate-200"
|
|
: i < stepIndex
|
|
? "bg-slate-400"
|
|
: "bg-slate-100"
|
|
} cursor-pointer focus:outline-none p-0 border-none relative group`}
|
|
>
|
|
<AnimatePresence>
|
|
{hoveredStep === i && (
|
|
<motion.div
|
|
initial={{
|
|
opacity: 0,
|
|
y: 5,
|
|
x: "-50%",
|
|
scale: 0.9,
|
|
}}
|
|
animate={{
|
|
opacity: 1,
|
|
y: isSticky ? -35 : -40,
|
|
x: "-50%",
|
|
scale: 1,
|
|
}}
|
|
exit={{ opacity: 0, y: 5, x: "-50%", scale: 0.9 }}
|
|
className="absolute left-1/2 px-3 py-1.5 bg-white text-slate-900 text-[10px] font-black uppercase tracking-[0.15em] rounded-md whitespace-nowrap pointer-events-none z-50 shadow-[0_10px_30px_rgba(0,0,0,0.1)] border border-slate-100"
|
|
>
|
|
{step.title.replace(
|
|
"{company}",
|
|
state.companyName || "Unternehmen",
|
|
)}
|
|
</motion.div>
|
|
)}
|
|
</AnimatePresence>
|
|
</button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
{!isSticky && (
|
|
<div className="flex justify-between mt-4 px-1">
|
|
{chapters.map((chapter, _idx) => {
|
|
const chapterSteps = activeSteps.filter(
|
|
(s) => s.chapter === chapter.id,
|
|
);
|
|
if (chapterSteps.length === 0) return null;
|
|
|
|
const firstStepIdx = activeSteps.indexOf(chapterSteps[0]);
|
|
const lastStepIdx = activeSteps.indexOf(
|
|
chapterSteps[chapterSteps.length - 1],
|
|
);
|
|
const isActive =
|
|
stepIndex >= firstStepIdx && stepIndex <= lastStepIdx;
|
|
|
|
return (
|
|
<div
|
|
key={chapter.id}
|
|
className={`text-[10px] font-bold uppercase tracking-[0.2em] transition-colors duration-500 ${
|
|
isActive ? "text-slate-900" : "text-slate-300"
|
|
}`}
|
|
>
|
|
{chapter.title}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<form
|
|
id="contact-form"
|
|
onSubmit={handleSubmit}
|
|
className="min-h-[450px] relative pt-12"
|
|
>
|
|
<AnimatePresence mode="wait">
|
|
<motion.div
|
|
key={activeSteps[stepIndex].id}
|
|
initial={{ opacity: 0, x: 20 }}
|
|
animate={{ opacity: 1, x: 0 }}
|
|
exit={{ opacity: 0, x: -20 }}
|
|
transition={{ duration: 0.5, ease: [0.23, 1, 0.32, 1] }}
|
|
>
|
|
{renderStepContent()}
|
|
</motion.div>
|
|
</AnimatePresence>
|
|
|
|
{/* Contextual Help / Why this matters */}
|
|
<motion.div
|
|
initial={{ opacity: 0, y: 20 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
transition={{ delay: 0.8 }}
|
|
className="mt-24 p-10 bg-slate-50 rounded-[3rem] border border-slate-100 flex gap-8 items-start relative overflow-hidden"
|
|
>
|
|
<div className="absolute top-0 right-0 p-12 opacity-[0.03] pointer-events-none">
|
|
<Sparkles size={160} />
|
|
</div>
|
|
<div className="w-14 h-14 shrink-0 bg-white rounded-2xl flex items-center justify-center text-slate-900 shadow-sm relative z-10">
|
|
<Info size={28} />
|
|
</div>
|
|
<div className="space-y-2 relative z-10">
|
|
<h4 className="text-xl font-bold text-slate-900">
|
|
Warum das wichtig ist
|
|
</h4>
|
|
<p className="text-lg text-slate-500 leading-relaxed max-w-2xl">
|
|
{stepIndex === 0 &&
|
|
"Die Wahl zwischen Website und Web App bestimmt die technologische Basis. Web Apps nutzen oft Frameworks wie React oder Next.js für hochgradig interaktive Prozesse, während Websites auf Content-Präsentation optimiert sind."}
|
|
{stepIndex === 1 &&
|
|
"Ihr Unternehmen und dessen Größe helfen mir, die Skalierbarkeit und die notwendige Infrastruktur von Beginn an richtig zu dimensionieren."}
|
|
{stepIndex === 2 &&
|
|
"Bestehende Kanäle geben Aufschluss über Ihre aktuelle Markenidentität. Wir können diese entweder konsequent weiterführen oder einen bewussten Neuanfang wagen."}
|
|
{stepIndex > 2 &&
|
|
"Jede Auswahl beeinflusst die Komplexität und damit auch die Entwicklungszeit. Mein Ziel ist es, für Sie das beste Preis-Leistungs-Verhältnis zu finden."}
|
|
</p>
|
|
</div>
|
|
</motion.div>
|
|
</form>
|
|
</div>
|
|
<PriceCalculation
|
|
state={state}
|
|
totals={totals}
|
|
isClient={isClient}
|
|
_qrCodeData={qrCodeData}
|
|
onShare={handleShare}
|
|
/>
|
|
|
|
<ShareModal
|
|
isOpen={isShareModalOpen}
|
|
onClose={() => setIsShareModalOpen(false)}
|
|
url={currentUrl}
|
|
qrCodeData={qrCodeData}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|