This commit is contained in:
@@ -1,72 +0,0 @@
|
||||
pipeline:
|
||||
# Test stage
|
||||
test:
|
||||
image: node:22-alpine
|
||||
commands:
|
||||
- npm ci
|
||||
- npm run test:smoke
|
||||
- npm run test:links
|
||||
|
||||
# Build stage
|
||||
build:
|
||||
image: node:22-alpine
|
||||
commands:
|
||||
- npm ci
|
||||
- npm run build
|
||||
when:
|
||||
branch: [main, master]
|
||||
event: [push, tag]
|
||||
|
||||
# Build and push Docker image
|
||||
docker-build:
|
||||
image: docker:24-dind
|
||||
environment:
|
||||
- DOCKER_HOST=tcp://docker:2375
|
||||
commands:
|
||||
- docker build -t mintel-website:${CI_COMMIT_SHA} -f docker/Dockerfile .
|
||||
- docker tag mintel-website:${CI_COMMIT_SHA} ${REGISTRY_URL}:latest
|
||||
when:
|
||||
branch: [main, master]
|
||||
event: [push, tag]
|
||||
|
||||
# Deploy to Hetzner
|
||||
deploy:
|
||||
image: appleboy/ssh-action
|
||||
environment:
|
||||
- SSH_KEY=${SSH_PRIVATE_KEY}
|
||||
commands:
|
||||
- echo "$SSH_PRIVATE_KEY" > /tmp/deploy_key
|
||||
- chmod 600 /tmp/deploy_key
|
||||
- ssh -o StrictHostKeyChecking=no -i /tmp/deploy_key ${DEPLOY_USER}@${DEPLOY_HOST} "
|
||||
cd /opt/mintel &&
|
||||
echo '${REGISTRY_URL}:latest' > .env.deploy &&
|
||||
docker-compose pull &&
|
||||
docker-compose up -d --remove-orphans &&
|
||||
docker system prune -f"
|
||||
when:
|
||||
branch: [main, master]
|
||||
event: [push]
|
||||
|
||||
# Services
|
||||
services:
|
||||
docker:
|
||||
image: docker:24-dind
|
||||
privileged: true
|
||||
environment:
|
||||
- DOCKER_TLS_CERTDIR=/certs
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
|
||||
# Notifications
|
||||
notify:
|
||||
- name: slack
|
||||
image: plugins/slack
|
||||
settings:
|
||||
webhook: ${SLACK_WEBHOOK}
|
||||
channel: deployments
|
||||
template: |
|
||||
🚀 Mintel Blog Deployed!
|
||||
Branch: {{CI_COMMIT_BRANCH}}
|
||||
Commit: {{CI_COMMIT_SHA}}
|
||||
Author: {{CI_COMMIT_AUTHOR}}
|
||||
URL: https://mintel.me
|
||||
63
deploy.sh
63
deploy.sh
@@ -1,63 +0,0 @@
|
||||
#!/bin/bash
|
||||
# Simple deployment script for Hetzner
|
||||
|
||||
set -e
|
||||
|
||||
# Configuration
|
||||
DOMAIN=${DOMAIN:-mintel.me}
|
||||
ADMIN_EMAIL=${ADMIN_EMAIL:-marc@mintel.me}
|
||||
DEPLOY_HOST=${DEPLOY_HOST:-$1}
|
||||
DEPLOY_USER=${DEPLOY_USER:-root}
|
||||
|
||||
if [ -z "$DEPLOY_HOST" ]; then
|
||||
echo "Usage: ./deploy.sh <host-ip> or set DEPLOY_HOST env var"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "🚀 Deploying Mintel Blog to $DEPLOY_HOST..."
|
||||
|
||||
# Create deployment directory
|
||||
ssh $DEPLOY_USER@$DEPLOY_HOST "mkdir -p /opt/mintel"
|
||||
|
||||
# Copy files
|
||||
echo "📦 Copying files..."
|
||||
scp docker-compose.yml docker/Caddyfile docker/Dockerfile docker/nginx.conf $DEPLOY_USER@$DEPLOY_HOST:/opt/mintel/
|
||||
|
||||
# Create environment file
|
||||
echo "🔧 Setting up environment..."
|
||||
if [ -f .env ]; then
|
||||
echo "Using local .env file..."
|
||||
scp .env $DEPLOY_USER@$DEPLOY_HOST:/opt/mintel/
|
||||
else
|
||||
echo "Creating .env from variables..."
|
||||
ssh $DEPLOY_USER@$DEPLOY_HOST "cat > /opt/mintel/.env << EOF
|
||||
DOMAIN=${DOMAIN:-mintel.me}
|
||||
ADMIN_EMAIL=${ADMIN_EMAIL:-admin@mintel.me}
|
||||
REDIS_URL=${REDIS_URL:-redis://redis:6379}
|
||||
PLAUSIBLE_DOMAIN=${PLAUSIBLE_DOMAIN:-$DOMAIN}
|
||||
PLAUSIBLE_SCRIPT_URL=${PLAUSIBLE_SCRIPT_URL:-https://plausible.yourdomain.com/js/script.js}
|
||||
EOF"
|
||||
fi
|
||||
|
||||
# Deploy
|
||||
echo "🚀 Starting services..."
|
||||
ssh $DEPLOY_USER@$DEPLOY_HOST "
|
||||
cd /opt/mintel &&
|
||||
docker-compose down &&
|
||||
docker-compose pull &&
|
||||
docker-compose up -d --build &&
|
||||
docker system prune -f
|
||||
"
|
||||
|
||||
echo "✅ Deployment complete!"
|
||||
echo "🌐 Website: https://$DOMAIN"
|
||||
echo "📊 Health check: https://$DOMAIN/health"
|
||||
|
||||
# Wait for health check
|
||||
echo "⏳ Waiting for health check..."
|
||||
sleep 10
|
||||
if curl -f https://$DOMAIN/health > /dev/null 2>&1; then
|
||||
echo "✅ Health check passed!"
|
||||
else
|
||||
echo "⚠️ Health check failed. Check logs with: ssh $DEPLOY_USER@$DEPLOY_HOST 'docker logs mintel-website'"
|
||||
fi
|
||||
@@ -3,6 +3,7 @@ version: '3.8'
|
||||
services:
|
||||
# Main website - Next.js standalone
|
||||
website:
|
||||
image: registry.infra.mintel.me/mintel/mintel.me:latest
|
||||
build:
|
||||
context: .
|
||||
dockerfile: docker/Dockerfile
|
||||
@@ -15,8 +16,10 @@ services:
|
||||
- NEXT_PUBLIC_GLITCHTIP_DSN=${NEXT_PUBLIC_GLITCHTIP_DSN}
|
||||
container_name: mintel-website
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "3000:3000"
|
||||
# Port 3000 is internal to the docker network, Caddy will proxy to it.
|
||||
# We can expose it for debugging if needed, but it's safer to keep it internal.
|
||||
expose:
|
||||
- "3000"
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- REDIS_URL=redis://redis:6379
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
# Main website
|
||||
{$DOMAIN:-localhost} {
|
||||
# Reverse proxy to website container
|
||||
reverse_proxy website:80
|
||||
reverse_proxy website:3000
|
||||
|
||||
# Security headers
|
||||
header {
|
||||
|
||||
@@ -4,16 +4,18 @@ import * as React from 'react';
|
||||
import { useState, useMemo, useEffect, useRef } from 'react';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { ChevronRight, ChevronLeft, Send, Check, Share2 } from 'lucide-react';
|
||||
import { ChevronRight, ChevronLeft, Send, Check } from 'lucide-react';
|
||||
import * as QRCode from 'qrcode';
|
||||
|
||||
import { FormState, Step, ProjectType } from './ContactForm/types';
|
||||
import { FormState, Step } from './ContactForm/types';
|
||||
import { PRICING, initialState } from './ContactForm/constants';
|
||||
import { PriceCalculation } from './ContactForm/components/PriceCalculation';
|
||||
import { ShareModal } from './ShareModal';
|
||||
|
||||
// Steps
|
||||
import { TypeStep } from './ContactForm/steps/TypeStep';
|
||||
import { CompanyStep } from './ContactForm/steps/CompanyStep';
|
||||
import { PresenceStep } from './ContactForm/steps/PresenceStep';
|
||||
import { BaseStep } from './ContactForm/steps/BaseStep';
|
||||
import { FeaturesStep } from './ContactForm/steps/FeaturesStep';
|
||||
import { DesignStep } from './ContactForm/steps/DesignStep';
|
||||
@@ -77,19 +79,41 @@ export function ContactForm() {
|
||||
|
||||
const configData = {
|
||||
projectType: state.projectType,
|
||||
companyName: state.companyName,
|
||||
employeeCount: state.employeeCount,
|
||||
existingWebsite: state.existingWebsite,
|
||||
socialMedia: state.socialMedia,
|
||||
socialMediaUrls: state.socialMediaUrls,
|
||||
existingDomain: state.existingDomain,
|
||||
wishedDomain: state.wishedDomain,
|
||||
websiteTopic: state.websiteTopic,
|
||||
selectedPages: state.selectedPages,
|
||||
otherPages: state.otherPages,
|
||||
otherPagesCount: state.otherPagesCount,
|
||||
features: state.features,
|
||||
otherFeatures: state.otherFeatures,
|
||||
otherFeaturesCount: state.otherFeaturesCount,
|
||||
functions: state.functions,
|
||||
otherFunctions: state.otherFunctions,
|
||||
otherFunctionsCount: state.otherFunctionsCount,
|
||||
apiSystems: state.apiSystems,
|
||||
otherTech: state.otherTech,
|
||||
otherTechCount: state.otherTechCount,
|
||||
assets: state.assets,
|
||||
otherAssets: state.otherAssets,
|
||||
otherAssetsCount: state.otherAssetsCount,
|
||||
cmsSetup: state.cmsSetup,
|
||||
languagesCount: state.languagesCount,
|
||||
languagesList: state.languagesList,
|
||||
deadline: state.deadline,
|
||||
designVibe: state.designVibe,
|
||||
colorScheme: state.colorScheme,
|
||||
targetAudience: state.targetAudience,
|
||||
userRoles: state.userRoles,
|
||||
dataSensitivity: state.dataSensitivity,
|
||||
platformType: state.platformType
|
||||
platformType: state.platformType,
|
||||
dontKnows: state.dontKnows,
|
||||
visualStaging: state.visualStaging,
|
||||
complexInteractions: state.complexInteractions
|
||||
};
|
||||
|
||||
const stateString = btoa(unescape(encodeURIComponent(JSON.stringify(configData))));
|
||||
@@ -106,28 +130,28 @@ export function ContactForm() {
|
||||
}, [currentUrl]);
|
||||
|
||||
const totalPagesCount = useMemo(() => {
|
||||
return state.selectedPages.length + state.otherPages.length;
|
||||
}, [state.selectedPages, state.otherPages]);
|
||||
return state.selectedPages.length + state.otherPages.length + (state.otherPagesCount || 0);
|
||||
}, [state.selectedPages, state.otherPages, state.otherPagesCount]);
|
||||
|
||||
const totalPrice = useMemo(() => {
|
||||
if (state.projectType !== 'website') return 0;
|
||||
|
||||
let total = PRICING.BASE_WEBSITE;
|
||||
total += totalPagesCount * PRICING.PAGE;
|
||||
total += (state.features.length + state.otherFeatures.length) * PRICING.FEATURE;
|
||||
total += (state.functions.length + state.otherFunctions.length) * PRICING.FUNCTION;
|
||||
total += state.complexInteractions * PRICING.COMPLEX_INTERACTION;
|
||||
total += state.newDatasets * PRICING.NEW_DATASET;
|
||||
total += (state.apiSystems.length + state.otherTech.length) * PRICING.API_INTEGRATION;
|
||||
total += (state.features.length + state.otherFeatures.length + (state.otherFeaturesCount || 0)) * PRICING.FEATURE;
|
||||
total += (state.functions.length + state.otherFunctions.length + (state.otherFunctionsCount || 0)) * PRICING.FUNCTION;
|
||||
total += (state.apiSystems.length + state.otherTech.length + (state.otherTechCount || 0)) * PRICING.API_INTEGRATION;
|
||||
total += (state.newDatasets || 0) * PRICING.NEW_DATASET;
|
||||
|
||||
if (state.cmsSetup) {
|
||||
total += PRICING.CMS_SETUP;
|
||||
total += (state.features.length + state.otherFeatures.length) * PRICING.CMS_CONNECTION_PER_FEATURE;
|
||||
total += (state.features.length + state.otherFeatures.length + (state.otherFeaturesCount || 0)) * PRICING.CMS_CONNECTION_PER_FEATURE;
|
||||
}
|
||||
|
||||
// Multi-language factor (e.g. +20% per additional language)
|
||||
if (state.languagesCount > 1) {
|
||||
total *= (1 + (state.languagesCount - 1) * 0.2);
|
||||
const languagesCount = state.languagesList.length || 1;
|
||||
if (languagesCount > 1) {
|
||||
total *= (1 + (languagesCount - 1) * 0.2);
|
||||
}
|
||||
|
||||
return Math.round(total);
|
||||
@@ -177,8 +201,10 @@ export function ContactForm() {
|
||||
|
||||
const steps: Step[] = [
|
||||
{ id: 'type', title: 'Das Ziel', description: 'Was möchten Sie realisieren?', illustration: <ConceptTarget className="w-full h-full" /> },
|
||||
{ id: 'base', title: 'Die Seiten', description: 'Welche Seiten benötigen wir?', illustration: <ConceptWebsite className="w-full h-full" /> },
|
||||
{ id: 'company', title: 'Unternehmen', description: 'Wer sind Sie?', illustration: <ConceptCommunication className="w-full h-full" /> },
|
||||
{ id: 'presence', title: 'Präsenz', description: 'Bestehende Kanäle.', illustration: <ConceptSystem className="w-full h-full" /> },
|
||||
{ id: 'features', title: 'Die Systeme', description: 'Welche inhaltlichen Bereiche planen wir?', illustration: <ConceptPrototyping className="w-full h-full" /> },
|
||||
{ id: 'base', title: 'Die Seiten', description: 'Welche Seiten benötigen wir?', illustration: <ConceptWebsite className="w-full h-full" /> },
|
||||
{ id: 'design', title: 'Design-Wünsche', description: 'Wie soll die Seite wirken?', illustration: <ConceptCommunication className="w-full h-full" /> },
|
||||
{ id: 'assets', title: 'Ihre Assets', description: 'Was bringen Sie bereits mit?', illustration: <ConceptSystem className="w-full h-full" /> },
|
||||
{ id: 'functions', title: 'Die Logik', description: 'Welche Funktionen werden benötigt?', illustration: <ConceptCode className="w-full h-full" /> },
|
||||
@@ -197,6 +223,8 @@ export function ContactForm() {
|
||||
// Web App flow
|
||||
return [
|
||||
steps.find(s => s.id === 'type')!,
|
||||
steps.find(s => s.id === 'company')!,
|
||||
steps.find(s => s.id === 'presence')!,
|
||||
steps.find(s => s.id === 'webapp')!,
|
||||
{ ...steps.find(s => s.id === 'functions')!, title: 'Funktionen', description: 'Kern-Features Ihrer Anwendung.' },
|
||||
{ ...steps.find(s => s.id === 'api')!, title: 'Integrationen', description: 'Anbindung an bestehende Systeme.' },
|
||||
@@ -214,6 +242,10 @@ export function ContactForm() {
|
||||
switch (currentStep.id) {
|
||||
case 'type':
|
||||
return <TypeStep state={state} updateState={updateState} />;
|
||||
case 'company':
|
||||
return <CompanyStep state={state} updateState={updateState} />;
|
||||
case 'presence':
|
||||
return <PresenceStep state={state} updateState={updateState} toggleItem={toggleItem} />;
|
||||
case 'base':
|
||||
return <BaseStep state={state} updateState={updateState} toggleItem={toggleItem} />;
|
||||
case 'features':
|
||||
@@ -287,11 +319,11 @@ export function ContactForm() {
|
||||
<p className="text-xl text-slate-500 leading-relaxed">{activeSteps[stepIndex].description}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-3 h-1.5">
|
||||
<div className="flex gap-3 h-4">
|
||||
{activeSteps.map((step, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex-1 h-8 -my-3.5 flex items-center relative"
|
||||
className="flex-1 h-full flex items-center relative"
|
||||
onMouseEnter={() => setHoveredStep(i)}
|
||||
onMouseLeave={() => setHoveredStep(null)}
|
||||
>
|
||||
@@ -301,7 +333,7 @@ export function ContactForm() {
|
||||
setStepIndex(i);
|
||||
setTimeout(scrollToTop, 50);
|
||||
}}
|
||||
className={`w-full h-1.5 rounded-full transition-all duration-700 ${i <= stepIndex ? 'bg-slate-900' : 'bg-slate-100'} cursor-pointer focus:outline-none p-0 border-none`}
|
||||
className={`w-full h-full rounded-full transition-all duration-700 ${i <= stepIndex ? 'bg-slate-900' : 'bg-slate-100'} cursor-pointer focus:outline-none p-0 border-none`}
|
||||
/>
|
||||
<AnimatePresence>
|
||||
{hoveredStep === i && (
|
||||
@@ -309,7 +341,7 @@ export function ContactForm() {
|
||||
initial={{ opacity: 0, y: 5, x: '-50%' }}
|
||||
animate={{ opacity: 1, y: 0, x: '-50%' }}
|
||||
exit={{ opacity: 0, y: 5, x: '-50%' }}
|
||||
className="absolute bottom-full left-1/2 mb-1 px-4 py-2 bg-slate-900 text-white text-sm font-bold uppercase tracking-wider rounded-lg whitespace-nowrap pointer-events-none z-50 shadow-xl"
|
||||
className="absolute bottom-full left-1/2 mb-3 px-4 py-2 bg-slate-900 text-white text-sm font-bold uppercase tracking-wider rounded-lg whitespace-nowrap pointer-events-none z-50 shadow-xl"
|
||||
>
|
||||
{step.title}
|
||||
<div className="absolute top-full left-1/2 -translate-x-1/2 -mt-1 border-4 border-transparent border-t-slate-900" />
|
||||
|
||||
@@ -15,16 +15,16 @@ export function Checkbox({ label, desc, checked, onChange }: CheckboxProps) {
|
||||
<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 ${
|
||||
className={`w-full p-5 rounded-[2rem] border-2 text-left transition-all duration-300 flex items-start gap-4 focus:outline-none overflow-hidden relative ${
|
||||
checked ? 'border-slate-900 bg-slate-900 text-white' : 'border-slate-100 bg-white hover:border-slate-200'
|
||||
}`}
|
||||
>
|
||||
<div className={`mt-1 w-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>
|
||||
<div className="flex-grow">
|
||||
<h4 className={`text-xl font-bold mb-1 ${checked ? 'text-white' : 'text-slate-900'}`}>{label}</h4>
|
||||
<p className={`text-base leading-relaxed ${checked ? 'text-slate-200' : 'text-slate-500'}`}>{desc}</p>
|
||||
{desc && <p className={`text-base leading-relaxed ${checked ? 'text-slate-300' : 'text-slate-500'}`}>{desc}</p>}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
|
||||
@@ -34,6 +34,11 @@ export function PriceCalculation({
|
||||
qrCodeData,
|
||||
onShare
|
||||
}: PriceCalculationProps) {
|
||||
const totalPages = totalPagesCount + (state.otherPagesCount || 0);
|
||||
const totalFeatures = state.features.length + state.otherFeatures.length + (state.otherFeaturesCount || 0);
|
||||
const totalFunctions = state.functions.length + state.otherFunctions.length + (state.otherFunctionsCount || 0);
|
||||
const totalApis = state.apiSystems.length + state.otherTech.length + (state.otherTechCount || 0);
|
||||
|
||||
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">
|
||||
@@ -43,12 +48,13 @@ export function PriceCalculation({
|
||||
<>
|
||||
<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>)}
|
||||
{totalPages > 0 && (<div className="flex justify-between items-center text-sm"><span className="text-slate-500">{totalPages}x Seite</span><span className="font-medium text-slate-900">{(totalPages * PRICING.PAGE).toLocaleString()} €</span></div>)}
|
||||
{totalFeatures > 0 && (<div className="flex justify-between items-center text-sm"><span className="text-slate-500">{totalFeatures}x System-Modul</span><span className="font-medium text-slate-900">{(totalFeatures * PRICING.FEATURE).toLocaleString()} €</span></div>)}
|
||||
{totalFunctions > 0 && (<div className="flex justify-between items-center text-sm"><span className="text-slate-500">{totalFunctions}x Logik-Funktion</span><span className="font-medium text-slate-900">{(totalFunctions * PRICING.FUNCTION).toLocaleString()} €</span></div>)}
|
||||
{state.visualStaging > 0 && (<div className="flex justify-between items-center text-sm"><span className="text-slate-500">{state.visualStaging}x Visuelle Inszenierung</span><span className="font-medium text-slate-900">{(state.visualStaging * PRICING.VISUAL_STAGING).toLocaleString()} €</span></div>)}
|
||||
{state.complexInteractions > 0 && (<div className="flex justify-between items-center text-sm"><span className="text-slate-500">{state.complexInteractions}x Komplexe Interaktion</span><span className="font-medium text-slate-900">{(state.complexInteractions * PRICING.COMPLEX_INTERACTION).toLocaleString()} €</span></div>)}
|
||||
{totalApis > 0 && (<div className="flex justify-between items-center text-sm"><span className="text-slate-500">{totalApis}x API Sync</span><span className="font-medium text-slate-900">{(totalApis * PRICING.API_INTEGRATION).toLocaleString()} €</span></div>)}
|
||||
{state.cmsSetup && (<div className="flex justify-between items-center text-sm"><span className="text-slate-500">CMS Setup & Anbindung</span><span className="font-medium text-slate-900">{(PRICING.CMS_SETUP + totalFeatures * PRICING.CMS_CONNECTION_PER_FEATURE).toLocaleString()} €</span></div>)}
|
||||
{state.newDatasets > 0 && (<div className="flex justify-between items-center text-sm"><span className="text-slate-500">{state.newDatasets}x Inhalte einpflegen</span><span className="font-medium text-slate-900">{(state.newDatasets * PRICING.NEW_DATASET).toLocaleString()} €</span></div>)}
|
||||
{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>
|
||||
|
||||
@@ -6,7 +6,6 @@ export const PRICING = {
|
||||
PAGE: 800,
|
||||
FEATURE: 2000,
|
||||
FUNCTION: 1000,
|
||||
COMPLEX_INTERACTION: 1500,
|
||||
NEW_DATASET: 400,
|
||||
HOSTING_MONTHLY: 120,
|
||||
STORAGE_EXPANSION_MONTHLY: 10,
|
||||
@@ -18,17 +17,32 @@ export const PRICING = {
|
||||
|
||||
export const initialState: FormState = {
|
||||
projectType: 'website',
|
||||
// Company
|
||||
companyName: '',
|
||||
employeeCount: '',
|
||||
// Existing Presence
|
||||
existingWebsite: '',
|
||||
socialMedia: [],
|
||||
socialMediaUrls: {},
|
||||
existingDomain: '',
|
||||
wishedDomain: '',
|
||||
// Project
|
||||
websiteTopic: '',
|
||||
selectedPages: ['Home'],
|
||||
otherPages: [],
|
||||
otherPagesCount: 0,
|
||||
features: [],
|
||||
otherFeatures: [],
|
||||
otherFeaturesCount: 0,
|
||||
functions: [],
|
||||
otherFunctions: [],
|
||||
otherFunctionsCount: 0,
|
||||
apiSystems: [],
|
||||
otherTech: [],
|
||||
otherTechCount: 0,
|
||||
assets: [],
|
||||
otherAssets: [],
|
||||
complexInteractions: 0,
|
||||
otherAssetsCount: 0,
|
||||
newDatasets: 0,
|
||||
cmsSetup: false,
|
||||
storageExpansion: 0,
|
||||
@@ -38,17 +52,23 @@ export const initialState: FormState = {
|
||||
message: '',
|
||||
sitemapFile: null,
|
||||
contactFiles: [],
|
||||
// Design
|
||||
designVibe: 'minimal',
|
||||
colorScheme: ['#ffffff', '#f8fafc', '#0f172a'],
|
||||
references: [],
|
||||
designWishes: '',
|
||||
// Maintenance
|
||||
expectedAdjustments: 'low',
|
||||
languagesCount: 1,
|
||||
languagesList: ['Deutsch'],
|
||||
// Timeline
|
||||
deadline: 'flexible',
|
||||
// Web App specific
|
||||
targetAudience: 'internal',
|
||||
userRoles: [],
|
||||
dataSensitivity: 'standard',
|
||||
platformType: 'web-only',
|
||||
// Meta
|
||||
dontKnows: [],
|
||||
};
|
||||
|
||||
export const PAGE_SAMPLES = [
|
||||
@@ -85,9 +105,13 @@ export const API_OPTIONS = [
|
||||
{ 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.' },
|
||||
{ id: 'maps', label: 'Google Maps / Places', desc: 'Standortsuche und Kartenintegration.' },
|
||||
{ id: 'analytics', label: 'Custom Analytics', desc: 'Anbindung an spezialisierte Tracking-Tools.' },
|
||||
{ id: 'auth', label: 'Auth-Provider', desc: 'NextAuth, Clerk, Auth0 Integration.' },
|
||||
];
|
||||
|
||||
export const ASSET_OPTIONS = [
|
||||
{ id: 'existing_website', label: 'Bestehende Website', desc: 'Inhalte oder Struktur können übernommen werden.' },
|
||||
{ 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.' },
|
||||
@@ -146,13 +170,18 @@ export const DESIGN_VIBES = [
|
||||
},
|
||||
];
|
||||
|
||||
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'],
|
||||
export const EMPLOYEE_OPTIONS = [
|
||||
{ id: '1-5', label: '1-5 Mitarbeiter' },
|
||||
{ id: '6-20', label: '6-20 Mitarbeiter' },
|
||||
{ id: '21-100', label: '21-100 Mitarbeiter' },
|
||||
{ id: '100+', label: '100+ Mitarbeiter' },
|
||||
];
|
||||
|
||||
export const SOCIAL_MEDIA_OPTIONS = [
|
||||
{ id: 'instagram', label: 'Instagram' },
|
||||
{ id: 'linkedin', label: 'LinkedIn' },
|
||||
{ id: 'facebook', label: 'Facebook' },
|
||||
{ id: 'twitter', label: 'Twitter / X' },
|
||||
{ id: 'tiktok', label: 'TikTok' },
|
||||
{ id: 'youtube', label: 'YouTube' },
|
||||
];
|
||||
|
||||
@@ -4,6 +4,9 @@ import * as React from 'react';
|
||||
import { FormState } from '../types';
|
||||
import { Checkbox } from '../components/Checkbox';
|
||||
import { RepeatableList } from '../components/RepeatableList';
|
||||
import { Minus, Plus, Share2, ListPlus } from 'lucide-react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { Reveal } from '../../Reveal';
|
||||
|
||||
interface ApiStepProps {
|
||||
state: FormState;
|
||||
@@ -14,50 +17,134 @@ interface ApiStepProps {
|
||||
export function ApiStep({ state, updateState, toggleItem }: ApiStepProps) {
|
||||
const isWebApp = state.projectType === 'web-app';
|
||||
|
||||
const toggleDontKnow = (id: string) => {
|
||||
const current = state.dontKnows || [];
|
||||
if (current.includes(id)) {
|
||||
updateState({ dontKnows: current.filter(i => i !== id) });
|
||||
} else {
|
||||
updateState({ dontKnows: [...current, id] });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-12">
|
||||
<div className="space-y-6">
|
||||
<h4 className="text-2xl font-bold text-slate-900">
|
||||
{isWebApp ? 'Integrationen & Datenquellen' : 'Schnittstellen (API)'}
|
||||
</h4>
|
||||
<p className="text-lg text-slate-500 leading-relaxed">
|
||||
{isWebApp
|
||||
? 'Mit welchen Systemen soll die Web App kommunizieren?'
|
||||
: 'Datenaustausch mit Drittsystemen zur Automatisierung.'}
|
||||
</p>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<Checkbox
|
||||
label="CRM / ERP" desc="HubSpot, Salesforce, SAP, Xentral etc."
|
||||
checked={state.apiSystems.includes('crm_erp')}
|
||||
onChange={() => updateState({ apiSystems: toggleItem(state.apiSystems, 'crm_erp') })}
|
||||
/>
|
||||
<Checkbox
|
||||
label="Payment" desc="Stripe, PayPal, Klarna Integration."
|
||||
checked={state.apiSystems.includes('payment')}
|
||||
onChange={() => updateState({ apiSystems: toggleItem(state.apiSystems, 'payment') })}
|
||||
/>
|
||||
<Checkbox
|
||||
label="Marketing" desc="Newsletter (Mailchimp), Social Media Sync."
|
||||
checked={state.apiSystems.includes('marketing')}
|
||||
onChange={() => updateState({ apiSystems: toggleItem(state.apiSystems, 'marketing') })}
|
||||
/>
|
||||
<Checkbox
|
||||
label="E-Commerce" desc="Shopify, WooCommerce, Lagerbestand-Sync."
|
||||
checked={state.apiSystems.includes('ecommerce')}
|
||||
onChange={() => updateState({ apiSystems: toggleItem(state.apiSystems, 'ecommerce') })}
|
||||
/>
|
||||
<Reveal width="100%" delay={0.1}>
|
||||
<div className="space-y-8">
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-12 h-12 bg-slate-50 rounded-2xl flex items-center justify-center text-slate-900 shadow-sm">
|
||||
<Share2 size={24} />
|
||||
</div>
|
||||
<h4 className="text-2xl font-bold text-slate-900">
|
||||
{isWebApp ? 'Integrationen & Datenquellen' : 'Schnittstellen (API)'}
|
||||
</h4>
|
||||
</div>
|
||||
<motion.button
|
||||
whileHover={{ scale: 1.05 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
type="button"
|
||||
onClick={() => toggleDontKnow('api')}
|
||||
className={`px-6 py-3 rounded-full text-sm font-bold transition-all shadow-sm ${
|
||||
state.dontKnows?.includes('api') ? 'bg-slate-900 text-white' : 'bg-slate-100 text-slate-500 hover:bg-slate-200'
|
||||
}`}
|
||||
>
|
||||
Ich weiß es nicht
|
||||
</motion.button>
|
||||
</div>
|
||||
<p className="text-lg text-slate-500 leading-relaxed ml-2">
|
||||
{isWebApp
|
||||
? 'Mit welchen Systemen soll die Web App kommunizieren?'
|
||||
: 'Datenaustausch mit Drittsystemen zur Automatisierung.'}
|
||||
</p>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{[
|
||||
{ id: 'crm_erp', label: 'CRM / ERP', desc: 'HubSpot, Salesforce, SAP, Xentral etc.' },
|
||||
{ id: 'payment', label: 'Payment', desc: 'Stripe, PayPal, Klarna Integration.' },
|
||||
{ id: 'marketing', label: 'Marketing', desc: 'Newsletter (Mailchimp), Social Media Sync.' },
|
||||
{ id: 'ecommerce', label: 'E-Commerce', desc: 'Shopify, WooCommerce, Lagerbestand-Sync.' },
|
||||
{ id: 'maps', label: 'Google Maps / Places', desc: 'Standortsuche und Kartenintegration.' },
|
||||
{ id: 'auth', label: 'Auth-Provider', desc: 'NextAuth, Clerk, Auth0 Integration.' },
|
||||
].map((opt, index) => (
|
||||
<motion.div
|
||||
key={opt.id}
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: index * 0.05 }}
|
||||
>
|
||||
<Checkbox
|
||||
label={opt.label} desc={opt.desc}
|
||||
checked={state.apiSystems.includes(opt.id)}
|
||||
onChange={() => updateState({ apiSystems: toggleItem(state.apiSystems, opt.id) })}
|
||||
/>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Reveal>
|
||||
|
||||
<div className="space-y-6">
|
||||
<p className="text-lg font-bold text-slate-900">Weitere Systeme oder eigene APIs?</p>
|
||||
<RepeatableList
|
||||
items={state.otherTech}
|
||||
onAdd={(v) => updateState({ otherTech: [...state.otherTech, v] })}
|
||||
onRemove={(i) => updateTech(i)}
|
||||
placeholder="z.B. Microsoft Graph, Google Maps, Custom REST API..."
|
||||
/>
|
||||
</div>
|
||||
<Reveal width="100%" delay={0.2}>
|
||||
<div className="space-y-12">
|
||||
<div className="space-y-8">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-12 h-12 bg-slate-50 rounded-2xl flex items-center justify-center text-slate-900 shadow-sm">
|
||||
<ListPlus size={24} />
|
||||
</div>
|
||||
<h4 className="text-2xl font-bold text-slate-900">Weitere Systeme oder eigene APIs?</h4>
|
||||
</div>
|
||||
<RepeatableList
|
||||
items={state.otherTech}
|
||||
onAdd={(v) => updateState({ otherTech: [...state.otherTech, v] })}
|
||||
onRemove={(i) => updateTech(i)}
|
||||
placeholder="z.B. Microsoft Graph, Google Search Console, Custom REST API..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ delay: 0.4 }}
|
||||
className="p-10 bg-white border border-slate-100 rounded-[3rem] space-y-6 shadow-sm hover:shadow-xl transition-all duration-500"
|
||||
>
|
||||
<div className="flex flex-col md:flex-row justify-between items-center gap-8">
|
||||
<div>
|
||||
<h4 className="text-xl font-bold text-slate-900">Anzahl weiterer Schnittstellen</h4>
|
||||
<p className="text-base text-slate-500 mt-1">Falls Sie weitere Integrationen planen, diese aber noch nicht benennen können.</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-8">
|
||||
<motion.button
|
||||
whileHover={{ scale: 1.1 }}
|
||||
whileTap={{ scale: 0.9 }}
|
||||
type="button"
|
||||
onClick={() => updateState({ otherTechCount: Math.max(0, state.otherTechCount - 1) })}
|
||||
className="w-14 h-14 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} />
|
||||
</motion.button>
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.span
|
||||
key={state.otherTechCount}
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -10 }}
|
||||
className="text-5xl font-bold w-12 text-center"
|
||||
>
|
||||
{state.otherTechCount}
|
||||
</motion.span>
|
||||
</AnimatePresence>
|
||||
<motion.button
|
||||
whileHover={{ scale: 1.1 }}
|
||||
whileTap={{ scale: 0.9 }}
|
||||
type="button"
|
||||
onClick={() => updateState({ otherTechCount: state.otherTechCount + 1 })}
|
||||
className="w-14 h-14 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} />
|
||||
</motion.button>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</Reveal>
|
||||
</div>
|
||||
);
|
||||
|
||||
|
||||
@@ -5,6 +5,9 @@ import { FormState } from '../types';
|
||||
import { ASSET_OPTIONS } from '../constants';
|
||||
import { Checkbox } from '../components/Checkbox';
|
||||
import { RepeatableList } from '../components/RepeatableList';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { Minus, Plus, Briefcase, ListPlus } from 'lucide-react';
|
||||
import { Reveal } from '../../Reveal';
|
||||
|
||||
interface AssetsStepProps {
|
||||
state: FormState;
|
||||
@@ -13,26 +16,120 @@ interface AssetsStepProps {
|
||||
}
|
||||
|
||||
export function AssetsStep({ state, updateState, toggleItem }: AssetsStepProps) {
|
||||
const toggleDontKnow = (id: string) => {
|
||||
const current = state.dontKnows || [];
|
||||
if (current.includes(id)) {
|
||||
updateState({ dontKnows: current.filter(i => i !== id) });
|
||||
} else {
|
||||
updateState({ dontKnows: [...current, id] });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-12">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{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-6">
|
||||
<p className="text-lg font-bold text-slate-900">Weitere vorhandene Unterlagen?</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. Lastenheft, Wireframes, Bilddatenbank..."
|
||||
/>
|
||||
</div>
|
||||
<Reveal width="100%" delay={0.1}>
|
||||
<div className="space-y-8">
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-12 h-12 bg-slate-50 rounded-2xl flex items-center justify-center text-slate-900 shadow-sm">
|
||||
<Briefcase size={24} />
|
||||
</div>
|
||||
<h4 className="text-2xl font-bold text-slate-900">Vorhandene Assets</h4>
|
||||
</div>
|
||||
<motion.button
|
||||
whileHover={{ scale: 1.05 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
type="button"
|
||||
onClick={() => toggleDontKnow('assets')}
|
||||
className={`px-6 py-3 rounded-full text-sm font-bold transition-all shadow-sm ${
|
||||
state.dontKnows?.includes('assets') ? 'bg-slate-900 text-white' : 'bg-slate-100 text-slate-500 hover:bg-slate-200'
|
||||
}`}
|
||||
>
|
||||
Ich weiß es nicht
|
||||
</motion.button>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{ASSET_OPTIONS.map((opt, index) => (
|
||||
<motion.div
|
||||
key={opt.id}
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: index * 0.05 }}
|
||||
>
|
||||
<Checkbox
|
||||
key={opt.id} label={opt.label} desc={opt.desc}
|
||||
checked={state.assets.includes(opt.id)}
|
||||
onChange={() => updateState({ assets: toggleItem(state.assets, opt.id) })}
|
||||
/>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Reveal>
|
||||
|
||||
<Reveal width="100%" delay={0.2}>
|
||||
<div className="space-y-12">
|
||||
<div className="space-y-8">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-12 h-12 bg-slate-50 rounded-2xl flex items-center justify-center text-slate-900 shadow-sm">
|
||||
<ListPlus size={24} />
|
||||
</div>
|
||||
<h4 className="text-2xl font-bold text-slate-900">Weitere vorhandene Unterlagen?</h4>
|
||||
</div>
|
||||
<RepeatableList
|
||||
items={state.otherAssets}
|
||||
onAdd={(v) => updateState({ otherAssets: [...state.otherAssets, v] })}
|
||||
onRemove={(i) => updateState({ otherAssets: state.otherAssets.filter((_, idx) => idx !== i) })}
|
||||
placeholder="z.B. Lastenheft, Wireframes, Bilddatenbank..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ delay: 0.4 }}
|
||||
className="p-10 bg-white border border-slate-100 rounded-[3rem] space-y-6 shadow-sm hover:shadow-xl transition-all duration-500"
|
||||
>
|
||||
<div className="flex flex-col md:flex-row justify-between items-center gap-8">
|
||||
<div>
|
||||
<h4 className="text-xl font-bold text-slate-900">Anzahl weiterer Assets</h4>
|
||||
<p className="text-base text-slate-500 mt-1">Falls Sie weitere Unterlagen haben, diese aber noch nicht benennen können.</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-8">
|
||||
<motion.button
|
||||
whileHover={{ scale: 1.1 }}
|
||||
whileTap={{ scale: 0.9 }}
|
||||
type="button"
|
||||
onClick={() => updateState({ otherAssetsCount: Math.max(0, state.otherAssetsCount - 1) })}
|
||||
className="w-14 h-14 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} />
|
||||
</motion.button>
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.span
|
||||
key={state.otherAssetsCount}
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -10 }}
|
||||
className="text-5xl font-bold w-12 text-center"
|
||||
>
|
||||
{state.otherAssetsCount}
|
||||
</motion.span>
|
||||
</AnimatePresence>
|
||||
<motion.button
|
||||
whileHover={{ scale: 1.1 }}
|
||||
whileTap={{ scale: 0.9 }}
|
||||
type="button"
|
||||
onClick={() => updateState({ otherAssetsCount: state.otherAssetsCount + 1 })}
|
||||
className="w-14 h-14 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} />
|
||||
</motion.button>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</Reveal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,8 @@ import * as React from 'react';
|
||||
import { FormState } from '../types';
|
||||
import { Checkbox } from '../components/Checkbox';
|
||||
import { RepeatableList } from '../components/RepeatableList';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { Minus, Plus, FileText, ListPlus } from 'lucide-react';
|
||||
|
||||
interface BaseStepProps {
|
||||
state: FormState;
|
||||
@@ -12,32 +14,137 @@ interface BaseStepProps {
|
||||
}
|
||||
|
||||
export function BaseStep({ state, updateState, toggleItem }: BaseStepProps) {
|
||||
const toggleDontKnow = (id: string) => {
|
||||
const current = state.dontKnows || [];
|
||||
if (current.includes(id)) {
|
||||
updateState({ dontKnows: current.filter(i => i !== id) });
|
||||
} else {
|
||||
updateState({ dontKnows: [...current, id] });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-12">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{[
|
||||
{ id: 'Home', label: 'Startseite', desc: 'Der erste Eindruck Ihrer Marke.' },
|
||||
{ id: 'About', label: 'Über uns', desc: 'Ihre Geschichte und Ihr Team.' },
|
||||
{ id: 'Services', label: 'Leistungen', desc: 'Übersicht Ihres Angebots.' },
|
||||
{ id: 'Contact', label: 'Kontakt', desc: 'Anlaufstelle für Ihre Kunden.' },
|
||||
{ id: 'Landing', label: 'Landingpage', desc: 'Optimiert für Marketing-Kampagnen.' },
|
||||
{ id: 'Legal', label: 'Rechtliches', desc: 'Impressum & Datenschutz.' },
|
||||
].map(opt => (
|
||||
<Checkbox
|
||||
key={opt.id} label={opt.label} desc={opt.desc}
|
||||
checked={state.selectedPages.includes(opt.id)}
|
||||
onChange={() => updateState({ selectedPages: toggleItem(state.selectedPages, opt.id) })}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="space-y-6">
|
||||
<p className="text-lg font-bold text-slate-900">Weitere individuelle 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. Karriere, FAQ, Team-Detail..."
|
||||
<div className="space-y-16">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="space-y-8"
|
||||
>
|
||||
<h4 className="text-2xl font-bold text-slate-900">Thema der Website</h4>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="z.B. Portfolio für Architektur, Onlineshop für Bio-Tee..."
|
||||
value={state.websiteTopic}
|
||||
onChange={(e) => updateState({ websiteTopic: e.target.value })}
|
||||
className="w-full p-10 bg-white border border-slate-100 rounded-[3rem] focus:outline-none focus:border-slate-900 transition-all duration-500 text-xl shadow-sm focus:shadow-2xl"
|
||||
/>
|
||||
</motion.div>
|
||||
|
||||
<div className="space-y-8">
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-12 h-12 bg-slate-50 rounded-2xl flex items-center justify-center text-slate-900 shadow-sm">
|
||||
<FileText size={24} />
|
||||
</div>
|
||||
<h4 className="text-2xl font-bold text-slate-900">Die Seiten</h4>
|
||||
</div>
|
||||
<motion.button
|
||||
whileHover={{ scale: 1.05 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
type="button"
|
||||
onClick={() => toggleDontKnow('pages')}
|
||||
className={`px-6 py-3 rounded-full text-sm font-bold transition-all shadow-sm ${
|
||||
state.dontKnows?.includes('pages') ? 'bg-slate-900 text-white' : 'bg-slate-100 text-slate-500 hover:bg-slate-200'
|
||||
}`}
|
||||
>
|
||||
Ich weiß es nicht
|
||||
</motion.button>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{[
|
||||
{ id: 'Home', label: 'Startseite', desc: 'Der erste Eindruck Ihrer Marke.' },
|
||||
{ id: 'About', label: 'Über uns', desc: 'Ihre Geschichte und Ihr Team.' },
|
||||
{ id: 'Services', label: 'Leistungen', desc: 'Übersicht Ihres Angebots.' },
|
||||
{ id: 'Contact', label: 'Kontakt', desc: 'Anlaufstelle für Ihre Kunden.' },
|
||||
{ id: 'Landing', label: 'Landingpage', desc: 'Optimiert für Marketing-Kampagnen.' },
|
||||
{ id: 'Legal', label: 'Rechtliches', desc: 'Impressum & Datenschutz.' },
|
||||
].map((opt, index) => (
|
||||
<motion.div
|
||||
key={opt.id}
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: index * 0.05 }}
|
||||
>
|
||||
<Checkbox
|
||||
label={opt.label} desc={opt.desc}
|
||||
checked={state.selectedPages.includes(opt.id)}
|
||||
onChange={() => updateState({ selectedPages: toggleItem(state.selectedPages, opt.id) })}
|
||||
/>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-12">
|
||||
<div className="space-y-8">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-12 h-12 bg-slate-50 rounded-2xl flex items-center justify-center text-slate-900 shadow-sm">
|
||||
<ListPlus size={24} />
|
||||
</div>
|
||||
<h4 className="text-2xl font-bold text-slate-900">Weitere individuelle Seiten?</h4>
|
||||
</div>
|
||||
<RepeatableList
|
||||
items={state.otherPages}
|
||||
onAdd={(v) => updateState({ otherPages: [...state.otherPages, v] })}
|
||||
onRemove={(i) => updateState({ otherPages: state.otherPages.filter((_, idx) => idx !== i) })}
|
||||
placeholder="z.B. Karriere, FAQ, Team-Detail..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ delay: 0.4 }}
|
||||
className="p-10 bg-white border border-slate-100 rounded-[3rem] space-y-6 shadow-sm hover:shadow-xl transition-all duration-500"
|
||||
>
|
||||
<div className="flex flex-col md:flex-row justify-between items-center gap-8">
|
||||
<div>
|
||||
<h4 className="text-xl font-bold text-slate-900">Anzahl weiterer Seiten</h4>
|
||||
<p className="text-base text-slate-500 mt-1">Falls Sie die Namen noch nicht wissen, aber die Menge schätzen können.</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-8">
|
||||
<motion.button
|
||||
whileHover={{ scale: 1.1 }}
|
||||
whileTap={{ scale: 0.9 }}
|
||||
type="button"
|
||||
onClick={() => updateState({ otherPagesCount: Math.max(0, state.otherPagesCount - 1) })}
|
||||
className="w-14 h-14 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} />
|
||||
</motion.button>
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.span
|
||||
key={state.otherPagesCount}
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -10 }}
|
||||
className="text-5xl font-bold w-12 text-center"
|
||||
>
|
||||
{state.otherPagesCount}
|
||||
</motion.span>
|
||||
</AnimatePresence>
|
||||
<motion.button
|
||||
whileHover={{ scale: 1.1 }}
|
||||
whileTap={{ scale: 0.9 }}
|
||||
type="button"
|
||||
onClick={() => updateState({ otherPagesCount: state.otherPagesCount + 1 })}
|
||||
className="w-14 h-14 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} />
|
||||
</motion.button>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
67
src/components/ContactForm/steps/CompanyStep.tsx
Normal file
67
src/components/ContactForm/steps/CompanyStep.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { FormState } from '../types';
|
||||
import { EMPLOYEE_OPTIONS } from '../constants';
|
||||
import { motion } from 'framer-motion';
|
||||
import { Building2, Users } from 'lucide-react';
|
||||
import { Reveal } from '../../Reveal';
|
||||
|
||||
interface CompanyStepProps {
|
||||
state: FormState;
|
||||
updateState: (updates: Partial<FormState>) => void;
|
||||
}
|
||||
|
||||
export function CompanyStep({ state, updateState }: CompanyStepProps) {
|
||||
return (
|
||||
<div className="space-y-16">
|
||||
<Reveal width="100%" delay={0.1}>
|
||||
<div className="space-y-8">
|
||||
<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 shadow-sm">
|
||||
<Building2 size={24} />
|
||||
</div>
|
||||
<h4 className="text-2xl font-bold text-slate-900">Unternehmen</h4>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<label className="block text-sm font-bold uppercase tracking-widest text-slate-400 ml-2">Name des Unternehmens</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="z.B. Muster GmbH"
|
||||
value={state.companyName}
|
||||
onChange={(e) => updateState({ companyName: e.target.value })}
|
||||
className="w-full p-8 bg-white border border-slate-100 rounded-[3rem] focus:outline-none focus:border-slate-900 transition-all duration-500 text-xl shadow-sm focus:shadow-2xl"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Reveal>
|
||||
|
||||
<Reveal width="100%" delay={0.2}>
|
||||
<div className="space-y-8">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-12 h-12 bg-slate-50 rounded-2xl flex items-center justify-center text-slate-900 shadow-sm">
|
||||
<Users size={24} />
|
||||
</div>
|
||||
<h4 className="text-2xl font-bold text-slate-900">Mitarbeiteranzahl</h4>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-6">
|
||||
{EMPLOYEE_OPTIONS.map((option, index) => (
|
||||
<motion.button
|
||||
key={option.id}
|
||||
whileHover={{ y: -5, boxShadow: '0 10px 15px -3px rgb(0 0 0 / 0.1)' }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
type="button"
|
||||
onClick={() => updateState({ employeeCount: option.id })}
|
||||
className={`p-6 rounded-[2rem] border-2 transition-all duration-300 font-bold text-lg ${
|
||||
state.employeeCount === option.id ? 'border-slate-900 bg-slate-900 text-white shadow-lg' : 'border-slate-100 bg-white hover:border-slate-300 text-slate-600'
|
||||
}`}
|
||||
>
|
||||
{option.label}
|
||||
</motion.button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Reveal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,7 +2,9 @@
|
||||
|
||||
import * as React from 'react';
|
||||
import { FormState } from '../types';
|
||||
import { FileText, Upload, X } from 'lucide-react';
|
||||
import { FileText, Upload, X, User, Mail, Briefcase, MessageSquare } from 'lucide-react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { Reveal } from '../../Reveal';
|
||||
|
||||
interface ContactStepProps {
|
||||
state: FormState;
|
||||
@@ -11,93 +13,166 @@ interface ContactStepProps {
|
||||
|
||||
export function ContactStep({ state, updateState }: ContactStepProps) {
|
||||
return (
|
||||
<div className="space-y-10">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<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 text-lg"
|
||||
/>
|
||||
<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 text-lg"
|
||||
/>
|
||||
</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 text-lg"
|
||||
/>
|
||||
<textarea
|
||||
placeholder="Erzählen Sie mir kurz von Ihrem Projekt..."
|
||||
value={state.message}
|
||||
onChange={(e) => updateState({ message: e.target.value })}
|
||||
rows={5}
|
||||
className="w-full p-8 bg-white border border-slate-100 rounded-[2rem] focus:outline-none focus:border-slate-900 transition-colors resize-none text-lg"
|
||||
/>
|
||||
|
||||
<div className="space-y-6">
|
||||
<p className="text-lg font-bold text-slate-900">Dateien hochladen (optional)</p>
|
||||
<div
|
||||
className={`relative group border-2 border-dashed rounded-[3rem] p-10 transition-all duration-300 flex flex-col items-center justify-center gap-6 cursor-pointer min-h-[200px] ${
|
||||
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-3">
|
||||
{state.contactFiles.map((file, idx) => (
|
||||
<div key={idx} className="flex items-center justify-between p-4 bg-white border border-slate-100 rounded-2xl shadow-sm">
|
||||
<div className="flex items-center gap-4 text-slate-900">
|
||||
<FileText size={24} className="text-slate-400" />
|
||||
<span className="font-bold text-base truncate max-w-[250px]">{file.name}</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
updateState({ contactFiles: state.contactFiles.filter((_, i) => i !== idx) });
|
||||
}}
|
||||
className="p-2 hover:bg-slate-100 rounded-full transition-colors focus:outline-none"
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<p className="text-xs text-slate-400 text-center mt-6">Klicken oder ziehen, um weitere Dateien hinzuzufügen</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<Upload size={48} className="text-slate-400 group-hover:text-slate-900 transition-colors" />
|
||||
<div className="text-center">
|
||||
<p className="text-lg font-bold text-slate-900">Dateien hierher ziehen</p>
|
||||
<p className="text-base text-slate-500 mt-1">oder klicken zum Auswählen</p>
|
||||
<div className="space-y-12">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
|
||||
<Reveal width="100%" delay={0.1}>
|
||||
<div className="space-y-4">
|
||||
<label className="block text-sm font-bold uppercase tracking-widest text-slate-400 ml-2">Ihr Name</label>
|
||||
<div className="relative group">
|
||||
<div className="absolute left-6 top-1/2 -translate-y-1/2 text-slate-300 group-focus-within:text-slate-900 transition-colors">
|
||||
<User size={24} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Max Mustermann"
|
||||
required
|
||||
value={state.name}
|
||||
onChange={(e) => updateState({ name: e.target.value })}
|
||||
className="w-full p-8 pl-16 bg-white border border-slate-100 rounded-[2.5rem] focus:outline-none focus:border-slate-900 transition-all duration-500 text-xl shadow-sm focus:shadow-2xl"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Reveal>
|
||||
|
||||
<Reveal width="100%" delay={0.1}>
|
||||
<div className="space-y-4">
|
||||
<label className="block text-sm font-bold uppercase tracking-widest text-slate-400 ml-2">Ihre Email</label>
|
||||
<div className="relative group">
|
||||
<div className="absolute left-6 top-1/2 -translate-y-1/2 text-slate-300 group-focus-within:text-slate-900 transition-colors">
|
||||
<Mail size={24} />
|
||||
</div>
|
||||
<input
|
||||
type="email"
|
||||
placeholder="max@beispiel.de"
|
||||
required
|
||||
value={state.email}
|
||||
onChange={(e) => updateState({ email: e.target.value })}
|
||||
className="w-full p-8 pl-16 bg-white border border-slate-100 rounded-[2.5rem] focus:outline-none focus:border-slate-900 transition-all duration-500 text-xl shadow-sm focus:shadow-2xl"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Reveal>
|
||||
</div>
|
||||
|
||||
<Reveal width="100%" delay={0.2}>
|
||||
<div className="space-y-4">
|
||||
<label className="block text-sm font-bold uppercase tracking-widest text-slate-400 ml-2">Ihre Rolle</label>
|
||||
<div className="relative group">
|
||||
<div className="absolute left-6 top-1/2 -translate-y-1/2 text-slate-300 group-focus-within:text-slate-900 transition-colors">
|
||||
<Briefcase size={24} />
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="z.B. CEO, Marketing Manager..."
|
||||
value={state.role}
|
||||
onChange={(e) => updateState({ role: e.target.value })}
|
||||
className="w-full p-8 pl-16 bg-white border border-slate-100 rounded-[2.5rem] focus:outline-none focus:border-slate-900 transition-all duration-500 text-xl shadow-sm focus:shadow-2xl"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Reveal>
|
||||
|
||||
<Reveal width="100%" delay={0.3}>
|
||||
<div className="space-y-4">
|
||||
<label className="block text-sm font-bold uppercase tracking-widest text-slate-400 ml-2">Nachricht</label>
|
||||
<div className="relative group">
|
||||
<div className="absolute left-6 top-10 text-slate-300 group-focus-within:text-slate-900 transition-colors">
|
||||
<MessageSquare size={24} />
|
||||
</div>
|
||||
<textarea
|
||||
placeholder="Erzählen Sie mir kurz von Ihrem Projekt..."
|
||||
value={state.message}
|
||||
onChange={(e) => updateState({ message: e.target.value })}
|
||||
rows={5}
|
||||
className="w-full p-8 pl-16 bg-white border border-slate-100 rounded-[2.5rem] focus:outline-none focus:border-slate-900 transition-all duration-500 resize-none text-xl shadow-sm focus:shadow-2xl"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Reveal>
|
||||
|
||||
<Reveal width="100%" delay={0.4}>
|
||||
<div className="space-y-6">
|
||||
<p className="text-lg font-bold text-slate-900 ml-2">Dateien hochladen (optional)</p>
|
||||
<div
|
||||
className={`relative group border-2 border-dashed rounded-[3rem] p-12 transition-all duration-500 flex flex-col items-center justify-center gap-6 cursor-pointer min-h-[250px] ${
|
||||
state.contactFiles.length > 0 ? 'border-slate-900 bg-slate-50 shadow-inner' : 'border-slate-200 hover:border-slate-400 bg-white hover:shadow-xl'
|
||||
}`}
|
||||
onDragOver={(e) => { e.preventDefault(); e.stopPropagation(); }}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const files = Array.from(e.dataTransfer.files);
|
||||
if (files.length > 0) updateState({ contactFiles: [...state.contactFiles, ...files] });
|
||||
}}
|
||||
onClick={() => document.getElementById('contact-upload')?.click()}
|
||||
>
|
||||
<input id="contact-upload" type="file" multiple className="hidden" onChange={(e) => {
|
||||
const files = e.target.files ? Array.from(e.target.files) : [];
|
||||
if (files.length > 0) updateState({ contactFiles: [...state.contactFiles, ...files] });
|
||||
}} />
|
||||
|
||||
<AnimatePresence mode="wait">
|
||||
{state.contactFiles.length > 0 ? (
|
||||
<motion.div
|
||||
key="files"
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.95 }}
|
||||
className="w-full space-y-4"
|
||||
>
|
||||
{state.contactFiles.map((file, idx) => (
|
||||
<motion.div
|
||||
key={`${file.name}-${idx}`}
|
||||
initial={{ opacity: 0, x: -10 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ delay: idx * 0.05 }}
|
||||
className="flex items-center justify-between p-5 bg-white border border-slate-100 rounded-2xl shadow-sm group/file"
|
||||
>
|
||||
<div className="flex items-center gap-4 text-slate-900">
|
||||
<div className="w-10 h-10 bg-slate-50 rounded-xl flex items-center justify-center text-slate-400 group-hover/file:text-slate-900 transition-colors">
|
||||
<FileText size={20} />
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<span className="font-bold text-base truncate max-w-[250px]">{file.name}</span>
|
||||
<span className="text-[10px] text-slate-400 uppercase font-bold">{(file.size / 1024 / 1024).toFixed(2)} MB</span>
|
||||
</div>
|
||||
</div>
|
||||
<motion.button
|
||||
whileHover={{ scale: 1.1, backgroundColor: '#fee2e2', color: '#ef4444' }}
|
||||
whileTap={{ scale: 0.9 }}
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
updateState({ contactFiles: state.contactFiles.filter((_, i) => i !== idx) });
|
||||
}}
|
||||
className="p-2 bg-slate-50 text-slate-400 rounded-full transition-colors focus:outline-none"
|
||||
>
|
||||
<X size={20} />
|
||||
</motion.button>
|
||||
</motion.div>
|
||||
))}
|
||||
<p className="text-xs text-slate-400 text-center mt-8 font-medium">Klicken oder ziehen, um weitere Dateien hinzuzufügen</p>
|
||||
</motion.div>
|
||||
) : (
|
||||
<motion.div
|
||||
key="empty"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
className="flex flex-col items-center gap-6"
|
||||
>
|
||||
<div className="w-20 h-20 bg-slate-50 rounded-[2rem] flex items-center justify-center text-slate-400 group-hover:text-slate-900 group-hover:scale-110 transition-all duration-500">
|
||||
<Upload size={32} />
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<p className="text-xl font-bold text-slate-900">Dateien hierher ziehen</p>
|
||||
<p className="text-lg text-slate-500 mt-1">oder klicken zum Auswählen</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>
|
||||
</Reveal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
|
||||
import * as React from 'react';
|
||||
import { FormState } from '../types';
|
||||
import { Zap, AlertCircle, Minus, Plus } from 'lucide-react';
|
||||
import { Zap, AlertCircle, Minus, Plus, Settings2, BarChart3 } from 'lucide-react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { Reveal } from '../../Reveal';
|
||||
|
||||
interface ContentStepProps {
|
||||
state: FormState;
|
||||
@@ -10,74 +12,167 @@ interface ContentStepProps {
|
||||
}
|
||||
|
||||
export function ContentStep({ state, updateState }: ContentStepProps) {
|
||||
return (
|
||||
<div className="space-y-12">
|
||||
<div className="flex items-center justify-between p-10 bg-white border border-slate-100 rounded-[3rem]">
|
||||
<div className="max-w-[70%]">
|
||||
<h4 className="text-2xl font-bold text-slate-900">Inhalte selbst verwalten (CMS)</h4>
|
||||
<p className="text-lg text-slate-500 mt-2">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-20 h-11 rounded-full transition-colors relative focus:outline-none ${state.cmsSetup ? 'bg-slate-900' : 'bg-slate-200'}`}
|
||||
>
|
||||
<div className={`absolute top-1.5 left-1.5 w-8 h-8 bg-white rounded-full transition-transform ${state.cmsSetup ? 'translate-x-9' : ''}`} />
|
||||
</button>
|
||||
</div>
|
||||
const toggleDontKnow = (id: string) => {
|
||||
const current = state.dontKnows || [];
|
||||
if (current.includes(id)) {
|
||||
updateState({ dontKnows: current.filter(i => i !== id) });
|
||||
} else {
|
||||
updateState({ dontKnows: [...current, id] });
|
||||
}
|
||||
};
|
||||
|
||||
<div className="p-10 bg-slate-50 rounded-[3rem] border border-slate-100 space-y-6">
|
||||
<p className="text-lg font-bold text-slate-900">Wie oft ändern sich Ihre Inhalte?</p>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
{[
|
||||
{ id: 'low', label: 'Selten', desc: 'Wenige Male im Jahr.' },
|
||||
{ id: 'medium', label: 'Regelmäßig', desc: 'Monatliche Updates.' },
|
||||
{ id: 'high', label: 'Häufig', desc: 'Wöchentlich oder täglich.' },
|
||||
].map(opt => (
|
||||
<button
|
||||
key={opt.id}
|
||||
return (
|
||||
<div className="space-y-16">
|
||||
<Reveal width="100%" delay={0.1}>
|
||||
<div className="flex flex-col md:flex-row items-center justify-between p-10 bg-white border border-slate-100 rounded-[3rem] shadow-sm gap-8">
|
||||
<div className="max-w-2xl space-y-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-12 h-12 bg-slate-50 rounded-2xl flex items-center justify-center text-slate-900 shadow-inner">
|
||||
<Settings2 size={24} />
|
||||
</div>
|
||||
<h4 className="text-2xl font-bold text-slate-900">Inhalte selbst verwalten (CMS)</h4>
|
||||
</div>
|
||||
<p className="text-lg text-slate-500 leading-relaxed">Möchten Sie Datensätze (z.B. Blogartikel, Produkte) selbst über eine einfache Oberfläche pflegen?</p>
|
||||
</div>
|
||||
<div className="flex flex-col items-center md:items-end gap-6">
|
||||
<motion.button
|
||||
whileHover={{ scale: 1.05 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
type="button"
|
||||
onClick={() => updateState({ expectedAdjustments: opt.id })}
|
||||
className={`p-6 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'
|
||||
onClick={() => toggleDontKnow('cms')}
|
||||
className={`px-6 py-3 rounded-full text-sm font-bold transition-all shadow-sm ${
|
||||
state.dontKnows?.includes('cms') ? 'bg-slate-900 text-white' : 'bg-slate-100 text-slate-500 hover:bg-slate-200'
|
||||
}`}
|
||||
>
|
||||
<p className="font-bold text-lg">{opt.label}</p>
|
||||
<p className={`text-sm mt-1 ${state.expectedAdjustments === opt.id ? 'text-slate-200' : 'text-slate-500'}`}>{opt.desc}</p>
|
||||
Ich weiß es nicht
|
||||
</motion.button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => updateState({ cmsSetup: !state.cmsSetup })}
|
||||
className={`w-24 h-12 rounded-full transition-all duration-500 relative focus:outline-none shadow-inner ${state.cmsSetup ? 'bg-slate-900' : 'bg-slate-200'}`}
|
||||
>
|
||||
<motion.div
|
||||
animate={{ x: state.cmsSetup ? 48 : 0 }}
|
||||
transition={{ type: "spring", stiffness: 500, damping: 30 }}
|
||||
className="absolute top-1.5 left-1.5 w-9 h-9 bg-white rounded-full shadow-lg"
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-8 mt-8">
|
||||
<div className="p-8 bg-white rounded-[2rem] border border-slate-100 space-y-3">
|
||||
<div className="flex items-center gap-3 text-slate-900 font-bold text-sm uppercase tracking-wider">
|
||||
<Zap size={18} /> Vorteil CMS
|
||||
</div>
|
||||
<p className="text-sm text-slate-500 leading-relaxed">
|
||||
Volle Kontrolle über Ihre Inhalte und keine laufenden Kosten für kleine Textänderungen oder neue Blog-Beiträge.
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-8 bg-white rounded-[2rem] border border-slate-100 space-y-3">
|
||||
<div className="flex items-center gap-3 text-slate-900 font-bold text-sm uppercase tracking-wider">
|
||||
<AlertCircle size={18} /> Fokus Design
|
||||
</div>
|
||||
<p className="text-sm text-slate-500 leading-relaxed">
|
||||
Ohne CMS bleibt die technische Komplexität geringer und das Design ist maximal geschützt vor ungewollten Änderungen.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Reveal>
|
||||
|
||||
<div className="flex flex-col gap-6 p-10 bg-white border border-slate-100 rounded-[3rem]">
|
||||
<div>
|
||||
<h4 className="text-2xl font-bold text-slate-900">Inhalte einpflegen</h4>
|
||||
<p className="text-lg text-slate-500 mt-2 leading-relaxed">Für wie viele Datensätze soll ich die initiale Befüllung übernehmen?</p>
|
||||
<Reveal width="100%" delay={0.2}>
|
||||
<div className="p-10 bg-slate-50 rounded-[3rem] border border-slate-100 space-y-10">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-12 h-12 bg-white rounded-2xl flex items-center justify-center text-slate-900 shadow-sm">
|
||||
<BarChart3 size={24} />
|
||||
</div>
|
||||
<p className="text-xl font-bold text-slate-900">Wie oft ändern sich Ihre Inhalte?</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
{[
|
||||
{ id: 'low', label: 'Selten', desc: 'Wenige Male im Jahr.' },
|
||||
{ id: 'medium', label: 'Regelmäßig', desc: 'Monatliche Updates.' },
|
||||
{ id: 'high', label: 'Häufig', desc: 'Wöchentlich oder täglich.' },
|
||||
].map((opt, index) => (
|
||||
<motion.button
|
||||
key={opt.id}
|
||||
whileHover={{ y: -5, boxShadow: '0 10px 15px -3px rgb(0 0 0 / 0.1)' }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
type="button"
|
||||
onClick={() => updateState({ expectedAdjustments: opt.id })}
|
||||
className={`p-6 rounded-[2rem] border-2 text-left transition-all duration-300 focus:outline-none ${
|
||||
state.expectedAdjustments === opt.id ? 'border-slate-900 bg-slate-900 text-white shadow-xl' : 'border-slate-200 bg-white hover:border-slate-400'
|
||||
}`}
|
||||
>
|
||||
<p className={`font-bold text-lg ${state.expectedAdjustments === opt.id ? 'text-white' : 'text-slate-900'}`}>{opt.label}</p>
|
||||
<p className={`text-sm mt-2 leading-relaxed ${state.expectedAdjustments === opt.id ? 'text-slate-200' : 'text-slate-500'}`}>{opt.desc}</p>
|
||||
</motion.button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<AnimatePresence>
|
||||
{state.expectedAdjustments === 'high' && !state.cmsSetup && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, height: 0, y: 20 }}
|
||||
animate={{ opacity: 1, height: 'auto', y: 0 }}
|
||||
exit={{ opacity: 0, height: 0, y: 20 }}
|
||||
className="p-8 bg-amber-50 rounded-[2.5rem] border border-amber-100 flex gap-6 items-start shadow-sm"
|
||||
>
|
||||
<div className="w-12 h-12 bg-white rounded-xl flex items-center justify-center text-amber-600 shadow-sm shrink-0">
|
||||
<AlertCircle size={24} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<p className="text-amber-900 text-xl font-bold">Empfehlung: CMS nutzen</p>
|
||||
<p className="text-amber-800 text-base leading-relaxed max-w-3xl">
|
||||
Bei täglichen oder wöchentlichen Änderungen sparen Sie mit einem CMS langfristig viel Geld, da Sie keine externen Entwickler für Inhalts-Updates benötigen.
|
||||
</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 mt-8">
|
||||
<div className="p-8 bg-white rounded-[2.5rem] border border-slate-100 space-y-4 shadow-sm">
|
||||
<div className="flex items-center gap-3 text-slate-900 font-bold text-sm uppercase tracking-[0.2em]">
|
||||
<Zap size={18} /> Vorteil CMS
|
||||
</div>
|
||||
<p className="text-base text-slate-500 leading-relaxed">
|
||||
Volle Kontrolle über Ihre Inhalte und keine laufenden Kosten für kleine Textänderungen oder neue Blog-Beiträge.
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-8 bg-white rounded-[2.5rem] border border-slate-100 space-y-4 shadow-sm">
|
||||
<div className="flex items-center gap-3 text-slate-900 font-bold text-sm uppercase tracking-[0.2em]">
|
||||
<AlertCircle size={18} /> Fokus Design
|
||||
</div>
|
||||
<p className="text-base text-slate-500 leading-relaxed">
|
||||
Ohne CMS bleibt die technische Komplexität geringer und das Design ist maximal geschützt vor ungewollten Änderungen.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-12 mt-2">
|
||||
<button type="button" onClick={() => updateState({ newDatasets: Math.max(0, state.newDatasets - 1) })} className="w-16 h-16 rounded-full bg-slate-50 border border-slate-100 flex items-center justify-center hover:border-slate-900 transition-colors focus:outline-none"><Minus size={28} /></button>
|
||||
<span className="text-5xl font-bold w-16 text-center">{state.newDatasets}</span>
|
||||
<button type="button" onClick={() => updateState({ newDatasets: state.newDatasets + 1 })} className="w-16 h-16 rounded-full bg-slate-50 border border-slate-100 flex items-center justify-center hover:border-slate-900 transition-colors focus:outline-none"><Plus size={28} /></button>
|
||||
</Reveal>
|
||||
|
||||
<Reveal width="100%" delay={0.3}>
|
||||
<div className="flex flex-col gap-8 p-10 bg-white border border-slate-100 rounded-[3rem] shadow-sm">
|
||||
<div className="space-y-2">
|
||||
<h4 className="text-2xl font-bold text-slate-900">Inhalte einpflegen</h4>
|
||||
<p className="text-lg text-slate-500 leading-relaxed">Für wie viele Datensätze soll ich die initiale Befüllung übernehmen?</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-12 py-2">
|
||||
<motion.button
|
||||
whileHover={{ scale: 1.1 }}
|
||||
whileTap={{ scale: 0.9 }}
|
||||
type="button"
|
||||
onClick={() => updateState({ newDatasets: Math.max(0, state.newDatasets - 1) })}
|
||||
className="w-16 h-16 rounded-full bg-slate-50 border border-slate-100 flex items-center justify-center hover:border-slate-900 transition-colors focus:outline-none shadow-sm"
|
||||
>
|
||||
<Minus size={28} />
|
||||
</motion.button>
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.span
|
||||
key={state.newDatasets}
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -10 }}
|
||||
className="text-7xl font-bold w-20 text-center tabular-nums"
|
||||
>
|
||||
{state.newDatasets}
|
||||
</motion.span>
|
||||
</AnimatePresence>
|
||||
<motion.button
|
||||
whileHover={{ scale: 1.1 }}
|
||||
whileTap={{ scale: 0.9 }}
|
||||
type="button"
|
||||
onClick={() => updateState({ newDatasets: state.newDatasets + 1 })}
|
||||
className="w-16 h-16 rounded-full bg-slate-50 border border-slate-100 flex items-center justify-center hover:border-slate-900 transition-colors focus:outline-none shadow-sm"
|
||||
>
|
||||
<Plus size={28} />
|
||||
</motion.button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Reveal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,8 +2,10 @@
|
||||
|
||||
import * as React from 'react';
|
||||
import { FormState } from '../types';
|
||||
import { DESIGN_VIBES, HARMONIOUS_PALETTES } from '../constants';
|
||||
import { motion } from 'framer-motion';
|
||||
import { DESIGN_VIBES } from '../constants';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { Plus, X, Palette, Pipette, RefreshCw } from 'lucide-react';
|
||||
import { Reveal } from '../../Reveal';
|
||||
|
||||
interface DesignStepProps {
|
||||
state: FormState;
|
||||
@@ -11,60 +13,225 @@ interface DesignStepProps {
|
||||
}
|
||||
|
||||
export function DesignStep({ state, updateState }: DesignStepProps) {
|
||||
return (
|
||||
<div className="space-y-12">
|
||||
<div className="space-y-6">
|
||||
<h4 className="text-2xl font-bold text-slate-900">Design-Richtung</h4>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{DESIGN_VIBES.map((vibe) => (
|
||||
<button
|
||||
key={vibe.id}
|
||||
type="button"
|
||||
onClick={() => updateState({ designVibe: vibe.id })}
|
||||
className={`p-8 rounded-[2rem] border-2 text-left transition-all duration-300 focus:outline-none overflow-hidden relative ${
|
||||
state.designVibe === vibe.id ? 'border-slate-900 bg-slate-900 text-white' : 'border-slate-100 bg-white hover:border-slate-200'
|
||||
}`}
|
||||
>
|
||||
<div className={`w-16 h-10 mb-4 ${state.designVibe === vibe.id ? 'text-white' : 'text-slate-900'}`}>{vibe.illustration}</div>
|
||||
<h4 className={`text-xl font-bold mb-2 ${state.designVibe === vibe.id ? 'text-white' : 'text-slate-900'}`}>{vibe.label}</h4>
|
||||
<p className={`text-base leading-relaxed ${state.designVibe === vibe.id ? 'text-slate-200' : 'text-slate-500'}`}>{vibe.desc}</p>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
const addColor = () => {
|
||||
if (state.colorScheme.length < 5) {
|
||||
updateState({ colorScheme: [...state.colorScheme, '#000000'] });
|
||||
}
|
||||
};
|
||||
|
||||
<div className="space-y-6">
|
||||
<h4 className="text-2xl font-bold text-slate-900">Farbschema</h4>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
{HARMONIOUS_PALETTES.map((palette, i) => (
|
||||
<button
|
||||
key={i}
|
||||
const removeColor = (index: number) => {
|
||||
if (state.colorScheme.length > 1) {
|
||||
const newScheme = [...state.colorScheme];
|
||||
newScheme.splice(index, 1);
|
||||
updateState({ colorScheme: newScheme });
|
||||
}
|
||||
};
|
||||
|
||||
const updateColor = (index: number, value: string) => {
|
||||
const newScheme = [...state.colorScheme];
|
||||
newScheme[index] = value;
|
||||
updateState({ colorScheme: newScheme });
|
||||
};
|
||||
|
||||
const toggleDontKnow = (id: string) => {
|
||||
const current = state.dontKnows || [];
|
||||
if (current.includes(id)) {
|
||||
updateState({ dontKnows: current.filter(i => i !== id) });
|
||||
} else {
|
||||
updateState({ dontKnows: [...current, id] });
|
||||
}
|
||||
};
|
||||
|
||||
const generateHarmonicPalette = () => {
|
||||
const hue = Math.floor(Math.random() * 360);
|
||||
const saturation = 40 + Math.floor(Math.random() * 40);
|
||||
const lightness = 40 + Math.floor(Math.random() * 40);
|
||||
|
||||
const hslToHex = (h: number, s: number, l: number) => {
|
||||
l /= 100;
|
||||
const a = s * Math.min(l, 1 - l) / 100;
|
||||
const f = (n: number) => {
|
||||
const k = (n + h / 30) % 12;
|
||||
const color = l - a * Math.max(Math.min(k - 3, 9 - k, 1), -1);
|
||||
return Math.round(255 * color).toString(16).padStart(2, '0');
|
||||
};
|
||||
return `#${f(0)}${f(8)}${f(4)}`;
|
||||
};
|
||||
|
||||
const palette = [
|
||||
hslToHex(hue, saturation, 95), // Light
|
||||
hslToHex(hue, saturation, lightness), // Main
|
||||
hslToHex((hue + 30) % 360, saturation, lightness - 10), // Analogous
|
||||
hslToHex((hue + 180) % 360, saturation - 10, 20), // Complementary Dark
|
||||
];
|
||||
updateState({ colorScheme: palette });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-16">
|
||||
{/* Design Vibe */}
|
||||
<Reveal width="100%" delay={0.1}>
|
||||
<div className="space-y-8">
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="space-y-1">
|
||||
<h4 className="text-2xl font-bold text-slate-900">Design-Richtung</h4>
|
||||
<p className="text-slate-500">Welche Ästhetik passt zu Ihrer Marke?</p>
|
||||
</div>
|
||||
<motion.button
|
||||
whileHover={{ scale: 1.05 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
type="button"
|
||||
onClick={() => updateState({ colorScheme: palette })}
|
||||
className={`p-4 rounded-2xl border-2 transition-all ${
|
||||
JSON.stringify(state.colorScheme) === JSON.stringify(palette) ? 'border-slate-900 bg-slate-50' : 'border-slate-100 bg-white hover:border-slate-200'
|
||||
onClick={() => toggleDontKnow('design_vibe')}
|
||||
className={`px-6 py-3 rounded-full text-sm font-bold transition-all shadow-sm ${
|
||||
state.dontKnows?.includes('design_vibe') ? 'bg-slate-900 text-white' : 'bg-slate-100 text-slate-500 hover:bg-slate-200'
|
||||
}`}
|
||||
>
|
||||
<div className="flex h-12 w-full rounded-lg overflow-hidden">
|
||||
{palette.map((color, j) => (
|
||||
Ich weiß es nicht
|
||||
</motion.button>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{DESIGN_VIBES.map((vibe, index) => (
|
||||
<motion.button
|
||||
key={vibe.id}
|
||||
whileHover={{ y: -5, boxShadow: '0 20px 25px -5px rgb(0 0 0 / 0.1)' }}
|
||||
type="button"
|
||||
onClick={() => updateState({ designVibe: vibe.id })}
|
||||
className={`p-8 rounded-[2.5rem] border-2 text-left transition-all duration-500 focus:outline-none overflow-hidden relative group ${
|
||||
state.designVibe === vibe.id ? 'border-slate-900 bg-slate-900 text-white' : 'border-slate-100 bg-white hover:border-slate-300'
|
||||
}`}
|
||||
>
|
||||
<div className={`w-16 h-10 mb-6 transition-transform duration-500 group-hover:scale-110 ${state.designVibe === vibe.id ? 'text-white' : 'text-slate-900'}`}>{vibe.illustration}</div>
|
||||
<h4 className={`text-2xl font-bold mb-3 ${state.designVibe === vibe.id ? 'text-white' : 'text-slate-900'}`}>{vibe.label}</h4>
|
||||
<p className={`text-lg leading-relaxed ${state.designVibe === vibe.id ? 'text-slate-200' : 'text-slate-500'}`}>{vibe.desc}</p>
|
||||
{state.designVibe === vibe.id && (
|
||||
<motion.div layoutId="activeVibe" className="absolute top-4 right-4 w-3 h-3 bg-white rounded-full" />
|
||||
)}
|
||||
</motion.button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Reveal>
|
||||
|
||||
{/* Color Scheme */}
|
||||
<Reveal width="100%" delay={0.2}>
|
||||
<div className="space-y-12">
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="space-y-1">
|
||||
<h4 className="text-2xl font-bold text-slate-900">Farbschema</h4>
|
||||
<p className="text-slate-500">Generieren Sie eine harmonische Palette oder definieren Sie eigene Farben.</p>
|
||||
</div>
|
||||
<motion.button
|
||||
whileHover={{ scale: 1.05 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
type="button"
|
||||
onClick={() => toggleDontKnow('color_scheme')}
|
||||
className={`px-6 py-3 rounded-full text-sm font-bold transition-all shadow-sm ${
|
||||
state.dontKnows?.includes('color_scheme') ? 'bg-slate-900 text-white' : 'bg-slate-100 text-slate-500 hover:bg-slate-200'
|
||||
}`}
|
||||
>
|
||||
Ich weiß es nicht
|
||||
</motion.button>
|
||||
</div>
|
||||
|
||||
{/* Generator */}
|
||||
<div className="space-y-6">
|
||||
<motion.button
|
||||
whileHover={{ scale: 1.02 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
type="button"
|
||||
onClick={generateHarmonicPalette}
|
||||
className="w-full p-8 rounded-[3rem] border-2 border-slate-100 bg-white hover:border-slate-900 transition-all duration-500 flex items-center justify-between group shadow-sm hover:shadow-xl"
|
||||
>
|
||||
<div className="flex items-center gap-6">
|
||||
<div className="w-14 h-14 bg-slate-50 rounded-2xl flex items-center justify-center text-slate-900 group-hover:rotate-180 transition-transform duration-700">
|
||||
<RefreshCw size={28} />
|
||||
</div>
|
||||
<div className="text-left">
|
||||
<p className="text-xl font-bold text-slate-900">Harmonischer Zufall</p>
|
||||
<p className="text-slate-500">Erzeugt eine mathematisch abgestimmte Palette.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex h-12 w-48 rounded-xl overflow-hidden shadow-inner border border-slate-100">
|
||||
{state.colorScheme.map((color, j) => (
|
||||
<div key={j} className="flex-1" style={{ backgroundColor: color }} />
|
||||
))}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</motion.button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
<h4 className="text-2xl font-bold text-slate-900">Individuelle Wünsche</h4>
|
||||
<textarea
|
||||
placeholder="Haben Sie bereits konkrete Vorstellungen oder Referenzen?"
|
||||
value={state.designWishes}
|
||||
onChange={(e) => updateState({ designWishes: 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 text-lg"
|
||||
/>
|
||||
</div>
|
||||
{/* Custom Picker */}
|
||||
<div className="space-y-8 p-10 bg-slate-50 rounded-[3rem] border border-slate-100">
|
||||
<div className="flex items-center gap-3 text-slate-400 font-bold uppercase tracking-widest text-xs">
|
||||
<Pipette size={16} />
|
||||
Individuelle Farben
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-6">
|
||||
<AnimatePresence mode="popLayout">
|
||||
{state.colorScheme.map((color, i) => (
|
||||
<motion.div
|
||||
key={`${i}-${color}`}
|
||||
layout
|
||||
initial={{ opacity: 0, scale: 0.8 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.8 }}
|
||||
className="relative group"
|
||||
>
|
||||
<div className="relative">
|
||||
<input
|
||||
type="color"
|
||||
value={color}
|
||||
onChange={(e) => updateColor(i, e.target.value)}
|
||||
className="w-24 h-24 rounded-3xl cursor-pointer border-4 border-white shadow-lg overflow-hidden transition-transform duration-300 group-hover:scale-105"
|
||||
/>
|
||||
<div className="absolute inset-0 rounded-3xl pointer-events-none border border-black/5" />
|
||||
</div>
|
||||
<div className="mt-2 text-center font-mono text-[10px] text-slate-400 uppercase">{color}</div>
|
||||
{state.colorScheme.length > 1 && (
|
||||
<motion.button
|
||||
whileHover={{ scale: 1.1 }}
|
||||
whileTap={{ scale: 0.9 }}
|
||||
type="button"
|
||||
onClick={() => removeColor(i)}
|
||||
className="absolute -top-3 -right-3 w-8 h-8 bg-white text-red-500 rounded-full flex items-center justify-center shadow-xl border border-slate-100 opacity-0 group-hover:opacity-100 transition-all duration-300 z-10"
|
||||
>
|
||||
<X size={16} strokeWidth={3} />
|
||||
</motion.button>
|
||||
)}
|
||||
</motion.div>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
{state.colorScheme.length < 5 && (
|
||||
<motion.button
|
||||
layout
|
||||
whileHover={{ scale: 1.05, borderColor: '#0f172a', color: '#0f172a' }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
type="button"
|
||||
onClick={addColor}
|
||||
className="w-24 h-24 rounded-3xl border-2 border-dashed border-slate-300 flex flex-col items-center justify-center text-slate-400 transition-all duration-300 bg-white/50 hover:bg-white"
|
||||
>
|
||||
<Plus size={32} />
|
||||
<span className="text-[10px] font-bold uppercase mt-1">Add</span>
|
||||
</motion.button>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-slate-400 font-medium">Klicken Sie auf eine Farbe, um sie anzupassen. Sie können bis zu 5 Farben definieren.</p>
|
||||
</div>
|
||||
</div>
|
||||
</Reveal>
|
||||
|
||||
{/* Wishes */}
|
||||
<Reveal width="100%" delay={0.3}>
|
||||
<div className="space-y-6">
|
||||
<h4 className="text-2xl font-bold text-slate-900">Individuelle Wünsche</h4>
|
||||
<textarea
|
||||
placeholder="Haben Sie bereits konkrete Vorstellungen oder Referenzen?"
|
||||
value={state.designWishes}
|
||||
onChange={(e) => updateState({ designWishes: e.target.value })}
|
||||
rows={4}
|
||||
className="w-full p-8 bg-white border border-slate-100 rounded-[3rem] focus:outline-none focus:border-slate-900 transition-all duration-500 resize-none text-xl shadow-sm focus:shadow-2xl"
|
||||
/>
|
||||
</div>
|
||||
</Reveal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,9 +2,11 @@
|
||||
|
||||
import * as React from 'react';
|
||||
import { FormState } from '../types';
|
||||
import { FEATURE_OPTIONS } from '../constants';
|
||||
import { Checkbox } from '../components/Checkbox';
|
||||
import { RepeatableList } from '../components/RepeatableList';
|
||||
import { FEATURE_OPTIONS } from '../constants';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { Minus, Plus, LayoutGrid, ListPlus } from 'lucide-react';
|
||||
|
||||
interface FeaturesStepProps {
|
||||
state: FormState;
|
||||
@@ -13,25 +15,115 @@ interface FeaturesStepProps {
|
||||
}
|
||||
|
||||
export function FeaturesStep({ state, updateState, toggleItem }: FeaturesStepProps) {
|
||||
const toggleDontKnow = (id: string) => {
|
||||
const current = state.dontKnows || [];
|
||||
if (current.includes(id)) {
|
||||
updateState({ dontKnows: current.filter(i => i !== id) });
|
||||
} else {
|
||||
updateState({ dontKnows: [...current, id] });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-12">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{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 className="space-y-16">
|
||||
<div className="space-y-8">
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-12 h-12 bg-slate-50 rounded-2xl flex items-center justify-center text-slate-900 shadow-sm">
|
||||
<LayoutGrid size={24} />
|
||||
</div>
|
||||
<h4 className="text-2xl font-bold text-slate-900">System-Module</h4>
|
||||
</div>
|
||||
<motion.button
|
||||
whileHover={{ scale: 1.05 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
type="button"
|
||||
onClick={() => toggleDontKnow('features')}
|
||||
className={`px-6 py-3 rounded-full text-sm font-bold transition-all shadow-sm ${
|
||||
state.dontKnows?.includes('features') ? 'bg-slate-900 text-white' : 'bg-slate-100 text-slate-500 hover:bg-slate-200'
|
||||
}`}
|
||||
>
|
||||
Ich weiß es nicht
|
||||
</motion.button>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{FEATURE_OPTIONS.map((opt, index) => (
|
||||
<motion.div
|
||||
key={opt.id}
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: index * 0.05 }}
|
||||
>
|
||||
<Checkbox
|
||||
label={opt.label} desc={opt.desc}
|
||||
checked={state.features.includes(opt.id)}
|
||||
onChange={() => updateState({ features: toggleItem(state.features, opt.id) })}
|
||||
/>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-6">
|
||||
<p className="text-lg font-bold text-slate-900">Weitere inhaltliche Module?</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. Glossar, Download-Center, Partner-Bereich..."
|
||||
/>
|
||||
|
||||
<div className="space-y-12">
|
||||
<div className="space-y-8">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-12 h-12 bg-slate-50 rounded-2xl flex items-center justify-center text-slate-900 shadow-sm">
|
||||
<ListPlus size={24} />
|
||||
</div>
|
||||
<h4 className="text-2xl font-bold text-slate-900">Weitere inhaltliche Module?</h4>
|
||||
</div>
|
||||
<RepeatableList
|
||||
items={state.otherFeatures}
|
||||
onAdd={(v) => updateState({ otherFeatures: [...state.otherFeatures, v] })}
|
||||
onRemove={(i) => updateState({ otherFeatures: state.otherFeatures.filter((_, idx) => idx !== i) })}
|
||||
placeholder="z.B. Glossar, Download-Center, Partner-Bereich..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ delay: 0.4 }}
|
||||
className="p-10 bg-white border border-slate-100 rounded-[3rem] space-y-6 shadow-sm hover:shadow-xl transition-all duration-500"
|
||||
>
|
||||
<div className="flex flex-col md:flex-row justify-between items-center gap-8">
|
||||
<div>
|
||||
<h4 className="text-xl font-bold text-slate-900">Anzahl weiterer Module</h4>
|
||||
<p className="text-base text-slate-500 mt-1">Falls Sie weitere Systeme planen, diese aber noch nicht benennen können.</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-8">
|
||||
<motion.button
|
||||
whileHover={{ scale: 1.1 }}
|
||||
whileTap={{ scale: 0.9 }}
|
||||
type="button"
|
||||
onClick={() => updateState({ otherFeaturesCount: Math.max(0, state.otherFeaturesCount - 1) })}
|
||||
className="w-14 h-14 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} />
|
||||
</motion.button>
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.span
|
||||
key={state.otherFeaturesCount}
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -10 }}
|
||||
className="text-5xl font-bold w-12 text-center"
|
||||
>
|
||||
{state.otherFeaturesCount}
|
||||
</motion.span>
|
||||
</AnimatePresence>
|
||||
<motion.button
|
||||
whileHover={{ scale: 1.1 }}
|
||||
whileTap={{ scale: 0.9 }}
|
||||
type="button"
|
||||
onClick={() => updateState({ otherFeaturesCount: state.otherFeaturesCount + 1 })}
|
||||
className="w-14 h-14 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} />
|
||||
</motion.button>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -4,7 +4,9 @@ import * as React from 'react';
|
||||
import { FormState } from '../types';
|
||||
import { Checkbox } from '../components/Checkbox';
|
||||
import { RepeatableList } from '../components/RepeatableList';
|
||||
import { Minus, Plus } from 'lucide-react';
|
||||
import { Minus, Plus, Cpu, ListPlus } from 'lucide-react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { Reveal } from '../../Reveal';
|
||||
|
||||
interface FunctionsStepProps {
|
||||
state: FormState;
|
||||
@@ -15,88 +17,155 @@ interface FunctionsStepProps {
|
||||
export function FunctionsStep({ state, updateState, toggleItem }: FunctionsStepProps) {
|
||||
const isWebApp = state.projectType === 'web-app';
|
||||
|
||||
const toggleDontKnow = (id: string) => {
|
||||
const current = state.dontKnows || [];
|
||||
if (current.includes(id)) {
|
||||
updateState({ dontKnows: current.filter(i => i !== id) });
|
||||
} else {
|
||||
updateState({ dontKnows: [...current, id] });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-12">
|
||||
<div className="space-y-6">
|
||||
<h4 className="text-2xl font-bold text-slate-900">
|
||||
{isWebApp ? 'Funktionale Anforderungen' : 'Erweiterte Funktionen'}
|
||||
</h4>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{isWebApp ? (
|
||||
<>
|
||||
<Checkbox
|
||||
label="Dashboard & Analytics" desc="Visualisierung von Daten und Kennzahlen."
|
||||
checked={state.functions.includes('dashboard')}
|
||||
onChange={() => updateState({ functions: toggleItem(state.functions, 'dashboard') })}
|
||||
/>
|
||||
<Checkbox
|
||||
label="Dateiverwaltung" desc="Upload, Download und Organisation von Dokumenten."
|
||||
checked={state.functions.includes('files')}
|
||||
onChange={() => updateState({ functions: toggleItem(state.functions, 'files') })}
|
||||
/>
|
||||
<Checkbox
|
||||
label="Benachrichtigungen" desc="E-Mail, Push oder In-App Alerts."
|
||||
checked={state.functions.includes('notifications')}
|
||||
onChange={() => updateState({ functions: toggleItem(state.functions, 'notifications') })}
|
||||
/>
|
||||
<Checkbox
|
||||
label="Export-Funktionen" desc="CSV, Excel oder PDF Generierung."
|
||||
checked={state.functions.includes('export')}
|
||||
onChange={() => updateState({ functions: toggleItem(state.functions, 'export') })}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Checkbox
|
||||
label="Suche" desc="Volltextsuche über alle Inhalte."
|
||||
checked={state.functions.includes('search')}
|
||||
onChange={() => updateState({ functions: toggleItem(state.functions, 'search') })}
|
||||
/>
|
||||
<Checkbox
|
||||
label="Filter-Systeme" desc="Kategorisierung und Sortierung."
|
||||
checked={state.functions.includes('filter')}
|
||||
onChange={() => updateState({ functions: toggleItem(state.functions, 'filter') })}
|
||||
/>
|
||||
<Checkbox
|
||||
label="PDF-Export" desc="Automatisierte PDF-Erstellung."
|
||||
checked={state.functions.includes('pdf')}
|
||||
onChange={() => updateState({ functions: toggleItem(state.functions, 'pdf') })}
|
||||
/>
|
||||
<Checkbox
|
||||
label="Erweiterte Formulare" desc="Komplexe Abfragen & Logik."
|
||||
checked={state.functions.includes('forms')}
|
||||
onChange={() => updateState({ functions: toggleItem(state.functions, 'forms') })}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
<p className="text-lg font-bold text-slate-900">Weitere spezifische Wünsche?</p>
|
||||
<RepeatableList
|
||||
items={state.otherFunctions}
|
||||
onAdd={(v) => updateState({ otherFunctions: [...state.otherFunctions, v] })}
|
||||
onRemove={(i) => updateState({ otherFunctions: state.otherFunctions.filter((_, idx) => idx !== i) })}
|
||||
placeholder={isWebApp ? "z.B. Echtzeit-Chat, KI-Anbindung, Offline-Modus..." : "z.B. Login-Bereich, Buchungssystem..."}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{!isWebApp && (
|
||||
<div className="p-10 bg-white border border-slate-100 rounded-[3rem] space-y-6">
|
||||
<div className="flex justify-between items-start">
|
||||
<div>
|
||||
<h4 className="text-2xl font-bold text-slate-900">Besondere Interaktionen</h4>
|
||||
<p className="text-lg text-slate-500 mt-2">Aufwendige Animationen oder komplexe UI-Logik pro Abschnitt.</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-8">
|
||||
<button type="button" onClick={() => updateState({ complexInteractions: Math.max(0, state.complexInteractions - 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={24} /></button>
|
||||
<span className="text-4xl font-bold w-12 text-center">{state.complexInteractions}</span>
|
||||
<button type="button" onClick={() => updateState({ complexInteractions: state.complexInteractions + 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={24} /></button>
|
||||
<Reveal width="100%" delay={0.1}>
|
||||
<div className="space-y-8">
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-12 h-12 bg-slate-50 rounded-2xl flex items-center justify-center text-slate-900 shadow-sm">
|
||||
<Cpu size={24} />
|
||||
</div>
|
||||
<h4 className="text-2xl font-bold text-slate-900">
|
||||
{isWebApp ? 'Funktionale Anforderungen' : 'Erweiterte Funktionen'}
|
||||
</h4>
|
||||
</div>
|
||||
<motion.button
|
||||
whileHover={{ scale: 1.05 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
type="button"
|
||||
onClick={() => toggleDontKnow('functions')}
|
||||
className={`px-6 py-3 rounded-full text-sm font-bold transition-all shadow-sm ${
|
||||
state.dontKnows?.includes('functions') ? 'bg-slate-900 text-white' : 'bg-slate-100 text-slate-500 hover:bg-slate-200'
|
||||
}`}
|
||||
>
|
||||
Ich weiß es nicht
|
||||
</motion.button>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{isWebApp ? (
|
||||
<>
|
||||
<Checkbox
|
||||
label="Dashboard & Analytics" desc="Visualisierung von Daten und Kennzahlen."
|
||||
checked={state.functions.includes('dashboard')}
|
||||
onChange={() => updateState({ functions: toggleItem(state.functions, 'dashboard') })}
|
||||
/>
|
||||
<Checkbox
|
||||
label="Dateiverwaltung" desc="Upload, Download und Organisation von Dokumenten."
|
||||
checked={state.functions.includes('files')}
|
||||
onChange={() => updateState({ functions: toggleItem(state.functions, 'files') })}
|
||||
/>
|
||||
<Checkbox
|
||||
label="Benachrichtigungen" desc="E-Mail, Push oder In-App Alerts."
|
||||
checked={state.functions.includes('notifications')}
|
||||
onChange={() => updateState({ functions: toggleItem(state.functions, 'notifications') })}
|
||||
/>
|
||||
<Checkbox
|
||||
label="Export-Funktionen" desc="CSV, Excel oder PDF Generierung."
|
||||
checked={state.functions.includes('export')}
|
||||
onChange={() => updateState({ functions: toggleItem(state.functions, 'export') })}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Checkbox
|
||||
label="Suche" desc="Volltextsuche über alle Inhalte."
|
||||
checked={state.functions.includes('search')}
|
||||
onChange={() => updateState({ functions: toggleItem(state.functions, 'search') })}
|
||||
/>
|
||||
<Checkbox
|
||||
label="Filter-Systeme" desc="Kategorisierung und Sortierung."
|
||||
checked={state.functions.includes('filter')}
|
||||
onChange={() => updateState({ functions: toggleItem(state.functions, 'filter') })}
|
||||
/>
|
||||
<Checkbox
|
||||
label="PDF-Export" desc="Automatisierte PDF-Erstellung."
|
||||
checked={state.functions.includes('pdf')}
|
||||
onChange={() => updateState({ functions: toggleItem(state.functions, 'pdf') })}
|
||||
/>
|
||||
<Checkbox
|
||||
label="Erweiterte Formulare" desc="Komplexe Abfragen & Logik."
|
||||
checked={state.functions.includes('forms')}
|
||||
onChange={() => updateState({ functions: toggleItem(state.functions, 'forms') })}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Reveal>
|
||||
|
||||
<Reveal width="100%" delay={0.2}>
|
||||
<div className="space-y-12">
|
||||
<div className="space-y-8">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-12 h-12 bg-slate-50 rounded-2xl flex items-center justify-center text-slate-900 shadow-sm">
|
||||
<ListPlus size={24} />
|
||||
</div>
|
||||
<h4 className="text-2xl font-bold text-slate-900">Weitere spezifische Wünsche?</h4>
|
||||
</div>
|
||||
<RepeatableList
|
||||
items={state.otherFunctions}
|
||||
onAdd={(v) => updateState({ otherFunctions: [...state.otherFunctions, v] })}
|
||||
onRemove={(i) => updateState({ otherFunctions: state.otherFunctions.filter((_, idx) => idx !== i) })}
|
||||
placeholder={isWebApp ? "z.B. Echtzeit-Chat, KI-Anbindung, Offline-Modus..." : "z.B. Mitgliederbereich, Event-Kalender, geschützte Downloads..."}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ delay: 0.4 }}
|
||||
className="p-10 bg-white border border-slate-100 rounded-[3rem] space-y-6 shadow-sm hover:shadow-xl transition-all duration-500"
|
||||
>
|
||||
<div className="flex flex-col md:flex-row justify-between items-center gap-8">
|
||||
<div>
|
||||
<h4 className="text-xl font-bold text-slate-900">Anzahl weiterer Funktionen</h4>
|
||||
<p className="text-base text-slate-500 mt-1">Falls Sie weitere Logik-Bausteine planen, diese aber noch nicht benennen können.</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-8">
|
||||
<motion.button
|
||||
whileHover={{ scale: 1.1 }}
|
||||
whileTap={{ scale: 0.9 }}
|
||||
type="button"
|
||||
onClick={() => updateState({ otherFunctionsCount: Math.max(0, state.otherFunctionsCount - 1) })}
|
||||
className="w-14 h-14 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} />
|
||||
</motion.button>
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.span
|
||||
key={state.otherFunctionsCount}
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -10 }}
|
||||
className="text-5xl font-bold w-12 text-center"
|
||||
>
|
||||
{state.otherFunctionsCount}
|
||||
</motion.span>
|
||||
</AnimatePresence>
|
||||
<motion.button
|
||||
whileHover={{ scale: 1.1 }}
|
||||
whileTap={{ scale: 0.9 }}
|
||||
type="button"
|
||||
onClick={() => updateState({ otherFunctionsCount: state.otherFunctionsCount + 1 })}
|
||||
className="w-14 h-14 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} />
|
||||
</motion.button>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</Reveal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,8 +2,10 @@
|
||||
|
||||
import * as React from 'react';
|
||||
import { FormState } from '../types';
|
||||
import { Globe, Minus, Plus, Info } from 'lucide-react';
|
||||
import { Globe, Info, ListPlus } from 'lucide-react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { RepeatableList } from '../components/RepeatableList';
|
||||
import { Reveal } from '../../Reveal';
|
||||
|
||||
interface LanguageStepProps {
|
||||
state: FormState;
|
||||
@@ -13,79 +15,86 @@ interface LanguageStepProps {
|
||||
export function LanguageStep({ state, updateState }: LanguageStepProps) {
|
||||
const basePriceExplanation = "Jede zusätzliche Sprache erhöht den Gesamtaufwand für Design, Entwicklung und Qualitätssicherung um ca. 20%. Dies deckt die technische Implementierung der Übersetzungsschicht sowie die Anpassung von Layouts für unterschiedliche Textlängen ab.";
|
||||
|
||||
const toggleDontKnow = (id: string) => {
|
||||
const current = state.dontKnows || [];
|
||||
if (current.includes(id)) {
|
||||
updateState({ dontKnows: current.filter(i => i !== id) });
|
||||
} else {
|
||||
updateState({ dontKnows: [...current, id] });
|
||||
}
|
||||
};
|
||||
|
||||
const languagesCount = state.languagesList.length || 1;
|
||||
|
||||
return (
|
||||
<div className="space-y-12">
|
||||
<div className="p-10 bg-white border border-slate-100 rounded-[3rem] space-y-8">
|
||||
<div className="flex items-center gap-6">
|
||||
<div className="w-16 h-16 bg-slate-50 rounded-2xl flex items-center justify-center text-slate-900">
|
||||
<Globe size={32} />
|
||||
<Reveal width="100%" delay={0.1}>
|
||||
<div className="space-y-8">
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-12 h-12 bg-slate-50 rounded-2xl flex items-center justify-center text-slate-900 shadow-sm">
|
||||
<Globe size={24} />
|
||||
</div>
|
||||
<h4 className="text-2xl font-bold text-slate-900">Sprachen</h4>
|
||||
</div>
|
||||
<motion.button
|
||||
whileHover={{ scale: 1.05 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
type="button"
|
||||
onClick={() => toggleDontKnow('languages')}
|
||||
className={`px-6 py-3 rounded-full text-sm font-bold transition-all shadow-sm ${
|
||||
state.dontKnows?.includes('languages') ? 'bg-slate-900 text-white' : 'bg-slate-100 text-slate-500 hover:bg-slate-200'
|
||||
}`}
|
||||
>
|
||||
Ich weiß es nicht
|
||||
</motion.button>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="text-2xl font-bold text-slate-900">Mehrsprachigkeit</h4>
|
||||
<p className="text-lg text-slate-500">In wie vielen Sprachen soll Ihre Website verfügbar sein?</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-12 py-6">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => updateState({ languagesCount: Math.max(1, state.languagesCount - 1) })}
|
||||
className="w-20 h-20 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={32} />
|
||||
</button>
|
||||
<div className="flex flex-col items-center">
|
||||
<span className="text-7xl font-bold text-slate-900">{state.languagesCount}</span>
|
||||
<span className="text-sm font-bold uppercase tracking-widest text-slate-400 mt-3">
|
||||
{state.languagesCount === 1 ? 'Sprache' : 'Sprachen'}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => updateState({ languagesCount: state.languagesCount + 1 })}
|
||||
className="w-20 h-20 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={32} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="p-10 bg-slate-900 text-white rounded-[3rem] space-y-6"
|
||||
>
|
||||
<div className="flex items-center gap-4 text-slate-400">
|
||||
<Info size={24} />
|
||||
<span className="text-sm font-bold uppercase tracking-widest">Warum dieser Faktor?</span>
|
||||
</div>
|
||||
<p className="text-lg leading-relaxed text-slate-300">
|
||||
{basePriceExplanation}
|
||||
</p>
|
||||
{state.languagesCount > 1 && (
|
||||
<div className="pt-6 border-t border-white/10">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-lg font-medium">Aktueller Aufschlagsfaktor:</span>
|
||||
<span className="text-3xl font-bold text-white">+{((state.languagesCount - 1) * 20)}%</span>
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-10 h-10 bg-slate-50 rounded-xl flex items-center justify-center text-slate-900 shadow-sm">
|
||||
<ListPlus size={20} />
|
||||
</div>
|
||||
<p className="text-lg font-bold text-slate-900">Welche Sprachen planen Sie?</p>
|
||||
</div>
|
||||
<div className="p-2">
|
||||
<RepeatableList
|
||||
items={state.languagesList}
|
||||
onAdd={(v) => updateState({ languagesList: [...state.languagesList, v] })}
|
||||
onRemove={(i) => updateState({ languagesList: state.languagesList.filter((_, idx) => idx !== i) })}
|
||||
placeholder="z.B. Englisch, Französisch, Spanisch..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
</div>
|
||||
</Reveal>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div className="p-8 bg-white border border-slate-100 rounded-[2rem]">
|
||||
<h5 className="text-lg font-bold text-slate-900 mb-3">Technische Basis</h5>
|
||||
<p className="text-base text-slate-500 leading-relaxed">
|
||||
Wir nutzen moderne i18n-Frameworks, die SEO-optimierte URLs für jede Sprache generieren (z.B. /en, /fr).
|
||||
<Reveal width="100%" delay={0.3}>
|
||||
<div className="p-10 bg-slate-900 text-white rounded-[3rem] space-y-8 shadow-2xl relative overflow-hidden">
|
||||
<div className="absolute top-0 right-0 w-64 h-64 bg-white/5 rounded-full -mr-32 -mt-32 blur-3xl" />
|
||||
<div className="flex items-center gap-4 text-slate-400 relative z-10">
|
||||
<Info size={24} />
|
||||
<span className="text-sm font-bold uppercase tracking-widest">Warum dieser Faktor?</span>
|
||||
</div>
|
||||
<p className="text-lg leading-relaxed text-slate-300 relative z-10 max-w-3xl">
|
||||
{basePriceExplanation}
|
||||
</p>
|
||||
{languagesCount > 1 && (
|
||||
<div className="pt-8 border-t border-white/10 relative z-10">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-lg font-medium text-slate-400">Aktueller Aufschlagsfaktor:</span>
|
||||
<motion.span
|
||||
initial={{ scale: 0.8 }}
|
||||
animate={{ scale: 1 }}
|
||||
className="text-4xl font-bold text-white"
|
||||
>
|
||||
+{((languagesCount - 1) * 20)}%
|
||||
</motion.span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="p-8 bg-white border border-slate-100 rounded-[2rem]">
|
||||
<h5 className="text-lg font-bold text-slate-900 mb-3">Content Management</h5>
|
||||
<p className="text-base text-slate-500 leading-relaxed">
|
||||
Falls ein CMS gewählt wurde, können Sie alle Übersetzungen bequem selbst pflegen.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Reveal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
151
src/components/ContactForm/steps/PresenceStep.tsx
Normal file
151
src/components/ContactForm/steps/PresenceStep.tsx
Normal file
@@ -0,0 +1,151 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { FormState } from '../types';
|
||||
import { Checkbox } from '../components/Checkbox';
|
||||
import { RepeatableList } from '../components/RepeatableList';
|
||||
import { Minus, Plus, Link2, Globe, Share2 } from 'lucide-react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { Reveal } from '../../Reveal';
|
||||
|
||||
interface PresenceStepProps {
|
||||
state: FormState;
|
||||
updateState: (updates: Partial<FormState>) => void;
|
||||
toggleItem: (list: string[], id: string) => string[];
|
||||
}
|
||||
|
||||
export function PresenceStep({ state, updateState, toggleItem }: PresenceStepProps) {
|
||||
const updateUrl = (id: string, url: string) => {
|
||||
updateState({
|
||||
socialMediaUrls: {
|
||||
...state.socialMediaUrls,
|
||||
[id]: url
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const SOCIAL_PLATFORMS = [
|
||||
{ id: 'instagram', label: 'Instagram' },
|
||||
{ id: 'linkedin', label: 'LinkedIn' },
|
||||
{ id: 'facebook', label: 'Facebook' },
|
||||
{ id: 'twitter', label: 'Twitter / X' },
|
||||
{ id: 'tiktok', label: 'TikTok' },
|
||||
{ id: 'youtube', label: 'YouTube' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-16">
|
||||
<Reveal width="100%" delay={0.1}>
|
||||
<div className="space-y-8">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-12 h-12 bg-slate-50 rounded-2xl flex items-center justify-center text-slate-900 shadow-sm">
|
||||
<Globe size={24} />
|
||||
</div>
|
||||
<h4 className="text-2xl font-bold text-slate-900">Bestehende Website</h4>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<label className="block text-sm font-bold uppercase tracking-widest text-slate-400 ml-2">URL (falls vorhanden)</label>
|
||||
<div className="relative group">
|
||||
<div className="absolute left-6 top-1/2 -translate-y-1/2 text-slate-300 group-focus-within:text-slate-900 transition-colors">
|
||||
<Link2 size={24} />
|
||||
</div>
|
||||
<input
|
||||
type="url"
|
||||
placeholder="https://www.beispiel.de"
|
||||
value={state.existingWebsite}
|
||||
onChange={(e) => updateState({ existingWebsite: e.target.value })}
|
||||
className="w-full p-8 pl-16 bg-white border border-slate-100 rounded-[3rem] focus:outline-none focus:border-slate-900 transition-all duration-500 text-xl shadow-sm focus:shadow-2xl"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Reveal>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
|
||||
<Reveal width="100%" delay={0.2}>
|
||||
<div className="space-y-4">
|
||||
<label className="block text-sm font-bold uppercase tracking-widest text-slate-400 ml-2">Bestehende Domain</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="z.B. beispiel.de"
|
||||
value={state.existingDomain}
|
||||
onChange={(e) => updateState({ existingDomain: e.target.value })}
|
||||
className="w-full p-6 bg-white border border-slate-100 rounded-[2rem] focus:outline-none focus:border-slate-900 transition-all duration-500 text-lg shadow-sm focus:shadow-xl"
|
||||
/>
|
||||
</div>
|
||||
</Reveal>
|
||||
<Reveal width="100%" delay={0.2}>
|
||||
<div className="space-y-4">
|
||||
<label className="block text-sm font-bold uppercase tracking-widest text-slate-400 ml-2">Wunsch-Domain</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="z.B. neue-marke.de"
|
||||
value={state.wishedDomain}
|
||||
onChange={(e) => updateState({ wishedDomain: e.target.value })}
|
||||
className="w-full p-6 bg-white border border-slate-100 rounded-[2rem] focus:outline-none focus:border-slate-900 transition-all duration-500 text-lg shadow-sm focus:shadow-xl"
|
||||
/>
|
||||
</div>
|
||||
</Reveal>
|
||||
</div>
|
||||
|
||||
<Reveal width="100%" delay={0.3}>
|
||||
<div className="space-y-8">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-12 h-12 bg-slate-50 rounded-2xl flex items-center justify-center text-slate-900 shadow-sm">
|
||||
<Share2 size={24} />
|
||||
</div>
|
||||
<h4 className="text-2xl font-bold text-slate-900">Social Media Accounts</h4>
|
||||
</div>
|
||||
<p className="text-lg text-slate-500 ml-2">Welche Kanäle nutzen Sie bereits? Bitte geben Sie die URLs an.</p>
|
||||
<div className="grid grid-cols-1 gap-4">
|
||||
{SOCIAL_PLATFORMS.map((option, index) => {
|
||||
const isSelected = state.socialMedia.includes(option.id);
|
||||
return (
|
||||
<motion.div
|
||||
key={option.id}
|
||||
className={`p-6 rounded-[2.5rem] border-2 transition-all duration-500 ${
|
||||
isSelected ? 'border-slate-900 bg-slate-50 shadow-md' : 'border-slate-100 bg-white hover:border-slate-300'
|
||||
}`}
|
||||
>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<Checkbox
|
||||
label={option.label}
|
||||
desc=""
|
||||
checked={isSelected}
|
||||
onChange={() => updateState({ socialMedia: toggleItem(state.socialMedia, option.id) })}
|
||||
/>
|
||||
</div>
|
||||
<AnimatePresence>
|
||||
{isSelected && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: 'auto' }}
|
||||
exit={{ opacity: 0, height: 0 }}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
<div className="relative group/input pt-2">
|
||||
<div className="absolute left-5 top-[calc(50%+4px)] -translate-y-1/2 text-slate-300 group-focus-within/input:text-slate-900 transition-colors">
|
||||
<Link2 size={18} />
|
||||
</div>
|
||||
<input
|
||||
type="url"
|
||||
placeholder={`URL zu Ihrem ${option.label} Profil`}
|
||||
value={state.socialMediaUrls[option.id] || ''}
|
||||
onChange={(e) => updateUrl(option.id, e.target.value)}
|
||||
className="w-full p-4 pl-14 bg-white border border-slate-200 rounded-2xl focus:outline-none focus:border-slate-900 transition-all duration-300 text-base shadow-inner"
|
||||
/>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</Reveal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -10,27 +10,50 @@ interface TimelineStepProps {
|
||||
}
|
||||
|
||||
export function TimelineStep({ state, updateState }: TimelineStepProps) {
|
||||
const toggleDontKnow = (id: string) => {
|
||||
const current = state.dontKnows || [];
|
||||
if (current.includes(id)) {
|
||||
updateState({ dontKnows: current.filter(i => i !== id) });
|
||||
} else {
|
||||
updateState({ dontKnows: [...current, id] });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-12">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{[
|
||||
{ id: 'asap', label: 'So schnell wie möglich', desc: 'Priorisierter Start gewünscht.' },
|
||||
{ id: '2-3-months', label: 'In 2-3 Monaten', desc: 'Normaler Projektvorlauf.' },
|
||||
{ id: '3-6-months', label: 'In 3-6 Monaten', desc: 'Langfristige Planung.' },
|
||||
{ id: 'flexible', label: 'Flexibel', desc: 'Kein fester Termindruck.' },
|
||||
].map(opt => (
|
||||
<button
|
||||
key={opt.id}
|
||||
<div className="space-y-6">
|
||||
<div className="flex justify-between items-center">
|
||||
<h4 className="text-2xl font-bold text-slate-900">Zeitplan</h4>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => updateState({ deadline: opt.id })}
|
||||
className={`p-10 rounded-[2.5rem] border-2 text-left transition-all focus:outline-none overflow-hidden relative ${
|
||||
state.deadline === opt.id ? 'border-slate-900 bg-slate-900 text-white' : 'border-slate-100 bg-white hover:border-slate-200'
|
||||
onClick={() => toggleDontKnow('timeline')}
|
||||
className={`px-4 py-2 rounded-full text-sm font-bold transition-all ${
|
||||
state.dontKnows?.includes('timeline') ? 'bg-slate-900 text-white' : 'bg-slate-100 text-slate-500 hover:bg-slate-200'
|
||||
}`}
|
||||
>
|
||||
<h4 className={`text-2xl font-bold mb-2 ${state.deadline === opt.id ? 'text-white' : 'text-slate-900'}`}>{opt.label}</h4>
|
||||
<p className={`text-lg ${state.deadline === opt.id ? 'text-slate-200' : 'text-slate-500'}`}>{opt.desc}</p>
|
||||
Ich weiß es nicht
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{[
|
||||
{ id: 'asap', label: 'So schnell wie möglich', desc: 'Priorisierter Start gewünscht.' },
|
||||
{ id: '2-3-months', label: 'In 2-3 Monaten', desc: 'Normaler Projektvorlauf.' },
|
||||
{ id: '3-6-months', label: 'In 3-6 Monaten', desc: 'Langfristige Planung.' },
|
||||
{ id: 'flexible', label: 'Flexibel', desc: 'Kein fester Termindruck.' },
|
||||
].map(opt => (
|
||||
<button
|
||||
key={opt.id}
|
||||
type="button"
|
||||
onClick={() => updateState({ deadline: opt.id })}
|
||||
className={`p-10 rounded-[2.5rem] border-2 text-left transition-all focus:outline-none overflow-hidden relative ${
|
||||
state.deadline === opt.id ? 'border-slate-900 bg-slate-900 text-white' : 'border-slate-100 bg-white hover:border-slate-200'
|
||||
}`}
|
||||
>
|
||||
<h4 className={`text-2xl font-bold mb-2 ${state.deadline === opt.id ? 'text-white' : 'text-slate-900'}`}>{opt.label}</h4>
|
||||
<p className={`text-lg ${state.deadline === opt.id ? 'text-slate-200' : 'text-slate-500'}`}>{opt.desc}</p>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{state.deadline === 'asap' && (
|
||||
<div className="p-8 bg-slate-50 rounded-[2rem] border border-slate-100 flex gap-6 items-start">
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
import * as React from 'react';
|
||||
import { FormState, ProjectType } from '../types';
|
||||
import { ConceptWebsite, ConceptSystem } from '../../Landing/ConceptIllustrations';
|
||||
import { motion } from 'framer-motion';
|
||||
import { Reveal } from '../../Reveal';
|
||||
|
||||
interface TypeStepProps {
|
||||
state: FormState;
|
||||
@@ -11,23 +13,33 @@ interface TypeStepProps {
|
||||
|
||||
export function TypeStep({ state, updateState }: TypeStepProps) {
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
|
||||
{[
|
||||
{ id: 'website', label: 'Website', desc: 'Klassische Webpräsenz, Portfolio oder Blog.', illustration: <ConceptWebsite className="w-16 h-16 mb-4" /> },
|
||||
{ id: 'web-app', label: 'Web App', 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 ${
|
||||
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-3xl font-bold mb-3 ${state.projectType === type.id ? 'text-white' : 'text-slate-900'}`}>{type.label}</h4>
|
||||
<p className={`text-xl leading-relaxed ${state.projectType === type.id ? 'text-slate-200' : 'text-slate-500'}`}>{type.desc}</p>
|
||||
</button>
|
||||
{ id: 'website', label: 'Website', desc: 'Klassische Webpräsenz, Portfolio oder Blog.', illustration: <ConceptWebsite className="w-20 h-20 mb-6" /> },
|
||||
{ id: 'web-app', label: 'Web App', desc: 'Internes Tool, Dashboard oder Prozess-Logik.', illustration: <ConceptSystem className="w-20 h-20 mb-6" /> },
|
||||
].map((type, index) => (
|
||||
<Reveal key={type.id} width="100%" delay={index * 0.1}>
|
||||
<motion.button
|
||||
whileHover={{ y: -8, boxShadow: '0 25px 50px -12px rgb(0 0 0 / 0.15)' }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
type="button"
|
||||
onClick={() => updateState({ projectType: type.id as ProjectType })}
|
||||
className={`w-full p-10 rounded-[3rem] border-2 text-left transition-all duration-500 focus:outline-none overflow-hidden relative group ${
|
||||
state.projectType === type.id ? 'border-slate-900 bg-slate-900 text-white' : 'border-slate-100 bg-white hover:border-slate-300'
|
||||
}`}
|
||||
>
|
||||
<div className={`transition-transform duration-500 group-hover:scale-110 ${state.projectType === type.id ? 'text-white' : 'text-slate-900'}`}>{type.illustration}</div>
|
||||
<h4 className={`text-4xl font-bold mb-4 ${state.projectType === type.id ? 'text-white' : 'text-slate-900'}`}>{type.label}</h4>
|
||||
<p className={`text-xl leading-relaxed ${state.projectType === type.id ? 'text-slate-200' : 'text-slate-500'}`}>{type.desc}</p>
|
||||
|
||||
{state.projectType === type.id && (
|
||||
<motion.div
|
||||
layoutId="activeType"
|
||||
className="absolute top-6 right-6 w-4 h-4 bg-white rounded-full"
|
||||
/>
|
||||
)}
|
||||
</motion.button>
|
||||
</Reveal>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,18 +1,35 @@
|
||||
import * as React from 'react';
|
||||
|
||||
export type ProjectType = 'website' | 'web-app';
|
||||
|
||||
export interface FormState {
|
||||
projectType: ProjectType;
|
||||
// Company
|
||||
companyName: string;
|
||||
employeeCount: string;
|
||||
// Existing Presence
|
||||
existingWebsite: string;
|
||||
socialMedia: string[];
|
||||
socialMediaUrls: Record<string, string>;
|
||||
existingDomain: string;
|
||||
wishedDomain: string;
|
||||
// Project
|
||||
websiteTopic: string;
|
||||
selectedPages: string[];
|
||||
otherPages: string[];
|
||||
otherPagesCount: number;
|
||||
features: string[];
|
||||
otherFeatures: string[];
|
||||
otherFeaturesCount: number;
|
||||
functions: string[];
|
||||
otherFunctions: string[];
|
||||
otherFunctionsCount: number;
|
||||
apiSystems: string[];
|
||||
otherTech: string[];
|
||||
otherTechCount: number;
|
||||
assets: string[];
|
||||
otherAssets: string[];
|
||||
complexInteractions: number;
|
||||
otherAssetsCount: number;
|
||||
newDatasets: number;
|
||||
cmsSetup: boolean;
|
||||
storageExpansion: number;
|
||||
@@ -29,7 +46,7 @@ export interface FormState {
|
||||
designWishes: string;
|
||||
// Maintenance
|
||||
expectedAdjustments: string;
|
||||
languagesCount: number;
|
||||
languagesList: string[];
|
||||
// Timeline
|
||||
deadline: string;
|
||||
// Web App specific
|
||||
@@ -37,6 +54,8 @@ export interface FormState {
|
||||
userRoles: string[];
|
||||
dataSensitivity: string;
|
||||
platformType: string;
|
||||
// Meta
|
||||
dontKnows: string[];
|
||||
}
|
||||
|
||||
export interface Step {
|
||||
|
||||
@@ -240,7 +240,18 @@ export const EstimationPDF = ({ state, totalPrice, monthlyPrice, totalPagesCount
|
||||
hr: 'HR / Recruiting',
|
||||
realestate: 'Immobilien',
|
||||
calendar: 'Termine / Booking',
|
||||
social: 'Social Media Sync'
|
||||
social: 'Social Media Sync',
|
||||
maps: 'Google Maps / Places',
|
||||
auth: 'Auth-Provider'
|
||||
};
|
||||
|
||||
const socialLabels: Record<string, string> = {
|
||||
instagram: 'Instagram',
|
||||
linkedin: 'LinkedIn',
|
||||
facebook: 'Facebook',
|
||||
twitter: 'Twitter / X',
|
||||
tiktok: 'TikTok',
|
||||
youtube: 'YouTube'
|
||||
};
|
||||
|
||||
const positions = [];
|
||||
@@ -317,11 +328,21 @@ export const EstimationPDF = ({ state, totalPrice, monthlyPrice, totalPagesCount
|
||||
});
|
||||
}
|
||||
|
||||
if (state.visualStaging > 0) {
|
||||
positions.push({
|
||||
pos: pos++,
|
||||
title: 'Visuelle Inszenierung',
|
||||
desc: `Umsetzung von ${state.visualStaging} Hero-Stories, Scroll-Effekten oder speziell inszenierten Sektionen.`,
|
||||
qty: state.visualStaging,
|
||||
price: state.visualStaging * pricing.VISUAL_STAGING
|
||||
});
|
||||
}
|
||||
|
||||
if (state.complexInteractions > 0) {
|
||||
positions.push({
|
||||
pos: pos++,
|
||||
title: 'Besondere Interaktionen',
|
||||
desc: `Umsetzung von ${state.complexInteractions} komplexen UI-Animationen oder interaktiven Logik-Abschnitten.`,
|
||||
title: 'Komplexe Interaktion',
|
||||
desc: `Umsetzung von ${state.complexInteractions} Konfiguratoren, Live-Previews oder mehrstufigen Auswahlprozessen.`,
|
||||
qty: state.complexInteractions,
|
||||
price: state.complexInteractions * pricing.COMPLEX_INTERACTION
|
||||
});
|
||||
@@ -365,6 +386,7 @@ export const EstimationPDF = ({ state, totalPrice, monthlyPrice, totalPagesCount
|
||||
<View style={styles.recipientSection}>
|
||||
<Text style={styles.recipientLabel}>Ansprechpartner</Text>
|
||||
<Text style={styles.recipientName}>{state.name || 'Interessent'}</Text>
|
||||
{state.companyName && <Text style={styles.recipientRole}>{state.companyName}</Text>}
|
||||
{state.role && <Text style={styles.recipientRole}>{state.role}</Text>}
|
||||
<Text style={styles.recipientRole}>{state.email}</Text>
|
||||
</View>
|
||||
@@ -441,6 +463,10 @@ export const EstimationPDF = ({ state, totalPrice, monthlyPrice, totalPagesCount
|
||||
<View style={styles.configGrid}>
|
||||
{state.projectType === 'website' ? (
|
||||
<>
|
||||
<View style={styles.configItem}>
|
||||
<Text style={styles.configLabel}>Thema</Text>
|
||||
<Text style={styles.configValue}>{state.websiteTopic || 'Nicht angegeben'}</Text>
|
||||
</View>
|
||||
<View style={styles.configItem}>
|
||||
<Text style={styles.configLabel}>Design-Vibe</Text>
|
||||
<Text style={styles.configValue}>{vibeLabels[state.designVibe] || state.designVibe}</Text>
|
||||
@@ -470,6 +496,22 @@ export const EstimationPDF = ({ state, totalPrice, monthlyPrice, totalPagesCount
|
||||
</View>
|
||||
</>
|
||||
)}
|
||||
<View style={styles.configItem}>
|
||||
<Text style={styles.configLabel}>Mitarbeiter</Text>
|
||||
<Text style={styles.configValue}>{state.employeeCount || 'Nicht angegeben'}</Text>
|
||||
</View>
|
||||
<View style={styles.configItem}>
|
||||
<Text style={styles.configLabel}>Bestehende Website</Text>
|
||||
<Text style={styles.configValue}>{state.existingWebsite || 'Keine'}</Text>
|
||||
</View>
|
||||
<View style={styles.configItem}>
|
||||
<Text style={styles.configLabel}>Bestehende Domain</Text>
|
||||
<Text style={styles.configValue}>{state.existingDomain || 'Keine'}</Text>
|
||||
</View>
|
||||
<View style={styles.configItem}>
|
||||
<Text style={styles.configLabel}>Wunsch-Domain</Text>
|
||||
<Text style={styles.configValue}>{state.wishedDomain || 'Keine'}</Text>
|
||||
</View>
|
||||
<View style={styles.configItem}>
|
||||
<Text style={styles.configLabel}>Zeitplan</Text>
|
||||
<Text style={styles.configValue}>{deadlineLabels[state.deadline] || state.deadline}</Text>
|
||||
@@ -486,7 +528,7 @@ export const EstimationPDF = ({ state, totalPrice, monthlyPrice, totalPagesCount
|
||||
)}
|
||||
<View style={styles.configItem}>
|
||||
<Text style={styles.configLabel}>Sprachen</Text>
|
||||
<Text style={styles.configValue}>{state.languagesCount}</Text>
|
||||
<Text style={styles.configValue}>{state.languagesCount} ({state.languagesList.join(', ')})</Text>
|
||||
</View>
|
||||
{state.projectType === 'website' && (
|
||||
<>
|
||||
@@ -505,6 +547,17 @@ export const EstimationPDF = ({ state, totalPrice, monthlyPrice, totalPagesCount
|
||||
)}
|
||||
</View>
|
||||
|
||||
{state.socialMedia.length > 0 && (
|
||||
<View style={{ marginTop: 15 }}>
|
||||
<Text style={styles.configLabel}>Social Media Accounts</Text>
|
||||
{state.socialMedia.map((id: string) => (
|
||||
<Text key={id} style={[styles.configValue, { lineHeight: 1.4 }]}>
|
||||
{socialLabels[id] || id}: {state.socialMediaUrls[id] || 'Keine URL angegeben'}
|
||||
</Text>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
|
||||
{state.designWishes && (
|
||||
<View style={{ marginTop: 15 }}>
|
||||
<Text style={styles.configLabel}>Design-Vorstellungen</Text>
|
||||
|
||||
Reference in New Issue
Block a user