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:
|
services:
|
||||||
# Main website - Next.js standalone
|
# Main website - Next.js standalone
|
||||||
website:
|
website:
|
||||||
|
image: registry.infra.mintel.me/mintel/mintel.me:latest
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: docker/Dockerfile
|
dockerfile: docker/Dockerfile
|
||||||
@@ -15,8 +16,10 @@ services:
|
|||||||
- NEXT_PUBLIC_GLITCHTIP_DSN=${NEXT_PUBLIC_GLITCHTIP_DSN}
|
- NEXT_PUBLIC_GLITCHTIP_DSN=${NEXT_PUBLIC_GLITCHTIP_DSN}
|
||||||
container_name: mintel-website
|
container_name: mintel-website
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
# Port 3000 is internal to the docker network, Caddy will proxy to it.
|
||||||
- "3000:3000"
|
# We can expose it for debugging if needed, but it's safer to keep it internal.
|
||||||
|
expose:
|
||||||
|
- "3000"
|
||||||
environment:
|
environment:
|
||||||
- NODE_ENV=production
|
- NODE_ENV=production
|
||||||
- REDIS_URL=redis://redis:6379
|
- REDIS_URL=redis://redis:6379
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
# Main website
|
# Main website
|
||||||
{$DOMAIN:-localhost} {
|
{$DOMAIN:-localhost} {
|
||||||
# Reverse proxy to website container
|
# Reverse proxy to website container
|
||||||
reverse_proxy website:80
|
reverse_proxy website:3000
|
||||||
|
|
||||||
# Security headers
|
# Security headers
|
||||||
header {
|
header {
|
||||||
|
|||||||
@@ -4,16 +4,18 @@ import * as React from 'react';
|
|||||||
import { useState, useMemo, useEffect, useRef } from 'react';
|
import { useState, useMemo, useEffect, useRef } from 'react';
|
||||||
import { useRouter, useSearchParams } from 'next/navigation';
|
import { useRouter, useSearchParams } from 'next/navigation';
|
||||||
import { motion, AnimatePresence } from 'framer-motion';
|
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 * as QRCode from 'qrcode';
|
||||||
|
|
||||||
import { FormState, Step, ProjectType } from './ContactForm/types';
|
import { FormState, Step } from './ContactForm/types';
|
||||||
import { PRICING, initialState } from './ContactForm/constants';
|
import { PRICING, initialState } from './ContactForm/constants';
|
||||||
import { PriceCalculation } from './ContactForm/components/PriceCalculation';
|
import { PriceCalculation } from './ContactForm/components/PriceCalculation';
|
||||||
import { ShareModal } from './ShareModal';
|
import { ShareModal } from './ShareModal';
|
||||||
|
|
||||||
// Steps
|
// Steps
|
||||||
import { TypeStep } from './ContactForm/steps/TypeStep';
|
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 { BaseStep } from './ContactForm/steps/BaseStep';
|
||||||
import { FeaturesStep } from './ContactForm/steps/FeaturesStep';
|
import { FeaturesStep } from './ContactForm/steps/FeaturesStep';
|
||||||
import { DesignStep } from './ContactForm/steps/DesignStep';
|
import { DesignStep } from './ContactForm/steps/DesignStep';
|
||||||
@@ -77,19 +79,41 @@ export function ContactForm() {
|
|||||||
|
|
||||||
const configData = {
|
const configData = {
|
||||||
projectType: state.projectType,
|
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,
|
selectedPages: state.selectedPages,
|
||||||
|
otherPages: state.otherPages,
|
||||||
|
otherPagesCount: state.otherPagesCount,
|
||||||
features: state.features,
|
features: state.features,
|
||||||
|
otherFeatures: state.otherFeatures,
|
||||||
|
otherFeaturesCount: state.otherFeaturesCount,
|
||||||
functions: state.functions,
|
functions: state.functions,
|
||||||
|
otherFunctions: state.otherFunctions,
|
||||||
|
otherFunctionsCount: state.otherFunctionsCount,
|
||||||
apiSystems: state.apiSystems,
|
apiSystems: state.apiSystems,
|
||||||
|
otherTech: state.otherTech,
|
||||||
|
otherTechCount: state.otherTechCount,
|
||||||
|
assets: state.assets,
|
||||||
|
otherAssets: state.otherAssets,
|
||||||
|
otherAssetsCount: state.otherAssetsCount,
|
||||||
cmsSetup: state.cmsSetup,
|
cmsSetup: state.cmsSetup,
|
||||||
languagesCount: state.languagesCount,
|
languagesList: state.languagesList,
|
||||||
deadline: state.deadline,
|
deadline: state.deadline,
|
||||||
designVibe: state.designVibe,
|
designVibe: state.designVibe,
|
||||||
colorScheme: state.colorScheme,
|
colorScheme: state.colorScheme,
|
||||||
targetAudience: state.targetAudience,
|
targetAudience: state.targetAudience,
|
||||||
userRoles: state.userRoles,
|
userRoles: state.userRoles,
|
||||||
dataSensitivity: state.dataSensitivity,
|
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))));
|
const stateString = btoa(unescape(encodeURIComponent(JSON.stringify(configData))));
|
||||||
@@ -106,28 +130,28 @@ export function ContactForm() {
|
|||||||
}, [currentUrl]);
|
}, [currentUrl]);
|
||||||
|
|
||||||
const totalPagesCount = useMemo(() => {
|
const totalPagesCount = useMemo(() => {
|
||||||
return state.selectedPages.length + state.otherPages.length;
|
return state.selectedPages.length + state.otherPages.length + (state.otherPagesCount || 0);
|
||||||
}, [state.selectedPages, state.otherPages]);
|
}, [state.selectedPages, state.otherPages, state.otherPagesCount]);
|
||||||
|
|
||||||
const totalPrice = useMemo(() => {
|
const totalPrice = useMemo(() => {
|
||||||
if (state.projectType !== 'website') return 0;
|
if (state.projectType !== 'website') return 0;
|
||||||
|
|
||||||
let total = PRICING.BASE_WEBSITE;
|
let total = PRICING.BASE_WEBSITE;
|
||||||
total += totalPagesCount * PRICING.PAGE;
|
total += totalPagesCount * PRICING.PAGE;
|
||||||
total += (state.features.length + state.otherFeatures.length) * PRICING.FEATURE;
|
total += (state.features.length + state.otherFeatures.length + (state.otherFeaturesCount || 0)) * PRICING.FEATURE;
|
||||||
total += (state.functions.length + state.otherFunctions.length) * PRICING.FUNCTION;
|
total += (state.functions.length + state.otherFunctions.length + (state.otherFunctionsCount || 0)) * PRICING.FUNCTION;
|
||||||
total += state.complexInteractions * PRICING.COMPLEX_INTERACTION;
|
total += (state.apiSystems.length + state.otherTech.length + (state.otherTechCount || 0)) * PRICING.API_INTEGRATION;
|
||||||
total += state.newDatasets * PRICING.NEW_DATASET;
|
total += (state.newDatasets || 0) * PRICING.NEW_DATASET;
|
||||||
total += (state.apiSystems.length + state.otherTech.length) * PRICING.API_INTEGRATION;
|
|
||||||
|
|
||||||
if (state.cmsSetup) {
|
if (state.cmsSetup) {
|
||||||
total += PRICING.CMS_SETUP;
|
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)
|
// Multi-language factor (e.g. +20% per additional language)
|
||||||
if (state.languagesCount > 1) {
|
const languagesCount = state.languagesList.length || 1;
|
||||||
total *= (1 + (state.languagesCount - 1) * 0.2);
|
if (languagesCount > 1) {
|
||||||
|
total *= (1 + (languagesCount - 1) * 0.2);
|
||||||
}
|
}
|
||||||
|
|
||||||
return Math.round(total);
|
return Math.round(total);
|
||||||
@@ -177,8 +201,10 @@ export function ContactForm() {
|
|||||||
|
|
||||||
const steps: Step[] = [
|
const steps: Step[] = [
|
||||||
{ id: 'type', title: 'Das Ziel', description: 'Was möchten Sie realisieren?', illustration: <ConceptTarget className="w-full h-full" /> },
|
{ 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: '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: '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: '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" /> },
|
{ 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
|
// Web App flow
|
||||||
return [
|
return [
|
||||||
steps.find(s => s.id === 'type')!,
|
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 === 'webapp')!,
|
||||||
{ ...steps.find(s => s.id === 'functions')!, title: 'Funktionen', description: 'Kern-Features Ihrer Anwendung.' },
|
{ ...steps.find(s => s.id === 'functions')!, title: 'Funktionen', description: 'Kern-Features Ihrer Anwendung.' },
|
||||||
{ ...steps.find(s => s.id === 'api')!, title: 'Integrationen', description: 'Anbindung an bestehende Systeme.' },
|
{ ...steps.find(s => s.id === 'api')!, title: 'Integrationen', description: 'Anbindung an bestehende Systeme.' },
|
||||||
@@ -214,6 +242,10 @@ export function ContactForm() {
|
|||||||
switch (currentStep.id) {
|
switch (currentStep.id) {
|
||||||
case 'type':
|
case 'type':
|
||||||
return <TypeStep state={state} updateState={updateState} />;
|
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':
|
case 'base':
|
||||||
return <BaseStep state={state} updateState={updateState} toggleItem={toggleItem} />;
|
return <BaseStep state={state} updateState={updateState} toggleItem={toggleItem} />;
|
||||||
case 'features':
|
case 'features':
|
||||||
@@ -287,11 +319,11 @@ export function ContactForm() {
|
|||||||
<p className="text-xl text-slate-500 leading-relaxed">{activeSteps[stepIndex].description}</p>
|
<p className="text-xl text-slate-500 leading-relaxed">{activeSteps[stepIndex].description}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-3 h-1.5">
|
<div className="flex gap-3 h-4">
|
||||||
{activeSteps.map((step, i) => (
|
{activeSteps.map((step, i) => (
|
||||||
<div
|
<div
|
||||||
key={i}
|
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)}
|
onMouseEnter={() => setHoveredStep(i)}
|
||||||
onMouseLeave={() => setHoveredStep(null)}
|
onMouseLeave={() => setHoveredStep(null)}
|
||||||
>
|
>
|
||||||
@@ -301,7 +333,7 @@ export function ContactForm() {
|
|||||||
setStepIndex(i);
|
setStepIndex(i);
|
||||||
setTimeout(scrollToTop, 50);
|
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>
|
<AnimatePresence>
|
||||||
{hoveredStep === i && (
|
{hoveredStep === i && (
|
||||||
@@ -309,7 +341,7 @@ export function ContactForm() {
|
|||||||
initial={{ opacity: 0, y: 5, x: '-50%' }}
|
initial={{ opacity: 0, y: 5, x: '-50%' }}
|
||||||
animate={{ opacity: 1, y: 0, x: '-50%' }}
|
animate={{ opacity: 1, y: 0, x: '-50%' }}
|
||||||
exit={{ opacity: 0, y: 5, 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}
|
{step.title}
|
||||||
<div className="absolute top-full left-1/2 -translate-x-1/2 -mt-1 border-4 border-transparent border-t-slate-900" />
|
<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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onChange}
|
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'
|
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'}`}>
|
<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} />}
|
{checked && <Check size={14} strokeWidth={4} />}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div className="flex-grow">
|
||||||
<h4 className={`text-xl font-bold mb-1 ${checked ? 'text-white' : 'text-slate-900'}`}>{label}</h4>
|
<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>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -34,6 +34,11 @@ export function PriceCalculation({
|
|||||||
qrCodeData,
|
qrCodeData,
|
||||||
onShare
|
onShare
|
||||||
}: PriceCalculationProps) {
|
}: 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 (
|
return (
|
||||||
<div className="lg:col-span-4 lg:sticky lg:top-24">
|
<div className="lg:col-span-4 lg:sticky lg:top-24">
|
||||||
<div className="p-10 bg-slate-50 border border-slate-100 rounded-[3rem] space-y-10">
|
<div className="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="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">
|
<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>)}
|
{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>)}
|
||||||
{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>)}
|
{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>)}
|
||||||
{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>)}
|
{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.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.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.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.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>)}
|
||||||
{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>)}
|
{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.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>)}
|
{state.languagesCount > 1 && (<div className="flex justify-between items-center text-sm text-slate-900 font-bold pt-2 border-t border-slate-100"><span className="text-slate-500">Mehrsprachigkeit ({state.languagesCount}x)</span><span>+{(totalPrice - (totalPrice / (1 + (state.languagesCount - 1) * 0.2))).toLocaleString()} €</span></div>)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ export const PRICING = {
|
|||||||
PAGE: 800,
|
PAGE: 800,
|
||||||
FEATURE: 2000,
|
FEATURE: 2000,
|
||||||
FUNCTION: 1000,
|
FUNCTION: 1000,
|
||||||
COMPLEX_INTERACTION: 1500,
|
|
||||||
NEW_DATASET: 400,
|
NEW_DATASET: 400,
|
||||||
HOSTING_MONTHLY: 120,
|
HOSTING_MONTHLY: 120,
|
||||||
STORAGE_EXPANSION_MONTHLY: 10,
|
STORAGE_EXPANSION_MONTHLY: 10,
|
||||||
@@ -18,17 +17,32 @@ export const PRICING = {
|
|||||||
|
|
||||||
export const initialState: FormState = {
|
export const initialState: FormState = {
|
||||||
projectType: 'website',
|
projectType: 'website',
|
||||||
|
// Company
|
||||||
|
companyName: '',
|
||||||
|
employeeCount: '',
|
||||||
|
// Existing Presence
|
||||||
|
existingWebsite: '',
|
||||||
|
socialMedia: [],
|
||||||
|
socialMediaUrls: {},
|
||||||
|
existingDomain: '',
|
||||||
|
wishedDomain: '',
|
||||||
|
// Project
|
||||||
|
websiteTopic: '',
|
||||||
selectedPages: ['Home'],
|
selectedPages: ['Home'],
|
||||||
otherPages: [],
|
otherPages: [],
|
||||||
|
otherPagesCount: 0,
|
||||||
features: [],
|
features: [],
|
||||||
otherFeatures: [],
|
otherFeatures: [],
|
||||||
|
otherFeaturesCount: 0,
|
||||||
functions: [],
|
functions: [],
|
||||||
otherFunctions: [],
|
otherFunctions: [],
|
||||||
|
otherFunctionsCount: 0,
|
||||||
apiSystems: [],
|
apiSystems: [],
|
||||||
otherTech: [],
|
otherTech: [],
|
||||||
|
otherTechCount: 0,
|
||||||
assets: [],
|
assets: [],
|
||||||
otherAssets: [],
|
otherAssets: [],
|
||||||
complexInteractions: 0,
|
otherAssetsCount: 0,
|
||||||
newDatasets: 0,
|
newDatasets: 0,
|
||||||
cmsSetup: false,
|
cmsSetup: false,
|
||||||
storageExpansion: 0,
|
storageExpansion: 0,
|
||||||
@@ -38,17 +52,23 @@ export const initialState: FormState = {
|
|||||||
message: '',
|
message: '',
|
||||||
sitemapFile: null,
|
sitemapFile: null,
|
||||||
contactFiles: [],
|
contactFiles: [],
|
||||||
|
// Design
|
||||||
designVibe: 'minimal',
|
designVibe: 'minimal',
|
||||||
colorScheme: ['#ffffff', '#f8fafc', '#0f172a'],
|
colorScheme: ['#ffffff', '#f8fafc', '#0f172a'],
|
||||||
references: [],
|
references: [],
|
||||||
designWishes: '',
|
designWishes: '',
|
||||||
|
// Maintenance
|
||||||
expectedAdjustments: 'low',
|
expectedAdjustments: 'low',
|
||||||
languagesCount: 1,
|
languagesList: ['Deutsch'],
|
||||||
|
// Timeline
|
||||||
deadline: 'flexible',
|
deadline: 'flexible',
|
||||||
|
// Web App specific
|
||||||
targetAudience: 'internal',
|
targetAudience: 'internal',
|
||||||
userRoles: [],
|
userRoles: [],
|
||||||
dataSensitivity: 'standard',
|
dataSensitivity: 'standard',
|
||||||
platformType: 'web-only',
|
platformType: 'web-only',
|
||||||
|
// Meta
|
||||||
|
dontKnows: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
export const PAGE_SAMPLES = [
|
export const PAGE_SAMPLES = [
|
||||||
@@ -85,9 +105,13 @@ export const API_OPTIONS = [
|
|||||||
{ id: 'realestate', label: 'Immobilien', desc: 'OpenImmo, FlowFact, Immowelt Sync.' },
|
{ id: 'realestate', label: 'Immobilien', desc: 'OpenImmo, FlowFact, Immowelt Sync.' },
|
||||||
{ id: 'calendar', label: 'Termine / Booking', desc: 'Calendly, Shore, Doctolib etc.' },
|
{ id: 'calendar', label: 'Termine / Booking', desc: 'Calendly, Shore, Doctolib etc.' },
|
||||||
{ id: 'social', label: 'Social Media Sync', desc: 'Automatisierte Posts oder Feeds.' },
|
{ 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 = [
|
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: 'logo', label: 'Logo', desc: 'Vektordatei Ihres Logos.' },
|
||||||
{ id: 'styleguide', label: 'Styleguide', desc: 'Farben, Schriften, Design-Vorgaben.' },
|
{ id: 'styleguide', label: 'Styleguide', desc: 'Farben, Schriften, Design-Vorgaben.' },
|
||||||
{ id: 'content_concept', label: 'Inhalts-Konzept', desc: 'Struktur und Texte sind bereits geplant.' },
|
{ 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 = [
|
export const EMPLOYEE_OPTIONS = [
|
||||||
['#ffffff', '#f8fafc', '#0f172a'],
|
{ id: '1-5', label: '1-5 Mitarbeiter' },
|
||||||
['#000000', '#facc15', '#ffffff'],
|
{ id: '6-20', label: '6-20 Mitarbeiter' },
|
||||||
['#fdfcfb', '#e2e8f0', '#1e293b'],
|
{ id: '21-100', label: '21-100 Mitarbeiter' },
|
||||||
['#0f172a', '#38bdf8', '#ffffff'],
|
{ id: '100+', label: '100+ Mitarbeiter' },
|
||||||
['#fafaf9', '#78716c', '#1c1917'],
|
];
|
||||||
['#f0fdf4', '#16a34a', '#064e3b'],
|
|
||||||
['#fff7ed', '#ea580c', '#7c2d12'],
|
export const SOCIAL_MEDIA_OPTIONS = [
|
||||||
['#f5f3ff', '#7c3aed', '#2e1065'],
|
{ 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 { FormState } from '../types';
|
||||||
import { Checkbox } from '../components/Checkbox';
|
import { Checkbox } from '../components/Checkbox';
|
||||||
import { RepeatableList } from '../components/RepeatableList';
|
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 {
|
interface ApiStepProps {
|
||||||
state: FormState;
|
state: FormState;
|
||||||
@@ -14,50 +17,134 @@ interface ApiStepProps {
|
|||||||
export function ApiStep({ state, updateState, toggleItem }: ApiStepProps) {
|
export function ApiStep({ state, updateState, toggleItem }: ApiStepProps) {
|
||||||
const isWebApp = state.projectType === 'web-app';
|
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 (
|
return (
|
||||||
<div className="space-y-12">
|
<div className="space-y-12">
|
||||||
<div className="space-y-6">
|
<Reveal width="100%" delay={0.1}>
|
||||||
<h4 className="text-2xl font-bold text-slate-900">
|
<div className="space-y-8">
|
||||||
{isWebApp ? 'Integrationen & Datenquellen' : 'Schnittstellen (API)'}
|
<div className="flex justify-between items-center">
|
||||||
</h4>
|
<div className="flex items-center gap-4">
|
||||||
<p className="text-lg text-slate-500 leading-relaxed">
|
<div className="w-12 h-12 bg-slate-50 rounded-2xl flex items-center justify-center text-slate-900 shadow-sm">
|
||||||
{isWebApp
|
<Share2 size={24} />
|
||||||
? 'Mit welchen Systemen soll die Web App kommunizieren?'
|
</div>
|
||||||
: 'Datenaustausch mit Drittsystemen zur Automatisierung.'}
|
<h4 className="text-2xl font-bold text-slate-900">
|
||||||
</p>
|
{isWebApp ? 'Integrationen & Datenquellen' : 'Schnittstellen (API)'}
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
</h4>
|
||||||
<Checkbox
|
</div>
|
||||||
label="CRM / ERP" desc="HubSpot, Salesforce, SAP, Xentral etc."
|
<motion.button
|
||||||
checked={state.apiSystems.includes('crm_erp')}
|
whileHover={{ scale: 1.05 }}
|
||||||
onChange={() => updateState({ apiSystems: toggleItem(state.apiSystems, 'crm_erp') })}
|
whileTap={{ scale: 0.95 }}
|
||||||
/>
|
type="button"
|
||||||
<Checkbox
|
onClick={() => toggleDontKnow('api')}
|
||||||
label="Payment" desc="Stripe, PayPal, Klarna Integration."
|
className={`px-6 py-3 rounded-full text-sm font-bold transition-all shadow-sm ${
|
||||||
checked={state.apiSystems.includes('payment')}
|
state.dontKnows?.includes('api') ? 'bg-slate-900 text-white' : 'bg-slate-100 text-slate-500 hover:bg-slate-200'
|
||||||
onChange={() => updateState({ apiSystems: toggleItem(state.apiSystems, 'payment') })}
|
}`}
|
||||||
/>
|
>
|
||||||
<Checkbox
|
Ich weiß es nicht
|
||||||
label="Marketing" desc="Newsletter (Mailchimp), Social Media Sync."
|
</motion.button>
|
||||||
checked={state.apiSystems.includes('marketing')}
|
</div>
|
||||||
onChange={() => updateState({ apiSystems: toggleItem(state.apiSystems, 'marketing') })}
|
<p className="text-lg text-slate-500 leading-relaxed ml-2">
|
||||||
/>
|
{isWebApp
|
||||||
<Checkbox
|
? 'Mit welchen Systemen soll die Web App kommunizieren?'
|
||||||
label="E-Commerce" desc="Shopify, WooCommerce, Lagerbestand-Sync."
|
: 'Datenaustausch mit Drittsystemen zur Automatisierung.'}
|
||||||
checked={state.apiSystems.includes('ecommerce')}
|
</p>
|
||||||
onChange={() => updateState({ apiSystems: toggleItem(state.apiSystems, 'ecommerce') })}
|
<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>
|
||||||
</div>
|
</Reveal>
|
||||||
|
|
||||||
<div className="space-y-6">
|
<Reveal width="100%" delay={0.2}>
|
||||||
<p className="text-lg font-bold text-slate-900">Weitere Systeme oder eigene APIs?</p>
|
<div className="space-y-12">
|
||||||
<RepeatableList
|
<div className="space-y-8">
|
||||||
items={state.otherTech}
|
<div className="flex items-center gap-4">
|
||||||
onAdd={(v) => updateState({ otherTech: [...state.otherTech, v] })}
|
<div className="w-12 h-12 bg-slate-50 rounded-2xl flex items-center justify-center text-slate-900 shadow-sm">
|
||||||
onRemove={(i) => updateTech(i)}
|
<ListPlus size={24} />
|
||||||
placeholder="z.B. Microsoft Graph, Google Maps, Custom REST API..."
|
</div>
|
||||||
/>
|
<h4 className="text-2xl font-bold text-slate-900">Weitere Systeme oder eigene APIs?</h4>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,9 @@ import { FormState } from '../types';
|
|||||||
import { ASSET_OPTIONS } from '../constants';
|
import { ASSET_OPTIONS } from '../constants';
|
||||||
import { Checkbox } from '../components/Checkbox';
|
import { Checkbox } from '../components/Checkbox';
|
||||||
import { RepeatableList } from '../components/RepeatableList';
|
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 {
|
interface AssetsStepProps {
|
||||||
state: FormState;
|
state: FormState;
|
||||||
@@ -13,26 +16,120 @@ interface AssetsStepProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function AssetsStep({ state, updateState, toggleItem }: 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 (
|
return (
|
||||||
<div className="space-y-12">
|
<div className="space-y-12">
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
<Reveal width="100%" delay={0.1}>
|
||||||
{ASSET_OPTIONS.map(opt => (
|
<div className="space-y-8">
|
||||||
<Checkbox
|
<div className="flex justify-between items-center">
|
||||||
key={opt.id} label={opt.label} desc={opt.desc}
|
<div className="flex items-center gap-4">
|
||||||
checked={state.assets.includes(opt.id)}
|
<div className="w-12 h-12 bg-slate-50 rounded-2xl flex items-center justify-center text-slate-900 shadow-sm">
|
||||||
onChange={() => updateState({ assets: toggleItem(state.assets, opt.id) })}
|
<Briefcase size={24} />
|
||||||
/>
|
</div>
|
||||||
))}
|
<h4 className="text-2xl font-bold text-slate-900">Vorhandene Assets</h4>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-6">
|
<motion.button
|
||||||
<p className="text-lg font-bold text-slate-900">Weitere vorhandene Unterlagen?</p>
|
whileHover={{ scale: 1.05 }}
|
||||||
<RepeatableList
|
whileTap={{ scale: 0.95 }}
|
||||||
items={state.otherAssets}
|
type="button"
|
||||||
onAdd={(v) => updateState({ otherAssets: [...state.otherAssets, v] })}
|
onClick={() => toggleDontKnow('assets')}
|
||||||
onRemove={(i) => updateState({ otherAssets: state.otherAssets.filter((_, idx) => idx !== i) })}
|
className={`px-6 py-3 rounded-full text-sm font-bold transition-all shadow-sm ${
|
||||||
placeholder="z.B. Lastenheft, Wireframes, Bilddatenbank..."
|
state.dontKnows?.includes('assets') ? 'bg-slate-900 text-white' : 'bg-slate-100 text-slate-500 hover:bg-slate-200'
|
||||||
/>
|
}`}
|
||||||
</div>
|
>
|
||||||
|
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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import * as React from 'react';
|
|||||||
import { FormState } from '../types';
|
import { FormState } from '../types';
|
||||||
import { Checkbox } from '../components/Checkbox';
|
import { Checkbox } from '../components/Checkbox';
|
||||||
import { RepeatableList } from '../components/RepeatableList';
|
import { RepeatableList } from '../components/RepeatableList';
|
||||||
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
|
import { Minus, Plus, FileText, ListPlus } from 'lucide-react';
|
||||||
|
|
||||||
interface BaseStepProps {
|
interface BaseStepProps {
|
||||||
state: FormState;
|
state: FormState;
|
||||||
@@ -12,32 +14,137 @@ interface BaseStepProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function BaseStep({ state, updateState, toggleItem }: 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 (
|
return (
|
||||||
<div className="space-y-12">
|
<div className="space-y-16">
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
<motion.div
|
||||||
{[
|
initial={{ opacity: 0, y: 20 }}
|
||||||
{ id: 'Home', label: 'Startseite', desc: 'Der erste Eindruck Ihrer Marke.' },
|
animate={{ opacity: 1, y: 0 }}
|
||||||
{ id: 'About', label: 'Über uns', desc: 'Ihre Geschichte und Ihr Team.' },
|
className="space-y-8"
|
||||||
{ id: 'Services', label: 'Leistungen', desc: 'Übersicht Ihres Angebots.' },
|
>
|
||||||
{ id: 'Contact', label: 'Kontakt', desc: 'Anlaufstelle für Ihre Kunden.' },
|
<h4 className="text-2xl font-bold text-slate-900">Thema der Website</h4>
|
||||||
{ id: 'Landing', label: 'Landingpage', desc: 'Optimiert für Marketing-Kampagnen.' },
|
<input
|
||||||
{ id: 'Legal', label: 'Rechtliches', desc: 'Impressum & Datenschutz.' },
|
type="text"
|
||||||
].map(opt => (
|
placeholder="z.B. Portfolio für Architektur, Onlineshop für Bio-Tee..."
|
||||||
<Checkbox
|
value={state.websiteTopic}
|
||||||
key={opt.id} label={opt.label} desc={opt.desc}
|
onChange={(e) => updateState({ websiteTopic: e.target.value })}
|
||||||
checked={state.selectedPages.includes(opt.id)}
|
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"
|
||||||
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..."
|
|
||||||
/>
|
/>
|
||||||
|
</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>
|
||||||
</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 * as React from 'react';
|
||||||
import { FormState } from '../types';
|
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 {
|
interface ContactStepProps {
|
||||||
state: FormState;
|
state: FormState;
|
||||||
@@ -11,93 +13,166 @@ interface ContactStepProps {
|
|||||||
|
|
||||||
export function ContactStep({ state, updateState }: ContactStepProps) {
|
export function ContactStep({ state, updateState }: ContactStepProps) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-10">
|
<div className="space-y-12">
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
|
||||||
<input
|
<Reveal width="100%" delay={0.1}>
|
||||||
type="text"
|
<div className="space-y-4">
|
||||||
placeholder="Ihr Name"
|
<label className="block text-sm font-bold uppercase tracking-widest text-slate-400 ml-2">Ihr Name</label>
|
||||||
required
|
<div className="relative group">
|
||||||
value={state.name}
|
<div className="absolute left-6 top-1/2 -translate-y-1/2 text-slate-300 group-focus-within:text-slate-900 transition-colors">
|
||||||
onChange={(e) => updateState({ name: e.target.value })}
|
<User size={24} />
|
||||||
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>
|
</div>
|
||||||
</>
|
<input
|
||||||
)}
|
type="text"
|
||||||
</div>
|
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>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,9 @@
|
|||||||
|
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { FormState } from '../types';
|
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 {
|
interface ContentStepProps {
|
||||||
state: FormState;
|
state: FormState;
|
||||||
@@ -10,74 +12,167 @@ interface ContentStepProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function ContentStep({ state, updateState }: ContentStepProps) {
|
export function ContentStep({ state, updateState }: ContentStepProps) {
|
||||||
return (
|
const toggleDontKnow = (id: string) => {
|
||||||
<div className="space-y-12">
|
const current = state.dontKnows || [];
|
||||||
<div className="flex items-center justify-between p-10 bg-white border border-slate-100 rounded-[3rem]">
|
if (current.includes(id)) {
|
||||||
<div className="max-w-[70%]">
|
updateState({ dontKnows: current.filter(i => i !== id) });
|
||||||
<h4 className="text-2xl font-bold text-slate-900">Inhalte selbst verwalten (CMS)</h4>
|
} else {
|
||||||
<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>
|
updateState({ dontKnows: [...current, id] });
|
||||||
</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>
|
|
||||||
|
|
||||||
<div className="p-10 bg-slate-50 rounded-[3rem] border border-slate-100 space-y-6">
|
return (
|
||||||
<p className="text-lg font-bold text-slate-900">Wie oft ändern sich Ihre Inhalte?</p>
|
<div className="space-y-16">
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
<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">
|
||||||
{ id: 'low', label: 'Selten', desc: 'Wenige Male im Jahr.' },
|
<div className="max-w-2xl space-y-4">
|
||||||
{ id: 'medium', label: 'Regelmäßig', desc: 'Monatliche Updates.' },
|
<div className="flex items-center gap-4">
|
||||||
{ id: 'high', label: 'Häufig', desc: 'Wöchentlich oder täglich.' },
|
<div className="w-12 h-12 bg-slate-50 rounded-2xl flex items-center justify-center text-slate-900 shadow-inner">
|
||||||
].map(opt => (
|
<Settings2 size={24} />
|
||||||
<button
|
</div>
|
||||||
key={opt.id}
|
<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"
|
type="button"
|
||||||
onClick={() => updateState({ expectedAdjustments: opt.id })}
|
onClick={() => toggleDontKnow('cms')}
|
||||||
className={`p-6 rounded-2xl border-2 text-left transition-all focus:outline-none ${
|
className={`px-6 py-3 rounded-full text-sm font-bold transition-all shadow-sm ${
|
||||||
state.expectedAdjustments === opt.id ? 'border-slate-900 bg-slate-900 text-white' : 'border-slate-200 bg-white hover:border-slate-400'
|
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>
|
Ich weiß es nicht
|
||||||
<p className={`text-sm mt-1 ${state.expectedAdjustments === opt.id ? 'text-slate-200' : 'text-slate-500'}`}>{opt.desc}</p>
|
</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>
|
</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>
|
</div>
|
||||||
</div>
|
</Reveal>
|
||||||
|
|
||||||
<div className="flex flex-col gap-6 p-10 bg-white border border-slate-100 rounded-[3rem]">
|
<Reveal width="100%" delay={0.2}>
|
||||||
<div>
|
<div className="p-10 bg-slate-50 rounded-[3rem] border border-slate-100 space-y-10">
|
||||||
<h4 className="text-2xl font-bold text-slate-900">Inhalte einpflegen</h4>
|
<div className="flex items-center gap-4">
|
||||||
<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>
|
<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>
|
||||||
<div className="flex items-center gap-12 mt-2">
|
</Reveal>
|
||||||
<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>
|
<Reveal width="100%" delay={0.3}>
|
||||||
<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>
|
<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>
|
||||||
</div>
|
</Reveal>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,8 +2,10 @@
|
|||||||
|
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { FormState } from '../types';
|
import { FormState } from '../types';
|
||||||
import { DESIGN_VIBES, HARMONIOUS_PALETTES } from '../constants';
|
import { DESIGN_VIBES } from '../constants';
|
||||||
import { motion } from 'framer-motion';
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
|
import { Plus, X, Palette, Pipette, RefreshCw } from 'lucide-react';
|
||||||
|
import { Reveal } from '../../Reveal';
|
||||||
|
|
||||||
interface DesignStepProps {
|
interface DesignStepProps {
|
||||||
state: FormState;
|
state: FormState;
|
||||||
@@ -11,60 +13,225 @@ interface DesignStepProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function DesignStep({ state, updateState }: DesignStepProps) {
|
export function DesignStep({ state, updateState }: DesignStepProps) {
|
||||||
return (
|
const addColor = () => {
|
||||||
<div className="space-y-12">
|
if (state.colorScheme.length < 5) {
|
||||||
<div className="space-y-6">
|
updateState({ colorScheme: [...state.colorScheme, '#000000'] });
|
||||||
<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>
|
|
||||||
|
|
||||||
<div className="space-y-6">
|
const removeColor = (index: number) => {
|
||||||
<h4 className="text-2xl font-bold text-slate-900">Farbschema</h4>
|
if (state.colorScheme.length > 1) {
|
||||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
const newScheme = [...state.colorScheme];
|
||||||
{HARMONIOUS_PALETTES.map((palette, i) => (
|
newScheme.splice(index, 1);
|
||||||
<button
|
updateState({ colorScheme: newScheme });
|
||||||
key={i}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
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"
|
type="button"
|
||||||
onClick={() => updateState({ colorScheme: palette })}
|
onClick={() => toggleDontKnow('design_vibe')}
|
||||||
className={`p-4 rounded-2xl border-2 transition-all ${
|
className={`px-6 py-3 rounded-full text-sm font-bold transition-all shadow-sm ${
|
||||||
JSON.stringify(state.colorScheme) === JSON.stringify(palette) ? 'border-slate-900 bg-slate-50' : 'border-slate-100 bg-white hover:border-slate-200'
|
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">
|
Ich weiß es nicht
|
||||||
{palette.map((color, j) => (
|
</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 key={j} className="flex-1" style={{ backgroundColor: color }} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</motion.button>
|
||||||
))}
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-6">
|
{/* Custom Picker */}
|
||||||
<h4 className="text-2xl font-bold text-slate-900">Individuelle Wünsche</h4>
|
<div className="space-y-8 p-10 bg-slate-50 rounded-[3rem] border border-slate-100">
|
||||||
<textarea
|
<div className="flex items-center gap-3 text-slate-400 font-bold uppercase tracking-widest text-xs">
|
||||||
placeholder="Haben Sie bereits konkrete Vorstellungen oder Referenzen?"
|
<Pipette size={16} />
|
||||||
value={state.designWishes}
|
Individuelle Farben
|
||||||
onChange={(e) => updateState({ designWishes: e.target.value })}
|
</div>
|
||||||
rows={4}
|
<div className="flex flex-wrap gap-6">
|
||||||
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"
|
<AnimatePresence mode="popLayout">
|
||||||
/>
|
{state.colorScheme.map((color, i) => (
|
||||||
</div>
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,9 +2,11 @@
|
|||||||
|
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { FormState } from '../types';
|
import { FormState } from '../types';
|
||||||
|
import { FEATURE_OPTIONS } from '../constants';
|
||||||
import { Checkbox } from '../components/Checkbox';
|
import { Checkbox } from '../components/Checkbox';
|
||||||
import { RepeatableList } from '../components/RepeatableList';
|
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 {
|
interface FeaturesStepProps {
|
||||||
state: FormState;
|
state: FormState;
|
||||||
@@ -13,25 +15,115 @@ interface FeaturesStepProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function FeaturesStep({ state, updateState, toggleItem }: 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 (
|
return (
|
||||||
<div className="space-y-12">
|
<div className="space-y-16">
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
<div className="space-y-8">
|
||||||
{FEATURE_OPTIONS.map(opt => (
|
<div className="flex justify-between items-center">
|
||||||
<Checkbox
|
<div className="flex items-center gap-4">
|
||||||
key={opt.id} label={opt.label} desc={opt.desc}
|
<div className="w-12 h-12 bg-slate-50 rounded-2xl flex items-center justify-center text-slate-900 shadow-sm">
|
||||||
checked={state.features.includes(opt.id)}
|
<LayoutGrid size={24} />
|
||||||
onChange={() => updateState({ features: toggleItem(state.features, opt.id) })}
|
</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>
|
||||||
<div className="space-y-6">
|
|
||||||
<p className="text-lg font-bold text-slate-900">Weitere inhaltliche Module?</p>
|
<div className="space-y-12">
|
||||||
<RepeatableList
|
<div className="space-y-8">
|
||||||
items={state.otherFeatures}
|
<div className="flex items-center gap-4">
|
||||||
onAdd={(v) => updateState({ otherFeatures: [...state.otherFeatures, v] })}
|
<div className="w-12 h-12 bg-slate-50 rounded-2xl flex items-center justify-center text-slate-900 shadow-sm">
|
||||||
onRemove={(i) => updateState({ otherFeatures: state.otherFeatures.filter((_, idx) => idx !== i) })}
|
<ListPlus size={24} />
|
||||||
placeholder="z.B. Glossar, Download-Center, Partner-Bereich..."
|
</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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -4,7 +4,9 @@ import * as React from 'react';
|
|||||||
import { FormState } from '../types';
|
import { FormState } from '../types';
|
||||||
import { Checkbox } from '../components/Checkbox';
|
import { Checkbox } from '../components/Checkbox';
|
||||||
import { RepeatableList } from '../components/RepeatableList';
|
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 {
|
interface FunctionsStepProps {
|
||||||
state: FormState;
|
state: FormState;
|
||||||
@@ -15,88 +17,155 @@ interface FunctionsStepProps {
|
|||||||
export function FunctionsStep({ state, updateState, toggleItem }: FunctionsStepProps) {
|
export function FunctionsStep({ state, updateState, toggleItem }: FunctionsStepProps) {
|
||||||
const isWebApp = state.projectType === 'web-app';
|
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 (
|
return (
|
||||||
<div className="space-y-12">
|
<div className="space-y-12">
|
||||||
<div className="space-y-6">
|
<Reveal width="100%" delay={0.1}>
|
||||||
<h4 className="text-2xl font-bold text-slate-900">
|
<div className="space-y-8">
|
||||||
{isWebApp ? 'Funktionale Anforderungen' : 'Erweiterte Funktionen'}
|
<div className="flex justify-between items-center">
|
||||||
</h4>
|
<div className="flex items-center gap-4">
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
<div className="w-12 h-12 bg-slate-50 rounded-2xl flex items-center justify-center text-slate-900 shadow-sm">
|
||||||
{isWebApp ? (
|
<Cpu size={24} />
|
||||||
<>
|
</div>
|
||||||
<Checkbox
|
<h4 className="text-2xl font-bold text-slate-900">
|
||||||
label="Dashboard & Analytics" desc="Visualisierung von Daten und Kennzahlen."
|
{isWebApp ? 'Funktionale Anforderungen' : 'Erweiterte Funktionen'}
|
||||||
checked={state.functions.includes('dashboard')}
|
</h4>
|
||||||
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>
|
|
||||||
</div>
|
</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>
|
||||||
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,8 +2,10 @@
|
|||||||
|
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { FormState } from '../types';
|
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 { motion } from 'framer-motion';
|
||||||
|
import { RepeatableList } from '../components/RepeatableList';
|
||||||
|
import { Reveal } from '../../Reveal';
|
||||||
|
|
||||||
interface LanguageStepProps {
|
interface LanguageStepProps {
|
||||||
state: FormState;
|
state: FormState;
|
||||||
@@ -13,79 +15,86 @@ interface LanguageStepProps {
|
|||||||
export function LanguageStep({ state, updateState }: 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 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 (
|
return (
|
||||||
<div className="space-y-12">
|
<div className="space-y-12">
|
||||||
<div className="p-10 bg-white border border-slate-100 rounded-[3rem] space-y-8">
|
<Reveal width="100%" delay={0.1}>
|
||||||
<div className="flex items-center gap-6">
|
<div className="space-y-8">
|
||||||
<div className="w-16 h-16 bg-slate-50 rounded-2xl flex items-center justify-center text-slate-900">
|
<div className="flex justify-between items-center">
|
||||||
<Globe size={32} />
|
<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>
|
||||||
<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">
|
<div className="space-y-6">
|
||||||
<button
|
<div className="flex items-center gap-4">
|
||||||
type="button"
|
<div className="w-10 h-10 bg-slate-50 rounded-xl flex items-center justify-center text-slate-900 shadow-sm">
|
||||||
onClick={() => updateState({ languagesCount: Math.max(1, state.languagesCount - 1) })}
|
<ListPlus size={20} />
|
||||||
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"
|
</div>
|
||||||
>
|
<p className="text-lg font-bold text-slate-900">Welche Sprachen planen Sie?</p>
|
||||||
<Minus size={32} />
|
</div>
|
||||||
</button>
|
<div className="p-2">
|
||||||
<div className="flex flex-col items-center">
|
<RepeatableList
|
||||||
<span className="text-7xl font-bold text-slate-900">{state.languagesCount}</span>
|
items={state.languagesList}
|
||||||
<span className="text-sm font-bold uppercase tracking-widest text-slate-400 mt-3">
|
onAdd={(v) => updateState({ languagesList: [...state.languagesList, v] })}
|
||||||
{state.languagesCount === 1 ? 'Sprache' : 'Sprachen'}
|
onRemove={(i) => updateState({ languagesList: state.languagesList.filter((_, idx) => idx !== i) })}
|
||||||
</span>
|
placeholder="z.B. Englisch, Französisch, Spanisch..."
|
||||||
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
</div>
|
||||||
</motion.div>
|
</Reveal>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
<Reveal width="100%" delay={0.3}>
|
||||||
<div className="p-8 bg-white border border-slate-100 rounded-[2rem]">
|
<div className="p-10 bg-slate-900 text-white rounded-[3rem] space-y-8 shadow-2xl relative overflow-hidden">
|
||||||
<h5 className="text-lg font-bold text-slate-900 mb-3">Technische Basis</h5>
|
<div className="absolute top-0 right-0 w-64 h-64 bg-white/5 rounded-full -mr-32 -mt-32 blur-3xl" />
|
||||||
<p className="text-base text-slate-500 leading-relaxed">
|
<div className="flex items-center gap-4 text-slate-400 relative z-10">
|
||||||
Wir nutzen moderne i18n-Frameworks, die SEO-optimierte URLs für jede Sprache generieren (z.B. /en, /fr).
|
<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>
|
</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>
|
||||||
<div className="p-8 bg-white border border-slate-100 rounded-[2rem]">
|
</Reveal>
|
||||||
<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>
|
|
||||||
</div>
|
</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) {
|
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 (
|
return (
|
||||||
<div className="space-y-12">
|
<div className="space-y-12">
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
<div className="space-y-6">
|
||||||
{[
|
<div className="flex justify-between items-center">
|
||||||
{ id: 'asap', label: 'So schnell wie möglich', desc: 'Priorisierter Start gewünscht.' },
|
<h4 className="text-2xl font-bold text-slate-900">Zeitplan</h4>
|
||||||
{ id: '2-3-months', label: 'In 2-3 Monaten', desc: 'Normaler Projektvorlauf.' },
|
<button
|
||||||
{ 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"
|
type="button"
|
||||||
onClick={() => updateState({ deadline: opt.id })}
|
onClick={() => toggleDontKnow('timeline')}
|
||||||
className={`p-10 rounded-[2.5rem] border-2 text-left transition-all focus:outline-none overflow-hidden relative ${
|
className={`px-4 py-2 rounded-full text-sm font-bold transition-all ${
|
||||||
state.deadline === opt.id ? 'border-slate-900 bg-slate-900 text-white' : 'border-slate-100 bg-white hover:border-slate-200'
|
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>
|
Ich weiß es nicht
|
||||||
<p className={`text-lg ${state.deadline === opt.id ? 'text-slate-200' : 'text-slate-500'}`}>{opt.desc}</p>
|
|
||||||
</button>
|
</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>
|
</div>
|
||||||
{state.deadline === 'asap' && (
|
{state.deadline === 'asap' && (
|
||||||
<div className="p-8 bg-slate-50 rounded-[2rem] border border-slate-100 flex gap-6 items-start">
|
<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 * as React from 'react';
|
||||||
import { FormState, ProjectType } from '../types';
|
import { FormState, ProjectType } from '../types';
|
||||||
import { ConceptWebsite, ConceptSystem } from '../../Landing/ConceptIllustrations';
|
import { ConceptWebsite, ConceptSystem } from '../../Landing/ConceptIllustrations';
|
||||||
|
import { motion } from 'framer-motion';
|
||||||
|
import { Reveal } from '../../Reveal';
|
||||||
|
|
||||||
interface TypeStepProps {
|
interface TypeStepProps {
|
||||||
state: FormState;
|
state: FormState;
|
||||||
@@ -11,23 +13,33 @@ interface TypeStepProps {
|
|||||||
|
|
||||||
export function TypeStep({ state, updateState }: TypeStepProps) {
|
export function TypeStep({ state, updateState }: TypeStepProps) {
|
||||||
return (
|
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: '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-16 h-16 mb-4" /> },
|
{ id: 'web-app', label: 'Web App', desc: 'Internes Tool, Dashboard oder Prozess-Logik.', illustration: <ConceptSystem className="w-20 h-20 mb-6" /> },
|
||||||
].map((type) => (
|
].map((type, index) => (
|
||||||
<button
|
<Reveal key={type.id} width="100%" delay={index * 0.1}>
|
||||||
key={type.id}
|
<motion.button
|
||||||
type="button"
|
whileHover={{ y: -8, boxShadow: '0 25px 50px -12px rgb(0 0 0 / 0.15)' }}
|
||||||
onClick={() => updateState({ projectType: type.id as ProjectType })}
|
whileTap={{ scale: 0.98 }}
|
||||||
className={`p-10 rounded-[2.5rem] border-2 text-left transition-all duration-300 focus:outline-none overflow-hidden relative ${
|
type="button"
|
||||||
state.projectType === type.id ? 'border-slate-900 bg-slate-900 text-white' : 'border-slate-100 bg-white hover:border-slate-200'
|
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={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>
|
<div className={`transition-transform duration-500 group-hover:scale-110 ${state.projectType === type.id ? 'text-white' : 'text-slate-900'}`}>{type.illustration}</div>
|
||||||
</button>
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,18 +1,35 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
|
||||||
export type ProjectType = 'website' | 'web-app';
|
export type ProjectType = 'website' | 'web-app';
|
||||||
|
|
||||||
export interface FormState {
|
export interface FormState {
|
||||||
projectType: ProjectType;
|
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[];
|
selectedPages: string[];
|
||||||
otherPages: string[];
|
otherPages: string[];
|
||||||
|
otherPagesCount: number;
|
||||||
features: string[];
|
features: string[];
|
||||||
otherFeatures: string[];
|
otherFeatures: string[];
|
||||||
|
otherFeaturesCount: number;
|
||||||
functions: string[];
|
functions: string[];
|
||||||
otherFunctions: string[];
|
otherFunctions: string[];
|
||||||
|
otherFunctionsCount: number;
|
||||||
apiSystems: string[];
|
apiSystems: string[];
|
||||||
otherTech: string[];
|
otherTech: string[];
|
||||||
|
otherTechCount: number;
|
||||||
assets: string[];
|
assets: string[];
|
||||||
otherAssets: string[];
|
otherAssets: string[];
|
||||||
complexInteractions: number;
|
otherAssetsCount: number;
|
||||||
newDatasets: number;
|
newDatasets: number;
|
||||||
cmsSetup: boolean;
|
cmsSetup: boolean;
|
||||||
storageExpansion: number;
|
storageExpansion: number;
|
||||||
@@ -29,7 +46,7 @@ export interface FormState {
|
|||||||
designWishes: string;
|
designWishes: string;
|
||||||
// Maintenance
|
// Maintenance
|
||||||
expectedAdjustments: string;
|
expectedAdjustments: string;
|
||||||
languagesCount: number;
|
languagesList: string[];
|
||||||
// Timeline
|
// Timeline
|
||||||
deadline: string;
|
deadline: string;
|
||||||
// Web App specific
|
// Web App specific
|
||||||
@@ -37,6 +54,8 @@ export interface FormState {
|
|||||||
userRoles: string[];
|
userRoles: string[];
|
||||||
dataSensitivity: string;
|
dataSensitivity: string;
|
||||||
platformType: string;
|
platformType: string;
|
||||||
|
// Meta
|
||||||
|
dontKnows: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Step {
|
export interface Step {
|
||||||
|
|||||||
@@ -240,7 +240,18 @@ export const EstimationPDF = ({ state, totalPrice, monthlyPrice, totalPagesCount
|
|||||||
hr: 'HR / Recruiting',
|
hr: 'HR / Recruiting',
|
||||||
realestate: 'Immobilien',
|
realestate: 'Immobilien',
|
||||||
calendar: 'Termine / Booking',
|
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 = [];
|
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) {
|
if (state.complexInteractions > 0) {
|
||||||
positions.push({
|
positions.push({
|
||||||
pos: pos++,
|
pos: pos++,
|
||||||
title: 'Besondere Interaktionen',
|
title: 'Komplexe Interaktion',
|
||||||
desc: `Umsetzung von ${state.complexInteractions} komplexen UI-Animationen oder interaktiven Logik-Abschnitten.`,
|
desc: `Umsetzung von ${state.complexInteractions} Konfiguratoren, Live-Previews oder mehrstufigen Auswahlprozessen.`,
|
||||||
qty: state.complexInteractions,
|
qty: state.complexInteractions,
|
||||||
price: state.complexInteractions * pricing.COMPLEX_INTERACTION
|
price: state.complexInteractions * pricing.COMPLEX_INTERACTION
|
||||||
});
|
});
|
||||||
@@ -365,6 +386,7 @@ export const EstimationPDF = ({ state, totalPrice, monthlyPrice, totalPagesCount
|
|||||||
<View style={styles.recipientSection}>
|
<View style={styles.recipientSection}>
|
||||||
<Text style={styles.recipientLabel}>Ansprechpartner</Text>
|
<Text style={styles.recipientLabel}>Ansprechpartner</Text>
|
||||||
<Text style={styles.recipientName}>{state.name || 'Interessent'}</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>}
|
{state.role && <Text style={styles.recipientRole}>{state.role}</Text>}
|
||||||
<Text style={styles.recipientRole}>{state.email}</Text>
|
<Text style={styles.recipientRole}>{state.email}</Text>
|
||||||
</View>
|
</View>
|
||||||
@@ -441,6 +463,10 @@ export const EstimationPDF = ({ state, totalPrice, monthlyPrice, totalPagesCount
|
|||||||
<View style={styles.configGrid}>
|
<View style={styles.configGrid}>
|
||||||
{state.projectType === 'website' ? (
|
{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}>
|
<View style={styles.configItem}>
|
||||||
<Text style={styles.configLabel}>Design-Vibe</Text>
|
<Text style={styles.configLabel}>Design-Vibe</Text>
|
||||||
<Text style={styles.configValue}>{vibeLabels[state.designVibe] || state.designVibe}</Text>
|
<Text style={styles.configValue}>{vibeLabels[state.designVibe] || state.designVibe}</Text>
|
||||||
@@ -470,6 +496,22 @@ export const EstimationPDF = ({ state, totalPrice, monthlyPrice, totalPagesCount
|
|||||||
</View>
|
</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}>
|
<View style={styles.configItem}>
|
||||||
<Text style={styles.configLabel}>Zeitplan</Text>
|
<Text style={styles.configLabel}>Zeitplan</Text>
|
||||||
<Text style={styles.configValue}>{deadlineLabels[state.deadline] || state.deadline}</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}>
|
<View style={styles.configItem}>
|
||||||
<Text style={styles.configLabel}>Sprachen</Text>
|
<Text style={styles.configLabel}>Sprachen</Text>
|
||||||
<Text style={styles.configValue}>{state.languagesCount}</Text>
|
<Text style={styles.configValue}>{state.languagesCount} ({state.languagesList.join(', ')})</Text>
|
||||||
</View>
|
</View>
|
||||||
{state.projectType === 'website' && (
|
{state.projectType === 'website' && (
|
||||||
<>
|
<>
|
||||||
@@ -505,6 +547,17 @@ export const EstimationPDF = ({ state, totalPrice, monthlyPrice, totalPagesCount
|
|||||||
)}
|
)}
|
||||||
</View>
|
</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 && (
|
{state.designWishes && (
|
||||||
<View style={{ marginTop: 15 }}>
|
<View style={{ marginTop: 15 }}>
|
||||||
<Text style={styles.configLabel}>Design-Vorstellungen</Text>
|
<Text style={styles.configLabel}>Design-Vorstellungen</Text>
|
||||||
|
|||||||
Reference in New Issue
Block a user