form
Some checks failed
Build & Deploy Mintel Blog / build-and-deploy (push) Failing after 58s

This commit is contained in:
2026-01-30 17:48:13 +01:00
parent 1f57bae339
commit 8c1a7f6b5a
23 changed files with 1709 additions and 651 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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 {

View File

@@ -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" />

View File

@@ -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>
); );

View File

@@ -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>

View File

@@ -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' },
]; ];

View File

@@ -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}>
<div className="space-y-8">
<div className="flex justify-between items-center">
<div className="flex items-center gap-4">
<div className="w-12 h-12 bg-slate-50 rounded-2xl flex items-center justify-center text-slate-900 shadow-sm">
<Share2 size={24} />
</div>
<h4 className="text-2xl font-bold text-slate-900"> <h4 className="text-2xl font-bold text-slate-900">
{isWebApp ? 'Integrationen & Datenquellen' : 'Schnittstellen (API)'} {isWebApp ? 'Integrationen & Datenquellen' : 'Schnittstellen (API)'}
</h4> </h4>
<p className="text-lg text-slate-500 leading-relaxed"> </div>
<motion.button
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
type="button"
onClick={() => toggleDontKnow('api')}
className={`px-6 py-3 rounded-full text-sm font-bold transition-all shadow-sm ${
state.dontKnows?.includes('api') ? 'bg-slate-900 text-white' : 'bg-slate-100 text-slate-500 hover:bg-slate-200'
}`}
>
Ich weiß es nicht
</motion.button>
</div>
<p className="text-lg text-slate-500 leading-relaxed ml-2">
{isWebApp {isWebApp
? 'Mit welchen Systemen soll die Web App kommunizieren?' ? 'Mit welchen Systemen soll die Web App kommunizieren?'
: 'Datenaustausch mit Drittsystemen zur Automatisierung.'} : 'Datenaustausch mit Drittsystemen zur Automatisierung.'}
</p> </p>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6"> <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 <Checkbox
label="CRM / ERP" desc="HubSpot, Salesforce, SAP, Xentral etc." label={opt.label} desc={opt.desc}
checked={state.apiSystems.includes('crm_erp')} checked={state.apiSystems.includes(opt.id)}
onChange={() => updateState({ apiSystems: toggleItem(state.apiSystems, 'crm_erp') })} onChange={() => updateState({ apiSystems: toggleItem(state.apiSystems, opt.id) })}
/>
<Checkbox
label="Payment" desc="Stripe, PayPal, Klarna Integration."
checked={state.apiSystems.includes('payment')}
onChange={() => updateState({ apiSystems: toggleItem(state.apiSystems, 'payment') })}
/>
<Checkbox
label="Marketing" desc="Newsletter (Mailchimp), Social Media Sync."
checked={state.apiSystems.includes('marketing')}
onChange={() => updateState({ apiSystems: toggleItem(state.apiSystems, 'marketing') })}
/>
<Checkbox
label="E-Commerce" desc="Shopify, WooCommerce, Lagerbestand-Sync."
checked={state.apiSystems.includes('ecommerce')}
onChange={() => updateState({ apiSystems: toggleItem(state.apiSystems, 'ecommerce') })}
/> />
</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">
<div className="space-y-8">
<div className="flex items-center gap-4">
<div className="w-12 h-12 bg-slate-50 rounded-2xl flex items-center justify-center text-slate-900 shadow-sm">
<ListPlus size={24} />
</div>
<h4 className="text-2xl font-bold text-slate-900">Weitere Systeme oder eigene APIs?</h4>
</div>
<RepeatableList <RepeatableList
items={state.otherTech} items={state.otherTech}
onAdd={(v) => updateState({ otherTech: [...state.otherTech, v] })} onAdd={(v) => updateState({ otherTech: [...state.otherTech, v] })}
onRemove={(i) => updateTech(i)} onRemove={(i) => updateTech(i)}
placeholder="z.B. Microsoft Graph, Google Maps, Custom REST API..." placeholder="z.B. Microsoft Graph, Google Search Console, Custom REST API..."
/> />
</div> </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>
); );

View File

@@ -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,19 +16,66 @@ 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">
<Reveal width="100%" delay={0.1}>
<div className="space-y-8">
<div className="flex justify-between items-center">
<div className="flex items-center gap-4">
<div className="w-12 h-12 bg-slate-50 rounded-2xl flex items-center justify-center text-slate-900 shadow-sm">
<Briefcase size={24} />
</div>
<h4 className="text-2xl font-bold text-slate-900">Vorhandene Assets</h4>
</div>
<motion.button
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
type="button"
onClick={() => toggleDontKnow('assets')}
className={`px-6 py-3 rounded-full text-sm font-bold transition-all shadow-sm ${
state.dontKnows?.includes('assets') ? 'bg-slate-900 text-white' : 'bg-slate-100 text-slate-500 hover:bg-slate-200'
}`}
>
Ich weiß es nicht
</motion.button>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6"> <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{ASSET_OPTIONS.map(opt => ( {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 <Checkbox
key={opt.id} label={opt.label} desc={opt.desc} key={opt.id} label={opt.label} desc={opt.desc}
checked={state.assets.includes(opt.id)} checked={state.assets.includes(opt.id)}
onChange={() => updateState({ assets: toggleItem(state.assets, opt.id) })} onChange={() => updateState({ assets: toggleItem(state.assets, opt.id) })}
/> />
</motion.div>
))} ))}
</div> </div>
<div className="space-y-6"> </div>
<p className="text-lg font-bold text-slate-900">Weitere vorhandene Unterlagen?</p> </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 <RepeatableList
items={state.otherAssets} items={state.otherAssets}
onAdd={(v) => updateState({ otherAssets: [...state.otherAssets, v] })} onAdd={(v) => updateState({ otherAssets: [...state.otherAssets, v] })}
@@ -33,6 +83,53 @@ export function AssetsStep({ state, updateState, toggleItem }: AssetsStepProps)
placeholder="z.B. Lastenheft, Wireframes, Bilddatenbank..." placeholder="z.B. Lastenheft, Wireframes, Bilddatenbank..."
/> />
</div> </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>
); );
} }

View File

@@ -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,8 +14,52 @@ 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">
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
className="space-y-8"
>
<h4 className="text-2xl font-bold text-slate-900">Thema der Website</h4>
<input
type="text"
placeholder="z.B. Portfolio für Architektur, Onlineshop für Bio-Tee..."
value={state.websiteTopic}
onChange={(e) => updateState({ websiteTopic: e.target.value })}
className="w-full p-10 bg-white border border-slate-100 rounded-[3rem] focus:outline-none focus:border-slate-900 transition-all duration-500 text-xl shadow-sm focus:shadow-2xl"
/>
</motion.div>
<div className="space-y-8">
<div className="flex justify-between items-center">
<div className="flex items-center gap-4">
<div className="w-12 h-12 bg-slate-50 rounded-2xl flex items-center justify-center text-slate-900 shadow-sm">
<FileText size={24} />
</div>
<h4 className="text-2xl font-bold text-slate-900">Die Seiten</h4>
</div>
<motion.button
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
type="button"
onClick={() => toggleDontKnow('pages')}
className={`px-6 py-3 rounded-full text-sm font-bold transition-all shadow-sm ${
state.dontKnows?.includes('pages') ? 'bg-slate-900 text-white' : 'bg-slate-100 text-slate-500 hover:bg-slate-200'
}`}
>
Ich weiß es nicht
</motion.button>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6"> <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{[ {[
{ id: 'Home', label: 'Startseite', desc: 'Der erste Eindruck Ihrer Marke.' }, { id: 'Home', label: 'Startseite', desc: 'Der erste Eindruck Ihrer Marke.' },
@@ -22,16 +68,31 @@ export function BaseStep({ state, updateState, toggleItem }: BaseStepProps) {
{ id: 'Contact', label: 'Kontakt', desc: 'Anlaufstelle für Ihre Kunden.' }, { id: 'Contact', label: 'Kontakt', desc: 'Anlaufstelle für Ihre Kunden.' },
{ id: 'Landing', label: 'Landingpage', desc: 'Optimiert für Marketing-Kampagnen.' }, { id: 'Landing', label: 'Landingpage', desc: 'Optimiert für Marketing-Kampagnen.' },
{ id: 'Legal', label: 'Rechtliches', desc: 'Impressum & Datenschutz.' }, { id: 'Legal', label: 'Rechtliches', desc: 'Impressum & Datenschutz.' },
].map(opt => ( ].map((opt, index) => (
<motion.div
key={opt.id}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: index * 0.05 }}
>
<Checkbox <Checkbox
key={opt.id} label={opt.label} desc={opt.desc} label={opt.label} desc={opt.desc}
checked={state.selectedPages.includes(opt.id)} checked={state.selectedPages.includes(opt.id)}
onChange={() => updateState({ selectedPages: toggleItem(state.selectedPages, opt.id) })} onChange={() => updateState({ selectedPages: toggleItem(state.selectedPages, opt.id) })}
/> />
</motion.div>
))} ))}
</div> </div>
<div className="space-y-6"> </div>
<p className="text-lg font-bold text-slate-900">Weitere individuelle Seiten?</p>
<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 <RepeatableList
items={state.otherPages} items={state.otherPages}
onAdd={(v) => updateState({ otherPages: [...state.otherPages, v] })} onAdd={(v) => updateState({ otherPages: [...state.otherPages, v] })}
@@ -39,6 +100,52 @@ export function BaseStep({ state, updateState, toggleItem }: BaseStepProps) {
placeholder="z.B. Karriere, FAQ, Team-Detail..." placeholder="z.B. Karriere, FAQ, Team-Detail..."
/> />
</div> </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>
); );
} }

View 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>
);
}

View File

@@ -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,45 +13,89 @@ 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> </div>
<input <input
type="text" type="text"
placeholder="Ihre Rolle (z.B. CEO, Marketing Manager...)" placeholder="Max Mustermann"
required
value={state.name}
onChange={(e) => updateState({ name: e.target.value })}
className="w-full p-8 pl-16 bg-white border border-slate-100 rounded-[2.5rem] focus:outline-none focus:border-slate-900 transition-all duration-500 text-xl shadow-sm focus:shadow-2xl"
/>
</div>
</div>
</Reveal>
<Reveal width="100%" delay={0.1}>
<div className="space-y-4">
<label className="block text-sm font-bold uppercase tracking-widest text-slate-400 ml-2">Ihre Email</label>
<div className="relative group">
<div className="absolute left-6 top-1/2 -translate-y-1/2 text-slate-300 group-focus-within:text-slate-900 transition-colors">
<Mail size={24} />
</div>
<input
type="email"
placeholder="max@beispiel.de"
required
value={state.email}
onChange={(e) => updateState({ email: e.target.value })}
className="w-full p-8 pl-16 bg-white border border-slate-100 rounded-[2.5rem] focus:outline-none focus:border-slate-900 transition-all duration-500 text-xl shadow-sm focus:shadow-2xl"
/>
</div>
</div>
</Reveal>
</div>
<Reveal width="100%" delay={0.2}>
<div className="space-y-4">
<label className="block text-sm font-bold uppercase tracking-widest text-slate-400 ml-2">Ihre Rolle</label>
<div className="relative group">
<div className="absolute left-6 top-1/2 -translate-y-1/2 text-slate-300 group-focus-within:text-slate-900 transition-colors">
<Briefcase size={24} />
</div>
<input
type="text"
placeholder="z.B. CEO, Marketing Manager..."
value={state.role} value={state.role}
onChange={(e) => updateState({ role: e.target.value })} 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" 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 <textarea
placeholder="Erzählen Sie mir kurz von Ihrem Projekt..." placeholder="Erzählen Sie mir kurz von Ihrem Projekt..."
value={state.message} value={state.message}
onChange={(e) => updateState({ message: e.target.value })} onChange={(e) => updateState({ message: e.target.value })}
rows={5} 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" 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"> <div className="space-y-6">
<p className="text-lg font-bold text-slate-900">Dateien hochladen (optional)</p> <p className="text-lg font-bold text-slate-900 ml-2">Dateien hochladen (optional)</p>
<div <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] ${ 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' : 'border-slate-200 hover:border-slate-400 bg-white' 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(); }} onDragOver={(e) => { e.preventDefault(); e.stopPropagation(); }}
onDrop={(e) => { onDrop={(e) => {
@@ -65,39 +111,68 @@ export function ContactStep({ state, updateState }: ContactStepProps) {
if (files.length > 0) updateState({ contactFiles: [...state.contactFiles, ...files] }); if (files.length > 0) updateState({ contactFiles: [...state.contactFiles, ...files] });
}} /> }} />
<AnimatePresence mode="wait">
{state.contactFiles.length > 0 ? ( {state.contactFiles.length > 0 ? (
<div className="w-full space-y-3"> <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) => ( {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"> <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="flex items-center gap-4 text-slate-900">
<FileText size={24} className="text-slate-400" /> <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">
<span className="font-bold text-base truncate max-w-[250px]">{file.name}</span> <FileText size={20} />
</div> </div>
<button <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" type="button"
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
updateState({ contactFiles: state.contactFiles.filter((_, i) => i !== idx) }); updateState({ contactFiles: state.contactFiles.filter((_, i) => i !== idx) });
}} }}
className="p-2 hover:bg-slate-100 rounded-full transition-colors focus:outline-none" className="p-2 bg-slate-50 text-slate-400 rounded-full transition-colors focus:outline-none"
> >
<X size={20} /> <X size={20} />
</button> </motion.button>
</div> </motion.div>
))} ))}
<p className="text-xs text-slate-400 text-center mt-6">Klicken oder ziehen, um weitere Dateien hinzuzufügen</p> <p className="text-xs text-slate-400 text-center mt-8 font-medium">Klicken oder ziehen, um weitere Dateien hinzuzufügen</p>
</div> </motion.div>
) : ( ) : (
<> <motion.div
<Upload size={48} className="text-slate-400 group-hover:text-slate-900 transition-colors" /> 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"> <div className="text-center">
<p className="text-lg font-bold text-slate-900">Dateien hierher ziehen</p> <p className="text-xl font-bold text-slate-900">Dateien hierher ziehen</p>
<p className="text-base text-slate-500 mt-1">oder klicken zum Auswählen</p> <p className="text-lg text-slate-500 mt-1">oder klicken zum Auswählen</p>
</div> </div>
</> </motion.div>
)} )}
</AnimatePresence>
</div> </div>
</div> </div>
</Reveal>
</div> </div>
); );
} }

View File

@@ -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) {
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="flex items-center justify-between p-10 bg-white border border-slate-100 rounded-[3rem]"> <Reveal width="100%" delay={0.1}>
<div className="max-w-[70%]"> <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">
<h4 className="text-2xl font-bold text-slate-900">Inhalte selbst verwalten (CMS)</h4> <div className="max-w-2xl space-y-4">
<p className="text-lg text-slate-500 mt-2">Möchten Sie Datensätze (z.B. Blogartikel, Produkte) selbst über eine einfache Oberfläche pflegen?</p> <div className="flex items-center gap-4">
<div className="w-12 h-12 bg-slate-50 rounded-2xl flex items-center justify-center text-slate-900 shadow-inner">
<Settings2 size={24} />
</div> </div>
<h4 className="text-2xl font-bold text-slate-900">Inhalte selbst verwalten (CMS)</h4>
</div>
<p className="text-lg text-slate-500 leading-relaxed">Möchten Sie Datensätze (z.B. Blogartikel, Produkte) selbst über eine einfache Oberfläche pflegen?</p>
</div>
<div className="flex flex-col items-center md:items-end gap-6">
<motion.button
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
type="button"
onClick={() => toggleDontKnow('cms')}
className={`px-6 py-3 rounded-full text-sm font-bold transition-all shadow-sm ${
state.dontKnows?.includes('cms') ? 'bg-slate-900 text-white' : 'bg-slate-100 text-slate-500 hover:bg-slate-200'
}`}
>
Ich weiß es nicht
</motion.button>
<button <button
type="button" type="button"
onClick={() => updateState({ cmsSetup: !state.cmsSetup })} 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'}`} 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'}`}
> >
<div className={`absolute top-1.5 left-1.5 w-8 h-8 bg-white rounded-full transition-transform ${state.cmsSetup ? 'translate-x-9' : ''}`} /> <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>
</div>
</Reveal>
<Reveal width="100%" delay={0.2}>
<div className="p-10 bg-slate-50 rounded-[3rem] border border-slate-100 space-y-10">
<div className="flex items-center gap-4">
<div className="w-12 h-12 bg-white rounded-2xl flex items-center justify-center text-slate-900 shadow-sm">
<BarChart3 size={24} />
</div>
<p className="text-xl font-bold text-slate-900">Wie oft ändern sich Ihre Inhalte?</p>
</div>
<div className="p-10 bg-slate-50 rounded-[3rem] border border-slate-100 space-y-6">
<p className="text-lg font-bold text-slate-900">Wie oft ändern sich Ihre Inhalte?</p>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6"> <div className="grid grid-cols-1 md:grid-cols-3 gap-6">
{[ {[
{ id: 'low', label: 'Selten', desc: 'Wenige Male im Jahr.' }, { id: 'low', label: 'Selten', desc: 'Wenige Male im Jahr.' },
{ id: 'medium', label: 'Regelmäßig', desc: 'Monatliche Updates.' }, { id: 'medium', label: 'Regelmäßig', desc: 'Monatliche Updates.' },
{ id: 'high', label: 'Häufig', desc: 'Wöchentlich oder täglich.' }, { id: 'high', label: 'Häufig', desc: 'Wöchentlich oder täglich.' },
].map(opt => ( ].map((opt, index) => (
<button <motion.button
key={opt.id} key={opt.id}
whileHover={{ y: -5, boxShadow: '0 10px 15px -3px rgb(0 0 0 / 0.1)' }}
whileTap={{ scale: 0.98 }}
type="button" type="button"
onClick={() => updateState({ expectedAdjustments: opt.id })} onClick={() => updateState({ expectedAdjustments: opt.id })}
className={`p-6 rounded-2xl border-2 text-left transition-all focus:outline-none ${ 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' : 'border-slate-200 bg-white hover:border-slate-400' 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">{opt.label}</p> <p className={`font-bold text-lg ${state.expectedAdjustments === opt.id ? 'text-white' : 'text-slate-900'}`}>{opt.label}</p>
<p className={`text-sm mt-1 ${state.expectedAdjustments === opt.id ? 'text-slate-200' : 'text-slate-500'}`}>{opt.desc}</p> <p className={`text-sm mt-2 leading-relaxed ${state.expectedAdjustments === opt.id ? 'text-slate-200' : 'text-slate-500'}`}>{opt.desc}</p>
</button> </motion.button>
))} ))}
</div> </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"> <AnimatePresence>
<div className="flex items-center gap-3 text-slate-900 font-bold text-sm uppercase tracking-wider"> {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 <Zap size={18} /> Vorteil CMS
</div> </div>
<p className="text-sm text-slate-500 leading-relaxed"> <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. Volle Kontrolle über Ihre Inhalte und keine laufenden Kosten für kleine Textänderungen oder neue Blog-Beiträge.
</p> </p>
</div> </div>
<div className="p-8 bg-white rounded-[2rem] border border-slate-100 space-y-3"> <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-wider"> <div className="flex items-center gap-3 text-slate-900 font-bold text-sm uppercase tracking-[0.2em]">
<AlertCircle size={18} /> Fokus Design <AlertCircle size={18} /> Fokus Design
</div> </div>
<p className="text-sm text-slate-500 leading-relaxed"> <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. Ohne CMS bleibt die technische Komplexität geringer und das Design ist maximal geschützt vor ungewollten Änderungen.
</p> </p>
</div> </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.3}>
<div> <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> <h4 className="text-2xl font-bold text-slate-900">Inhalte einpflegen</h4>
<p className="text-lg text-slate-500 mt-2 leading-relaxed">Für wie viele Datensätze soll ich die initiale Befüllung übernehmen?</p> <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>
<div className="flex items-center gap-12 mt-2"> <div className="flex items-center gap-12 py-2">
<button type="button" onClick={() => updateState({ newDatasets: Math.max(0, state.newDatasets - 1) })} className="w-16 h-16 rounded-full bg-slate-50 border border-slate-100 flex items-center justify-center hover:border-slate-900 transition-colors focus:outline-none"><Minus size={28} /></button> <motion.button
<span className="text-5xl font-bold w-16 text-center">{state.newDatasets}</span> whileHover={{ scale: 1.1 }}
<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> 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>
); );
} }

View File

@@ -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,50 +13,214 @@ interface DesignStepProps {
} }
export function DesignStep({ state, updateState }: DesignStepProps) { export function DesignStep({ state, updateState }: DesignStepProps) {
const addColor = () => {
if (state.colorScheme.length < 5) {
updateState({ colorScheme: [...state.colorScheme, '#000000'] });
}
};
const removeColor = (index: number) => {
if (state.colorScheme.length > 1) {
const newScheme = [...state.colorScheme];
newScheme.splice(index, 1);
updateState({ colorScheme: newScheme });
}
};
const updateColor = (index: number, value: string) => {
const newScheme = [...state.colorScheme];
newScheme[index] = value;
updateState({ colorScheme: newScheme });
};
const toggleDontKnow = (id: string) => {
const current = state.dontKnows || [];
if (current.includes(id)) {
updateState({ dontKnows: current.filter(i => i !== id) });
} else {
updateState({ dontKnows: [...current, id] });
}
};
const generateHarmonicPalette = () => {
const hue = Math.floor(Math.random() * 360);
const saturation = 40 + Math.floor(Math.random() * 40);
const lightness = 40 + Math.floor(Math.random() * 40);
const hslToHex = (h: number, s: number, l: number) => {
l /= 100;
const a = s * Math.min(l, 1 - l) / 100;
const f = (n: number) => {
const k = (n + h / 30) % 12;
const color = l - a * Math.max(Math.min(k - 3, 9 - k, 1), -1);
return Math.round(255 * color).toString(16).padStart(2, '0');
};
return `#${f(0)}${f(8)}${f(4)}`;
};
const palette = [
hslToHex(hue, saturation, 95), // Light
hslToHex(hue, saturation, lightness), // Main
hslToHex((hue + 30) % 360, saturation, lightness - 10), // Analogous
hslToHex((hue + 180) % 360, saturation - 10, 20), // Complementary Dark
];
updateState({ colorScheme: palette });
};
return ( return (
<div className="space-y-12"> <div className="space-y-16">
<div className="space-y-6"> {/* 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> <h4 className="text-2xl font-bold text-slate-900">Design-Richtung</h4>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6"> <p className="text-slate-500">Welche Ästhetik passt zu Ihrer Marke?</p>
{DESIGN_VIBES.map((vibe) => ( </div>
<button <motion.button
key={vibe.id} whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
type="button" type="button"
onClick={() => updateState({ designVibe: vibe.id })} onClick={() => toggleDontKnow('design_vibe')}
className={`p-8 rounded-[2rem] border-2 text-left transition-all duration-300 focus:outline-none overflow-hidden relative ${ className={`px-6 py-3 rounded-full text-sm font-bold transition-all shadow-sm ${
state.designVibe === vibe.id ? 'border-slate-900 bg-slate-900 text-white' : '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={`w-16 h-10 mb-4 ${state.designVibe === vibe.id ? 'text-white' : 'text-slate-900'}`}>{vibe.illustration}</div> Ich weiß es nicht
<h4 className={`text-xl font-bold mb-2 ${state.designVibe === vibe.id ? 'text-white' : 'text-slate-900'}`}>{vibe.label}</h4> </motion.button>
<p className={`text-base leading-relaxed ${state.designVibe === vibe.id ? 'text-slate-200' : 'text-slate-500'}`}>{vibe.desc}</p> </div>
</button> <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>
</div> </div>
</Reveal>
<div className="space-y-6"> {/* 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> <h4 className="text-2xl font-bold text-slate-900">Farbschema</h4>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4"> <p className="text-slate-500">Generieren Sie eine harmonische Palette oder definieren Sie eigene Farben.</p>
{HARMONIOUS_PALETTES.map((palette, i) => ( </div>
<button <motion.button
key={i} whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
type="button" type="button"
onClick={() => updateState({ colorScheme: palette })} onClick={() => toggleDontKnow('color_scheme')}
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('color_scheme') ? '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>
{/* 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>
{/* Custom Picker */}
<div className="space-y-8 p-10 bg-slate-50 rounded-[3rem] border border-slate-100">
<div className="flex items-center gap-3 text-slate-400 font-bold uppercase tracking-widest text-xs">
<Pipette size={16} />
Individuelle Farben
</div>
<div className="flex flex-wrap gap-6">
<AnimatePresence mode="popLayout">
{state.colorScheme.map((color, i) => (
<motion.div
key={`${i}-${color}`}
layout
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.8 }}
className="relative group"
>
<div className="relative">
<input
type="color"
value={color}
onChange={(e) => updateColor(i, e.target.value)}
className="w-24 h-24 rounded-3xl cursor-pointer border-4 border-white shadow-lg overflow-hidden transition-transform duration-300 group-hover:scale-105"
/>
<div className="absolute inset-0 rounded-3xl pointer-events-none border border-black/5" />
</div>
<div className="mt-2 text-center font-mono text-[10px] text-slate-400 uppercase">{color}</div>
{state.colorScheme.length > 1 && (
<motion.button
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.9 }}
type="button"
onClick={() => removeColor(i)}
className="absolute -top-3 -right-3 w-8 h-8 bg-white text-red-500 rounded-full flex items-center justify-center shadow-xl border border-slate-100 opacity-0 group-hover:opacity-100 transition-all duration-300 z-10"
>
<X size={16} strokeWidth={3} />
</motion.button>
)}
</motion.div>
))}
</AnimatePresence>
{state.colorScheme.length < 5 && (
<motion.button
layout
whileHover={{ scale: 1.05, borderColor: '#0f172a', color: '#0f172a' }}
whileTap={{ scale: 0.95 }}
type="button"
onClick={addColor}
className="w-24 h-24 rounded-3xl border-2 border-dashed border-slate-300 flex flex-col items-center justify-center text-slate-400 transition-all duration-300 bg-white/50 hover:bg-white"
>
<Plus size={32} />
<span className="text-[10px] font-bold uppercase mt-1">Add</span>
</motion.button>
)}
</div>
<p className="text-sm text-slate-400 font-medium">Klicken Sie auf eine Farbe, um sie anzupassen. Sie können bis zu 5 Farben definieren.</p>
</div>
</div>
</Reveal>
{/* Wishes */}
<Reveal width="100%" delay={0.3}>
<div className="space-y-6"> <div className="space-y-6">
<h4 className="text-2xl font-bold text-slate-900">Individuelle Wünsche</h4> <h4 className="text-2xl font-bold text-slate-900">Individuelle Wünsche</h4>
<textarea <textarea
@@ -62,9 +228,10 @@ export function DesignStep({ state, updateState }: DesignStepProps) {
value={state.designWishes} value={state.designWishes}
onChange={(e) => updateState({ designWishes: e.target.value })} onChange={(e) => updateState({ designWishes: e.target.value })}
rows={4} rows={4}
className="w-full p-8 bg-white border border-slate-100 rounded-[2rem] focus:outline-none focus:border-slate-900 transition-colors resize-none text-lg" 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> </div>
</Reveal>
</div> </div>
); );
} }

View File

@@ -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,19 +15,63 @@ 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="space-y-8">
<div className="flex justify-between items-center">
<div className="flex items-center gap-4">
<div className="w-12 h-12 bg-slate-50 rounded-2xl flex items-center justify-center text-slate-900 shadow-sm">
<LayoutGrid size={24} />
</div>
<h4 className="text-2xl font-bold text-slate-900">System-Module</h4>
</div>
<motion.button
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
type="button"
onClick={() => toggleDontKnow('features')}
className={`px-6 py-3 rounded-full text-sm font-bold transition-all shadow-sm ${
state.dontKnows?.includes('features') ? 'bg-slate-900 text-white' : 'bg-slate-100 text-slate-500 hover:bg-slate-200'
}`}
>
Ich weiß es nicht
</motion.button>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6"> <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{FEATURE_OPTIONS.map(opt => ( {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 <Checkbox
key={opt.id} label={opt.label} desc={opt.desc} label={opt.label} desc={opt.desc}
checked={state.features.includes(opt.id)} checked={state.features.includes(opt.id)}
onChange={() => updateState({ features: toggleItem(state.features, opt.id) })} onChange={() => updateState({ features: toggleItem(state.features, opt.id) })}
/> />
</motion.div>
))} ))}
</div> </div>
<div className="space-y-6"> </div>
<p className="text-lg font-bold text-slate-900">Weitere inhaltliche Module?</p>
<div className="space-y-12">
<div className="space-y-8">
<div className="flex items-center gap-4">
<div className="w-12 h-12 bg-slate-50 rounded-2xl flex items-center justify-center text-slate-900 shadow-sm">
<ListPlus size={24} />
</div>
<h4 className="text-2xl font-bold text-slate-900">Weitere inhaltliche Module?</h4>
</div>
<RepeatableList <RepeatableList
items={state.otherFeatures} items={state.otherFeatures}
onAdd={(v) => updateState({ otherFeatures: [...state.otherFeatures, v] })} onAdd={(v) => updateState({ otherFeatures: [...state.otherFeatures, v] })}
@@ -33,6 +79,52 @@ export function FeaturesStep({ state, updateState, toggleItem }: FeaturesStepPro
placeholder="z.B. Glossar, Download-Center, Partner-Bereich..." placeholder="z.B. Glossar, Download-Center, Partner-Bereich..."
/> />
</div> </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>
); );
} }

View File

@@ -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,12 +17,40 @@ 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}>
<div className="space-y-8">
<div className="flex justify-between items-center">
<div className="flex items-center gap-4">
<div className="w-12 h-12 bg-slate-50 rounded-2xl flex items-center justify-center text-slate-900 shadow-sm">
<Cpu size={24} />
</div>
<h4 className="text-2xl font-bold text-slate-900"> <h4 className="text-2xl font-bold text-slate-900">
{isWebApp ? 'Funktionale Anforderungen' : 'Erweiterte Funktionen'} {isWebApp ? 'Funktionale Anforderungen' : 'Erweiterte Funktionen'}
</h4> </h4>
</div>
<motion.button
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
type="button"
onClick={() => toggleDontKnow('functions')}
className={`px-6 py-3 rounded-full text-sm font-bold transition-all shadow-sm ${
state.dontKnows?.includes('functions') ? 'bg-slate-900 text-white' : 'bg-slate-100 text-slate-500 hover:bg-slate-200'
}`}
>
Ich weiß es nicht
</motion.button>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6"> <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{isWebApp ? ( {isWebApp ? (
<> <>
@@ -71,32 +101,71 @@ export function FunctionsStep({ state, updateState, toggleItem }: FunctionsStepP
)} )}
</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 spezifische Wünsche?</p> <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 <RepeatableList
items={state.otherFunctions} items={state.otherFunctions}
onAdd={(v) => updateState({ otherFunctions: [...state.otherFunctions, v] })} onAdd={(v) => updateState({ otherFunctions: [...state.otherFunctions, v] })}
onRemove={(i) => updateState({ otherFunctions: state.otherFunctions.filter((_, idx) => idx !== i) })} 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..."} placeholder={isWebApp ? "z.B. Echtzeit-Chat, KI-Anbindung, Offline-Modus..." : "z.B. Mitgliederbereich, Event-Kalender, geschützte Downloads..."}
/> />
</div> </div>
{!isWebApp && ( <motion.div
<div className="p-10 bg-white border border-slate-100 rounded-[3rem] space-y-6"> initial={{ opacity: 0 }}
<div className="flex justify-between items-start"> 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> <div>
<h4 className="text-2xl font-bold text-slate-900">Besondere Interaktionen</h4> <h4 className="text-xl font-bold text-slate-900">Anzahl weiterer Funktionen</h4>
<p className="text-lg text-slate-500 mt-2">Aufwendige Animationen oder komplexe UI-Logik pro Abschnitt.</p> <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>
<div className="flex items-center gap-8"> <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> <motion.button
<span className="text-4xl font-bold w-12 text-center">{state.complexInteractions}</span> whileHover={{ scale: 1.1 }}
<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> 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>
</div> </div>
</motion.div>
</div> </div>
)} </Reveal>
</div> </div>
); );
} }

View File

@@ -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> </div>
<div> <h4 className="text-2xl font-bold text-slate-900">Sprachen</h4>
<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>
<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 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"
>
<Minus size={32} />
</button>
<div className="flex flex-col items-center">
<span className="text-7xl font-bold text-slate-900">{state.languagesCount}</span>
<span className="text-sm font-bold uppercase tracking-widest text-slate-400 mt-3">
{state.languagesCount === 1 ? 'Sprache' : 'Sprachen'}
</span>
</div> </div>
<button <p className="text-lg font-bold text-slate-900">Welche Sprachen planen Sie?</p>
type="button" </div>
onClick={() => updateState({ languagesCount: state.languagesCount + 1 })} <div className="p-2">
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" <RepeatableList
> items={state.languagesList}
<Plus size={32} /> onAdd={(v) => updateState({ languagesList: [...state.languagesList, v] })}
</button> onRemove={(i) => updateState({ languagesList: state.languagesList.filter((_, idx) => idx !== i) })}
placeholder="z.B. Englisch, Französisch, Spanisch..."
/>
</div> </div>
</div> </div>
</div>
</Reveal>
<motion.div <Reveal width="100%" delay={0.3}>
initial={{ opacity: 0, y: 10 }} <div className="p-10 bg-slate-900 text-white rounded-[3rem] space-y-8 shadow-2xl relative overflow-hidden">
animate={{ opacity: 1, y: 0 }} <div className="absolute top-0 right-0 w-64 h-64 bg-white/5 rounded-full -mr-32 -mt-32 blur-3xl" />
className="p-10 bg-slate-900 text-white rounded-[3rem] space-y-6" <div className="flex items-center gap-4 text-slate-400 relative z-10">
>
<div className="flex items-center gap-4 text-slate-400">
<Info size={24} /> <Info size={24} />
<span className="text-sm font-bold uppercase tracking-widest">Warum dieser Faktor?</span> <span className="text-sm font-bold uppercase tracking-widest">Warum dieser Faktor?</span>
</div> </div>
<p className="text-lg leading-relaxed text-slate-300"> <p className="text-lg leading-relaxed text-slate-300 relative z-10 max-w-3xl">
{basePriceExplanation} {basePriceExplanation}
</p> </p>
{state.languagesCount > 1 && ( {languagesCount > 1 && (
<div className="pt-6 border-t border-white/10"> <div className="pt-8 border-t border-white/10 relative z-10">
<div className="flex justify-between items-center"> <div className="flex justify-between items-center">
<span className="text-lg font-medium">Aktueller Aufschlagsfaktor:</span> <span className="text-lg font-medium text-slate-400">Aktueller Aufschlagsfaktor:</span>
<span className="text-3xl font-bold text-white">+{((state.languagesCount - 1) * 20)}%</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>
)} )}
</motion.div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="p-8 bg-white border border-slate-100 rounded-[2rem]">
<h5 className="text-lg font-bold text-slate-900 mb-3">Technische Basis</h5>
<p className="text-base text-slate-500 leading-relaxed">
Wir nutzen moderne i18n-Frameworks, die SEO-optimierte URLs für jede Sprache generieren (z.B. /en, /fr).
</p>
</div>
<div className="p-8 bg-white border border-slate-100 rounded-[2rem]">
<h5 className="text-lg font-bold text-slate-900 mb-3">Content Management</h5>
<p className="text-base text-slate-500 leading-relaxed">
Falls ein CMS gewählt wurde, können Sie alle Übersetzungen bequem selbst pflegen.
</p>
</div>
</div> </div>
</Reveal>
</div> </div>
); );
} }

View 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>
);
}

View File

@@ -10,8 +10,30 @@ 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="space-y-6">
<div className="flex justify-between items-center">
<h4 className="text-2xl font-bold text-slate-900">Zeitplan</h4>
<button
type="button"
onClick={() => toggleDontKnow('timeline')}
className={`px-4 py-2 rounded-full text-sm font-bold transition-all ${
state.dontKnows?.includes('timeline') ? 'bg-slate-900 text-white' : 'bg-slate-100 text-slate-500 hover:bg-slate-200'
}`}
>
Ich weiß es nicht
</button>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6"> <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: 'asap', label: 'So schnell wie möglich', desc: 'Priorisierter Start gewünscht.' },
@@ -32,6 +54,7 @@ export function TimelineStep({ state, updateState }: TimelineStepProps) {
</button> </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">
<AlertCircle className="text-slate-900 shrink-0 mt-1" size={28} /> <AlertCircle className="text-slate-900 shrink-0 mt-1" size={28} />

View File

@@ -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
whileHover={{ y: -8, boxShadow: '0 25px 50px -12px rgb(0 0 0 / 0.15)' }}
whileTap={{ scale: 0.98 }}
type="button" type="button"
onClick={() => updateState({ projectType: type.id as ProjectType })} onClick={() => updateState({ projectType: type.id as ProjectType })}
className={`p-10 rounded-[2.5rem] border-2 text-left transition-all duration-300 focus:outline-none overflow-hidden relative ${ 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-200' 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> <div className={`transition-transform duration-500 group-hover:scale-110 ${state.projectType === type.id ? 'text-white' : 'text-slate-900'}`}>{type.illustration}</div>
<h4 className={`text-3xl font-bold mb-3 ${state.projectType === type.id ? 'text-white' : 'text-slate-900'}`}>{type.label}</h4> <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> <p className={`text-xl leading-relaxed ${state.projectType === type.id ? 'text-slate-200' : 'text-slate-500'}`}>{type.desc}</p>
</button>
{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>
); );

View File

@@ -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 {

View File

@@ -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>