feat: Integrate Remotion for video generation, add video compositions, and adapt ContactForm for Remotion compatibility.
Some checks failed
Build & Deploy Mintel Blog / build-and-deploy (push) Failing after 2m19s
Some checks failed
Build & Deploy Mintel Blog / build-and-deploy (push) Failing after 2m19s
This commit is contained in:
@@ -29,51 +29,87 @@ 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
|
||||
import {
|
||||
ConceptTarget,
|
||||
ConceptWebsite,
|
||||
ConceptPrototyping,
|
||||
ConceptCommunication,
|
||||
ConceptSystem,
|
||||
ConceptCode,
|
||||
ConceptAutomation,
|
||||
ConceptPrice,
|
||||
HeroArchitecture
|
||||
} from './Landing/ConceptIllustrations';
|
||||
|
||||
export function ContactForm() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const [stepIndex, setStepIndex] = useState(0);
|
||||
const [state, setState] = useState<FormState>(initialState);
|
||||
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 [isClient, setIsClient] = 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();
|
||||
// Stick when the container top reaches the sticky position
|
||||
setIsSticky(rect.top <= 80);
|
||||
}
|
||||
};
|
||||
window.addEventListener('scroll', handleScroll);
|
||||
// Initial check
|
||||
handleScroll();
|
||||
return () => window.removeEventListener('scroll', handleScroll);
|
||||
}, []);
|
||||
}, [isRemotion]);
|
||||
|
||||
useEffect(() => {
|
||||
setIsClient(true);
|
||||
}, []);
|
||||
if (!isRemotion) setIsClient(true);
|
||||
}, [isRemotion]);
|
||||
|
||||
// URL Binding
|
||||
useEffect(() => {
|
||||
if (!searchParams) return;
|
||||
const step = searchParams.get('step');
|
||||
if (step) setStepIndex(parseInt(step));
|
||||
|
||||
@@ -81,18 +117,18 @@ export function ContactForm() {
|
||||
if (config) {
|
||||
try {
|
||||
const decoded = JSON.parse(decodeURIComponent(escape(atob(config))));
|
||||
setState(s => ({ ...s, ...decoded }));
|
||||
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,
|
||||
@@ -131,19 +167,20 @@ export function ContactForm() {
|
||||
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 (currentUrl) {
|
||||
if (isRemotion) return;
|
||||
if (currentUrl && router) {
|
||||
router.replace(currentUrl, { scroll: false });
|
||||
QRCode.toDataURL(currentUrl, { margin: 1, width: 200 }).then(setQrCodeData);
|
||||
}
|
||||
}, [currentUrl]);
|
||||
}, [currentUrl, router, isRemotion]);
|
||||
|
||||
const totalPagesCount = useMemo(() => {
|
||||
return state.selectedPages.length + state.otherPages.length + (state.otherPagesCount || 0);
|
||||
@@ -179,7 +216,7 @@ export function ContactForm() {
|
||||
}, [state]);
|
||||
|
||||
const updateState = (updates: Partial<FormState>) => {
|
||||
setState((s) => ({ ...s, ...updates }));
|
||||
setState((s: FormState) => ({ ...s, ...updates }));
|
||||
};
|
||||
|
||||
const toggleItem = (list: string[], id: string) => {
|
||||
@@ -187,6 +224,7 @@ export function ContactForm() {
|
||||
};
|
||||
|
||||
const scrollToTop = () => {
|
||||
if (isRemotion) return;
|
||||
if (formContainerRef.current) {
|
||||
const offset = 120;
|
||||
const bodyRect = document.body.getBoundingClientRect().top;
|
||||
@@ -204,14 +242,14 @@ export function ContactForm() {
|
||||
const nextStep = () => {
|
||||
if (stepIndex < activeSteps.length - 1) {
|
||||
setStepIndex(stepIndex + 1);
|
||||
setTimeout(scrollToTop, 50);
|
||||
if (!isRemotion) setTimeout(scrollToTop, 50);
|
||||
}
|
||||
};
|
||||
|
||||
const prevStep = () => {
|
||||
if (stepIndex > 0) {
|
||||
setStepIndex(stepIndex - 1);
|
||||
setTimeout(scrollToTop, 50);
|
||||
if (!isRemotion) setTimeout(scrollToTop, 50);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -311,7 +349,7 @@ export function ContactForm() {
|
||||
${state.message}
|
||||
`;
|
||||
window.location.href = `mailto:marc@mintel.me?subject=Projektanfrage: ${state.name}&body=${encodeURIComponent(mailBody)}`;
|
||||
|
||||
|
||||
// Celebration!
|
||||
const duration = 5 * 1000;
|
||||
const animationEnd = Date.now() + duration;
|
||||
@@ -319,7 +357,7 @@ export function ContactForm() {
|
||||
|
||||
const randomInRange = (min: number, max: number) => Math.random() * (max - min) + min;
|
||||
|
||||
const interval: any = setInterval(function() {
|
||||
const interval: any = !isRemotion ? setInterval(function () {
|
||||
const timeLeft = animationEnd - Date.now();
|
||||
|
||||
if (timeLeft <= 0) {
|
||||
@@ -327,9 +365,9 @@ export function ContactForm() {
|
||||
}
|
||||
|
||||
const particleCount = 50 * (timeLeft / duration);
|
||||
confetti({ ...defaults, particleCount, origin: { x: randomInRange(0.1, 0.3), y: Math.random() - 0.2 } });
|
||||
confetti({ ...defaults, particleCount, origin: { x: randomInRange(0.7, 0.9), y: Math.random() - 0.2 } });
|
||||
}, 250);
|
||||
(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 {
|
||||
@@ -430,9 +468,10 @@ export function ContactForm() {
|
||||
<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"
|
||||
@@ -455,7 +494,7 @@ export function ContactForm() {
|
||||
)}
|
||||
</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) => (
|
||||
@@ -471,10 +510,9 @@ export function ContactForm() {
|
||||
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' :
|
||||
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`}
|
||||
} cursor-pointer focus:outline-none p-0 border-none relative group`}
|
||||
>
|
||||
<AnimatePresence>
|
||||
{hoveredStep === i && (
|
||||
@@ -492,23 +530,22 @@ export function ContactForm() {
|
||||
</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'
|
||||
}`}
|
||||
className={`text-[10px] font-bold uppercase tracking-[0.2em] transition-colors duration-500 ${isActive ? 'text-slate-900' : 'text-slate-300'
|
||||
}`}
|
||||
>
|
||||
{chapter.title}
|
||||
</div>
|
||||
@@ -533,7 +570,7 @@ export function ContactForm() {
|
||||
{renderStepContent()}
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
|
||||
|
||||
{/* Contextual Help / Why this matters */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
@@ -559,21 +596,21 @@ export function ContactForm() {
|
||||
</motion.div>
|
||||
</form>
|
||||
</div>
|
||||
<PriceCalculation
|
||||
state={state}
|
||||
totalPrice={totalPrice}
|
||||
monthlyPrice={monthlyPrice}
|
||||
totalPagesCount={totalPagesCount}
|
||||
isClient={isClient}
|
||||
qrCodeData={qrCodeData}
|
||||
<PriceCalculation
|
||||
state={state}
|
||||
totalPrice={totalPrice}
|
||||
monthlyPrice={monthlyPrice}
|
||||
totalPagesCount={totalPagesCount}
|
||||
isClient={isClient}
|
||||
qrCodeData={qrCodeData}
|
||||
onShare={handleShare}
|
||||
/>
|
||||
|
||||
<ShareModal
|
||||
isOpen={isShareModalOpen}
|
||||
onClose={() => setIsShareModalOpen(false)}
|
||||
url={currentUrl}
|
||||
qrCodeData={qrCodeData}
|
||||
<ShareModal
|
||||
isOpen={isShareModalOpen}
|
||||
onClose={() => setIsShareModalOpen(false)}
|
||||
url={currentUrl}
|
||||
qrCodeData={qrCodeData}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -17,7 +17,7 @@ export function CompanyStep({ state, updateState }: CompanyStepProps) {
|
||||
return (
|
||||
<div className="space-y-16">
|
||||
<Reveal width="100%" delay={0.1}>
|
||||
<div className="space-y-8">
|
||||
<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} />
|
||||
@@ -27,9 +27,9 @@ export function CompanyStep({ state, updateState }: CompanyStepProps) {
|
||||
</div>
|
||||
<Input
|
||||
label="Name des Unternehmens"
|
||||
placeholder="z.B. Muster GmbH"
|
||||
value={state.companyName}
|
||||
onChange={(e) => updateState({ companyName: e.target.value })}
|
||||
placeholder="z.B. Muster GmbH"
|
||||
value={state.companyName}
|
||||
onChange={(e) => updateState({ companyName: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</Reveal>
|
||||
@@ -50,9 +50,8 @@ export function CompanyStep({ state, updateState }: CompanyStepProps) {
|
||||
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'
|
||||
}`}
|
||||
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>
|
||||
|
||||
@@ -20,13 +20,13 @@ export function TypeStep({ state, updateState }: TypeStepProps) {
|
||||
].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'
|
||||
}`}
|
||||
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">
|
||||
@@ -34,7 +34,7 @@ export function TypeStep({ state, updateState }: TypeStepProps) {
|
||||
<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"
|
||||
|
||||
Reference in New Issue
Block a user