form
@@ -1,9 +1 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 128 128">
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg width="100%" height="100%" viewBox="0 0 745 744" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;"><rect id="Favicon" x="0.855" y="0.277" width="743.633" height="743.633" style="fill:none;"/><path d="M739.657,49.961c-0,-24.755 -20.098,-44.852 -44.853,-44.852l-644.264,-0c-24.755,-0 -44.853,20.097 -44.853,44.852l-0,644.265c-0,24.755 20.098,44.853 44.853,44.853l644.264,-0c24.755,-0 44.853,-20.098 44.853,-44.853l-0,-644.265Z"/><path d="M135.778,286.282c0.157,0.236 0.52,0.761 0.852,1.092c1.2,1.192 2.956,2.358 5.296,3.587l0.005,0.003l2,1.327l1.693,1.752l1.239,2.072l0.713,2.215l0.211,2.192l-0.208,2.068l-1.385,3.646l-2.46,2.971l-1.634,1.187l-1.911,0.914l-2.161,0.551l-2.323,0.093l-2.343,-0.412l-2.211,-0.892c-11.589,-6.087 -16.814,-12.713 -18.39,-18.621c-1.835,-6.879 0.575,-13.751 6.509,-19.965c12.824,-13.43 44.389,-24.257 57.136,-27.454l0.001,-0c30.563,-7.658 62.165,-12.34 93.648,-13.693c25.54,-1.098 54.423,-1.803 78.831,7.271c15.51,5.768 26.031,14.726 32.759,25.559c56.484,-46.707 118.893,-93.116 187.414,-119.225c10.958,-4.178 27.246,-8.604 40.339,-7.415c7.908,0.718 14.744,3.442 19.461,8.573c4.662,5.072 7.541,12.672 6.521,23.945c-3.634,40.091 -31.543,80.109 -52.575,112.911c-25.848,40.321 -53.663,79.557 -82.723,117.821c29.191,-23.305 58.382,-46.61 87.573,-69.913l1.731,-1.145l1.85,-0.796l3.75,-0.585l3.526,0.562l3.17,1.552l2.672,2.595l1.028,1.728l0.705,1.995l0.285,2.178l-0.185,2.22l-0.631,2.115l-0.992,1.91c-10.505,16.533 -21.014,33.063 -31.523,49.592l-0.001,0.003c-1.852,2.909 -11.995,19.195 -18.14,30.842c-0.096,0.182 -0.192,0.366 -0.288,0.553c13.673,-3.721 27.615,-13.517 38.776,-19.936c10.441,-6.004 20.778,-12.208 30.865,-18.787l0.003,-0.002l2.134,-1.1l2.356,-0.626l2.421,-0.072l2.289,0.463l2.022,0.893l1.703,1.201l2.495,3.003l1.373,3.598l0.232,1.999l-0.139,2.106l-0.573,2.146l-1.048,2.067l-1.498,1.843l-1.854,1.496l-0.007,0.005c-15.715,10.242 -31.914,19.975 -48.604,28.526c-6.986,3.579 -18.808,10.744 -29.918,13.789c-9.41,2.579 -18.37,2.143 -24.958,-2.988c-5.648,-4.398 -7.104,-11.077 -5.042,-18.895c3.104,-11.773 15.551,-27.001 19.276,-32.858l2.604,-4.095c-37.274,29.759 -74.551,59.518 -111.826,89.274l-1.82,1.214l-2.004,0.868l-2.111,0.471l-2.12,0.061l-2.039,-0.329l-1.892,-0.676l-3.201,-2.224l-2.259,-3.202l-0.689,-1.912l-0.33,-2.068l0.077,-2.148l0.5,-2.129l0.902,-2.005l1.242,-1.804c59.233,-71.996 118.215,-147.452 163.946,-228.856l0.002,-0.003c3.532,-6.277 19.498,-32.912 25.637,-54.458c1.456,-5.11 2.365,-9.885 2.213,-13.918c-0.128,-3.403 -1.052,-6.169 -4.397,-6.847c-6.883,-1.395 -14.409,-0.182 -21.911,2.075c-12.591,3.787 -25.072,10.598 -34.871,15.502l-0.002,0.001c-37.202,18.606 -72.519,40.846 -106.083,65.396c-19.253,14.077 -38.067,28.773 -56.448,44.009c5.948,31.922 -8.725,71.663 -25.261,97.617c-26.624,41.789 -61.114,78.396 -97.084,112.241c-35.155,33.081 -71.676,66.504 -111.219,94.355l-0.002,0.001c-4.905,3.453 -13.056,9.944 -21.245,13.763c-7.008,3.268 -14.124,4.488 -20.021,2.432c-7.666,-2.674 -10.711,-8.481 -11.105,-15.23c-0.288,-4.928 1.117,-10.472 2.745,-14.802l0.001,-0.003c17.424,-46.26 54.722,-88.018 86.091,-125.21c52.005,-61.657 108.894,-119.681 170.402,-171.929c-5.142,-9.861 -13.608,-17.675 -25.833,-20.957c-27.596,-7.404 -57.826,-6.098 -86.019,-3.428c-30.452,2.883 -61.745,7.625 -90.667,17.984c-6.667,2.388 -17.118,6.215 -22.892,11.134c-0.89,0.758 -1.884,2.115 -2.149,2.485Zm-6.046,295.699c3.853,-1.713 7.757,-4.116 11.623,-6.805c12.067,-8.393 23.538,-19.805 31.614,-26.433c45.641,-37.472 90.707,-76.66 129.793,-121.083c26.644,-30.283 57.988,-66.814 65.641,-107.833c1.02,-5.466 1.414,-11.09 1.137,-16.634c-41.419,35.852 -80.575,74.39 -117.54,114.67c-41.853,45.61 -85.416,93.945 -115.619,148.393c-0.671,1.213 -4.049,9.375 -6.649,15.725Z" style="fill:#fff;"/></svg>
|
||||||
<path d="M50.4 78.5a75.1 75.1 0 0 0-28.5 6.9l24.2-65.7c.7-2 1.9-3.2 3.4-3.2h29c1.5 0 2.7 1.2 3.4 3.2l24.2 65.7s-11.6-7-28.5-7L67 45.5c-.4-1.7-1.6-2.8-2.9-2.8-1.3 0-2.5 1.1-2.9 2.7L50.4 78.5Zm-1.1 28.2Zm-4.2-20.2c-2 6.6-.6 15.8 4.2 20.2a17.5 17.5 0 0 1 .2-.7 5.5 5.5 0 0 1 5.7-4.5c2.8.1 4.3 1.5 4.7 4.7.2 1.1.2 2.3.2 3.5v.4c0 2.7.7 5.2 2.2 7.4a13 13 0 0 0 5.7 4.9v-.3l-.2-.3c-1.8-5.6-.5-9.5 4.4-12.8l1.5-1a73 73 0 0 0 3.2-2.2 16 16 0 0 0 6.8-11.4c.3-2 .1-4-.6-6l-.8.6-1.6 1a37 37 0 0 1-22.4 2.7c-5-.7-9.7-2-13.2-6.2Z" />
|
|
||||||
<style>
|
|
||||||
path { fill: #000; }
|
|
||||||
@media (prefers-color-scheme: dark) {
|
|
||||||
path { fill: #FFF; }
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 749 B After Width: | Height: | Size: 4.0 KiB |
BIN
src/assets/header.webp
Executable file
|
After Width: | Height: | Size: 3.5 MiB |
BIN
src/assets/logo/Favicon.png
Normal file
|
After Width: | Height: | Size: 54 KiB |
BIN
src/assets/logo/Favicon@2x.png
Normal file
|
After Width: | Height: | Size: 133 KiB |
BIN
src/assets/logo/Icon Black Transparent.png
Normal file
|
After Width: | Height: | Size: 26 KiB |
12
src/assets/logo/Icon Black Transparent.svg
Normal file
|
After Width: | Height: | Size: 24 KiB |
BIN
src/assets/logo/Icon Black Transparent@2x.png
Normal file
|
After Width: | Height: | Size: 58 KiB |
BIN
src/assets/logo/Icon Black.png
Normal file
|
After Width: | Height: | Size: 54 KiB |
BIN
src/assets/logo/Icon Black@2x.png
Normal file
|
After Width: | Height: | Size: 129 KiB |
BIN
src/assets/logo/Icon White Transparent.png
Normal file
|
After Width: | Height: | Size: 32 KiB |
12
src/assets/logo/Icon White Transparent.svg
Normal file
|
After Width: | Height: | Size: 29 KiB |
BIN
src/assets/logo/Icon White Transparent@2x.png
Normal file
|
After Width: | Height: | Size: 77 KiB |
BIN
src/assets/logo/Icon White.png
Normal file
|
After Width: | Height: | Size: 52 KiB |
BIN
src/assets/logo/Icon White@2x.png
Normal file
|
After Width: | Height: | Size: 128 KiB |
BIN
src/assets/logo/Logo Black Transparent.png
Normal file
|
After Width: | Height: | Size: 13 KiB |
12
src/assets/logo/Logo Black Transparent.svg
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
src/assets/logo/Logo Black Transparent@2x.png
Normal file
|
After Width: | Height: | Size: 29 KiB |
BIN
src/assets/logo/Logo Black.png
Normal file
|
After Width: | Height: | Size: 26 KiB |
8
src/assets/logo/Logo Black.svg
Normal file
|
After Width: | Height: | Size: 30 KiB |
BIN
src/assets/logo/Logo Black@2x.png
Normal file
|
After Width: | Height: | Size: 59 KiB |
BIN
src/assets/logo/Logo White Transparent.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
12
src/assets/logo/Logo White Transparent.svg
Normal file
|
After Width: | Height: | Size: 19 KiB |
BIN
src/assets/logo/Logo White Transparent@2x.png
Normal file
|
After Width: | Height: | Size: 32 KiB |
BIN
src/assets/logo/Logo White.png
Normal file
|
After Width: | Height: | Size: 28 KiB |
BIN
src/assets/logo/Logo White@2x.png
Normal file
|
After Width: | Height: | Size: 65 KiB |
23
src/components/ContactForm/components/AnimatedNumber.tsx
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import * as React from 'react';
|
||||||
|
import { useEffect, useRef } from 'react';
|
||||||
|
import { useSpring, useTransform } from 'framer-motion';
|
||||||
|
|
||||||
|
export function AnimatedNumber({ value }: { value: number }) {
|
||||||
|
const spring = useSpring(value, { stiffness: 50, damping: 20 });
|
||||||
|
const display = useTransform(spring, (v) => Math.round(v).toLocaleString());
|
||||||
|
const ref = useRef<HTMLSpanElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
spring.set(value);
|
||||||
|
}, [value, spring]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return display.on('change', (v) => {
|
||||||
|
if (ref.current) ref.current.textContent = v;
|
||||||
|
});
|
||||||
|
}, [display]);
|
||||||
|
|
||||||
|
return <span ref={ref}>{value.toLocaleString()}</span>;
|
||||||
|
}
|
||||||
31
src/components/ContactForm/components/Checkbox.tsx
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import * as React from 'react';
|
||||||
|
import { Check } from 'lucide-react';
|
||||||
|
|
||||||
|
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={`p-6 rounded-[2rem] border-2 text-left transition-all duration-300 flex items-start gap-4 focus:outline-none overflow-hidden relative rounded-[2rem] ${
|
||||||
|
checked ? 'border-slate-900 bg-slate-900 text-white' : 'border-slate-100 bg-white hover:border-slate-200'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className={`mt-1 w-6 h-6 rounded-full border-2 flex items-center justify-center shrink-0 ${checked ? 'border-white bg-white text-slate-900' : 'border-slate-200'}`}>
|
||||||
|
{checked && <Check size={14} strokeWidth={4} />}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h4 className={`font-bold mb-1 ${checked ? 'text-white' : 'text-slate-900'}`}>{label}</h4>
|
||||||
|
<p className={`text-sm leading-relaxed ${checked ? 'text-slate-100' : 'text-slate-500'}`}>{desc}</p>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
114
src/components/ContactForm/components/PriceCalculation.tsx
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import * as React from 'react';
|
||||||
|
import { FormState } from '../types';
|
||||||
|
import { PRICING } from '../constants';
|
||||||
|
import { AnimatedNumber } from './AnimatedNumber';
|
||||||
|
import { ConceptPrice, ConceptAutomation } from '../../Landing/ConceptIllustrations';
|
||||||
|
import { Info, Download, Share2 } from 'lucide-react';
|
||||||
|
import dynamic from 'next/dynamic';
|
||||||
|
import { EstimationPDF } from '../../EstimationPDF';
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
totalPrice: number;
|
||||||
|
monthlyPrice: number;
|
||||||
|
totalPagesCount: number;
|
||||||
|
isClient: boolean;
|
||||||
|
qrCodeData: string;
|
||||||
|
onShare?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PriceCalculation({
|
||||||
|
state,
|
||||||
|
totalPrice,
|
||||||
|
monthlyPrice,
|
||||||
|
totalPagesCount,
|
||||||
|
isClient,
|
||||||
|
qrCodeData,
|
||||||
|
onShare
|
||||||
|
}: PriceCalculationProps) {
|
||||||
|
return (
|
||||||
|
<div className="lg:col-span-4 lg:sticky lg:top-24">
|
||||||
|
<div className="p-10 bg-slate-50 border border-slate-100 rounded-[3rem] space-y-10">
|
||||||
|
<div className="space-y-3"><div className="flex items-center gap-3"><ConceptPrice className="w-8 h-8" /><h3 className="text-2xl font-bold text-slate-900">Kalkulation</h3></div><p className="text-sm text-slate-500 leading-relaxed">Unverbindliche Schätzung.</p></div>
|
||||||
|
<div className="space-y-6">
|
||||||
|
{state.projectType === 'website' ? (
|
||||||
|
<>
|
||||||
|
<div className="flex justify-between items-center py-4 border-b border-slate-200"><span className="text-slate-600 font-medium">Basis Website</span><span className="font-bold text-lg text-slate-900">{PRICING.BASE_WEBSITE.toLocaleString()} €</span></div>
|
||||||
|
<div className="space-y-4 max-h-[400px] overflow-y-auto pr-2 hide-scrollbar">
|
||||||
|
{totalPagesCount > 0 && (<div className="flex justify-between items-center text-sm"><span className="text-slate-500">{totalPagesCount}x Seite</span><span className="font-medium text-slate-900">{(totalPagesCount * PRICING.PAGE).toLocaleString()} €</span></div>)}
|
||||||
|
{state.features.length + state.otherFeatures.length > 0 && (<div className="flex justify-between items-center text-sm"><span className="text-slate-500">{state.features.length + state.otherFeatures.length}x System-Modul</span><span className="font-medium text-slate-900">{((state.features.length + state.otherFeatures.length) * PRICING.FEATURE).toLocaleString()} €</span></div>)}
|
||||||
|
{state.functions.length + state.otherFunctions.length > 0 && (<div className="flex justify-between items-center text-sm"><span className="text-slate-500">{state.functions.length + state.otherFunctions.length}x Logik-Funktion</span><span className="font-medium text-slate-900">{((state.functions.length + state.otherFunctions.length) * PRICING.FUNCTION).toLocaleString()} €</span></div>)}
|
||||||
|
{state.complexInteractions > 0 && (<div className="flex justify-between items-center text-sm"><span className="text-slate-500">{state.complexInteractions}x Komplexes UI/Animation</span><span className="font-medium text-slate-900">{(state.complexInteractions * PRICING.COMPLEX_INTERACTION).toLocaleString()} €</span></div>)}
|
||||||
|
{state.apiSystems.length + state.otherTech.length > 0 && (<div className="flex justify-between items-center text-sm"><span className="text-slate-500">{state.apiSystems.length + state.otherTech.length}x API Sync</span><span className="font-medium text-slate-900">{((state.apiSystems.length + state.otherTech.length) * 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 + (state.features.length + state.otherFeatures.length) * 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>)}
|
||||||
|
{state.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 ({state.languagesCount}x)</span><span>+{(totalPrice - (totalPrice / (1 + (state.languagesCount - 1) * 0.2))).toLocaleString()} €</span></div>)}
|
||||||
|
</div>
|
||||||
|
<div className="pt-8 space-y-2"><div className="flex justify-between items-end"><span className="text-2xl font-bold text-slate-900">Gesamt</span><div className="text-right"><div className="text-4xl font-bold tracking-tighter text-slate-900"><AnimatedNumber value={totalPrice} /> €</div><p className="text-[10px] text-slate-400 mt-1 uppercase tracking-widest font-bold">Einmalig / Netto</p></div></div></div>
|
||||||
|
<div className="pt-8 border-t border-slate-200 space-y-4"><div className="flex justify-between items-center"><span className="text-slate-600 font-medium">Betrieb & Hosting</span><span className="font-bold text-lg text-slate-900">{monthlyPrice.toLocaleString()} € / Monat</span></div><div className="p-6 bg-white rounded-[2rem] text-xs text-slate-500 flex gap-4 leading-relaxed border border-slate-100"><Info size={18} className="shrink-0 text-slate-300" /><p>Inklusive Hosting, Sicherheitsupdates, Backups und Analytics-Reports.</p></div></div>
|
||||||
|
|
||||||
|
<div className="pt-6 space-y-4">
|
||||||
|
{isClient && (
|
||||||
|
<PDFDownloadLink
|
||||||
|
document={<EstimationPDF state={state} totalPrice={totalPrice} monthlyPrice={monthlyPrice} totalPagesCount={totalPagesCount} pricing={PRICING} qrCodeData={qrCodeData} />}
|
||||||
|
fileName={`kalkulation-${state.name.toLowerCase().replace(/\s+/g, '-') || 'projekt'}.pdf`}
|
||||||
|
className="w-full flex items-center justify-center gap-3 px-8 py-4 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 rounded-full"
|
||||||
|
>
|
||||||
|
{({ loading }) => (
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Download size={18} />
|
||||||
|
<span>{loading ? 'PDF wird erstellt...' : 'Als PDF speichern'}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</PDFDownloadLink>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{onShare && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onShare}
|
||||||
|
className="w-full flex items-center justify-center gap-3 px-8 py-4 rounded-full bg-slate-900 text-white font-bold text-sm uppercase tracking-widest hover:bg-slate-800 transition-all shadow-xl shadow-slate-200 focus:outline-none"
|
||||||
|
>
|
||||||
|
<Share2 size={18} />
|
||||||
|
<span>Konfiguration teilen</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="py-12 text-center space-y-6">
|
||||||
|
<div className="w-20 h-20 bg-white rounded-full flex items-center justify-center mx-auto shadow-sm">
|
||||||
|
<ConceptAutomation className="w-12 h-12" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-slate-600 text-sm leading-relaxed">Apps und Individual-Software werden nach tatsächlichem Aufwand abgerechnet.</p>
|
||||||
|
<p className="text-3xl font-bold text-slate-900">{PRICING.APP_HOURLY} € <span className="text-lg 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-8 py-4 rounded-full bg-slate-900 text-white font-bold text-sm uppercase tracking-widest hover:bg-slate-800 transition-all shadow-xl shadow-slate-200 focus:outline-none"
|
||||||
|
>
|
||||||
|
<Share2 size={18} />
|
||||||
|
<span>Konfiguration 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
74
src/components/ContactForm/components/RepeatableList.tsx
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import * as React from 'react';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
|
import { Plus, X } from 'lucide-react';
|
||||||
|
|
||||||
|
interface RepeatableListProps {
|
||||||
|
items: string[];
|
||||||
|
onAdd: (val: string) => void;
|
||||||
|
onRemove: (index: number) => void;
|
||||||
|
placeholder: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RepeatableList({
|
||||||
|
items,
|
||||||
|
onAdd,
|
||||||
|
onRemove,
|
||||||
|
placeholder
|
||||||
|
}: RepeatableListProps) {
|
||||||
|
const [input, setInput] = useState('');
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<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-6 bg-white border border-slate-100 rounded-[2rem] focus:outline-none focus:border-slate-900 transition-colors text-sm"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
if (input.trim()) {
|
||||||
|
onAdd(input.trim());
|
||||||
|
setInput('');
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="w-16 h-16 rounded-full bg-slate-900 text-white flex items-center justify-center hover:bg-slate-800 transition-colors shrink-0 focus:outline-none overflow-hidden relative rounded-[2rem]"
|
||||||
|
>
|
||||||
|
<Plus size={24} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<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-2 px-4 py-2 bg-slate-100 rounded-full text-sm font-medium text-slate-700"
|
||||||
|
>
|
||||||
|
<span className="truncate max-w-[200px]">{item}</span>
|
||||||
|
<button type="button" onClick={() => onRemove(i)} className="text-slate-400 hover:text-slate-900 transition-colors focus:outline-none overflow-hidden relative rounded-full">
|
||||||
|
<X size={14} />
|
||||||
|
</button>
|
||||||
|
</motion.div>
|
||||||
|
))}
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
154
src/components/ContactForm/constants.tsx
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { FormState } from './types';
|
||||||
|
|
||||||
|
export const PRICING = {
|
||||||
|
BASE_WEBSITE: 6000,
|
||||||
|
PAGE: 800,
|
||||||
|
FEATURE: 2000,
|
||||||
|
FUNCTION: 1000,
|
||||||
|
COMPLEX_INTERACTION: 1500,
|
||||||
|
NEW_DATASET: 400,
|
||||||
|
HOSTING_MONTHLY: 120,
|
||||||
|
STORAGE_EXPANSION_MONTHLY: 10,
|
||||||
|
CMS_SETUP: 1500,
|
||||||
|
CMS_CONNECTION_PER_FEATURE: 800,
|
||||||
|
API_INTEGRATION: 1000,
|
||||||
|
APP_HOURLY: 120,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const initialState: FormState = {
|
||||||
|
projectType: 'website',
|
||||||
|
selectedPages: ['Home'],
|
||||||
|
otherPages: [],
|
||||||
|
features: [],
|
||||||
|
otherFeatures: [],
|
||||||
|
functions: [],
|
||||||
|
otherFunctions: [],
|
||||||
|
apiSystems: [],
|
||||||
|
otherTech: [],
|
||||||
|
assets: [],
|
||||||
|
otherAssets: [],
|
||||||
|
complexInteractions: 0,
|
||||||
|
newDatasets: 0,
|
||||||
|
cmsSetup: false,
|
||||||
|
storageExpansion: 0,
|
||||||
|
name: '',
|
||||||
|
email: '',
|
||||||
|
role: '',
|
||||||
|
message: '',
|
||||||
|
sitemapFile: null,
|
||||||
|
contactFiles: [],
|
||||||
|
designVibe: 'minimal',
|
||||||
|
colorScheme: ['#ffffff', '#f8fafc', '#0f172a'],
|
||||||
|
references: [],
|
||||||
|
designWishes: '',
|
||||||
|
expectedAdjustments: 'low',
|
||||||
|
languagesCount: 1,
|
||||||
|
deadline: 'flexible',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const PAGE_SAMPLES = [
|
||||||
|
{ 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.' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const FEATURE_OPTIONS = [
|
||||||
|
{ id: 'blog_news', label: 'Blog / News', desc: 'Ein Bereich für aktuelle Beiträge und Neuigkeiten.' },
|
||||||
|
{ id: 'products', label: 'Produktbereich', desc: 'Katalog Ihrer Leistungen oder Produkte.' },
|
||||||
|
{ id: 'jobs', label: 'Karriere / Jobs', desc: 'Stellenanzeigen und Bewerbungsoptionen.' },
|
||||||
|
{ id: 'refs', label: 'Referenzen / Cases', desc: 'Präsentation Ihrer Projekte.' },
|
||||||
|
{ id: 'events', label: 'Events / Termine', desc: 'Veranstaltungskalender.' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const FUNCTION_OPTIONS = [
|
||||||
|
{ id: 'search', label: 'Suche', desc: 'Volltextsuche über alle Inhalte.' },
|
||||||
|
{ id: 'filter', label: 'Filter-Systeme', desc: 'Kategorisierung und Sortierung.' },
|
||||||
|
{ id: 'pdf', label: 'PDF-Export', desc: 'Automatisierte PDF-Erstellung.' },
|
||||||
|
{ id: 'forms', label: 'Erweiterte Formulare', desc: 'Komplexe Abfragen & Logik.' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const API_OPTIONS = [
|
||||||
|
{ id: 'crm', label: 'CRM System', desc: 'HubSpot, Salesforce, Pipedrive etc.' },
|
||||||
|
{ id: 'erp', label: 'ERP / Warenwirtschaft', desc: 'SAP, Microsoft Dynamics, Xentral etc.' },
|
||||||
|
{ id: 'stripe', label: 'Stripe / Payment', desc: 'Zahlungsabwicklung und Abonnements.' },
|
||||||
|
{ id: 'newsletter', label: 'Newsletter / Marketing', desc: 'Mailchimp, Brevo, ActiveCampaign etc.' },
|
||||||
|
{ id: 'ecommerce', label: 'E-Commerce / Shop', desc: 'Shopify, WooCommerce, Shopware Sync.' },
|
||||||
|
{ id: 'hr', label: 'HR / Recruiting', desc: 'Personio, Workday, Recruitee etc.' },
|
||||||
|
{ id: 'realestate', label: 'Immobilien', desc: 'OpenImmo, FlowFact, Immowelt Sync.' },
|
||||||
|
{ id: 'calendar', label: 'Termine / Booking', desc: 'Calendly, Shore, Doctolib etc.' },
|
||||||
|
{ id: 'social', label: 'Social Media Sync', desc: 'Automatisierte Posts oder Feeds.' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const ASSET_OPTIONS = [
|
||||||
|
{ id: 'logo', label: 'Logo', desc: 'Vektordatei Ihres Logos.' },
|
||||||
|
{ id: 'styleguide', label: 'Styleguide', desc: 'Farben, Schriften, Design-Vorgaben.' },
|
||||||
|
{ id: 'content_concept', label: 'Inhalts-Konzept', desc: 'Struktur und Texte sind bereits geplant.' },
|
||||||
|
{ id: 'media', label: 'Bild/Video-Material', desc: 'Professionelles Bildmaterial vorhanden.' },
|
||||||
|
{ id: 'icons', label: 'Icons', desc: 'Eigene Icon-Sets vorhanden.' },
|
||||||
|
{ id: 'illustrations', label: 'Illustrationen', desc: 'Eigene Illustrationen vorhanden.' },
|
||||||
|
{ id: 'fonts', label: 'Fonts', desc: 'Lizenzen für Hausschriften vorhanden.' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const DESIGN_VIBES = [
|
||||||
|
{
|
||||||
|
id: 'minimal',
|
||||||
|
label: 'Minimalistisch',
|
||||||
|
desc: 'Viel Weißraum, klare Typografie.',
|
||||||
|
illustration: (
|
||||||
|
<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>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'bold',
|
||||||
|
label: 'Mutig & Laut',
|
||||||
|
desc: 'Starke Kontraste, große Schriften.',
|
||||||
|
illustration: (
|
||||||
|
<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>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'nature',
|
||||||
|
label: 'Natürlich',
|
||||||
|
desc: 'Sanfte Erdtöne, organische Formen.',
|
||||||
|
illustration: (
|
||||||
|
<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>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'tech',
|
||||||
|
label: 'Technisch',
|
||||||
|
desc: 'Präzise Linien, dunkle Akzente.',
|
||||||
|
illustration: (
|
||||||
|
<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 HARMONIOUS_PALETTES = [
|
||||||
|
['#ffffff', '#f8fafc', '#0f172a'],
|
||||||
|
['#000000', '#facc15', '#ffffff'],
|
||||||
|
['#fdfcfb', '#e2e8f0', '#1e293b'],
|
||||||
|
['#0f172a', '#38bdf8', '#ffffff'],
|
||||||
|
['#fafaf9', '#78716c', '#1c1917'],
|
||||||
|
['#f0fdf4', '#16a34a', '#064e3b'],
|
||||||
|
['#fff7ed', '#ea580c', '#7c2d12'],
|
||||||
|
['#f5f3ff', '#7c3aed', '#2e1065'],
|
||||||
|
];
|
||||||
46
src/components/ContactForm/steps/ApiStep.tsx
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import * as React from 'react';
|
||||||
|
import { FormState } from '../types';
|
||||||
|
import { API_OPTIONS } from '../constants';
|
||||||
|
import { Checkbox } from '../components/Checkbox';
|
||||||
|
import { RepeatableList } from '../components/RepeatableList';
|
||||||
|
import { Info } from 'lucide-react';
|
||||||
|
|
||||||
|
interface ApiStepProps {
|
||||||
|
state: FormState;
|
||||||
|
updateState: (updates: Partial<FormState>) => void;
|
||||||
|
toggleItem: (list: string[], id: string) => string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ApiStep({ state, updateState, toggleItem }: ApiStepProps) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-8">
|
||||||
|
<div className="p-8 bg-slate-50 rounded-[2rem] border border-slate-100 flex gap-6 items-start">
|
||||||
|
<Info className="text-slate-400 shrink-0 mt-1" size={24} />
|
||||||
|
<div className="text-sm text-slate-600 space-y-2 leading-relaxed">
|
||||||
|
<p className="font-bold text-slate-900">Wichtig zu wissen</p>
|
||||||
|
<p>Ich biete diese Drittsysteme nicht selbst an, sondern entwickle die <strong>Schnittstelle (API)</strong>, damit Ihre Website nahtlos mit ihnen kommunizieren kann.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
{API_OPTIONS.map(opt => (
|
||||||
|
<Checkbox
|
||||||
|
key={opt.id} label={opt.label} desc={opt.desc}
|
||||||
|
checked={state.apiSystems.includes(opt.id)}
|
||||||
|
onChange={() => updateState({ apiSystems: toggleItem(state.apiSystems, opt.id) })}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<p className="text-sm font-bold text-slate-900">Weitere Systeme?</p>
|
||||||
|
<RepeatableList
|
||||||
|
items={state.otherTech}
|
||||||
|
onAdd={(v) => updateState({ otherTech: [...state.otherTech, v] })}
|
||||||
|
onRemove={(i) => updateState({ otherTech: state.otherTech.filter((_, idx) => idx !== i) })}
|
||||||
|
placeholder="z.B. Personio, DATEV, Salesforce..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
38
src/components/ContactForm/steps/AssetsStep.tsx
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
'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';
|
||||||
|
|
||||||
|
interface AssetsStepProps {
|
||||||
|
state: FormState;
|
||||||
|
updateState: (updates: Partial<FormState>) => void;
|
||||||
|
toggleItem: (list: string[], id: string) => string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AssetsStep({ state, updateState, toggleItem }: AssetsStepProps) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-8">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
{ASSET_OPTIONS.map(opt => (
|
||||||
|
<Checkbox
|
||||||
|
key={opt.id} label={opt.label} desc={opt.desc}
|
||||||
|
checked={state.assets.includes(opt.id)}
|
||||||
|
onChange={() => updateState({ assets: toggleItem(state.assets, opt.id) })}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<p className="text-sm font-bold text-slate-900">Weitere Materialien?</p>
|
||||||
|
<RepeatableList
|
||||||
|
items={state.otherAssets}
|
||||||
|
onAdd={(v) => updateState({ otherAssets: [...state.otherAssets, v] })}
|
||||||
|
onRemove={(i) => updateState({ otherAssets: state.otherAssets.filter((_, idx) => idx !== i) })}
|
||||||
|
placeholder="z.B. Stock-Fotos, Video-Footage, Präsentationen..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
81
src/components/ContactForm/steps/BaseStep.tsx
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import * as React from 'react';
|
||||||
|
import { FormState } from '../types';
|
||||||
|
import { PAGE_SAMPLES } from '../constants';
|
||||||
|
import { Checkbox } from '../components/Checkbox';
|
||||||
|
import { RepeatableList } from '../components/RepeatableList';
|
||||||
|
import { Info, FileText, Upload, X } from 'lucide-react';
|
||||||
|
|
||||||
|
interface BaseStepProps {
|
||||||
|
state: FormState;
|
||||||
|
updateState: (updates: Partial<FormState>) => void;
|
||||||
|
toggleItem: (list: string[], id: string) => string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BaseStep({ state, updateState, toggleItem }: BaseStepProps) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-8">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
{PAGE_SAMPLES.map(p => (
|
||||||
|
<Checkbox
|
||||||
|
key={p.id} label={p.label} desc={p.desc}
|
||||||
|
checked={state.selectedPages.includes(p.id)}
|
||||||
|
onChange={() => updateState({ selectedPages: toggleItem(state.selectedPages, p.id) })}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<p className="text-sm font-bold text-slate-900">Weitere Seiten?</p>
|
||||||
|
<RepeatableList
|
||||||
|
items={state.otherPages}
|
||||||
|
onAdd={(v) => updateState({ otherPages: [...state.otherPages, v] })}
|
||||||
|
onRemove={(i) => updateState({ otherPages: state.otherPages.filter((_, idx) => idx !== i) })}
|
||||||
|
placeholder="z.B. Team-Detail, FAQ..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<p className="text-sm font-bold text-slate-900">Sitemap hochladen (optional)</p>
|
||||||
|
<div
|
||||||
|
className={`relative group border-2 border-dashed rounded-[2rem] p-6 transition-all duration-300 flex flex-col items-center justify-center gap-2 cursor-pointer min-h-[120px] rounded-[2rem] ${
|
||||||
|
state.sitemapFile ? 'border-slate-900 bg-slate-50' : 'border-slate-200 hover:border-slate-400 bg-white'
|
||||||
|
}`}
|
||||||
|
onDragOver={(e) => { e.preventDefault(); e.stopPropagation(); }}
|
||||||
|
onDrop={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
const file = e.dataTransfer.files?.[0];
|
||||||
|
if (file) updateState({ sitemapFile: file });
|
||||||
|
}}
|
||||||
|
onClick={() => document.getElementById('sitemap-upload')?.click()}
|
||||||
|
>
|
||||||
|
<input id="sitemap-upload" type="file" className="hidden" onChange={(e) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (file) updateState({ sitemapFile: file });
|
||||||
|
}} />
|
||||||
|
{state.sitemapFile ? (
|
||||||
|
<div className="flex items-center gap-3 text-slate-900">
|
||||||
|
<FileText size={24} />
|
||||||
|
<span className="font-bold text-sm truncate max-w-[150px]">{state.sitemapFile.name}</span>
|
||||||
|
<button type="button" onClick={(e) => { e.stopPropagation(); updateState({ sitemapFile: null }); }} className="p-1 hover:bg-slate-200 rounded-full transition-colors focus:outline-none overflow-hidden relative rounded-full"><X size={16} /></button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Upload size={24} className="text-slate-400 group-hover:text-slate-900 transition-colors" />
|
||||||
|
<p className="text-xs text-slate-500 text-center">Sitemap hierher ziehen oder klicken</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="p-8 bg-slate-50 rounded-[2rem] border border-slate-100 flex gap-6 items-start">
|
||||||
|
<Info className="text-slate-400 shrink-0 mt-1" size={24} />
|
||||||
|
<div className="text-sm text-slate-600 space-y-2 leading-relaxed">
|
||||||
|
<p className="font-bold text-slate-900">Was zählt als Seite?</p>
|
||||||
|
<p>Eine Seite ist ein eigenständiges Layout. Wenn Sie 10 Leistungen haben, die alle das gleiche Layout nutzen, zählt das als 1 Seite plus ein "System-Modul" für die Verwaltung der Leistungen.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
77
src/components/ContactForm/steps/ContactStep.tsx
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import * as React from 'react';
|
||||||
|
import { FormState } from '../types';
|
||||||
|
import { FileText, Upload, X } from 'lucide-react';
|
||||||
|
|
||||||
|
interface ContactStepProps {
|
||||||
|
state: FormState;
|
||||||
|
updateState: (updates: Partial<FormState>) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ContactStep({ state, updateState }: ContactStepProps) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-8">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<input type="text" placeholder="Ihr Name" required value={state.name} onChange={(e) => updateState({ name: e.target.value })} className="w-full p-8 bg-white border border-slate-100 rounded-[2rem] focus:outline-none focus:border-slate-900 transition-colors rounded-[2rem]" />
|
||||||
|
<input type="email" placeholder="Ihre Email" required value={state.email} onChange={(e) => updateState({ email: e.target.value })} className="w-full p-8 bg-white border border-slate-100 rounded-[2rem] focus:outline-none focus:border-slate-900 transition-colors rounded-[2rem]" />
|
||||||
|
</div>
|
||||||
|
<input type="text" placeholder="Ihre Rolle (z.B. CEO, Marketing Manager...)" value={state.role} onChange={(e) => updateState({ role: e.target.value })} className="w-full p-8 bg-white border border-slate-100 rounded-[2rem] focus:outline-none focus:border-slate-900 transition-colors rounded-[2rem]" />
|
||||||
|
<textarea placeholder="Erzählen Sie mir kurz von Ihrem Projekt..." value={state.message} onChange={(e) => updateState({ message: e.target.value })} rows={4} className="w-full p-8 bg-white border border-slate-100 rounded-[2rem] focus:outline-none focus:border-slate-900 transition-colors resize-none rounded-[2rem]" />
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<p className="text-sm font-bold text-slate-900">Dateien hochladen (optional)</p>
|
||||||
|
<div
|
||||||
|
className={`relative group border-2 border-dashed rounded-[2rem] p-8 transition-all duration-300 flex flex-col items-center justify-center gap-4 cursor-pointer min-h-[160px] rounded-[2rem] ${
|
||||||
|
state.contactFiles.length > 0 ? 'border-slate-900 bg-slate-50' : 'border-slate-200 hover:border-slate-400 bg-white'
|
||||||
|
}`}
|
||||||
|
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] });
|
||||||
|
}} />
|
||||||
|
|
||||||
|
{state.contactFiles.length > 0 ? (
|
||||||
|
<div className="w-full space-y-2">
|
||||||
|
{state.contactFiles.map((file, idx) => (
|
||||||
|
<div key={idx} className="flex items-center justify-between p-3 bg-white border border-slate-100 rounded-xl shadow-sm">
|
||||||
|
<div className="flex items-center gap-3 text-slate-900">
|
||||||
|
<FileText size={20} className="text-slate-400" />
|
||||||
|
<span className="font-bold text-sm truncate max-w-[200px]">{file.name}</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
updateState({ contactFiles: state.contactFiles.filter((_, i) => i !== idx) });
|
||||||
|
}}
|
||||||
|
className="p-1 hover:bg-slate-100 rounded-full transition-colors focus:outline-none overflow-hidden relative rounded-full"
|
||||||
|
>
|
||||||
|
<X size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<p className="text-[10px] text-slate-400 text-center mt-4">Klicken oder ziehen, um weitere Dateien hinzuzufügen</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Upload size={32} className="text-slate-400 group-hover:text-slate-900 transition-colors" />
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="text-sm font-bold text-slate-900">Dateien hierher ziehen</p>
|
||||||
|
<p className="text-xs text-slate-500 mt-1">oder klicken zum Auswählen</p>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
83
src/components/ContactForm/steps/ContentStep.tsx
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import * as React from 'react';
|
||||||
|
import { FormState } from '../types';
|
||||||
|
import { Zap, AlertCircle, Minus, Plus } from 'lucide-react';
|
||||||
|
|
||||||
|
interface ContentStepProps {
|
||||||
|
state: FormState;
|
||||||
|
updateState: (updates: Partial<FormState>) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ContentStep({ state, updateState }: ContentStepProps) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-8">
|
||||||
|
<div className="flex items-center justify-between p-8 bg-white border border-slate-100 rounded-[2rem]">
|
||||||
|
<div className="max-w-[70%]">
|
||||||
|
<h4 className="text-xl font-bold text-slate-900">Inhalte selbst verwalten (CMS)</h4>
|
||||||
|
<p className="text-sm text-slate-500 mt-1">Möchten Sie Datensätze (z.B. Blogartikel, Produkte) selbst über eine einfache Oberfläche pflegen?</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => updateState({ cmsSetup: !state.cmsSetup })}
|
||||||
|
className={`w-16 h-9 rounded-full transition-colors relative focus:outline-none ${state.cmsSetup ? 'bg-slate-900' : 'bg-slate-200'}`}
|
||||||
|
>
|
||||||
|
<div className={`absolute top-1 left-1 w-7 h-7 bg-white rounded-full transition-transform ${state.cmsSetup ? 'translate-x-7' : ''}`} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-8 bg-slate-50 rounded-[2rem] border border-slate-100 space-y-4">
|
||||||
|
<p className="text-sm font-bold text-slate-900">Wie oft ändern sich Ihre Inhalte?</p>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
|
{[
|
||||||
|
{ 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 => (
|
||||||
|
<button
|
||||||
|
key={opt.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => updateState({ expectedAdjustments: opt.id })}
|
||||||
|
className={`p-4 rounded-2xl border-2 text-left transition-all 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-sm">{opt.label}</p>
|
||||||
|
<p className={`text-[10px] ${state.expectedAdjustments === opt.id ? 'text-slate-200' : 'text-slate-500'}`}>{opt.desc}</p>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 mt-6">
|
||||||
|
<div className="p-6 bg-white rounded-2xl border border-slate-100 space-y-2">
|
||||||
|
<div className="flex items-center gap-2 text-slate-900 font-bold text-xs uppercase tracking-wider">
|
||||||
|
<Zap size={14} /> Vorteil CMS
|
||||||
|
</div>
|
||||||
|
<p className="text-[11px] 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-6 bg-white rounded-2xl border border-slate-100 space-y-2">
|
||||||
|
<div className="flex items-center gap-2 text-slate-900 font-bold text-xs uppercase tracking-wider">
|
||||||
|
<AlertCircle size={14} /> Fokus Design
|
||||||
|
</div>
|
||||||
|
<p className="text-[11px] 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>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-4 p-8 bg-white border border-slate-100 rounded-[2rem]">
|
||||||
|
<div>
|
||||||
|
<h4 className="text-xl font-bold text-slate-900">Inhalte einpflegen</h4>
|
||||||
|
<p className="text-sm text-slate-500 mt-2 leading-relaxed">Für wie viele Datensätze soll ich die initiale Befüllung übernehmen?</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-8 mt-2">
|
||||||
|
<button type="button" onClick={() => updateState({ newDatasets: Math.max(0, state.newDatasets - 1) })} className="w-12 h-12 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={20} /></button>
|
||||||
|
<span className="text-3xl font-bold w-12 text-center">{state.newDatasets}</span>
|
||||||
|
<button type="button" onClick={() => updateState({ newDatasets: state.newDatasets + 1 })} className="w-12 h-12 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={20} /></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
116
src/components/ContactForm/steps/DesignStep.tsx
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import * as React from 'react';
|
||||||
|
import { FormState } from '../types';
|
||||||
|
import { DESIGN_VIBES, HARMONIOUS_PALETTES } from '../constants';
|
||||||
|
import { RepeatableList } from '../components/RepeatableList';
|
||||||
|
import { Palette, RefreshCw, Plus } from 'lucide-react';
|
||||||
|
|
||||||
|
interface DesignStepProps {
|
||||||
|
state: FormState;
|
||||||
|
updateState: (updates: Partial<FormState>) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DesignStep({ state, updateState }: DesignStepProps) {
|
||||||
|
const randomizeColors = () => {
|
||||||
|
const palette = HARMONIOUS_PALETTES[Math.floor(Math.random() * HARMONIOUS_PALETTES.length)];
|
||||||
|
// Maintain length if user added more colors
|
||||||
|
let finalPalette = [...palette];
|
||||||
|
if (state.colorScheme.length > palette.length) {
|
||||||
|
const diff = state.colorScheme.length - palette.length;
|
||||||
|
for(let i=0; i<diff; i++) {
|
||||||
|
finalPalette.push('#' + Math.floor(Math.random()*16777215).toString(16).padStart(6, '0'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
updateState({ colorScheme: finalPalette });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-12">
|
||||||
|
<div className="space-y-6">
|
||||||
|
<p className="text-sm font-bold text-slate-900 flex items-center gap-2"><Palette size={18} /> Design-Vibe wählen</p>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
{DESIGN_VIBES.map(vibe => (
|
||||||
|
<button
|
||||||
|
key={vibe.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => updateState({ designVibe: vibe.id })}
|
||||||
|
className={`p-6 rounded-[2rem] border-2 text-left transition-all duration-300 focus:outline-none overflow-hidden relative rounded-[2rem] group ${
|
||||||
|
state.designVibe === vibe.id ? 'border-slate-900 bg-slate-900 text-white' : 'border-slate-100 bg-white hover:border-slate-200'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex justify-between items-start mb-4">
|
||||||
|
<div className="w-12 h-12 rounded-xl bg-slate-50 flex items-center justify-center text-slate-900 group-hover:bg-white transition-colors">
|
||||||
|
{vibe.illustration}
|
||||||
|
</div>
|
||||||
|
<h4 className={`font-bold ${state.designVibe === vibe.id ? 'text-white' : 'text-slate-900'}`}>{vibe.label}</h4>
|
||||||
|
</div>
|
||||||
|
<p className={`text-sm leading-relaxed ${state.designVibe === vibe.id ? 'text-white opacity-90' : 'text-slate-500'}`}>{vibe.desc}</p>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex flex-col md:flex-row justify-between items-start md:items-center gap-4">
|
||||||
|
<p className="text-sm font-bold text-slate-900">Farbschema</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={randomizeColors}
|
||||||
|
className="w-full md:w-auto flex items-center justify-center gap-3 px-10 py-5 rounded-full bg-slate-900 text-white text-sm font-bold uppercase tracking-[0.2em] hover:bg-slate-800 transition-all focus:outline-none shadow-2xl shadow-slate-300 active:scale-95 overflow-hidden relative rounded-full group"
|
||||||
|
>
|
||||||
|
<RefreshCw size={20} className="group-hover:rotate-180 transition-transform duration-500" />
|
||||||
|
<span>Farbschema würfeln</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-6 p-8 bg-white border border-slate-100 rounded-[2rem]">
|
||||||
|
{state.colorScheme.map((color, i) => (
|
||||||
|
<div key={i} className="flex flex-col items-center gap-3">
|
||||||
|
<div className="relative w-16 h-16 rounded-2xl border border-slate-200 shadow-sm overflow-hidden rounded-2xl">
|
||||||
|
<input
|
||||||
|
type="color"
|
||||||
|
value={color}
|
||||||
|
onChange={(e) => {
|
||||||
|
const newColors = [...state.colorScheme];
|
||||||
|
newColors[i] = e.target.value;
|
||||||
|
updateState({ colorScheme: newColors });
|
||||||
|
}}
|
||||||
|
className="absolute inset-0 w-[200%] h-[200%] -translate-x-1/4 -translate-y-1/4 cursor-pointer"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className="text-[10px] font-mono text-slate-400 uppercase">{color}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => updateState({ colorScheme: [...state.colorScheme, '#000000'] })}
|
||||||
|
className="w-16 h-16 rounded-2xl border-2 border-dashed border-slate-200 flex items-center justify-center text-slate-300 hover:border-slate-900 hover:text-slate-900 transition-colors focus:outline-none overflow-hidden relative rounded-2xl"
|
||||||
|
>
|
||||||
|
<Plus size={24} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-6">
|
||||||
|
<p className="text-sm font-bold text-slate-900">Referenz-Webseiten</p>
|
||||||
|
<RepeatableList
|
||||||
|
items={state.references}
|
||||||
|
onAdd={(v) => updateState({ references: [...state.references, v] })}
|
||||||
|
onRemove={(i) => updateState({ references: state.references.filter((_, idx) => idx !== i) })}
|
||||||
|
placeholder="https://..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<p className="text-sm font-bold text-slate-900">Besondere Wünsche</p>
|
||||||
|
<textarea
|
||||||
|
value={state.designWishes}
|
||||||
|
onChange={(e) => updateState({ designWishes: e.target.value })}
|
||||||
|
placeholder="Erzählen Sie mir mehr über Ihre Vorstellungen..."
|
||||||
|
className="w-full p-8 bg-white border border-slate-100 rounded-[2rem] focus:outline-none focus:border-slate-900 transition-colors text-sm resize-none rounded-[2rem]"
|
||||||
|
rows={3}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
38
src/components/ContactForm/steps/FeaturesStep.tsx
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
'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';
|
||||||
|
|
||||||
|
interface FeaturesStepProps {
|
||||||
|
state: FormState;
|
||||||
|
updateState: (updates: Partial<FormState>) => void;
|
||||||
|
toggleItem: (list: string[], id: string) => string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FeaturesStep({ state, updateState, toggleItem }: FeaturesStepProps) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-8">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
{FEATURE_OPTIONS.map(opt => (
|
||||||
|
<Checkbox
|
||||||
|
key={opt.id} label={opt.label} desc={opt.desc}
|
||||||
|
checked={state.features.includes(opt.id)}
|
||||||
|
onChange={() => updateState({ features: toggleItem(state.features, opt.id) })}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<p className="text-sm font-bold text-slate-900">Weitere inhaltliche Bereiche?</p>
|
||||||
|
<RepeatableList
|
||||||
|
items={state.otherFeatures}
|
||||||
|
onAdd={(v) => updateState({ otherFeatures: [...state.otherFeatures, v] })}
|
||||||
|
onRemove={(i) => updateState({ otherFeatures: state.otherFeatures.filter((_, idx) => idx !== i) })}
|
||||||
|
placeholder="z.B. Partner-Portal, Download-Center..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
53
src/components/ContactForm/steps/FunctionsStep.tsx
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import * as React from 'react';
|
||||||
|
import { FormState } from '../types';
|
||||||
|
import { FUNCTION_OPTIONS } from '../constants';
|
||||||
|
import { Checkbox } from '../components/Checkbox';
|
||||||
|
import { RepeatableList } from '../components/RepeatableList';
|
||||||
|
import { Minus, Plus } from 'lucide-react';
|
||||||
|
|
||||||
|
interface FunctionsStepProps {
|
||||||
|
state: FormState;
|
||||||
|
updateState: (updates: Partial<FormState>) => void;
|
||||||
|
toggleItem: (list: string[], id: string) => string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FunctionsStep({ state, updateState, toggleItem }: FunctionsStepProps) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-8">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
{FUNCTION_OPTIONS.map(opt => (
|
||||||
|
<Checkbox
|
||||||
|
key={opt.id} label={opt.label} desc={opt.desc}
|
||||||
|
checked={state.functions.includes(opt.id)}
|
||||||
|
onChange={() => updateState({ functions: toggleItem(state.functions, opt.id) })}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<p className="text-sm font-bold text-slate-900">Weitere Funktionen?</p>
|
||||||
|
<RepeatableList
|
||||||
|
items={state.otherFunctions}
|
||||||
|
onAdd={(v) => updateState({ otherFunctions: [...state.otherFunctions, v] })}
|
||||||
|
onRemove={(i) => updateState({ otherFunctions: state.otherFunctions.filter((_, idx) => idx !== i) })}
|
||||||
|
placeholder="z.B. Login-Bereich, Buchungssystem..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-8 bg-white border border-slate-100 rounded-[2rem] space-y-4">
|
||||||
|
<div className="flex justify-between items-start">
|
||||||
|
<div>
|
||||||
|
<h4 className="text-xl font-bold text-slate-900">Besondere Interaktionen</h4>
|
||||||
|
<p className="text-sm text-slate-500 mt-1">Aufwendige Animationen oder komplexe UI-Logik pro Abschnitt.</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-6">
|
||||||
|
<button type="button" onClick={() => updateState({ complexInteractions: Math.max(0, state.complexInteractions - 1) })} className="w-10 h-10 rounded-full bg-slate-50 border border-slate-100 flex items-center justify-center hover:border-slate-900 transition-colors focus:outline-none overflow-hidden relative rounded-full"><Minus size={18} /></button>
|
||||||
|
<span className="text-2xl font-bold w-8 text-center">{state.complexInteractions}</span>
|
||||||
|
<button type="button" onClick={() => updateState({ complexInteractions: state.complexInteractions + 1 })} className="w-10 h-10 rounded-full bg-slate-50 border border-slate-100 flex items-center justify-center hover:border-slate-900 transition-colors focus:outline-none overflow-hidden relative rounded-full"><Plus size={18} /></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
91
src/components/ContactForm/steps/LanguageStep.tsx
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import * as React from 'react';
|
||||||
|
import { FormState } from '../types';
|
||||||
|
import { Globe, Minus, Plus, Info } from 'lucide-react';
|
||||||
|
import { motion } from 'framer-motion';
|
||||||
|
|
||||||
|
interface LanguageStepProps {
|
||||||
|
state: FormState;
|
||||||
|
updateState: (updates: Partial<FormState>) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
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.";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-8">
|
||||||
|
<div className="p-8 bg-white border border-slate-100 rounded-[2rem] space-y-6">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="w-12 h-12 bg-slate-50 rounded-2xl flex items-center justify-center text-slate-900">
|
||||||
|
<Globe size={24} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h4 className="text-xl font-bold text-slate-900">Mehrsprachigkeit</h4>
|
||||||
|
<p className="text-sm text-slate-500">In wie vielen Sprachen soll Ihre Website verfügbar sein?</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-8 py-4">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => updateState({ languagesCount: Math.max(1, state.languagesCount - 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={24} />
|
||||||
|
</button>
|
||||||
|
<div className="flex flex-col items-center">
|
||||||
|
<span className="text-5xl font-bold text-slate-900">{state.languagesCount}</span>
|
||||||
|
<span className="text-xs font-bold uppercase tracking-widest text-slate-400 mt-2">
|
||||||
|
{state.languagesCount === 1 ? 'Sprache' : 'Sprachen'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => updateState({ languagesCount: state.languagesCount + 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={24} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 10 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
className="p-8 bg-slate-900 text-white rounded-[2rem] space-y-4"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3 text-slate-400">
|
||||||
|
<Info size={18} />
|
||||||
|
<span className="text-xs font-bold uppercase tracking-widest">Warum dieser Faktor?</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm leading-relaxed text-slate-300">
|
||||||
|
{basePriceExplanation}
|
||||||
|
</p>
|
||||||
|
{state.languagesCount > 1 && (
|
||||||
|
<div className="pt-4 border-t border-white/10">
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<span className="text-sm font-medium">Aktueller Aufschlagsfaktor:</span>
|
||||||
|
<span className="text-xl font-bold text-white">+{((state.languagesCount - 1) * 20)}%</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div className="p-6 bg-white border border-slate-100 rounded-2xl">
|
||||||
|
<h5 className="font-bold text-slate-900 mb-2">Technische Basis</h5>
|
||||||
|
<p className="text-xs text-slate-500 leading-relaxed">
|
||||||
|
Wir nutzen moderne i18n-Frameworks, die SEO-optimierte URLs für jede Sprache generieren (z.B. /en, /fr).
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="p-6 bg-white border border-slate-100 rounded-2xl">
|
||||||
|
<h5 className="font-bold text-slate-900 mb-2">Content Management</h5>
|
||||||
|
<p className="text-xs text-slate-500 leading-relaxed">
|
||||||
|
Falls ein CMS gewählt wurde, können Sie alle Übersetzungen bequem selbst pflegen.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
45
src/components/ContactForm/steps/TimelineStep.tsx
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
'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) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-8">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
{[
|
||||||
|
{ 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-8 rounded-[2rem] border-2 text-left transition-all focus:outline-none overflow-hidden relative rounded-[2rem] ${
|
||||||
|
state.deadline === opt.id ? 'border-slate-900 bg-slate-900 text-white' : 'border-slate-100 bg-white hover:border-slate-200'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<h4 className={`font-bold mb-1 ${state.deadline === opt.id ? 'text-white' : 'text-slate-900'}`}>{opt.label}</h4>
|
||||||
|
<p className={`text-sm ${state.deadline === opt.id ? 'text-white opacity-80' : 'text-slate-500'}`}>{opt.desc}</p>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{state.deadline === 'asap' && (
|
||||||
|
<div className="p-6 bg-slate-50 rounded-2xl border border-slate-100 flex gap-4 items-start">
|
||||||
|
<AlertCircle className="text-slate-900 shrink-0 mt-1" size={20} />
|
||||||
|
<p className="text-xs 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>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
34
src/components/ContactForm/steps/TypeStep.tsx
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import * as React from 'react';
|
||||||
|
import { FormState, ProjectType } from '../types';
|
||||||
|
import { ConceptWebsite, ConceptSystem } from '../../Landing/ConceptIllustrations';
|
||||||
|
|
||||||
|
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-6">
|
||||||
|
{[
|
||||||
|
{ id: 'website', label: 'Website', desc: 'Klassische Webpräsenz, Portfolio oder Blog.', illustration: <ConceptWebsite className="w-16 h-16 mb-4" /> },
|
||||||
|
{ id: 'app', label: 'App / Software', desc: 'Internes Tool, Dashboard oder Prozess-Logik.', illustration: <ConceptSystem className="w-16 h-16 mb-4" /> },
|
||||||
|
].map((type) => (
|
||||||
|
<button
|
||||||
|
key={type.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => updateState({ projectType: type.id as ProjectType })}
|
||||||
|
className={`p-10 rounded-[2.5rem] border-2 text-left transition-all duration-300 focus:outline-none overflow-hidden relative rounded-[2.5rem] ${
|
||||||
|
state.projectType === type.id ? 'border-slate-900 bg-slate-900 text-white' : 'border-slate-100 bg-white hover:border-slate-200'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className={state.projectType === type.id ? 'text-white' : 'text-slate-900'}>{type.illustration}</div>
|
||||||
|
<h4 className={`text-2xl font-bold mb-3 ${state.projectType === type.id ? 'text-white' : 'text-slate-900'}`}>{type.label}</h4>
|
||||||
|
<p className={`text-lg leading-relaxed ${state.projectType === type.id ? 'text-slate-100' : 'text-slate-500'}`}>{type.desc}</p>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
42
src/components/ContactForm/types.ts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
export type ProjectType = 'website' | 'app';
|
||||||
|
|
||||||
|
export interface FormState {
|
||||||
|
projectType: ProjectType;
|
||||||
|
selectedPages: string[];
|
||||||
|
otherPages: string[];
|
||||||
|
features: string[];
|
||||||
|
otherFeatures: string[];
|
||||||
|
functions: string[];
|
||||||
|
otherFunctions: string[];
|
||||||
|
apiSystems: string[];
|
||||||
|
otherTech: string[];
|
||||||
|
assets: string[];
|
||||||
|
otherAssets: string[];
|
||||||
|
complexInteractions: 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;
|
||||||
|
languagesCount: number;
|
||||||
|
// Timeline
|
||||||
|
deadline: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Step {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
illustration: React.ReactNode;
|
||||||
|
}
|
||||||
@@ -227,7 +227,6 @@ export const EstimationPDF = ({ state, totalPrice, monthlyPrice, totalPagesCount
|
|||||||
const functionLabels: Record<string, string> = {
|
const functionLabels: Record<string, string> = {
|
||||||
search: 'Suche',
|
search: 'Suche',
|
||||||
filter: 'Filter-Systeme',
|
filter: 'Filter-Systeme',
|
||||||
i18n: 'Mehrsprachigkeit',
|
|
||||||
pdf: 'PDF-Export',
|
pdf: 'PDF-Export',
|
||||||
forms: 'Erweiterte Formulare'
|
forms: 'Erweiterte Formulare'
|
||||||
};
|
};
|
||||||
@@ -256,7 +255,7 @@ export const EstimationPDF = ({ state, totalPrice, monthlyPrice, totalPagesCount
|
|||||||
price: pricing.BASE_WEBSITE
|
price: pricing.BASE_WEBSITE
|
||||||
});
|
});
|
||||||
|
|
||||||
const allPages = [...state.selectedPages.map(p => p === 'Home' ? 'Startseite' : p), ...state.otherPages];
|
const allPages = [...state.selectedPages.map((p: string) => p === 'Home' ? 'Startseite' : p), ...state.otherPages];
|
||||||
positions.push({
|
positions.push({
|
||||||
pos: pos++,
|
pos: pos++,
|
||||||
title: 'Individuelle Seiten',
|
title: 'Individuelle Seiten',
|
||||||
@@ -266,7 +265,7 @@ export const EstimationPDF = ({ state, totalPrice, monthlyPrice, totalPagesCount
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (state.features.length > 0 || state.otherFeatures.length > 0) {
|
if (state.features.length > 0 || state.otherFeatures.length > 0) {
|
||||||
const allFeatures = [...state.features.map(f => featureLabels[f] || f), ...state.otherFeatures];
|
const allFeatures = [...state.features.map((f: string) => featureLabels[f] || f), ...state.otherFeatures];
|
||||||
positions.push({
|
positions.push({
|
||||||
pos: pos++,
|
pos: pos++,
|
||||||
title: 'System-Module',
|
title: 'System-Module',
|
||||||
@@ -277,7 +276,7 @@ export const EstimationPDF = ({ state, totalPrice, monthlyPrice, totalPagesCount
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (state.functions.length > 0 || state.otherFunctions.length > 0) {
|
if (state.functions.length > 0 || state.otherFunctions.length > 0) {
|
||||||
const allFunctions = [...state.functions.map(f => functionLabels[f] || f), ...state.otherFunctions];
|
const allFunctions = [...state.functions.map((f: string) => functionLabels[f] || f), ...state.otherFunctions];
|
||||||
positions.push({
|
positions.push({
|
||||||
pos: pos++,
|
pos: pos++,
|
||||||
title: 'Logik-Funktionen',
|
title: 'Logik-Funktionen',
|
||||||
@@ -288,7 +287,7 @@ export const EstimationPDF = ({ state, totalPrice, monthlyPrice, totalPagesCount
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (state.apiSystems.length > 0 || state.otherTech.length > 0) {
|
if (state.apiSystems.length > 0 || state.otherTech.length > 0) {
|
||||||
const allApis = [...state.apiSystems.map(a => apiLabels[a] || a), ...state.otherTech];
|
const allApis = [...state.apiSystems.map((a: string) => apiLabels[a] || a), ...state.otherTech];
|
||||||
positions.push({
|
positions.push({
|
||||||
pos: pos++,
|
pos: pos++,
|
||||||
title: 'Schnittstellen (API)',
|
title: 'Schnittstellen (API)',
|
||||||
@@ -454,7 +453,7 @@ export const EstimationPDF = ({ state, totalPrice, monthlyPrice, totalPagesCount
|
|||||||
</View>
|
</View>
|
||||||
<View style={styles.configItem}>
|
<View style={styles.configItem}>
|
||||||
<Text style={styles.configLabel}>Assets vorhanden</Text>
|
<Text style={styles.configLabel}>Assets vorhanden</Text>
|
||||||
<Text style={styles.configValue}>{state.assets.map(a => assetLabels[a] || a).join(', ') || 'Keine angegeben'}</Text>
|
<Text style={styles.configValue}>{state.assets.map((a: string) => assetLabels[a] || a).join(', ') || 'Keine angegeben'}</Text>
|
||||||
</View>
|
</View>
|
||||||
{state.otherAssets.length > 0 && (
|
{state.otherAssets.length > 0 && (
|
||||||
<View style={styles.configItem}>
|
<View style={styles.configItem}>
|
||||||
|
|||||||
@@ -1,436 +1,3 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import * as React from 'react';
|
export * from './Illustrations';
|
||||||
import { motion } from 'framer-motion';
|
|
||||||
|
|
||||||
interface IllustrationProps {
|
|
||||||
className?: string;
|
|
||||||
delay?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const ConceptCommunication: React.FC<IllustrationProps> = ({ className = "", delay = 0 }) => (
|
|
||||||
<svg className={className} viewBox="0 0 120 120" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<circle cx="20" cy="60" r="6" className="fill-slate-200 stroke-slate-300" strokeWidth="1" />
|
|
||||||
<circle cx="100" cy="60" r="6" className="fill-slate-900" />
|
|
||||||
<path d="M 26 60 H 94" stroke="currentColor" strokeWidth="1" className="text-slate-300" strokeDasharray="4 4" />
|
|
||||||
<motion.path
|
|
||||||
d="M 26 60 H 94"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="2"
|
|
||||||
className="text-slate-400"
|
|
||||||
initial={{ pathLength: 0 }}
|
|
||||||
animate={{ pathLength: [0, 1, 1, 0], opacity: [0, 1, 1, 0] }}
|
|
||||||
transition={{ duration: 3, repeat: Infinity, ease: "easeInOut", delay }}
|
|
||||||
/>
|
|
||||||
<motion.circle r="3" className="fill-slate-900">
|
|
||||||
<animateMotion
|
|
||||||
dur="3s"
|
|
||||||
repeatCount="indefinite"
|
|
||||||
path="M 26 60 H 94"
|
|
||||||
/>
|
|
||||||
</motion.circle>
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
|
|
||||||
export const ConceptPrototyping: React.FC<IllustrationProps> = ({ className = "", delay = 0 }) => (
|
|
||||||
<svg className={className} viewBox="0 0 120 120" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<rect x="15" y="25" width="90" height="70" rx="4" stroke="currentColor" strokeWidth="1" className="text-slate-400" />
|
|
||||||
<path d="M 15 40 H 105" stroke="currentColor" strokeWidth="1" className="text-slate-300" />
|
|
||||||
<motion.rect
|
|
||||||
x="25" y="50" width="40" height="8" rx="1"
|
|
||||||
className="fill-slate-300"
|
|
||||||
animate={{ width: [0, 40, 40, 0] }}
|
|
||||||
transition={{ duration: 4, repeat: Infinity, delay }}
|
|
||||||
/>
|
|
||||||
<motion.rect
|
|
||||||
x="25" y="65" width="60" height="8" rx="1"
|
|
||||||
className="fill-slate-200"
|
|
||||||
animate={{ width: [0, 60, 60, 0] }}
|
|
||||||
transition={{ duration: 4, repeat: Infinity, delay: 0.5 }}
|
|
||||||
/>
|
|
||||||
<motion.circle
|
|
||||||
cx="85" cy="75" r="10"
|
|
||||||
className="fill-slate-900"
|
|
||||||
animate={{ scale: [0.8, 1.1, 0.8] }}
|
|
||||||
transition={{ duration: 2, repeat: Infinity }}
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
|
|
||||||
export const ConceptCode: React.FC<IllustrationProps> = ({ className = "", delay = 0 }) => (
|
|
||||||
<svg className={className} viewBox="0 0 120 120" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
{[40, 55, 70, 85].map((y, i) => (
|
|
||||||
<motion.path
|
|
||||||
key={y}
|
|
||||||
d={`M 25 ${y} H ${25 + Math.random() * 60 + 20}`}
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="2"
|
|
||||||
strokeLinecap="round"
|
|
||||||
className="text-slate-400"
|
|
||||||
initial={{ pathLength: 0 }}
|
|
||||||
animate={{ pathLength: [0, 1, 1, 0] }}
|
|
||||||
transition={{ duration: 4, repeat: Infinity, delay: i * 0.2 + delay }}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
<motion.path
|
|
||||||
d="M 90 40 L 100 50 L 115 30"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="3"
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
className="text-slate-900"
|
|
||||||
animate={{ opacity: [0, 1, 1, 0], scale: [0.8, 1, 1, 0.8] }}
|
|
||||||
transition={{ duration: 4, repeat: Infinity, delay: 1.5 }}
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
|
|
||||||
export const ConceptPrice: React.FC<IllustrationProps> = ({ className = "", delay = 0 }) => (
|
|
||||||
<svg className={className} viewBox="0 0 120 120" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<rect x="30" y="30" width="60" height="70" rx="2" stroke="currentColor" strokeWidth="1" className="text-slate-400" />
|
|
||||||
<motion.path
|
|
||||||
d="M 40 50 H 80 M 40 65 H 80 M 40 80 H 60"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="1"
|
|
||||||
className="text-slate-300"
|
|
||||||
initial={{ pathLength: 0 }}
|
|
||||||
animate={{ pathLength: [0, 1, 1, 0] }}
|
|
||||||
transition={{ duration: 5, repeat: Infinity, delay }}
|
|
||||||
/>
|
|
||||||
<motion.circle
|
|
||||||
cx="85" cy="35" r="15"
|
|
||||||
className="fill-white stroke-slate-900"
|
|
||||||
strokeWidth="1"
|
|
||||||
animate={{ y: [0, -5, 0], rotate: [0, 10, 0] }}
|
|
||||||
transition={{ duration: 4, repeat: Infinity }}
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
|
|
||||||
export const ConceptWebsite: React.FC<IllustrationProps> = ({ className = "", delay = 0 }) => (
|
|
||||||
<svg className={className} viewBox="0 0 120 120" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<rect x="10" y="20" width="100" height="80" rx="4" stroke="currentColor" strokeWidth="1" className="text-slate-400" />
|
|
||||||
<motion.rect
|
|
||||||
x="20" y="35" width="80" height="15" rx="2"
|
|
||||||
className="fill-slate-200"
|
|
||||||
animate={{ opacity: [0.3, 0.6, 0.3] }}
|
|
||||||
transition={{ duration: 3, repeat: Infinity }}
|
|
||||||
/>
|
|
||||||
<motion.g
|
|
||||||
animate={{ y: [0, 10, 0] }}
|
|
||||||
transition={{ duration: 4, repeat: Infinity }}
|
|
||||||
>
|
|
||||||
<rect x="20" y="55" width="35" height="35" rx="2" className="fill-slate-300" />
|
|
||||||
<rect x="65" y="55" width="35" height="35" rx="2" className="fill-slate-300" />
|
|
||||||
</motion.g>
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
|
|
||||||
export const ConceptSystem: React.FC<IllustrationProps> = ({ className = "", delay = 0 }) => (
|
|
||||||
<svg className={className} viewBox="0 0 120 120" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<motion.circle cx="60" cy="60" r="15" className="fill-slate-900"
|
|
||||||
animate={{ scale: [1, 1.1, 1] }} transition={{ duration: 4, repeat: Infinity }} />
|
|
||||||
{[0, 72, 144, 216, 288].map((angle, i) => {
|
|
||||||
const x = 60 + Math.cos((angle * Math.PI) / 180) * 40;
|
|
||||||
const y = 60 + Math.sin((angle * Math.PI) / 180) * 40;
|
|
||||||
return (
|
|
||||||
<React.Fragment key={i}>
|
|
||||||
<motion.line
|
|
||||||
x1="60" y1="60" x2={x} y2={y}
|
|
||||||
stroke="currentColor" strokeWidth="1" className="text-slate-400"
|
|
||||||
animate={{ strokeDashoffset: [0, 10] }}
|
|
||||||
strokeDasharray="2 2"
|
|
||||||
transition={{ duration: 2, repeat: Infinity, ease: "linear" }}
|
|
||||||
/>
|
|
||||||
<motion.circle
|
|
||||||
cx={x} cy={y} r="6"
|
|
||||||
className="fill-white stroke-slate-300"
|
|
||||||
animate={{ scale: [1, 1.2, 1] }}
|
|
||||||
transition={{ duration: 3, repeat: Infinity, delay: i * 0.4 }}
|
|
||||||
/>
|
|
||||||
</React.Fragment>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
|
|
||||||
export const ConceptAutomation: React.FC<IllustrationProps> = ({ className = "", delay = 0 }) => (
|
|
||||||
<svg className={className} viewBox="0 0 120 120" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<motion.g
|
|
||||||
animate={{ rotate: 360 }}
|
|
||||||
transition={{ duration: 10, repeat: Infinity, ease: "linear" }}
|
|
||||||
style={{ originX: "40px", originY: "60px" }}
|
|
||||||
>
|
|
||||||
<path d="M 40 45 L 50 60 L 40 75 L 30 60 Z" className="fill-slate-300" />
|
|
||||||
</motion.g>
|
|
||||||
<motion.g
|
|
||||||
animate={{ rotate: -360 }}
|
|
||||||
transition={{ duration: 10, repeat: Infinity, ease: "linear" }}
|
|
||||||
style={{ originX: "75px", originY: "65px" }}
|
|
||||||
>
|
|
||||||
<path d="M 75 50 L 85 65 L 75 80 L 65 65 Z" className="fill-slate-500" />
|
|
||||||
</motion.g>
|
|
||||||
<motion.path
|
|
||||||
d="M 10 60 H 110"
|
|
||||||
stroke="currentColor" strokeWidth="1" className="text-slate-300"
|
|
||||||
strokeDasharray="4 4"
|
|
||||||
animate={{ strokeDashoffset: [0, -20] }}
|
|
||||||
transition={{ duration: 2, repeat: Infinity, ease: "linear" }}
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
|
|
||||||
export const ConceptTarget: React.FC<IllustrationProps> = ({ className = "", delay = 0 }) => (
|
|
||||||
<svg className={className} viewBox="0 0 120 120" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<motion.circle
|
|
||||||
cx="60" cy="60" r="50"
|
|
||||||
stroke="currentColor" strokeWidth="1" className="text-slate-300"
|
|
||||||
animate={{ scale: [1, 1.05, 1] }}
|
|
||||||
transition={{ duration: 4, repeat: Infinity }}
|
|
||||||
/>
|
|
||||||
<motion.circle
|
|
||||||
cx="60" cy="60" r="30"
|
|
||||||
stroke="currentColor" strokeWidth="1" className="text-slate-400"
|
|
||||||
/>
|
|
||||||
<motion.circle
|
|
||||||
cx="60" cy="60" r="10"
|
|
||||||
className="fill-slate-900"
|
|
||||||
animate={{ opacity: [0.5, 1, 0.5] }}
|
|
||||||
transition={{ duration: 2, repeat: Infinity }}
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
|
|
||||||
export const ConceptMessy: React.FC<IllustrationProps> = ({ className = "", delay = 0 }) => (
|
|
||||||
<svg className={className} viewBox="0 0 120 120" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<motion.path
|
|
||||||
d="M 20 60 C 30 20, 40 100, 50 60 C 60 20, 70 100, 80 60 C 90 20, 100 100, 110 60"
|
|
||||||
stroke="currentColor" strokeWidth="1" className="text-slate-500"
|
|
||||||
animate={{ strokeDashoffset: [0, 20] }}
|
|
||||||
strokeDasharray="4 4"
|
|
||||||
transition={{ duration: 5, repeat: Infinity, ease: "linear" }}
|
|
||||||
/>
|
|
||||||
<motion.path
|
|
||||||
d="M 20 40 L 100 80 M 20 80 L 100 40"
|
|
||||||
stroke="currentColor" strokeWidth="1" className="text-slate-200 opacity-50"
|
|
||||||
animate={{ opacity: [0.2, 0.5, 0.2] }} transition={{ duration: 3, repeat: Infinity }}
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
|
|
||||||
export const HeroArchitecture: React.FC<IllustrationProps> = ({ className = "", delay = 0 }) => (
|
|
||||||
<svg className={className} viewBox="0 0 400 300" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<motion.rect x="170" y="120" width="60" height="60" rx="8" className="stroke-slate-900 fill-white" strokeWidth="2"
|
|
||||||
animate={{ scale: [1, 1.05, 1] }} transition={{ duration: 4, repeat: Infinity }} />
|
|
||||||
{[
|
|
||||||
{ x: 80, y: 60 }, { x: 320, y: 60 },
|
|
||||||
{ x: 80, y: 240 }, { x: 320, y: 240 }
|
|
||||||
].map((node, i) => (
|
|
||||||
<React.Fragment key={i}>
|
|
||||||
<motion.path
|
|
||||||
d={`M 200 150 L ${node.x} ${node.y}`}
|
|
||||||
stroke="currentColor" strokeWidth="1" className="text-slate-400"
|
|
||||||
animate={{ strokeDashoffset: [0, -10] }} strokeDasharray="4 4"
|
|
||||||
transition={{ duration: 2, repeat: Infinity, ease: "linear" }}
|
|
||||||
/>
|
|
||||||
<motion.circle
|
|
||||||
cx={node.x} cy={node.y} r="12"
|
|
||||||
className="fill-white stroke-slate-300"
|
|
||||||
strokeWidth="1"
|
|
||||||
animate={{ scale: [1, 1.1, 1] }} transition={{ duration: 3, repeat: Infinity, delay: i * 0.5 }}
|
|
||||||
/>
|
|
||||||
</React.Fragment>
|
|
||||||
))}
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
|
|
||||||
// Binary text component for reuse
|
|
||||||
const BinaryText: React.FC<{ x: number; y: number; delay?: number }> = ({ x, y, delay = 0 }) => (
|
|
||||||
<motion.text
|
|
||||||
x={x}
|
|
||||||
y={y}
|
|
||||||
className="fill-slate-300 font-mono"
|
|
||||||
style={{ fontSize: 10 }}
|
|
||||||
animate={{ opacity: [0.2, 0.5, 0.2] }}
|
|
||||||
transition={{ duration: 2, repeat: Infinity, delay }}
|
|
||||||
>
|
|
||||||
{Math.random() > 0.5 ? '0' : '1'}
|
|
||||||
</motion.text>
|
|
||||||
);
|
|
||||||
|
|
||||||
export const HeroMainIllustration: React.FC<IllustrationProps> = ({ className = "", delay = 0 }) => {
|
|
||||||
return (
|
|
||||||
<svg className={className} viewBox="0 0 800 700" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
{/* Matrix-style Binary Rain Background */}
|
|
||||||
<g className="opacity-[0.08]">
|
|
||||||
{Array.from({ length: 20 }).map((_, col) => {
|
|
||||||
const colX = 20 + col * 40;
|
|
||||||
const speed = 8 + Math.random() * 6;
|
|
||||||
const startDelay = Math.random() * 5;
|
|
||||||
return (
|
|
||||||
<motion.g
|
|
||||||
key={`rain-col-${col}`}
|
|
||||||
initial={{ y: -700 }}
|
|
||||||
animate={{ y: 700 }}
|
|
||||||
transition={{
|
|
||||||
duration: speed,
|
|
||||||
repeat: Infinity,
|
|
||||||
ease: "linear",
|
|
||||||
delay: startDelay,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{Array.from({ length: 25 }).map((_, row) => (
|
|
||||||
<text
|
|
||||||
key={`${col}-${row}`}
|
|
||||||
x={colX}
|
|
||||||
y={row * 28}
|
|
||||||
className="fill-slate-900 font-mono"
|
|
||||||
style={{ fontSize: 12 }}
|
|
||||||
>
|
|
||||||
{Math.random() > 0.5 ? '1' : '0'}
|
|
||||||
</text>
|
|
||||||
))}
|
|
||||||
</motion.g>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</g>
|
|
||||||
|
|
||||||
{/* Layer 1: Base Platform */}
|
|
||||||
<motion.g
|
|
||||||
animate={{ y: [0, 8, 0] }}
|
|
||||||
transition={{ duration: 5, repeat: Infinity, ease: "easeInOut" }}
|
|
||||||
>
|
|
||||||
<rect x="150" y="500" width="500" height="30" rx="4" className="fill-slate-100 stroke-slate-300" strokeWidth="1" />
|
|
||||||
<rect x="170" y="510" width="460" height="10" rx="2" className="fill-slate-200" />
|
|
||||||
{/* Binary on base */}
|
|
||||||
<text x="180" y="518" className="fill-slate-400 font-mono" style={{ fontSize: 8 }}>01010101010101010101010101010101010101</text>
|
|
||||||
</motion.g>
|
|
||||||
|
|
||||||
{/* Layer 2: Server/Database Layer */}
|
|
||||||
<motion.g
|
|
||||||
animate={{ y: [0, 6, 0] }}
|
|
||||||
transition={{ duration: 5, repeat: Infinity, ease: "easeInOut", delay: 0.3 }}
|
|
||||||
>
|
|
||||||
{/* Left Server Block */}
|
|
||||||
<g transform="translate(200, 400)">
|
|
||||||
<rect x="0" y="0" width="120" height="80" rx="6" className="fill-white stroke-slate-900" strokeWidth="2" />
|
|
||||||
<rect x="10" y="10" width="100" height="15" rx="2" className="fill-slate-100" />
|
|
||||||
<rect x="10" y="30" width="80" height="10" rx="2" className="fill-slate-200" />
|
|
||||||
<rect x="10" y="45" width="60" height="10" rx="2" className="fill-slate-200" />
|
|
||||||
<circle cx="100" cy="65" r="5" className="fill-slate-900" />
|
|
||||||
<text x="15" y="20" className="fill-slate-500 font-mono" style={{ fontSize: 8 }}>SERVER</text>
|
|
||||||
</g>
|
|
||||||
|
|
||||||
{/* Right Database Block */}
|
|
||||||
<g transform="translate(480, 400)">
|
|
||||||
<rect x="0" y="0" width="120" height="80" rx="6" className="fill-white stroke-slate-900" strokeWidth="2" />
|
|
||||||
<rect x="10" y="10" width="100" height="15" rx="2" className="fill-slate-100" />
|
|
||||||
<rect x="10" y="30" width="100" height="8" rx="2" className="fill-slate-200" />
|
|
||||||
<rect x="10" y="42" width="100" height="8" rx="2" className="fill-slate-200" />
|
|
||||||
<rect x="10" y="54" width="100" height="8" rx="2" className="fill-slate-200" />
|
|
||||||
<text x="15" y="20" className="fill-slate-500 font-mono" style={{ fontSize: 8 }}>DATABASE</text>
|
|
||||||
</g>
|
|
||||||
|
|
||||||
{/* Connection Lines */}
|
|
||||||
<motion.path
|
|
||||||
d="M 320 440 L 400 440 L 480 440"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="2"
|
|
||||||
className="text-slate-400"
|
|
||||||
strokeDasharray="6 4"
|
|
||||||
animate={{ strokeDashoffset: [0, -20] }}
|
|
||||||
transition={{ duration: 1, repeat: Infinity, ease: "linear" }}
|
|
||||||
/>
|
|
||||||
</motion.g>
|
|
||||||
|
|
||||||
{/* Layer 3: Browser/Website */}
|
|
||||||
<motion.g
|
|
||||||
animate={{ y: [0, 4, 0] }}
|
|
||||||
transition={{ duration: 5, repeat: Infinity, ease: "easeInOut", delay: 0.6 }}
|
|
||||||
>
|
|
||||||
{/* Browser Window */}
|
|
||||||
<rect x="180" y="100" width="440" height="280" rx="8" className="fill-white stroke-slate-900" strokeWidth="2" />
|
|
||||||
|
|
||||||
{/* Browser Chrome */}
|
|
||||||
<rect x="180" y="100" width="440" height="30" rx="8" className="fill-slate-900" />
|
|
||||||
<rect x="180" y="120" width="440" height="10" className="fill-slate-900" />
|
|
||||||
|
|
||||||
{/* Browser Dots */}
|
|
||||||
<circle cx="200" cy="115" r="5" className="fill-slate-600" />
|
|
||||||
<circle cx="218" cy="115" r="5" className="fill-slate-600" />
|
|
||||||
<circle cx="236" cy="115" r="5" className="fill-slate-600" />
|
|
||||||
|
|
||||||
{/* Address Bar */}
|
|
||||||
<rect x="260" y="108" width="200" height="14" rx="3" className="fill-slate-700" />
|
|
||||||
|
|
||||||
{/* Website Content */}
|
|
||||||
<g transform="translate(200, 150)">
|
|
||||||
{/* Navigation */}
|
|
||||||
<rect x="0" y="0" width="400" height="20" className="fill-slate-50" />
|
|
||||||
<rect x="10" y="5" width="60" height="10" rx="2" className="fill-slate-900" />
|
|
||||||
<rect x="280" y="5" width="30" height="10" rx="2" className="fill-slate-300" />
|
|
||||||
<rect x="320" y="5" width="30" height="10" rx="2" className="fill-slate-300" />
|
|
||||||
<rect x="360" y="5" width="30" height="10" rx="2" className="fill-slate-300" />
|
|
||||||
|
|
||||||
{/* Hero Section */}
|
|
||||||
<rect x="0" y="30" width="400" height="100" className="fill-slate-100" />
|
|
||||||
<rect x="20" y="50" width="180" height="16" rx="2" className="fill-slate-900" />
|
|
||||||
<rect x="20" y="72" width="140" height="10" rx="2" className="fill-slate-400" />
|
|
||||||
<rect x="20" y="88" width="100" height="10" rx="2" className="fill-slate-400" />
|
|
||||||
<rect x="20" y="108" width="80" height="16" rx="4" className="fill-slate-900" />
|
|
||||||
|
|
||||||
{/* Hero Image Placeholder */}
|
|
||||||
<rect x="240" y="40" width="140" height="80" rx="4" className="fill-slate-200" />
|
|
||||||
<path d="M 280 80 L 310 60 L 340 80 L 310 100 Z" className="fill-slate-300" />
|
|
||||||
|
|
||||||
{/* Cards Section */}
|
|
||||||
<g transform="translate(0, 140)">
|
|
||||||
<rect x="0" y="0" width="125" height="70" rx="4" className="fill-slate-50 stroke-slate-200" strokeWidth="1" />
|
|
||||||
<rect x="10" y="10" width="105" height="30" rx="2" className="fill-slate-200" />
|
|
||||||
<rect x="10" y="48" width="80" height="8" rx="2" className="fill-slate-300" />
|
|
||||||
|
|
||||||
<rect x="137" y="0" width="125" height="70" rx="4" className="fill-slate-50 stroke-slate-200" strokeWidth="1" />
|
|
||||||
<rect x="147" y="10" width="105" height="30" rx="2" className="fill-slate-200" />
|
|
||||||
<rect x="147" y="48" width="80" height="8" rx="2" className="fill-slate-300" />
|
|
||||||
|
|
||||||
<rect x="274" y="0" width="125" height="70" rx="4" className="fill-slate-50 stroke-slate-200" strokeWidth="1" />
|
|
||||||
<rect x="284" y="10" width="105" height="30" rx="2" className="fill-slate-200" />
|
|
||||||
<rect x="284" y="48" width="80" height="8" rx="2" className="fill-slate-300" />
|
|
||||||
</g>
|
|
||||||
</g>
|
|
||||||
</motion.g>
|
|
||||||
|
|
||||||
{/* Connecting Lines from Browser to Infrastructure */}
|
|
||||||
<motion.g>
|
|
||||||
<motion.path
|
|
||||||
d="M 400 380 L 400 400"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="2"
|
|
||||||
className="text-slate-400"
|
|
||||||
strokeDasharray="4 4"
|
|
||||||
animate={{ strokeDashoffset: [0, -16] }}
|
|
||||||
transition={{ duration: 0.8, repeat: Infinity, ease: "linear" }}
|
|
||||||
/>
|
|
||||||
<motion.path
|
|
||||||
d="M 260 480 L 260 500"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="2"
|
|
||||||
className="text-slate-400"
|
|
||||||
strokeDasharray="4 4"
|
|
||||||
animate={{ strokeDashoffset: [0, -16] }}
|
|
||||||
transition={{ duration: 0.8, repeat: Infinity, ease: "linear" }}
|
|
||||||
/>
|
|
||||||
<motion.path
|
|
||||||
d="M 540 480 L 540 500"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="2"
|
|
||||||
className="text-slate-400"
|
|
||||||
strokeDasharray="4 4"
|
|
||||||
animate={{ strokeDashoffset: [0, -16] }}
|
|
||||||
transition={{ duration: 0.8, repeat: Infinity, ease: "linear" }}
|
|
||||||
/>
|
|
||||||
</motion.g>
|
|
||||||
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|||||||
31
src/components/Landing/Illustrations/ConceptAutomation.tsx
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import * as React from 'react';
|
||||||
|
import { motion } from 'framer-motion';
|
||||||
|
import { IllustrationProps } from './types';
|
||||||
|
|
||||||
|
export const ConceptAutomation: React.FC<IllustrationProps> = ({ className = "", delay = 0 }) => (
|
||||||
|
<svg className={className} viewBox="0 0 120 120" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<motion.g
|
||||||
|
animate={{ rotate: 360 }}
|
||||||
|
transition={{ duration: 10, repeat: Infinity, ease: "linear" }}
|
||||||
|
style={{ originX: "40px", originY: "60px" }}
|
||||||
|
>
|
||||||
|
<path d="M 40 45 L 50 60 L 40 75 L 30 60 Z" className="fill-slate-300" />
|
||||||
|
</motion.g>
|
||||||
|
<motion.g
|
||||||
|
animate={{ rotate: -360 }}
|
||||||
|
transition={{ duration: 10, repeat: Infinity, ease: "linear" }}
|
||||||
|
style={{ originX: "75px", originY: "65px" }}
|
||||||
|
>
|
||||||
|
<path d="M 75 50 L 85 65 L 75 80 L 65 65 Z" className="fill-slate-500" />
|
||||||
|
</motion.g>
|
||||||
|
<motion.path
|
||||||
|
d="M 10 60 H 110"
|
||||||
|
stroke="currentColor" strokeWidth="1" className="text-slate-300"
|
||||||
|
strokeDasharray="4 4"
|
||||||
|
animate={{ strokeDashoffset: [0, -20] }}
|
||||||
|
transition={{ duration: 2, repeat: Infinity, ease: "linear" }}
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
33
src/components/Landing/Illustrations/ConceptCode.tsx
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import * as React from 'react';
|
||||||
|
import { motion } from 'framer-motion';
|
||||||
|
import { IllustrationProps } from './types';
|
||||||
|
|
||||||
|
export const ConceptCode: React.FC<IllustrationProps> = ({ className = "", delay = 0 }) => (
|
||||||
|
<svg className={className} viewBox="0 0 120 120" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
{[40, 55, 70, 85].map((y, i) => (
|
||||||
|
<motion.path
|
||||||
|
key={y}
|
||||||
|
d={`M 25 ${y} H ${25 + ((i * 17) % 50) + 20}`}
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
className="text-slate-400"
|
||||||
|
initial={{ pathLength: 0 }}
|
||||||
|
animate={{ pathLength: [0, 1, 1, 0] }}
|
||||||
|
transition={{ duration: 4, repeat: Infinity, delay: i * 0.2 + delay }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
<motion.path
|
||||||
|
d="M 90 40 L 100 50 L 115 30"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="3"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
className="text-slate-900"
|
||||||
|
animate={{ opacity: [0, 1, 1, 0], scale: [0.8, 1, 1, 0.8] }}
|
||||||
|
transition={{ duration: 4, repeat: Infinity, delay: 1.5 }}
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import * as React from 'react';
|
||||||
|
import { motion } from 'framer-motion';
|
||||||
|
import { IllustrationProps } from './types';
|
||||||
|
|
||||||
|
export const ConceptCommunication: React.FC<IllustrationProps> = ({ className = "", delay = 0 }) => (
|
||||||
|
<svg className={className} viewBox="0 0 120 120" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<circle cx="20" cy="60" r="6" className="fill-slate-200 stroke-slate-300" strokeWidth="1" />
|
||||||
|
<circle cx="100" cy="60" r="6" className="fill-slate-900" />
|
||||||
|
<path d="M 26 60 H 94" stroke="currentColor" strokeWidth="1" className="text-slate-300" strokeDasharray="4 4" />
|
||||||
|
<motion.path
|
||||||
|
d="M 26 60 H 94"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
className="text-slate-400"
|
||||||
|
initial={{ pathLength: 0 }}
|
||||||
|
animate={{ pathLength: [0, 1, 1, 0], opacity: [0, 1, 1, 0] }}
|
||||||
|
transition={{ duration: 3, repeat: Infinity, ease: "easeInOut", delay }}
|
||||||
|
/>
|
||||||
|
<motion.circle r="3" className="fill-slate-900">
|
||||||
|
<animateMotion
|
||||||
|
dur="3s"
|
||||||
|
repeatCount="indefinite"
|
||||||
|
path="M 26 60 H 94"
|
||||||
|
/>
|
||||||
|
</motion.circle>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
22
src/components/Landing/Illustrations/ConceptMessy.tsx
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import * as React from 'react';
|
||||||
|
import { motion } from 'framer-motion';
|
||||||
|
import { IllustrationProps } from './types';
|
||||||
|
|
||||||
|
export const ConceptMessy: React.FC<IllustrationProps> = ({ className = "", delay = 0 }) => (
|
||||||
|
<svg className={className} viewBox="0 0 120 120" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<motion.path
|
||||||
|
d="M 20 60 C 30 20, 40 100, 50 60 C 60 20, 70 100, 80 60 C 90 20, 100 100, 110 60"
|
||||||
|
stroke="currentColor" strokeWidth="1" className="text-slate-500"
|
||||||
|
animate={{ strokeDashoffset: [0, 20] }}
|
||||||
|
strokeDasharray="4 4"
|
||||||
|
transition={{ duration: 5, repeat: Infinity, ease: "linear" }}
|
||||||
|
/>
|
||||||
|
<motion.path
|
||||||
|
d="M 20 40 L 100 80 M 20 80 L 100 40"
|
||||||
|
stroke="currentColor" strokeWidth="1" className="text-slate-200 opacity-50"
|
||||||
|
animate={{ opacity: [0.2, 0.5, 0.2] }} transition={{ duration: 3, repeat: Infinity }}
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
27
src/components/Landing/Illustrations/ConceptPrice.tsx
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import * as React from 'react';
|
||||||
|
import { motion } from 'framer-motion';
|
||||||
|
import { IllustrationProps } from './types';
|
||||||
|
|
||||||
|
export const ConceptPrice: React.FC<IllustrationProps> = ({ className = "", delay = 0 }) => (
|
||||||
|
<svg className={className} viewBox="0 0 120 120" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<rect x="30" y="30" width="60" height="70" rx="2" stroke="currentColor" strokeWidth="1" className="text-slate-400" />
|
||||||
|
<motion.path
|
||||||
|
d="M 40 50 H 80 M 40 65 H 80 M 40 80 H 60"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1"
|
||||||
|
className="text-slate-300"
|
||||||
|
initial={{ pathLength: 0 }}
|
||||||
|
animate={{ pathLength: [0, 1, 1, 0] }}
|
||||||
|
transition={{ duration: 5, repeat: Infinity, delay }}
|
||||||
|
/>
|
||||||
|
<motion.circle
|
||||||
|
cx="85" cy="35" r="15"
|
||||||
|
className="fill-white stroke-slate-900"
|
||||||
|
strokeWidth="1"
|
||||||
|
animate={{ y: [0, -5, 0], rotate: [0, 10, 0] }}
|
||||||
|
transition={{ duration: 4, repeat: Infinity }}
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
30
src/components/Landing/Illustrations/ConceptPrototyping.tsx
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import * as React from 'react';
|
||||||
|
import { motion } from 'framer-motion';
|
||||||
|
import { IllustrationProps } from './types';
|
||||||
|
|
||||||
|
export const ConceptPrototyping: React.FC<IllustrationProps> = ({ className = "", delay = 0 }) => (
|
||||||
|
<svg className={className} viewBox="0 0 120 120" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<rect x="15" y="25" width="90" height="70" rx="4" stroke="currentColor" strokeWidth="1" className="text-slate-400" />
|
||||||
|
<path d="M 15 40 H 105" stroke="currentColor" strokeWidth="1" className="text-slate-300" />
|
||||||
|
<motion.rect
|
||||||
|
x="25" y="50" width="40" height="8" rx="1"
|
||||||
|
className="fill-slate-300"
|
||||||
|
animate={{ width: [0, 40, 40, 0] }}
|
||||||
|
transition={{ duration: 4, repeat: Infinity, delay }}
|
||||||
|
/>
|
||||||
|
<motion.rect
|
||||||
|
x="25" y="65" width="60" height="8" rx="1"
|
||||||
|
className="fill-slate-200"
|
||||||
|
animate={{ width: [0, 60, 60, 0] }}
|
||||||
|
transition={{ duration: 4, repeat: Infinity, delay: 0.5 }}
|
||||||
|
/>
|
||||||
|
<motion.circle
|
||||||
|
cx="85" cy="75" r="10"
|
||||||
|
className="fill-slate-900"
|
||||||
|
animate={{ scale: [0.8, 1.1, 0.8] }}
|
||||||
|
transition={{ duration: 2, repeat: Infinity }}
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
33
src/components/Landing/Illustrations/ConceptSystem.tsx
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import * as React from 'react';
|
||||||
|
import { motion } from 'framer-motion';
|
||||||
|
import { IllustrationProps } from './types';
|
||||||
|
|
||||||
|
export const ConceptSystem: React.FC<IllustrationProps> = ({ className = "", delay = 0 }) => (
|
||||||
|
<svg className={className} viewBox="0 0 120 120" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<motion.circle cx="60" cy="60" r="15" className="fill-slate-900"
|
||||||
|
animate={{ scale: [1, 1.1, 1] }} transition={{ duration: 4, repeat: Infinity }} />
|
||||||
|
{[0, 72, 144, 216, 288].map((angle, i) => {
|
||||||
|
const x = 60 + Math.cos((angle * Math.PI) / 180) * 40;
|
||||||
|
const y = 60 + Math.sin((angle * Math.PI) / 180) * 40;
|
||||||
|
return (
|
||||||
|
<React.Fragment key={i}>
|
||||||
|
<motion.line
|
||||||
|
x1="60" y1="60" x2={x} y2={y}
|
||||||
|
stroke="currentColor" strokeWidth="1" className="text-slate-400"
|
||||||
|
animate={{ strokeDashoffset: [0, 10] }}
|
||||||
|
strokeDasharray="2 2"
|
||||||
|
transition={{ duration: 2, repeat: Infinity, ease: "linear" }}
|
||||||
|
/>
|
||||||
|
<motion.circle
|
||||||
|
cx={x} cy={y} r="6"
|
||||||
|
className="fill-white stroke-slate-300"
|
||||||
|
animate={{ scale: [1, 1.2, 1] }}
|
||||||
|
transition={{ duration: 3, repeat: Infinity, delay: i * 0.4 }}
|
||||||
|
/>
|
||||||
|
</React.Fragment>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
26
src/components/Landing/Illustrations/ConceptTarget.tsx
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import * as React from 'react';
|
||||||
|
import { motion } from 'framer-motion';
|
||||||
|
import { IllustrationProps } from './types';
|
||||||
|
|
||||||
|
export const ConceptTarget: React.FC<IllustrationProps> = ({ className = "", delay = 0 }) => (
|
||||||
|
<svg className={className} viewBox="0 0 120 120" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<motion.circle
|
||||||
|
cx="60" cy="60" r="50"
|
||||||
|
stroke="currentColor" strokeWidth="1" className="text-slate-300"
|
||||||
|
animate={{ scale: [1, 1.05, 1] }}
|
||||||
|
transition={{ duration: 4, repeat: Infinity }}
|
||||||
|
/>
|
||||||
|
<motion.circle
|
||||||
|
cx="60" cy="60" r="30"
|
||||||
|
stroke="currentColor" strokeWidth="1" className="text-slate-400"
|
||||||
|
/>
|
||||||
|
<motion.circle
|
||||||
|
cx="60" cy="60" r="10"
|
||||||
|
className="fill-slate-900"
|
||||||
|
animate={{ opacity: [0.5, 1, 0.5] }}
|
||||||
|
transition={{ duration: 2, repeat: Infinity }}
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
24
src/components/Landing/Illustrations/ConceptWebsite.tsx
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import * as React from 'react';
|
||||||
|
import { motion } from 'framer-motion';
|
||||||
|
import { IllustrationProps } from './types';
|
||||||
|
|
||||||
|
export const ConceptWebsite: React.FC<IllustrationProps> = ({ className = "", delay = 0 }) => (
|
||||||
|
<svg className={className} viewBox="0 0 120 120" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<rect x="10" y="20" width="100" height="80" rx="4" stroke="currentColor" strokeWidth="1" className="text-slate-400" />
|
||||||
|
<motion.rect
|
||||||
|
x="20" y="35" width="80" height="15" rx="2"
|
||||||
|
className="fill-slate-200"
|
||||||
|
animate={{ opacity: [0.3, 0.6, 0.3] }}
|
||||||
|
transition={{ duration: 3, repeat: Infinity }}
|
||||||
|
/>
|
||||||
|
<motion.g
|
||||||
|
animate={{ y: [0, 10, 0] }}
|
||||||
|
transition={{ duration: 4, repeat: Infinity }}
|
||||||
|
>
|
||||||
|
<rect x="20" y="55" width="35" height="35" rx="2" className="fill-slate-300" />
|
||||||
|
<rect x="65" y="55" width="35" height="35" rx="2" className="fill-slate-300" />
|
||||||
|
</motion.g>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
31
src/components/Landing/Illustrations/HeroArchitecture.tsx
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import * as React from 'react';
|
||||||
|
import { motion } from 'framer-motion';
|
||||||
|
import { IllustrationProps } from './types';
|
||||||
|
|
||||||
|
export const HeroArchitecture: React.FC<IllustrationProps> = ({ className = "", delay = 0 }) => (
|
||||||
|
<svg className={className} viewBox="0 0 400 300" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<motion.rect x="170" y="120" width="60" height="60" rx="8" className="stroke-slate-900 fill-white" strokeWidth="2"
|
||||||
|
animate={{ scale: [1, 1.05, 1] }} transition={{ duration: 4, repeat: Infinity }} />
|
||||||
|
{[
|
||||||
|
{ x: 80, y: 60 }, { x: 320, y: 60 },
|
||||||
|
{ x: 80, y: 240 }, { x: 320, y: 240 }
|
||||||
|
].map((node, i) => (
|
||||||
|
<React.Fragment key={i}>
|
||||||
|
<motion.path
|
||||||
|
d={`M 200 150 L ${node.x} ${node.y}`}
|
||||||
|
stroke="currentColor" strokeWidth="1" className="text-slate-400"
|
||||||
|
animate={{ strokeDashoffset: [0, -10] }} strokeDasharray="4 4"
|
||||||
|
transition={{ duration: 2, repeat: Infinity, ease: "linear" }}
|
||||||
|
/>
|
||||||
|
<motion.circle
|
||||||
|
cx={node.x} cy={node.y} r="12"
|
||||||
|
className="fill-white stroke-slate-300"
|
||||||
|
strokeWidth="1"
|
||||||
|
animate={{ scale: [1, 1.1, 1] }} transition={{ duration: 3, repeat: Infinity, delay: i * 0.5 }}
|
||||||
|
/>
|
||||||
|
</React.Fragment>
|
||||||
|
))}
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
182
src/components/Landing/Illustrations/HeroMainIllustration.tsx
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import * as React from 'react';
|
||||||
|
import { motion } from 'framer-motion';
|
||||||
|
import { IllustrationProps } from './types';
|
||||||
|
|
||||||
|
export const HeroMainIllustration: React.FC<IllustrationProps> = ({ className = "", delay = 0 }) => {
|
||||||
|
return (
|
||||||
|
<svg className={className} viewBox="0 0 800 700" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
{/* Matrix-style Binary Rain Background */}
|
||||||
|
<g className="opacity-[0.08]">
|
||||||
|
{Array.from({ length: 20 }).map((_, col) => {
|
||||||
|
const colX = 20 + col * 40;
|
||||||
|
const speed = 8 + (col % 6);
|
||||||
|
const startDelay = (col % 5);
|
||||||
|
return (
|
||||||
|
<motion.g
|
||||||
|
key={`rain-col-${col}`}
|
||||||
|
initial={{ y: -700 }}
|
||||||
|
animate={{ y: 700 }}
|
||||||
|
transition={{
|
||||||
|
duration: speed,
|
||||||
|
repeat: Infinity,
|
||||||
|
ease: "linear",
|
||||||
|
delay: startDelay,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{Array.from({ length: 25 }).map((_, row) => (
|
||||||
|
<text
|
||||||
|
key={`${col}-${row}`}
|
||||||
|
x={colX}
|
||||||
|
y={row * 28}
|
||||||
|
className="fill-slate-900 font-mono"
|
||||||
|
style={{ fontSize: 12 }}
|
||||||
|
>
|
||||||
|
{(col + row) % 2 === 0 ? '1' : '0'}
|
||||||
|
</text>
|
||||||
|
))}
|
||||||
|
</motion.g>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</g>
|
||||||
|
|
||||||
|
{/* Layer 1: Base Platform */}
|
||||||
|
<motion.g
|
||||||
|
animate={{ y: [0, 8, 0] }}
|
||||||
|
transition={{ duration: 5, repeat: Infinity, ease: "easeInOut" }}
|
||||||
|
>
|
||||||
|
<rect x="150" y="500" width="500" height="30" rx="4" className="fill-slate-100 stroke-slate-300" strokeWidth="1" />
|
||||||
|
<rect x="170" y="510" width="460" height="10" rx="2" className="fill-slate-200" />
|
||||||
|
{/* Binary on base */}
|
||||||
|
<text x="180" y="518" className="fill-slate-400 font-mono" style={{ fontSize: 8 }}>01010101010101010101010101010101010101</text>
|
||||||
|
</motion.g>
|
||||||
|
|
||||||
|
{/* Layer 2: Server/Database Layer */}
|
||||||
|
<motion.g
|
||||||
|
animate={{ y: [0, 6, 0] }}
|
||||||
|
transition={{ duration: 5, repeat: Infinity, ease: "easeInOut", delay: 0.3 }}
|
||||||
|
>
|
||||||
|
{/* Left Server Block */}
|
||||||
|
<g transform="translate(200, 400)">
|
||||||
|
<rect x="0" y="0" width="120" height="80" rx="6" className="fill-white stroke-slate-900" strokeWidth="2" />
|
||||||
|
<rect x="10" y="10" width="100" height="15" rx="2" className="fill-slate-100" />
|
||||||
|
<rect x="10" y="30" width="80" height="10" rx="2" className="fill-slate-200" />
|
||||||
|
<rect x="10" y="45" width="60" height="10" rx="2" className="fill-slate-200" />
|
||||||
|
<circle cx="100" cy="65" r="5" className="fill-slate-900" />
|
||||||
|
<text x="15" y="20" className="fill-slate-500 font-mono" style={{ fontSize: 8 }}>SERVER</text>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
{/* Right Database Block */}
|
||||||
|
<g transform="translate(480, 400)">
|
||||||
|
<rect x="0" y="0" width="120" height="80" rx="6" className="fill-white stroke-slate-900" strokeWidth="2" />
|
||||||
|
<rect x="10" y="10" width="100" height="15" rx="2" className="fill-slate-100" />
|
||||||
|
<rect x="10" y="30" width="100" height="8" rx="2" className="fill-slate-200" />
|
||||||
|
<rect x="10" y="42" width="100" height="8" rx="2" className="fill-slate-200" />
|
||||||
|
<rect x="10" y="54" width="100" height="8" rx="2" className="fill-slate-200" />
|
||||||
|
<text x="15" y="20" className="fill-slate-500 font-mono" style={{ fontSize: 8 }}>DATABASE</text>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
{/* Connection Lines */}
|
||||||
|
<motion.path
|
||||||
|
d="M 320 440 L 400 440 L 480 440"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
className="text-slate-400"
|
||||||
|
strokeDasharray="6 4"
|
||||||
|
animate={{ strokeDashoffset: [0, -20] }}
|
||||||
|
transition={{ duration: 1, repeat: Infinity, ease: "linear" }}
|
||||||
|
/>
|
||||||
|
</motion.g>
|
||||||
|
|
||||||
|
{/* Layer 3: Browser/Website */}
|
||||||
|
<motion.g
|
||||||
|
animate={{ y: [0, 4, 0] }}
|
||||||
|
transition={{ duration: 5, repeat: Infinity, ease: "easeInOut", delay: 0.6 }}
|
||||||
|
>
|
||||||
|
{/* Browser Window */}
|
||||||
|
<rect x="180" y="100" width="440" height="280" rx="8" className="fill-white stroke-slate-900" strokeWidth="2" />
|
||||||
|
|
||||||
|
{/* Browser Chrome */}
|
||||||
|
<rect x="180" y="100" width="440" height="30" rx="8" className="fill-slate-900" />
|
||||||
|
<rect x="180" y="120" width="440" height="10" className="fill-slate-900" />
|
||||||
|
|
||||||
|
{/* Browser Dots */}
|
||||||
|
<circle cx="200" cy="115" r="5" className="fill-slate-600" />
|
||||||
|
<circle cx="218" cy="115" r="5" className="fill-slate-600" />
|
||||||
|
<circle cx="236" cy="115" r="5" className="fill-slate-600" />
|
||||||
|
|
||||||
|
{/* Address Bar */}
|
||||||
|
<rect x="260" y="108" width="200" height="14" rx="3" className="fill-slate-700" />
|
||||||
|
|
||||||
|
{/* Website Content */}
|
||||||
|
<g transform="translate(200, 150)">
|
||||||
|
{/* Navigation */}
|
||||||
|
<rect x="0" y="0" width="400" height="20" className="fill-slate-50" />
|
||||||
|
<rect x="10" y="5" width="60" height="10" rx="2" className="fill-slate-900" />
|
||||||
|
<rect x="280" y="5" width="30" height="10" rx="2" className="fill-slate-300" />
|
||||||
|
<rect x="320" y="5" width="30" height="10" rx="2" className="fill-slate-300" />
|
||||||
|
<rect x="360" y="5" width="30" height="10" rx="2" className="fill-slate-300" />
|
||||||
|
|
||||||
|
{/* Hero Section */}
|
||||||
|
<rect x="0" y="30" width="400" height="100" className="fill-slate-100" />
|
||||||
|
<rect x="20" y="50" width="180" height="16" rx="2" className="fill-slate-900" />
|
||||||
|
<rect x="20" y="72" width="140" height="10" rx="2" className="fill-slate-400" />
|
||||||
|
<rect x="20" y="88" width="100" height="10" rx="2" className="fill-slate-400" />
|
||||||
|
<rect x="20" y="108" width="80" height="16" rx="4" className="fill-slate-900" />
|
||||||
|
|
||||||
|
{/* Hero Image Placeholder */}
|
||||||
|
<rect x="240" y="40" width="140" height="80" rx="4" className="fill-slate-200" />
|
||||||
|
<path d="M 280 80 L 310 60 L 340 80 L 310 100 Z" className="fill-slate-300" />
|
||||||
|
|
||||||
|
{/* Cards Section */}
|
||||||
|
<g transform="translate(0, 140)">
|
||||||
|
<rect x="0" y="0" width="125" height="70" rx="4" className="fill-slate-50 stroke-slate-200" strokeWidth="1" />
|
||||||
|
<rect x="10" y="10" width="105" height="30" rx="2" className="fill-slate-200" />
|
||||||
|
<rect x="10" y="48" width="80" height="8" rx="2" className="fill-slate-300" />
|
||||||
|
|
||||||
|
<rect x="137" y="0" width="125" height="70" rx="4" className="fill-slate-50 stroke-slate-200" strokeWidth="1" />
|
||||||
|
<rect x="147" y="10" width="105" height="30" rx="2" className="fill-slate-200" />
|
||||||
|
<rect x="147" y="48" width="80" height="8" rx="2" className="fill-slate-300" />
|
||||||
|
|
||||||
|
<rect x="274" y="0" width="125" height="70" rx="4" className="fill-slate-50 stroke-slate-200" strokeWidth="1" />
|
||||||
|
<rect x="284" y="10" width="105" height="30" rx="2" className="fill-slate-200" />
|
||||||
|
<rect x="284" y="48" width="80" height="8" rx="2" className="fill-slate-300" />
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</motion.g>
|
||||||
|
|
||||||
|
{/* Connecting Lines from Browser to Infrastructure */}
|
||||||
|
<motion.g>
|
||||||
|
<motion.path
|
||||||
|
d="M 400 380 L 400 400"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
className="text-slate-400"
|
||||||
|
strokeDasharray="4 4"
|
||||||
|
animate={{ strokeDashoffset: [0, -16] }}
|
||||||
|
transition={{ duration: 0.8, repeat: Infinity, ease: "linear" }}
|
||||||
|
/>
|
||||||
|
<motion.path
|
||||||
|
d="M 260 480 L 260 500"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
className="text-slate-400"
|
||||||
|
strokeDasharray="4 4"
|
||||||
|
animate={{ strokeDashoffset: [0, -16] }}
|
||||||
|
transition={{ duration: 0.8, repeat: Infinity, ease: "linear" }}
|
||||||
|
/>
|
||||||
|
<motion.path
|
||||||
|
d="M 540 480 L 540 500"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
className="text-slate-400"
|
||||||
|
strokeDasharray="4 4"
|
||||||
|
animate={{ strokeDashoffset: [0, -16] }}
|
||||||
|
transition={{ duration: 0.8, repeat: Infinity, ease: "linear" }}
|
||||||
|
/>
|
||||||
|
</motion.g>
|
||||||
|
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
};
|
||||||
12
src/components/Landing/Illustrations/index.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
export * from './types';
|
||||||
|
export * from './ConceptCommunication';
|
||||||
|
export * from './ConceptPrototyping';
|
||||||
|
export * from './ConceptCode';
|
||||||
|
export * from './ConceptPrice';
|
||||||
|
export * from './ConceptWebsite';
|
||||||
|
export * from './ConceptSystem';
|
||||||
|
export * from './ConceptAutomation';
|
||||||
|
export * from './ConceptTarget';
|
||||||
|
export * from './ConceptMessy';
|
||||||
|
export * from './HeroArchitecture';
|
||||||
|
export * from './HeroMainIllustration';
|
||||||
8
src/components/Landing/Illustrations/types.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import * as React from 'react';
|
||||||
|
|
||||||
|
export interface IllustrationProps {
|
||||||
|
className?: string;
|
||||||
|
delay?: number;
|
||||||
|
}
|
||||||
60
src/components/Modal.tsx
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import * as React from 'react';
|
||||||
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
|
import { X } from 'lucide-react';
|
||||||
|
|
||||||
|
interface ModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
title: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Modal({ isOpen, onClose, title, children }: ModalProps) {
|
||||||
|
// Close on escape key
|
||||||
|
React.useEffect(() => {
|
||||||
|
const handleEsc = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') onClose();
|
||||||
|
};
|
||||||
|
window.addEventListener('keydown', handleEsc);
|
||||||
|
return () => window.removeEventListener('keydown', handleEsc);
|
||||||
|
}, [onClose]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AnimatePresence>
|
||||||
|
{isOpen && (
|
||||||
|
<>
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
onClick={onClose}
|
||||||
|
className="fixed inset-0 bg-slate-900/40 backdrop-blur-sm z-[100]"
|
||||||
|
/>
|
||||||
|
<div className="fixed inset-0 flex items-center justify-center p-4 z-[101] pointer-events-none">
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, scale: 0.9, y: 20 }}
|
||||||
|
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, scale: 0.9, y: 20 }}
|
||||||
|
className="bg-white w-full max-w-lg rounded-[2.5rem] shadow-2xl pointer-events-auto overflow-hidden"
|
||||||
|
>
|
||||||
|
<div className="p-8 border-b border-slate-50 flex items-center justify-between">
|
||||||
|
<h3 className="text-2xl font-bold text-slate-900">{title}</h3>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="p-2 hover:bg-slate-50 rounded-full transition-colors text-slate-400 hover:text-slate-900"
|
||||||
|
>
|
||||||
|
<X size={24} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="p-8">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
);
|
||||||
|
}
|
||||||
81
src/components/ShareModal.tsx
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import * as React from 'react';
|
||||||
|
import { Modal } from './Modal';
|
||||||
|
import { Copy, Check, Share2 } from 'lucide-react';
|
||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
interface ShareModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
url: string;
|
||||||
|
qrCodeData?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ShareModal({ isOpen, onClose, url, qrCodeData }: ShareModalProps) {
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
|
||||||
|
const handleCopy = () => {
|
||||||
|
navigator.clipboard.writeText(url);
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 2000);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleNativeShare = async () => {
|
||||||
|
if (navigator.share) {
|
||||||
|
try {
|
||||||
|
await navigator.share({
|
||||||
|
title: 'Meine Projekt-Konfiguration',
|
||||||
|
url: url
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Share failed", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal isOpen={isOpen} onClose={onClose} title="Konfiguration teilen">
|
||||||
|
<div className="space-y-8">
|
||||||
|
<p className="text-slate-500 leading-relaxed">
|
||||||
|
Speichern Sie diesen Link, um Ihre Konfiguration später fortzusetzen oder teilen Sie ihn mit anderen.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{qrCodeData && (
|
||||||
|
<div className="flex flex-col items-center gap-4 p-8 bg-slate-50 rounded-[2rem]">
|
||||||
|
<img src={qrCodeData} alt="QR Code" className="w-48 h-48 rounded-xl shadow-sm" />
|
||||||
|
<p className="text-[10px] font-bold uppercase tracking-widest text-slate-400">QR-Code scannen</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="relative">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
readOnly
|
||||||
|
value={url}
|
||||||
|
className="w-full p-6 pr-20 bg-slate-50 border border-slate-100 rounded-2xl text-sm font-mono text-slate-600 focus:outline-none"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={handleCopy}
|
||||||
|
className="absolute right-2 top-2 bottom-2 px-4 bg-white border border-slate-100 rounded-xl text-slate-900 hover:bg-slate-900 hover:text-white transition-all flex items-center gap-2"
|
||||||
|
>
|
||||||
|
{copied ? <Check size={18} /> : <Copy size={18} />}
|
||||||
|
<span className="text-xs font-bold uppercase tracking-wider">{copied ? 'Kopiert' : 'Kopieren'}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{typeof navigator !== 'undefined' && navigator.share && (
|
||||||
|
<button
|
||||||
|
onClick={handleNativeShare}
|
||||||
|
className="w-full p-6 bg-slate-900 text-white rounded-2xl font-bold flex items-center justify-center gap-3 hover:bg-slate-800 transition-all"
|
||||||
|
>
|
||||||
|
<Share2 size={20} />
|
||||||
|
<span>System-Dialog öffnen</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||