10 Commits

Author SHA1 Message Date
1f57bae339 deploy
Some checks failed
Build & Deploy Mintel Blog / build-and-deploy (push) Failing after 1m26s
2026-01-30 13:57:36 +01:00
1468a48281 fix 2026-01-30 11:29:12 +01:00
a466309ee7 design 2026-01-30 11:19:05 +01:00
f8df944bd7 layout 2026-01-30 11:15:35 +01:00
316e4b6fe9 web app form 2026-01-30 11:04:48 +01:00
cea56ac58d form 2026-01-30 10:35:31 +01:00
ba08724a52 form 2026-01-30 09:36:23 +01:00
520be462f0 contact form 2026-01-30 01:43:07 +01:00
36bb12f656 contact 2026-01-30 00:47:39 +01:00
f536765b6c design 2026-01-30 00:27:59 +01:00
89 changed files with 4401 additions and 638 deletions

146
.gitea/workflows/deploy.yml Normal file
View File

@@ -0,0 +1,146 @@
name: Build & Deploy Mintel Blog
on:
push:
branches: [main]
jobs:
build-and-deploy:
runs-on: docker
steps:
- name: 📋 Log Workflow Start
run: |
echo "🚀 Starting deployment for ${{ github.repository }} (${{ github.ref }})"
echo " • Commit: ${{ github.sha }}"
echo " • Timestamp: $(date -u +'%Y-%m-%d %H:%M:%S UTC')"
- name: Checkout repository
uses: actions/checkout@v4
- name: 🔐 Login to private registry
run: |
echo "🔐 Authenticating with registry.infra.mintel.me..."
echo "${{ secrets.REGISTRY_PASS }}" | docker login registry.infra.mintel.me -u "${{ secrets.REGISTRY_USER }}" --password-stdin
- name: 🏗️ Build Docker image
run: |
echo "🏗️ Building Docker image (linux/arm64)..."
docker buildx build \
--pull \
--platform linux/arm64 \
--build-arg NEXT_PUBLIC_ANALYTICS_PROVIDER="${{ secrets.NEXT_PUBLIC_ANALYTICS_PROVIDER }}" \
--build-arg NEXT_PUBLIC_UMAMI_WEBSITE_ID="${{ secrets.NEXT_PUBLIC_UMAMI_WEBSITE_ID }}" \
--build-arg NEXT_PUBLIC_UMAMI_HOST_URL="${{ secrets.NEXT_PUBLIC_UMAMI_HOST_URL }}" \
--build-arg NEXT_PUBLIC_PLAUSIBLE_DOMAIN="${{ secrets.NEXT_PUBLIC_PLAUSIBLE_DOMAIN }}" \
--build-arg NEXT_PUBLIC_PLAUSIBLE_SCRIPT_URL="${{ secrets.NEXT_PUBLIC_PLAUSIBLE_SCRIPT_URL }}" \
--build-arg NEXT_PUBLIC_GLITCHTIP_DSN="${{ secrets.NEXT_PUBLIC_GLITCHTIP_DSN }}" \
-t registry.infra.mintel.me/mintel/mintel.me:latest \
--push -f docker/Dockerfile .
- name: 🚀 Deploy to production server
run: |
echo "🚀 Deploying to alpha.mintel.me..."
# Setup SSH
mkdir -p ~/.ssh
echo "${{ secrets.ALPHA_SSH_KEY }}" > ~/.ssh/id_ed25519
chmod 600 ~/.ssh/id_ed25519
ssh-keyscan -H alpha.mintel.me >> ~/.ssh/known_hosts 2>/dev/null
# Create .env file content
cat > /tmp/mintel.me.env << EOF
# ============================================================================
# Mintel Blog - Production Environment Configuration
# ============================================================================
# Auto-generated by CI/CD workflow
# ============================================================================
# Application
NODE_ENV=production
DOMAIN=mintel.me
ADMIN_EMAIL=${{ secrets.ADMIN_EMAIL }}
# Analytics
NEXT_PUBLIC_ANALYTICS_PROVIDER=${{ secrets.NEXT_PUBLIC_ANALYTICS_PROVIDER }}
NEXT_PUBLIC_UMAMI_WEBSITE_ID=${{ secrets.NEXT_PUBLIC_UMAMI_WEBSITE_ID }}
NEXT_PUBLIC_UMAMI_HOST_URL=${{ secrets.NEXT_PUBLIC_UMAMI_HOST_URL }}
NEXT_PUBLIC_PLAUSIBLE_DOMAIN=${{ secrets.NEXT_PUBLIC_PLAUSIBLE_DOMAIN }}
NEXT_PUBLIC_PLAUSIBLE_SCRIPT_URL=${{ secrets.NEXT_PUBLIC_PLAUSIBLE_SCRIPT_URL }}
# Error Tracking (GlitchTip/Sentry)
NEXT_PUBLIC_GLITCHTIP_DSN=${{ secrets.NEXT_PUBLIC_GLITCHTIP_DSN }}
# Redis
REDIS_URL=redis://redis:6379
EOF
# Upload .env and deploy
scp -o StrictHostKeyChecking=accept-new /tmp/mintel.me.env root@alpha.mintel.me:/home/deploy/sites/mintel.me/.env
ssh -o StrictHostKeyChecking=accept-new root@alpha.mintel.me bash << EOF
set -e
cd /home/deploy/sites/mintel.me
chmod 600 .env
chown deploy:deploy .env
echo "${{ secrets.REGISTRY_PASS }}" | docker login registry.infra.mintel.me -u "${{ secrets.REGISTRY_USER }}" --password-stdin
docker pull registry.infra.mintel.me/mintel/mintel.me:latest
docker-compose down
echo "🚀 Starting containers..."
docker-compose up -d
echo "⏳ Giving the app a few seconds to warm up..."
sleep 10
echo "🔍 Checking container status..."
docker-compose ps
if ! docker-compose ps | grep -q "Up"; then
echo "❌ Container failed to start"
docker-compose logs --tail=100
exit 1
fi
echo "✅ Deployment complete!"
EOF
rm -f /tmp/mintel.me.env
- name: 📊 Workflow Summary
if: always()
run: |
echo "📊 Status: ${{ job.status }}"
echo "🎯 Target: alpha.mintel.me"
- name: 🔔 Gotify Notification (Success)
if: success()
run: |
echo "Sending success notification to Gotify..."
curl -k -s -X POST "${{ secrets.GOTIFY_URL }}/message?token=${{ secrets.GOTIFY_TOKEN }}" \
-F "title=✅ Deployment Success: ${{ github.repository }}" \
-F "message=The deployment of ${{ github.repository }} (branch: ${{ github.ref }}) was successful.
Commit: ${{ github.sha }}
Actor: ${{ github.actor }}
Run ID: ${{ github.run_id }}" \
-F "priority=5"
- name: 🔔 Gotify Notification (Failure)
if: failure()
run: |
echo "Sending failure notification to Gotify..."
curl -k -s -X POST "${{ secrets.GOTIFY_URL }}/message?token=${{ secrets.GOTIFY_TOKEN }}" \
-F "title=❌ Deployment Failed: ${{ github.repository }}" \
-F "message=The deployment of ${{ github.repository }} (branch: ${{ github.ref }}) failed!
Commit: ${{ github.sha }}
Actor: ${{ github.actor }}
Run ID: ${{ github.run_id }}
Please check the logs for details." \
-F "priority=8"

View File

@@ -2,18 +2,25 @@ import * as React from 'react';
import { Reveal } from '../../src/components/Reveal';
import { PageHeader } from '../../src/components/PageHeader';
import { Section } from '../../src/components/Section';
import { ContactForm } from '../../src/components/ContactForm';
export default function ContactPage() {
return (
<div className="flex flex-col gap-24 py-12 md:py-24 overflow-hidden">
<PageHeader
title={<>Kontakt <br /><span className="text-slate-200">& Anfrage.</span></>}
description="Haben Sie ein Projekt im Kopf? Schreiben Sie mir einfach. Ich antworte meistens innerhalb von 24 Stunden."
<div className="flex flex-col gap-12 py-12 md:py-24 overflow-hidden">
<PageHeader
title={<>Projekt <br /><span className="text-slate-200">konfigurieren.</span></>}
description="Nutzen Sie den Konfigurator für eine erste Einschätzung oder schreiben Sie mir direkt eine Email."
backLink={{ href: '/', label: 'Zurück' }}
backgroundSymbol="@"
backgroundSymbol="?"
/>
<Section number="01" title="Direkt">
<Section number="01" title="Konfigurator" containerVariant="wide" className="!py-12">
<Reveal delay={0.2}>
<ContactForm />
</Reveal>
</Section>
<Section number="02" title="Direkt" className="!py-12">
<div className="grid grid-cols-1 gap-24">
<Reveal delay={0.4}>
<div className="space-y-8">
@@ -28,20 +35,6 @@ export default function ContactPage() {
</a>
</div>
</Reveal>
<Reveal delay={0.6}>
<div className="p-12 md:p-20 border border-slate-200 rounded-[3rem] bg-white relative overflow-hidden hover:border-slate-400 transition-all duration-500">
<div className="absolute top-0 right-0 text-[15rem] font-bold text-slate-50 select-none translate-x-1/4 -translate-y-1/4 opacity-50">!</div>
<div className="relative z-10 space-y-8">
<h2 className="text-2xl font-bold text-slate-900">Was ich von Ihnen brauche:</h2>
<ul className="space-y-6 text-xl md:text-2xl text-slate-600 font-serif italic">
<li className="flex gap-6"><span className="text-slate-900 font-bold font-sans not-italic">01</span> Was ist das Ziel des Projekts?</li>
<li className="flex gap-6"><span className="text-slate-900 font-bold font-sans not-italic">02</span> Gibt es eine Deadline?</li>
<li className="flex gap-6"><span className="text-slate-900 font-bold font-sans not-italic">03</span> Welches Budget ist eingeplant?</li>
</ul>
</div>
</div>
</Reveal>
</div>
</Section>
</div>

View File

@@ -66,8 +66,15 @@
/* Focus states */
a:focus,
button:focus,
input:focus {
@apply outline-none ring-2 ring-slate-900 ring-offset-2 rounded-sm;
input:focus,
textarea:focus {
outline: none !important;
box-shadow: none !important;
}
/* Remove default tap highlight on mobile */
* {
-webkit-tap-highlight-color: transparent !important;
}
}
@@ -166,15 +173,15 @@
/* Buttons */
.btn {
@apply inline-block px-6 py-3 bg-slate-900 text-white font-sans font-bold text-sm uppercase tracking-widest hover:bg-slate-800 hover:scale-[1.02] active:scale-[0.98] transition-all duration-300 rounded-full shadow-sm hover:shadow-md;
@apply inline-flex items-center justify-center px-6 py-3 border border-slate-200 bg-white text-slate-600 font-sans font-bold text-sm uppercase tracking-widest rounded-full transition-all duration-500 ease-[cubic-bezier(0.23,1,0.32,1)] hover:border-slate-400 hover:text-slate-900 hover:bg-slate-50 hover:-translate-y-0.5 hover:shadow-xl hover:shadow-slate-100 active:translate-y-0 active:shadow-sm;
}
.btn-primary {
@apply bg-slate-900 hover:bg-slate-800 text-white px-6 py-2.5 rounded-full transition-all duration-300 font-bold text-sm uppercase tracking-widest;
@apply border-slate-900 text-slate-900 hover:bg-slate-900 hover:text-white;
}
.btn-secondary {
@apply bg-white text-slate-900 hover:bg-slate-50 border border-slate-200 px-6 py-2.5 rounded-full transition-all duration-300 font-bold text-sm uppercase tracking-widest;
@apply border-slate-200 text-slate-500 hover:border-slate-400 hover:text-slate-900;
}
/* Hide scrollbars */
@@ -223,20 +230,21 @@
position: fixed;
bottom: 2rem;
right: 2rem;
width: 2.5rem;
height: 2.5rem;
width: 3rem;
height: 3rem;
background: white;
border: 1px solid #e2e8f0;
border-radius: 0.375rem;
border-radius: 9999px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
color: #64748b;
transition: all 0.15s ease;
transition: all 0.5s cubic-bezier(0.23, 1, 0.32, 1);
opacity: 0;
transform: translateY(8px);
z-index: 50;
}
.floating-back-to-top.visible {
@@ -247,8 +255,9 @@
.floating-back-to-top:hover {
background: #f8fafc;
color: #1e293b;
transform: translateY(-1px);
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
border-color: #cbd5e1;
transform: translateY(-4px);
box-shadow: 0 20px 25px -5px rgb(0 0 0 / 0.1), 0 8px 10px -6px rgb(0 0 0 / 0.1);
}
/* Reduced motion support */
@@ -359,7 +368,8 @@
}
.highlighter-tag:focus {
@apply ring-2 ring-slate-900 ring-offset-2 -translate-y-0.5 scale-105;
@apply -translate-y-0.5 scale-105;
outline: none !important;
}
/* Marker Title Styles */

View File

@@ -21,6 +21,9 @@ export const metadata: Metadata = {
},
description: "Technical problem solver's blog - practical insights and learning notes",
metadataBase: new URL('https://mintel.me'),
icons: {
icon: '/favicon.svg',
},
};
export default function RootLayout({

View File

@@ -2,38 +2,25 @@ import { ArrowRight } from 'lucide-react';
import {
CirclePattern,
ComparisonRow,
ConceptAutomation,
ConceptCode,
ConceptCommunication,
ConceptMessy,
ConceptPrice,
ConceptPrototyping,
ConceptSystem,
ConceptWebsite,
ConnectorBranch,
ConnectorEnd,
ConnectorSplit,
ConnectorStart,
ContactIllustration,
DifferenceIllustration,
FlowLines,
GridLines,
HeroLines,
ServicesFlow,
DirectCommunication,
FastPrototyping,
CleanCode,
FixedPrice,
MinimalistArchitect,
WebsitesIllustration,
SystemsIllustration,
AutomationIllustration,
DifferenceIllustration,
TargetGroupIllustration,
ContactIllustration,
PromiseSectionIllustration,
ServicesSectionIllustration,
ConceptCommunication,
ConceptPrototyping,
ConceptCode,
ConceptPrice,
ConceptWebsite,
ConceptSystem,
ConceptAutomation,
ConceptTarget,
ConceptMessy,
HeroArchitecture,
HeroMainIllustration
HeroMainIllustration,
ServicesFlow
} from '../src/components/Landing';
import { Reveal } from '../src/components/Reveal';
import { Section } from '../src/components/Section';
@@ -66,14 +53,13 @@ export default function LandingPage() {
Digital Architect
</div>
<h1 className="text-6xl md:text-8xl font-bold text-slate-900 tracking-tighter leading-[0.9]">
Digitale <br />
Systeme <br />
Websites <br />
<span className="text-slate-300">ohne <br />Overhead.</span>
</h1>
<div className="pt-8">
<a
href="#contact"
className="inline-flex items-center gap-4 px-8 py-4 bg-slate-900 text-white rounded-full font-bold text-sm uppercase tracking-widest hover:bg-slate-800 transition-all duration-300 group"
<a
href="#contact"
className="inline-flex items-center gap-4 px-8 py-4 border border-slate-200 bg-white text-slate-900 rounded-full font-bold text-sm uppercase tracking-widest hover:border-slate-400 hover:bg-slate-50 transition-all duration-500 ease-[cubic-bezier(0.23,1,0.32,1)] hover:-translate-y-0.5 hover:shadow-xl hover:shadow-slate-100 group"
>
Projekt anfragen
<ArrowRight className="w-4 h-4 group-hover:translate-x-1 transition-transform" />

View File

@@ -1,7 +1,6 @@
import * as React from 'react';
import { Info, Minus } from 'lucide-react';
import { Reveal } from '../../src/components/Reveal';
import { PageHeader } from '../../src/components/PageHeader';
import { Reveal } from '../../src/components/Reveal';
import { Section } from '../../src/components/Section';
export default function WebsitesPage() {
@@ -9,7 +8,7 @@ export default function WebsitesPage() {
<div className="flex flex-col gap-48 py-12 md:py-24 overflow-hidden">
<PageHeader
title={<>Websites <br /><span className="text-slate-200">& Preise.</span></>}
description="Ich baue digitale Systeme mit klaren Preisen und Ergebnissen keine Stunden, keine Überraschungen."
description="Ich baue Websites mit klaren Preisen und Ergebnissen keine Stunden, keine Überraschungen."
backLink={{ href: '/', label: 'Zurück' }}
backgroundSymbol="€"
/>

View File

@@ -1,35 +1,41 @@
version: '3.8'
services:
# Main website - Nginx serving static Astro build
# Main website - Next.js standalone
website:
build:
context: .
dockerfile: docker/Dockerfile
args:
- NEXT_PUBLIC_ANALYTICS_PROVIDER=${NEXT_PUBLIC_ANALYTICS_PROVIDER}
- NEXT_PUBLIC_UMAMI_WEBSITE_ID=${NEXT_PUBLIC_UMAMI_WEBSITE_ID}
- NEXT_PUBLIC_UMAMI_HOST_URL=${NEXT_PUBLIC_UMAMI_HOST_URL}
- NEXT_PUBLIC_PLAUSIBLE_DOMAIN=${NEXT_PUBLIC_PLAUSIBLE_DOMAIN}
- NEXT_PUBLIC_PLAUSIBLE_SCRIPT_URL=${NEXT_PUBLIC_PLAUSIBLE_SCRIPT_URL}
- NEXT_PUBLIC_GLITCHTIP_DSN=${NEXT_PUBLIC_GLITCHTIP_DSN}
container_name: mintel-website
restart: unless-stopped
ports:
- "8080:80"
- "3000:3000"
environment:
- NGINX_HOST=${DOMAIN:-localhost}
- NODE_ENV=production
- REDIS_URL=redis://redis:6379
- NEXT_PUBLIC_ANALYTICS_PROVIDER=${NEXT_PUBLIC_ANALYTICS_PROVIDER}
- NEXT_PUBLIC_UMAMI_WEBSITE_ID=${NEXT_PUBLIC_UMAMI_WEBSITE_ID}
- NEXT_PUBLIC_UMAMI_HOST_URL=${NEXT_PUBLIC_UMAMI_HOST_URL}
- NEXT_PUBLIC_PLAUSIBLE_DOMAIN=${NEXT_PUBLIC_PLAUSIBLE_DOMAIN}
- NEXT_PUBLIC_PLAUSIBLE_SCRIPT_URL=${NEXT_PUBLIC_PLAUSIBLE_SCRIPT_URL}
- NEXT_PUBLIC_GLITCHTIP_DSN=${NEXT_PUBLIC_GLITCHTIP_DSN}
depends_on:
- redis
networks:
- app-network
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost/health"]
interval: 30s
timeout: 10s
retries: 3
# Redis cache for performance
redis:
image: redis:7-alpine
container_name: mintel-redis
restart: unless-stopped
ports:
- "6379:6379"
volumes:
- redis-data:/data
command: redis-server --appendonly yes --maxmemory 256mb --maxmemory-policy allkeys-lru
@@ -61,23 +67,6 @@ services:
networks:
- app-network
# Plausible Analytics (if you want to run it alongside)
# Uncomment if you need to spin up a new Plausible instance
# plausible:
# image: plausible/analytics:v2.0
# container_name: mintel-plausible
# restart: unless-stopped
# ports:
# - "8081:8000"
# environment:
# - BASE_URL=https://analytics.${DOMAIN}
# - SECRET_KEY_BASE=${PLAUSIBLE_SECRET}
# depends_on:
# - postgres
# - clickhouse
# networks:
# - app-network
volumes:
redis-data:
caddy-data:
@@ -85,4 +74,4 @@ volumes:
networks:
app-network:
driver: bridge
driver: bridge

View File

@@ -1,34 +1,63 @@
# Multi-stage build for optimized Astro static site
FROM node:22-alpine AS builder
# Multi-stage build for Next.js
FROM node:22-alpine AS base
# 1. Install dependencies only when needed
FROM base AS deps
RUN apk add --no-cache libc6-compat
WORKDIR /app
# Copy package files
COPY package*.json ./
# Install dependencies based on the preferred package manager
COPY package.json package-lock.json* ./
RUN npm ci
# Install dependencies
RUN npm ci --only=production
# Copy source code
# 2. Rebuild the source code only when needed
FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
# Build the Astro site
# Next.js collects completely anonymous telemetry data about general usage.
# Learn more here: https://nextjs.org/telemetry
# Uncomment the following line in case you want to disable telemetry during the build.
# ENV NEXT_TELEMETRY_DISABLED 1
# Build arguments for environment variables needed at build time
ARG NEXT_PUBLIC_ANALYTICS_PROVIDER
ARG NEXT_PUBLIC_UMAMI_WEBSITE_ID
ARG NEXT_PUBLIC_UMAMI_HOST_URL
ARG NEXT_PUBLIC_PLAUSIBLE_DOMAIN
ARG NEXT_PUBLIC_PLAUSIBLE_SCRIPT_URL
ARG NEXT_PUBLIC_GLITCHTIP_DSN
RUN npm run build
# Production stage - Nginx for serving static files
FROM nginx:alpine
# 3. Production image, copy all the files and run next
FROM base AS runner
WORKDIR /app
# Remove default nginx static assets
RUN rm -rf /usr/share/nginx/html/*
ENV NODE_ENV production
# Uncomment the following line in case you want to disable telemetry during runtime.
# ENV NEXT_TELEMETRY_DISABLED 1
# Copy built assets from builder stage
COPY --from=builder /app/dist /usr/share/nginx/html
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
# Copy custom nginx config
COPY docker/nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=builder /app/public ./public
# Expose port
EXPOSE 80
# Set the correct permission for prune cache
RUN mkdir .next
RUN chown nextjs:nodejs .next
# Start nginx
CMD ["nginx", "-g", "daemon off;"]
# Automatically leverage output traces to reduce image size
# https://nextjs.org/docs/advanced-features/output-file-tracing
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
USER nextjs
EXPOSE 3000
ENV PORT 3000
ENV HOSTNAME "0.0.0.0"
CMD ["node", "server.js"]

View File

@@ -1,6 +1,7 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
reactStrictMode: true,
output: 'standalone',
};
export default nextConfig;

833
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -14,6 +14,7 @@
"test:file-examples": "tsx ./scripts/test-file-examples-comprehensive.ts"
},
"dependencies": {
"@react-pdf/renderer": "^4.3.2",
"@types/ioredis": "^4.28.10",
"@types/react": "^19.2.8",
"@types/react-dom": "^19.2.3",
@@ -24,6 +25,7 @@
"mermaid": "^11.12.2",
"next": "^16.1.6",
"prismjs": "^1.30.0",
"qrcode": "^1.5.4",
"react": "^19.2.3",
"react-dom": "^19.2.3",
"shiki": "^1.24.2",
@@ -33,6 +35,7 @@
"@tailwindcss/typography": "^0.5.15",
"@types/node": "^25.0.6",
"@types/prismjs": "^1.26.5",
"@types/qrcode": "^1.5.6",
"autoprefixer": "^10.4.20",
"postcss": "^8.4.49",
"tsx": "^4.21.0",

View File

@@ -1,9 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 128 128">
<path d="M50.4 78.5a75.1 75.1 0 0 0-28.5 6.9l24.2-65.7c.7-2 1.9-3.2 3.4-3.2h29c1.5 0 2.7 1.2 3.4 3.2l24.2 65.7s-11.6-7-28.5-7L67 45.5c-.4-1.7-1.6-2.8-2.9-2.8-1.3 0-2.5 1.1-2.9 2.7L50.4 78.5Zm-1.1 28.2Zm-4.2-20.2c-2 6.6-.6 15.8 4.2 20.2a17.5 17.5 0 0 1 .2-.7 5.5 5.5 0 0 1 5.7-4.5c2.8.1 4.3 1.5 4.7 4.7.2 1.1.2 2.3.2 3.5v.4c0 2.7.7 5.2 2.2 7.4a13 13 0 0 0 5.7 4.9v-.3l-.2-.3c-1.8-5.6-.5-9.5 4.4-12.8l1.5-1a73 73 0 0 0 3.2-2.2 16 16 0 0 0 6.8-11.4c.3-2 .1-4-.6-6l-.8.6-1.6 1a37 37 0 0 1-22.4 2.7c-5-.7-9.7-2-13.2-6.2Z" />
<style>
path { fill: #000; }
@media (prefers-color-scheme: dark) {
path { fill: #FFF; }
}
</style>
</svg>
<?xml version="1.0" encoding="UTF-8" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg width="100%" height="100%" viewBox="0 0 745 744" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;"><rect id="Favicon" x="0.855" y="0.277" width="743.633" height="743.633" style="fill:none;"/><path d="M739.657,49.961c-0,-24.755 -20.098,-44.852 -44.853,-44.852l-644.264,-0c-24.755,-0 -44.853,20.097 -44.853,44.852l-0,644.265c-0,24.755 20.098,44.853 44.853,44.853l644.264,-0c24.755,-0 44.853,-20.098 44.853,-44.853l-0,-644.265Z"/><path d="M135.778,286.282c0.157,0.236 0.52,0.761 0.852,1.092c1.2,1.192 2.956,2.358 5.296,3.587l0.005,0.003l2,1.327l1.693,1.752l1.239,2.072l0.713,2.215l0.211,2.192l-0.208,2.068l-1.385,3.646l-2.46,2.971l-1.634,1.187l-1.911,0.914l-2.161,0.551l-2.323,0.093l-2.343,-0.412l-2.211,-0.892c-11.589,-6.087 -16.814,-12.713 -18.39,-18.621c-1.835,-6.879 0.575,-13.751 6.509,-19.965c12.824,-13.43 44.389,-24.257 57.136,-27.454l0.001,-0c30.563,-7.658 62.165,-12.34 93.648,-13.693c25.54,-1.098 54.423,-1.803 78.831,7.271c15.51,5.768 26.031,14.726 32.759,25.559c56.484,-46.707 118.893,-93.116 187.414,-119.225c10.958,-4.178 27.246,-8.604 40.339,-7.415c7.908,0.718 14.744,3.442 19.461,8.573c4.662,5.072 7.541,12.672 6.521,23.945c-3.634,40.091 -31.543,80.109 -52.575,112.911c-25.848,40.321 -53.663,79.557 -82.723,117.821c29.191,-23.305 58.382,-46.61 87.573,-69.913l1.731,-1.145l1.85,-0.796l3.75,-0.585l3.526,0.562l3.17,1.552l2.672,2.595l1.028,1.728l0.705,1.995l0.285,2.178l-0.185,2.22l-0.631,2.115l-0.992,1.91c-10.505,16.533 -21.014,33.063 -31.523,49.592l-0.001,0.003c-1.852,2.909 -11.995,19.195 -18.14,30.842c-0.096,0.182 -0.192,0.366 -0.288,0.553c13.673,-3.721 27.615,-13.517 38.776,-19.936c10.441,-6.004 20.778,-12.208 30.865,-18.787l0.003,-0.002l2.134,-1.1l2.356,-0.626l2.421,-0.072l2.289,0.463l2.022,0.893l1.703,1.201l2.495,3.003l1.373,3.598l0.232,1.999l-0.139,2.106l-0.573,2.146l-1.048,2.067l-1.498,1.843l-1.854,1.496l-0.007,0.005c-15.715,10.242 -31.914,19.975 -48.604,28.526c-6.986,3.579 -18.808,10.744 -29.918,13.789c-9.41,2.579 -18.37,2.143 -24.958,-2.988c-5.648,-4.398 -7.104,-11.077 -5.042,-18.895c3.104,-11.773 15.551,-27.001 19.276,-32.858l2.604,-4.095c-37.274,29.759 -74.551,59.518 -111.826,89.274l-1.82,1.214l-2.004,0.868l-2.111,0.471l-2.12,0.061l-2.039,-0.329l-1.892,-0.676l-3.201,-2.224l-2.259,-3.202l-0.689,-1.912l-0.33,-2.068l0.077,-2.148l0.5,-2.129l0.902,-2.005l1.242,-1.804c59.233,-71.996 118.215,-147.452 163.946,-228.856l0.002,-0.003c3.532,-6.277 19.498,-32.912 25.637,-54.458c1.456,-5.11 2.365,-9.885 2.213,-13.918c-0.128,-3.403 -1.052,-6.169 -4.397,-6.847c-6.883,-1.395 -14.409,-0.182 -21.911,2.075c-12.591,3.787 -25.072,10.598 -34.871,15.502l-0.002,0.001c-37.202,18.606 -72.519,40.846 -106.083,65.396c-19.253,14.077 -38.067,28.773 -56.448,44.009c5.948,31.922 -8.725,71.663 -25.261,97.617c-26.624,41.789 -61.114,78.396 -97.084,112.241c-35.155,33.081 -71.676,66.504 -111.219,94.355l-0.002,0.001c-4.905,3.453 -13.056,9.944 -21.245,13.763c-7.008,3.268 -14.124,4.488 -20.021,2.432c-7.666,-2.674 -10.711,-8.481 -11.105,-15.23c-0.288,-4.928 1.117,-10.472 2.745,-14.802l0.001,-0.003c17.424,-46.26 54.722,-88.018 86.091,-125.21c52.005,-61.657 108.894,-119.681 170.402,-171.929c-5.142,-9.861 -13.608,-17.675 -25.833,-20.957c-27.596,-7.404 -57.826,-6.098 -86.019,-3.428c-30.452,2.883 -61.745,7.625 -90.667,17.984c-6.667,2.388 -17.118,6.215 -22.892,11.134c-0.89,0.758 -1.884,2.115 -2.149,2.485Zm-6.046,295.699c3.853,-1.713 7.757,-4.116 11.623,-6.805c12.067,-8.393 23.538,-19.805 31.614,-26.433c45.641,-37.472 90.707,-76.66 129.793,-121.083c26.644,-30.283 57.988,-66.814 65.641,-107.833c1.02,-5.466 1.414,-11.09 1.137,-16.634c-41.419,35.852 -80.575,74.39 -117.54,114.67c-41.853,45.61 -85.416,93.945 -115.619,148.393c-0.671,1.213 -4.049,9.375 -6.649,15.725Z" style="fill:#fff;"/></svg>

Before

Width:  |  Height:  |  Size: 749 B

After

Width:  |  Height:  |  Size: 4.0 KiB

BIN
src/assets/header.webp Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 MiB

BIN
src/assets/logo/Favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 133 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 129 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

View File

@@ -1,22 +1,13 @@
'use client';
import React, { useEffect } from 'react';
import { createPlausibleAnalytics } from '../utils/analytics';
import { useEffect } from 'react';
import { getDefaultAnalytics } from '../utils/analytics';
import { getDefaultErrorTracking } from '../utils/error-tracking';
interface AnalyticsProps {
domain?: string;
scriptUrl?: string;
}
export const Analytics: React.FC<AnalyticsProps> = ({
domain = 'mintel.me',
scriptUrl = 'https://plausible.yourdomain.com/js/script.js'
}) => {
export const Analytics: React.FC = () => {
useEffect(() => {
const analytics = createPlausibleAnalytics({
domain: document.documentElement.lang || domain,
scriptUrl
});
const analytics = getDefaultAnalytics();
const errorTracking = getDefaultErrorTracking();
// Track page load performance
const trackPageLoad = () => {
@@ -58,14 +49,41 @@ export const Analytics: React.FC<AnalyticsProps> = ({
}
};
// Global error handler for error tracking
const handleGlobalError = (event: ErrorEvent) => {
errorTracking.captureException(event.error || event.message);
};
const handleUnhandledRejection = (event: PromiseRejectionEvent) => {
errorTracking.captureException(event.reason);
};
window.addEventListener('error', handleGlobalError);
window.addEventListener('unhandledrejection', handleUnhandledRejection);
trackPageLoad();
trackOutboundLinks();
const cleanupSearch = trackSearch();
return () => {
if (cleanupSearch) cleanupSearch();
window.removeEventListener('error', handleGlobalError);
window.removeEventListener('unhandledrejection', handleUnhandledRejection);
};
}, [domain, scriptUrl]);
}, []);
return null;
const analytics = getDefaultAnalytics();
const adapter = analytics.getAdapter();
const scriptTag = adapter.getScriptTag ? adapter.getScriptTag() : null;
if (!scriptTag) return null;
// We use dangerouslySetInnerHTML to inject the script tag from the adapter
// This is safe here because the script URLs and IDs come from our own config/env
return (
<div
dangerouslySetInnerHTML={{ __html: scriptTag }}
style={{ display: 'none' }}
/>
);
};

View File

@@ -87,9 +87,9 @@ export const BlogPostClient: React.FC<BlogPostClientProps> = ({ readingTime, tit
}`}
>
<div className="max-w-4xl mx-auto px-6 py-4 flex items-center justify-between">
<button
<button
onClick={handleBack}
className="flex items-center gap-2 text-slate-700 hover:text-slate-900 transition-colors duration-200 group"
className="flex items-center gap-2 px-4 py-2 border border-slate-200 rounded-full text-slate-600 hover:text-slate-900 hover:border-slate-400 hover:bg-slate-50 transition-all duration-500 ease-[cubic-bezier(0.23,1,0.32,1)] hover:-translate-y-0.5 hover:shadow-lg hover:shadow-slate-100 group"
aria-label="Back to home"
>
<svg className="w-5 h-5 group-hover:-translate-x-1 transition-transform duration-200" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -104,7 +104,7 @@ export const BlogPostClient: React.FC<BlogPostClientProps> = ({ readingTime, tit
</span>
<button
onClick={handleShare}
className="share-button-top flex items-center gap-1.5 px-3 py-1.5 bg-slate-50 hover:bg-slate-900 hover:text-white border border-slate-100 hover:border-slate-900 transition-all duration-200"
className="share-button-top flex items-center gap-1.5 px-3 py-1.5 border border-slate-200 rounded-full text-slate-600 hover:text-slate-900 hover:border-slate-400 hover:bg-slate-50 transition-all duration-500 ease-[cubic-bezier(0.23,1,0.32,1)] hover:-translate-y-0.5 hover:shadow-lg hover:shadow-slate-100"
aria-label="Share this post"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -116,9 +116,9 @@ export const BlogPostClient: React.FC<BlogPostClientProps> = ({ readingTime, tit
</nav>
<div className="mt-16 pt-8 border-t border-slate-50 text-center">
<button
<button
onClick={handleBack}
className="inline-flex items-center gap-2 px-8 py-4 bg-white border-2 border-slate-900 text-slate-900 hover:bg-slate-900 hover:text-white transition-all duration-300 font-bold uppercase tracking-widest text-xs group"
className="btn btn-primary group"
>
<svg className="w-4 h-4 group-hover:-translate-x-1 transition-transform duration-200" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M10 19l-7-7m0 0l7-7m-7 7h18" />

View File

@@ -0,0 +1,365 @@
'use client';
import * as React from 'react';
import { useState, useMemo, useEffect, useRef } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import { motion, AnimatePresence } from 'framer-motion';
import { ChevronRight, ChevronLeft, Send, Check, Share2 } from 'lucide-react';
import * as QRCode from 'qrcode';
import { FormState, Step, ProjectType } from './ContactForm/types';
import { PRICING, initialState } from './ContactForm/constants';
import { PriceCalculation } from './ContactForm/components/PriceCalculation';
import { ShareModal } from './ShareModal';
// Steps
import { TypeStep } from './ContactForm/steps/TypeStep';
import { BaseStep } from './ContactForm/steps/BaseStep';
import { FeaturesStep } from './ContactForm/steps/FeaturesStep';
import { DesignStep } from './ContactForm/steps/DesignStep';
import { AssetsStep } from './ContactForm/steps/AssetsStep';
import { FunctionsStep } from './ContactForm/steps/FunctionsStep';
import { ApiStep } from './ContactForm/steps/ApiStep';
import { ContentStep } from './ContactForm/steps/ContentStep';
import { LanguageStep } from './ContactForm/steps/LanguageStep';
import { TimelineStep } from './ContactForm/steps/TimelineStep';
import { ContactStep } from './ContactForm/steps/ContactStep';
import { WebAppStep } from './ContactForm/steps/WebAppStep';
import {
ConceptTarget,
ConceptWebsite,
ConceptPrototyping,
ConceptCommunication,
ConceptSystem,
ConceptCode,
ConceptAutomation,
ConceptPrice,
HeroArchitecture
} from './Landing/ConceptIllustrations';
export function ContactForm() {
const router = useRouter();
const searchParams = useSearchParams();
const [stepIndex, setStepIndex] = useState(0);
const [state, setState] = useState<FormState>(initialState);
const [isSubmitted, setIsSubmitted] = useState(false);
const [isClient, setIsClient] = useState(false);
const [qrCodeData, setQrCodeData] = useState<string>('');
const [isShareModalOpen, setIsShareModalOpen] = useState(false);
const [hoveredStep, setHoveredStep] = useState<number | null>(null);
const formContainerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
setIsClient(true);
}, []);
// URL Binding
useEffect(() => {
const step = searchParams.get('step');
if (step) setStepIndex(parseInt(step));
const config = searchParams.get('config');
if (config) {
try {
const decoded = JSON.parse(decodeURIComponent(escape(atob(config))));
setState(s => ({ ...s, ...decoded }));
} catch (e) {
console.error("Failed to decode config", e);
}
}
}, []);
const currentUrl = useMemo(() => {
if (!isClient) return '';
const params = new URLSearchParams();
params.set('step', stepIndex.toString());
const configData = {
projectType: state.projectType,
selectedPages: state.selectedPages,
features: state.features,
functions: state.functions,
apiSystems: state.apiSystems,
cmsSetup: state.cmsSetup,
languagesCount: state.languagesCount,
deadline: state.deadline,
designVibe: state.designVibe,
colorScheme: state.colorScheme,
targetAudience: state.targetAudience,
userRoles: state.userRoles,
dataSensitivity: state.dataSensitivity,
platformType: state.platformType
};
const stateString = btoa(unescape(encodeURIComponent(JSON.stringify(configData))));
params.set('config', stateString);
return `${window.location.origin}${window.location.pathname}?${params.toString()}`;
}, [state, stepIndex, isClient]);
useEffect(() => {
if (currentUrl) {
router.replace(currentUrl, { scroll: false });
QRCode.toDataURL(currentUrl, { margin: 1, width: 200 }).then(setQrCodeData);
}
}, [currentUrl]);
const totalPagesCount = useMemo(() => {
return state.selectedPages.length + state.otherPages.length;
}, [state.selectedPages, state.otherPages]);
const totalPrice = useMemo(() => {
if (state.projectType !== 'website') return 0;
let total = PRICING.BASE_WEBSITE;
total += totalPagesCount * PRICING.PAGE;
total += (state.features.length + state.otherFeatures.length) * PRICING.FEATURE;
total += (state.functions.length + state.otherFunctions.length) * PRICING.FUNCTION;
total += state.complexInteractions * PRICING.COMPLEX_INTERACTION;
total += state.newDatasets * PRICING.NEW_DATASET;
total += (state.apiSystems.length + state.otherTech.length) * PRICING.API_INTEGRATION;
if (state.cmsSetup) {
total += PRICING.CMS_SETUP;
total += (state.features.length + state.otherFeatures.length) * PRICING.CMS_CONNECTION_PER_FEATURE;
}
// Multi-language factor (e.g. +20% per additional language)
if (state.languagesCount > 1) {
total *= (1 + (state.languagesCount - 1) * 0.2);
}
return Math.round(total);
}, [state, totalPagesCount]);
const monthlyPrice = useMemo(() => {
if (state.projectType !== 'website') return 0;
return PRICING.HOSTING_MONTHLY + (state.storageExpansion * PRICING.STORAGE_EXPANSION_MONTHLY);
}, [state]);
const updateState = (updates: Partial<FormState>) => {
setState((s) => ({ ...s, ...updates }));
};
const toggleItem = (list: string[], id: string) => {
return list.includes(id) ? list.filter(i => i !== id) : [...list, id];
};
const scrollToTop = () => {
if (formContainerRef.current) {
const offset = 120;
const bodyRect = document.body.getBoundingClientRect().top;
const elementRect = formContainerRef.current.getBoundingClientRect().top;
const elementPosition = elementRect - bodyRect;
const offsetPosition = elementPosition - offset;
window.scrollTo({
top: offsetPosition,
behavior: 'smooth'
});
}
};
const nextStep = () => {
if (stepIndex < activeSteps.length - 1) {
setStepIndex(stepIndex + 1);
setTimeout(scrollToTop, 50);
}
};
const prevStep = () => {
if (stepIndex > 0) {
setStepIndex(stepIndex - 1);
setTimeout(scrollToTop, 50);
}
};
const steps: Step[] = [
{ id: 'type', title: 'Das Ziel', description: 'Was möchten Sie realisieren?', illustration: <ConceptTarget className="w-full h-full" /> },
{ id: 'base', title: 'Die Seiten', description: 'Welche Seiten benötigen wir?', illustration: <ConceptWebsite className="w-full h-full" /> },
{ id: 'features', title: 'Die Systeme', description: 'Welche inhaltlichen Bereiche planen wir?', illustration: <ConceptPrototyping className="w-full h-full" /> },
{ id: 'design', title: 'Design-Wünsche', description: 'Wie soll die Seite wirken?', illustration: <ConceptCommunication className="w-full h-full" /> },
{ id: 'assets', title: 'Ihre Assets', description: 'Was bringen Sie bereits mit?', illustration: <ConceptSystem className="w-full h-full" /> },
{ id: 'functions', title: 'Die Logik', description: 'Welche Funktionen werden benötigt?', illustration: <ConceptCode className="w-full h-full" /> },
{ id: 'api', title: 'Schnittstellen', description: 'Datenaustausch mit Drittsystemen.', illustration: <ConceptAutomation className="w-full h-full" /> },
{ id: 'content', title: 'Die Pflege', description: 'Wer kümmert sich um die Daten?', illustration: <ConceptPrice className="w-full h-full" /> },
{ id: 'language', title: 'Sprachen', description: 'Globale Reichweite planen.', illustration: <ConceptCommunication className="w-full h-full" /> },
{ id: 'timeline', title: 'Zeitplan', description: 'Wann soll das Projekt live gehen?', illustration: <HeroArchitecture className="w-full h-full" /> },
{ id: 'contact', title: 'Abschluss', description: 'Erzählen Sie mir mehr über Ihr Vorhaben.', illustration: <ConceptCommunication className="w-full h-full" /> },
{ id: 'webapp', title: 'Web App Details', description: 'Spezifische Anforderungen für Ihre Anwendung.', illustration: <ConceptSystem className="w-full h-full" /> },
];
const activeSteps = useMemo(() => {
if (state.projectType === 'website') {
return steps.filter(s => s.id !== 'webapp');
}
// Web App flow
return [
steps.find(s => s.id === 'type')!,
steps.find(s => s.id === 'webapp')!,
{ ...steps.find(s => s.id === 'functions')!, title: 'Funktionen', description: 'Kern-Features Ihrer Anwendung.' },
{ ...steps.find(s => s.id === 'api')!, title: 'Integrationen', description: 'Anbindung an bestehende Systeme.' },
steps.find(s => s.id === 'timeline')!,
steps.find(s => s.id === 'contact')!,
];
}, [state.projectType]);
useEffect(() => {
if (stepIndex >= activeSteps.length) setStepIndex(activeSteps.length - 1);
}, [activeSteps, stepIndex]);
const renderStepContent = () => {
const currentStep = activeSteps[stepIndex];
switch (currentStep.id) {
case 'type':
return <TypeStep state={state} updateState={updateState} />;
case 'base':
return <BaseStep state={state} updateState={updateState} toggleItem={toggleItem} />;
case 'features':
return <FeaturesStep state={state} updateState={updateState} toggleItem={toggleItem} />;
case 'design':
return <DesignStep state={state} updateState={updateState} />;
case 'assets':
return <AssetsStep state={state} updateState={updateState} toggleItem={toggleItem} />;
case 'functions':
return <FunctionsStep state={state} updateState={updateState} toggleItem={toggleItem} />;
case 'api':
return <ApiStep state={state} updateState={updateState} toggleItem={toggleItem} />;
case 'content':
return <ContentStep state={state} updateState={updateState} />;
case 'language':
return <LanguageStep state={state} updateState={updateState} />;
case 'timeline':
return <TimelineStep state={state} updateState={updateState} />;
case 'contact':
return <ContactStep state={state} updateState={updateState} />;
case 'webapp':
return <WebAppStep state={state} updateState={updateState} />;
default: return null;
}
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (stepIndex === activeSteps.length - 1) {
// Handle submission
const mailBody = `
Name: ${state.name}
Email: ${state.email}
Rolle: ${state.role}
Projekt: ${state.projectType}
Konfiguration: ${currentUrl}
Nachricht:
${state.message}
`;
window.location.href = `mailto:marc@mintel.me?subject=Projektanfrage: ${state.name}&body=${encodeURIComponent(mailBody)}`;
setIsSubmitted(true);
} else {
nextStep();
}
};
const handleShare = () => {
setIsShareModalOpen(true);
};
if (isSubmitted) {
return (
<motion.div initial={{ opacity: 0, scale: 0.95 }} animate={{ opacity: 1, scale: 1 }} className="p-12 md:p-24 bg-slate-900 text-white rounded-[4rem] text-center space-y-12">
<div className="w-24 h-24 bg-white text-slate-900 rounded-full flex items-center justify-center mx-auto"><Check size={48} strokeWidth={3} /></div>
<div className="space-y-6"><h2 className="text-5xl font-bold tracking-tight">Anfrage gesendet!</h2><p className="text-slate-400 text-2xl max-w-2xl mx-auto leading-relaxed">Vielen Dank, {state.name.split(' ')[0]}. Ich melde mich innerhalb von 24 Stunden bei Ihnen.</p></div>
<button type="button" onClick={() => { setIsSubmitted(false); setStepIndex(0); setState(initialState); }} className="text-slate-400 hover:text-white transition-colors underline underline-offset-8 text-lg focus:outline-none overflow-hidden relative rounded-full">Neue Anfrage starten</button>
</motion.div>
);
}
return (
<div ref={formContainerRef} className="grid grid-cols-1 lg:grid-cols-12 gap-16 items-start">
<div className="lg:col-span-8 space-y-12">
<div className="flex flex-col gap-10">
<div className="flex flex-col md:flex-row md:items-center gap-8">
<div className="w-32 h-32 shrink-0 bg-slate-50 rounded-[2.5rem] p-4 flex items-center justify-center">{activeSteps[stepIndex].illustration}</div>
<div className="space-y-2">
<span className="text-base font-bold uppercase tracking-[0.2em] text-slate-400">Schritt {stepIndex + 1} von {activeSteps.length}</span>
<h3 className="text-4xl font-bold tracking-tight text-slate-900">{activeSteps[stepIndex].title}</h3>
<p className="text-xl text-slate-500 leading-relaxed">{activeSteps[stepIndex].description}</p>
</div>
</div>
<div className="flex gap-3 h-1.5">
{activeSteps.map((step, i) => (
<div
key={i}
className="flex-1 h-8 -my-3.5 flex items-center relative"
onMouseEnter={() => setHoveredStep(i)}
onMouseLeave={() => setHoveredStep(null)}
>
<button
type="button"
onClick={() => {
setStepIndex(i);
setTimeout(scrollToTop, 50);
}}
className={`w-full h-1.5 rounded-full transition-all duration-700 ${i <= stepIndex ? 'bg-slate-900' : 'bg-slate-100'} cursor-pointer focus:outline-none p-0 border-none`}
/>
<AnimatePresence>
{hoveredStep === i && (
<motion.div
initial={{ opacity: 0, y: 5, x: '-50%' }}
animate={{ opacity: 1, y: 0, x: '-50%' }}
exit={{ opacity: 0, y: 5, x: '-50%' }}
className="absolute bottom-full left-1/2 mb-1 px-4 py-2 bg-slate-900 text-white text-sm font-bold uppercase tracking-wider rounded-lg whitespace-nowrap pointer-events-none z-50 shadow-xl"
>
{step.title}
<div className="absolute top-full left-1/2 -translate-x-1/2 -mt-1 border-4 border-transparent border-t-slate-900" />
</motion.div>
)}
</AnimatePresence>
</div>
))}
</div>
</div>
<div className="flex justify-between items-center py-6 border-y border-slate-50">
{stepIndex > 0 ? (
<button type="button" onClick={prevStep} className="text-base font-bold uppercase tracking-widest text-slate-400 hover:text-slate-900 transition-colors flex items-center gap-2 focus:outline-none relative overflow-hidden rounded-full">
<ChevronLeft size={18} /> Zurück
</button>
) : <div />}
<div className="flex items-center gap-4">
{stepIndex < activeSteps.length - 1 && (
<button type="button" onClick={nextStep} className="text-base font-bold uppercase tracking-widest text-slate-400 hover:text-slate-900 transition-colors flex items-center gap-2 focus:outline-none relative overflow-hidden rounded-full">
Weiter <ChevronRight size={18} />
</button>
)}
</div>
</div>
<form onSubmit={handleSubmit} className="min-h-[450px]">
<AnimatePresence mode="wait"><motion.div key={activeSteps[stepIndex].id} initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: -20 }} transition={{ duration: 0.4, ease: [0.23, 1, 0.32, 1] }}>{renderStepContent()}</motion.div></AnimatePresence>
<div className="flex justify-between mt-16">
{stepIndex > 0 ? (<button type="button" onClick={prevStep} className="flex items-center gap-3 px-10 py-5 rounded-full border border-slate-200 hover:border-slate-900 transition-all font-bold text-xl focus:outline-none overflow-hidden relative rounded-full"><ChevronLeft size={24} /> Zurück</button>) : <div />}
{stepIndex < activeSteps.length - 1 ? (<button type="button" onClick={nextStep} className="flex items-center gap-3 px-10 py-5 rounded-full bg-slate-900 text-white hover:bg-slate-800 transition-all font-bold text-xl shadow-xl shadow-slate-200 focus:outline-none overflow-hidden relative rounded-full">Weiter <ChevronRight size={24} /></button>) : (<button type="submit" disabled={!state.email || !state.name} className="flex items-center gap-3 px-10 py-5 rounded-full bg-slate-900 text-white hover:bg-slate-800 transition-all font-bold text-xl disabled:opacity-50 disabled:cursor-not-allowed shadow-xl shadow-slate-200 focus:outline-none overflow-hidden relative rounded-full">Anfrage senden <Send size={24} /></button>)}
</div>
</form>
</div>
<PriceCalculation
state={state}
totalPrice={totalPrice}
monthlyPrice={monthlyPrice}
totalPagesCount={totalPagesCount}
isClient={isClient}
qrCodeData={qrCodeData}
onShare={handleShare}
/>
<ShareModal
isOpen={isShareModalOpen}
onClose={() => setIsShareModalOpen(false)}
url={currentUrl}
qrCodeData={qrCodeData}
/>
</div>
);
}

View File

@@ -0,0 +1,23 @@
'use client';
import * as React from 'react';
import { useEffect, useRef } from 'react';
import { useSpring, useTransform } from 'framer-motion';
export function AnimatedNumber({ value }: { value: number }) {
const spring = useSpring(value, { stiffness: 50, damping: 20 });
const display = useTransform(spring, (v) => Math.round(v).toLocaleString());
const ref = useRef<HTMLSpanElement>(null);
useEffect(() => {
spring.set(value);
}, [value, spring]);
useEffect(() => {
return display.on('change', (v) => {
if (ref.current) ref.current.textContent = v;
});
}, [display]);
return <span ref={ref}>{value.toLocaleString()}</span>;
}

View File

@@ -0,0 +1,31 @@
'use client';
import * as React from 'react';
import { Check } from 'lucide-react';
interface CheckboxProps {
label: string;
desc: string;
checked: boolean;
onChange: () => void;
}
export function Checkbox({ label, desc, checked, onChange }: CheckboxProps) {
return (
<button
type="button"
onClick={onChange}
className={`p-6 rounded-[2rem] border-2 text-left transition-all duration-300 flex items-start gap-4 focus:outline-none overflow-hidden relative ${
checked ? 'border-slate-900 bg-slate-900 text-white' : 'border-slate-100 bg-white hover:border-slate-200'
}`}
>
<div className={`mt-1 w-6 h-6 rounded-full border-2 flex items-center justify-center shrink-0 ${checked ? 'border-white bg-white text-slate-900' : 'border-slate-200'}`}>
{checked && <Check size={14} strokeWidth={4} />}
</div>
<div>
<h4 className={`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>
</div>
</button>
);
}

View File

@@ -0,0 +1,114 @@
'use client';
import * as React from 'react';
import { FormState } from '../types';
import { PRICING } from '../constants';
import { AnimatedNumber } from './AnimatedNumber';
import { ConceptPrice, ConceptAutomation } from '../../Landing/ConceptIllustrations';
import { Info, Download, Share2 } from 'lucide-react';
import dynamic from 'next/dynamic';
import { EstimationPDF } from '../../EstimationPDF';
// Dynamically import PDF components to avoid SSR issues
const PDFDownloadLink = dynamic(
() => import('@react-pdf/renderer').then((mod) => mod.PDFDownloadLink),
{ ssr: false }
);
interface PriceCalculationProps {
state: FormState;
totalPrice: number;
monthlyPrice: number;
totalPagesCount: number;
isClient: boolean;
qrCodeData: string;
onShare?: () => void;
}
export function PriceCalculation({
state,
totalPrice,
monthlyPrice,
totalPagesCount,
isClient,
qrCodeData,
onShare
}: PriceCalculationProps) {
return (
<div className="lg:col-span-4 lg:sticky lg:top-24">
<div className="p-10 bg-slate-50 border border-slate-100 rounded-[3rem] space-y-10">
<div className="space-y-3"><div className="flex items-center gap-3"><ConceptPrice className="w-8 h-8" /><h3 className="text-2xl font-bold text-slate-900">Kalkulation</h3></div><p className="text-sm text-slate-500 leading-relaxed">Unverbindliche Schätzung.</p></div>
<div className="space-y-6">
{state.projectType === 'website' ? (
<>
<div className="flex justify-between items-center py-4 border-b border-slate-200"><span className="text-slate-600 font-medium">Basis Website</span><span className="font-bold text-lg text-slate-900">{PRICING.BASE_WEBSITE.toLocaleString()} </span></div>
<div className="space-y-4 max-h-[400px] overflow-y-auto pr-2 hide-scrollbar">
{totalPagesCount > 0 && (<div className="flex justify-between items-center text-sm"><span className="text-slate-500">{totalPagesCount}x Seite</span><span className="font-medium text-slate-900">{(totalPagesCount * PRICING.PAGE).toLocaleString()} </span></div>)}
{state.features.length + state.otherFeatures.length > 0 && (<div className="flex justify-between items-center text-sm"><span className="text-slate-500">{state.features.length + state.otherFeatures.length}x System-Modul</span><span className="font-medium text-slate-900">{((state.features.length + state.otherFeatures.length) * PRICING.FEATURE).toLocaleString()} </span></div>)}
{state.functions.length + state.otherFunctions.length > 0 && (<div className="flex justify-between items-center text-sm"><span className="text-slate-500">{state.functions.length + state.otherFunctions.length}x Logik-Funktion</span><span className="font-medium text-slate-900">{((state.functions.length + state.otherFunctions.length) * PRICING.FUNCTION).toLocaleString()} </span></div>)}
{state.complexInteractions > 0 && (<div className="flex justify-between items-center text-sm"><span className="text-slate-500">{state.complexInteractions}x Komplexes UI/Animation</span><span className="font-medium text-slate-900">{(state.complexInteractions * PRICING.COMPLEX_INTERACTION).toLocaleString()} </span></div>)}
{state.apiSystems.length + state.otherTech.length > 0 && (<div className="flex justify-between items-center text-sm"><span className="text-slate-500">{state.apiSystems.length + state.otherTech.length}x API Sync</span><span className="font-medium text-slate-900">{((state.apiSystems.length + state.otherTech.length) * PRICING.API_INTEGRATION).toLocaleString()} </span></div>)}
{state.cmsSetup && (<div className="flex justify-between items-center text-sm"><span className="text-slate-500">CMS Setup & Anbindung</span><span className="font-medium text-slate-900">{(PRICING.CMS_SETUP + (state.features.length + state.otherFeatures.length) * PRICING.CMS_CONNECTION_PER_FEATURE).toLocaleString()} </span></div>)}
{state.newDatasets > 0 && (<div className="flex justify-between items-center text-sm"><span className="text-slate-500">{state.newDatasets}x Inhalte einpflegen</span><span className="font-medium text-slate-900">{(state.newDatasets * PRICING.NEW_DATASET).toLocaleString()} </span></div>)}
{state.languagesCount > 1 && (<div className="flex justify-between items-center text-sm text-slate-900 font-bold pt-2 border-t border-slate-100"><span className="text-slate-500">Mehrsprachigkeit ({state.languagesCount}x)</span><span>+{(totalPrice - (totalPrice / (1 + (state.languagesCount - 1) * 0.2))).toLocaleString()} </span></div>)}
</div>
<div className="pt-8 space-y-2"><div className="flex justify-between items-end"><span className="text-2xl font-bold text-slate-900">Gesamt</span><div className="text-right"><div className="text-4xl font-bold tracking-tighter text-slate-900"><AnimatedNumber value={totalPrice} /> </div><p className="text-[10px] text-slate-400 mt-1 uppercase tracking-widest font-bold">Einmalig / Netto</p></div></div></div>
<div className="pt-8 border-t border-slate-200 space-y-4"><div className="flex justify-between items-center"><span className="text-slate-600 font-medium">Betrieb & Hosting</span><span className="font-bold text-lg text-slate-900">{monthlyPrice.toLocaleString()} / Monat</span></div><div className="p-6 bg-white rounded-[2rem] text-xs text-slate-500 flex gap-4 leading-relaxed border border-slate-100"><Info size={18} className="shrink-0 text-slate-300" /><p>Inklusive Hosting, Sicherheitsupdates, Backups und Analytics-Reports.</p></div></div>
<div className="pt-6 space-y-4">
{isClient && (
<PDFDownloadLink
document={<EstimationPDF state={state} totalPrice={totalPrice} monthlyPrice={monthlyPrice} totalPagesCount={totalPagesCount} pricing={PRICING} qrCodeData={qrCodeData} />}
fileName={`kalkulation-${state.name.toLowerCase().replace(/\s+/g, '-') || 'projekt'}.pdf`}
className="w-full flex items-center justify-center gap-3 px-8 py-4 rounded-full border border-slate-200 text-slate-900 font-bold text-sm uppercase tracking-widest hover:bg-white hover:border-slate-900 transition-all focus:outline-none overflow-hidden relative rounded-full"
>
{({ loading }) => (
<div className="flex items-center gap-3">
<Download size={18} />
<span>{loading ? 'PDF wird erstellt...' : 'Als PDF speichern'}</span>
</div>
)}
</PDFDownloadLink>
)}
{onShare && (
<button
type="button"
onClick={onShare}
className="w-full flex items-center justify-center gap-3 px-8 py-4 rounded-full bg-slate-900 text-white font-bold text-sm uppercase tracking-widest hover:bg-slate-800 transition-all shadow-xl shadow-slate-200 focus:outline-none"
>
<Share2 size={18} />
<span>Konfiguration teilen</span>
</button>
)}
</div>
</>
) : (
<div className="space-y-6">
<div className="py-12 text-center space-y-6">
<div className="w-20 h-20 bg-white rounded-full flex items-center justify-center mx-auto shadow-sm">
<ConceptAutomation className="w-12 h-12" />
</div>
<div className="space-y-2">
<p className="text-slate-600 text-sm leading-relaxed">Web Apps und Individual-Software werden nach tatsächlichem Aufwand abgerechnet.</p>
<p className="text-3xl font-bold text-slate-900">{PRICING.APP_HOURLY} <span className="text-lg text-slate-400 font-normal">/ Std.</span></p>
</div>
</div>
{onShare && (
<button
type="button"
onClick={onShare}
className="w-full flex items-center justify-center gap-3 px-8 py-4 rounded-full bg-slate-900 text-white font-bold text-sm uppercase tracking-widest hover:bg-slate-800 transition-all shadow-xl shadow-slate-200 focus:outline-none"
>
<Share2 size={18} />
<span>Konfiguration teilen</span>
</button>
)}
</div>
)}
</div>
<p className="text-[10px] leading-relaxed text-slate-400 italic text-center">Ein verbindliches Angebot erstelle ich nach einem persönlichen Gespräch.</p>
</div>
</div>
);
}

View File

@@ -0,0 +1,74 @@
'use client';
import * as React from 'react';
import { useState } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { Plus, X } from 'lucide-react';
interface RepeatableListProps {
items: string[];
onAdd: (val: string) => void;
onRemove: (index: number) => void;
placeholder: string;
}
export function RepeatableList({
items,
onAdd,
onRemove,
placeholder
}: RepeatableListProps) {
const [input, setInput] = useState('');
return (
<div className="space-y-6">
<div className="flex gap-3">
<input
type="text"
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
if (input.trim()) {
onAdd(input.trim());
setInput('');
}
}
}}
placeholder={placeholder}
className="flex-1 p-8 bg-white border border-slate-100 rounded-[2rem] focus:outline-none focus:border-slate-900 transition-colors text-lg"
/>
<button
type="button"
onClick={() => {
if (input.trim()) {
onAdd(input.trim());
setInput('');
}
}}
className="w-20 h-20 rounded-full bg-slate-900 text-white flex items-center justify-center hover:bg-slate-800 transition-colors shrink-0 focus:outline-none"
>
<Plus size={32} />
</button>
</div>
<div className="flex flex-wrap gap-3">
<AnimatePresence>
{items.map((item, i) => (
<motion.div
key={i}
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.8 }}
className="flex items-center gap-3 px-6 py-3 bg-slate-100 rounded-full text-base font-bold text-slate-700"
>
<span className="truncate max-w-[300px]">{item}</span>
<button type="button" onClick={() => onRemove(i)} className="text-slate-400 hover:text-slate-900 transition-colors focus:outline-none">
<X size={18} />
</button>
</motion.div>
))}
</AnimatePresence>
</div>
</div>
);
}

View File

@@ -0,0 +1,158 @@
import * as React from 'react';
import { FormState } from './types';
export const PRICING = {
BASE_WEBSITE: 6000,
PAGE: 800,
FEATURE: 2000,
FUNCTION: 1000,
COMPLEX_INTERACTION: 1500,
NEW_DATASET: 400,
HOSTING_MONTHLY: 120,
STORAGE_EXPANSION_MONTHLY: 10,
CMS_SETUP: 1500,
CMS_CONNECTION_PER_FEATURE: 800,
API_INTEGRATION: 1000,
APP_HOURLY: 120,
};
export const initialState: FormState = {
projectType: 'website',
selectedPages: ['Home'],
otherPages: [],
features: [],
otherFeatures: [],
functions: [],
otherFunctions: [],
apiSystems: [],
otherTech: [],
assets: [],
otherAssets: [],
complexInteractions: 0,
newDatasets: 0,
cmsSetup: false,
storageExpansion: 0,
name: '',
email: '',
role: '',
message: '',
sitemapFile: null,
contactFiles: [],
designVibe: 'minimal',
colorScheme: ['#ffffff', '#f8fafc', '#0f172a'],
references: [],
designWishes: '',
expectedAdjustments: 'low',
languagesCount: 1,
deadline: 'flexible',
targetAudience: 'internal',
userRoles: [],
dataSensitivity: 'standard',
platformType: 'web-only',
};
export const PAGE_SAMPLES = [
{ id: 'Home', label: 'Startseite', desc: 'Der erste Eindruck Ihrer Marke.' },
{ id: 'About', label: 'Über uns', desc: 'Ihre Geschichte und Ihr Team.' },
{ id: 'Services', label: 'Leistungen', desc: 'Übersicht Ihres Angebots.' },
{ id: 'Contact', label: 'Kontakt', desc: 'Anlaufstelle für Ihre Kunden.' },
{ id: 'Landing', label: 'Landingpage', desc: 'Optimiert für Marketing-Kampagnen.' },
{ id: 'Legal', label: 'Rechtliches', desc: 'Impressum & Datenschutz.' },
];
export const FEATURE_OPTIONS = [
{ id: 'blog_news', label: 'Blog / News', desc: 'Ein Bereich für aktuelle Beiträge und Neuigkeiten.' },
{ id: 'products', label: 'Produktbereich', desc: 'Katalog Ihrer Leistungen oder Produkte.' },
{ id: 'jobs', label: 'Karriere / Jobs', desc: 'Stellenanzeigen und Bewerbungsoptionen.' },
{ id: 'refs', label: 'Referenzen / Cases', desc: 'Präsentation Ihrer Projekte.' },
{ id: 'events', label: 'Events / Termine', desc: 'Veranstaltungskalender.' },
];
export const FUNCTION_OPTIONS = [
{ id: 'search', label: 'Suche', desc: 'Volltextsuche über alle Inhalte.' },
{ id: 'filter', label: 'Filter-Systeme', desc: 'Kategorisierung und Sortierung.' },
{ id: 'pdf', label: 'PDF-Export', desc: 'Automatisierte PDF-Erstellung.' },
{ id: 'forms', label: 'Erweiterte Formulare', desc: 'Komplexe Abfragen & Logik.' },
];
export const API_OPTIONS = [
{ id: 'crm', label: 'CRM System', desc: 'HubSpot, Salesforce, Pipedrive etc.' },
{ id: 'erp', label: 'ERP / Warenwirtschaft', desc: 'SAP, Microsoft Dynamics, Xentral etc.' },
{ id: 'stripe', label: 'Stripe / Payment', desc: 'Zahlungsabwicklung und Abonnements.' },
{ id: 'newsletter', label: 'Newsletter / Marketing', desc: 'Mailchimp, Brevo, ActiveCampaign etc.' },
{ id: 'ecommerce', label: 'E-Commerce / Shop', desc: 'Shopify, WooCommerce, Shopware Sync.' },
{ id: 'hr', label: 'HR / Recruiting', desc: 'Personio, Workday, Recruitee etc.' },
{ id: 'realestate', label: 'Immobilien', desc: 'OpenImmo, FlowFact, Immowelt Sync.' },
{ id: 'calendar', label: 'Termine / Booking', desc: 'Calendly, Shore, Doctolib etc.' },
{ id: 'social', label: 'Social Media Sync', desc: 'Automatisierte Posts oder Feeds.' },
];
export const ASSET_OPTIONS = [
{ id: 'logo', label: 'Logo', desc: 'Vektordatei Ihres Logos.' },
{ id: 'styleguide', label: 'Styleguide', desc: 'Farben, Schriften, Design-Vorgaben.' },
{ id: 'content_concept', label: 'Inhalts-Konzept', desc: 'Struktur und Texte sind bereits geplant.' },
{ id: 'media', label: 'Bild/Video-Material', desc: 'Professionelles Bildmaterial vorhanden.' },
{ id: 'icons', label: 'Icons', desc: 'Eigene Icon-Sets vorhanden.' },
{ id: 'illustrations', label: 'Illustrationen', desc: 'Eigene Illustrationen vorhanden.' },
{ id: 'fonts', label: 'Fonts', desc: 'Lizenzen für Hausschriften vorhanden.' },
];
export const DESIGN_VIBES = [
{
id: 'minimal',
label: 'Minimalistisch',
desc: 'Viel Weißraum, klare Typografie.',
illustration: (
<svg viewBox="0 0 100 60" className="w-full h-full opacity-40">
<rect x="10" y="10" width="80" height="2" rx="1" className="fill-current" />
<rect x="10" y="20" width="50" height="2" rx="1" className="fill-current" />
<rect x="10" y="40" width="30" height="10" rx="1" className="fill-current" />
</svg>
)
},
{
id: 'bold',
label: 'Mutig & Laut',
desc: 'Starke Kontraste, große Schriften.',
illustration: (
<svg viewBox="0 0 100 60" className="w-full h-full opacity-40">
<rect x="10" y="10" width="80" height="15" rx="2" className="fill-current" />
<rect x="10" y="35" width="80" height="15" rx="2" className="fill-current" />
</svg>
)
},
{
id: 'nature',
label: 'Natürlich',
desc: 'Sanfte Erdtöne, organische Formen.',
illustration: (
<svg viewBox="0 0 100 60" className="w-full h-full opacity-40">
<circle cx="30" cy="30" r="20" className="fill-current" />
<circle cx="70" cy="30" r="15" className="fill-current" />
</svg>
)
},
{
id: 'tech',
label: 'Technisch',
desc: 'Präzise Linien, dunkle Akzente.',
illustration: (
<svg viewBox="0 0 100 60" className="w-full h-full opacity-40">
<path d="M10 10 L90 10 L90 50 L10 50 Z" fill="none" stroke="currentColor" strokeWidth="2" />
<path d="M10 30 L90 30" stroke="currentColor" strokeWidth="1" strokeDasharray="4 2" />
<path d="M50 10 L50 50" stroke="currentColor" strokeWidth="1" strokeDasharray="4 2" />
</svg>
)
},
];
export const HARMONIOUS_PALETTES = [
['#ffffff', '#f8fafc', '#0f172a'],
['#000000', '#facc15', '#ffffff'],
['#fdfcfb', '#e2e8f0', '#1e293b'],
['#0f172a', '#38bdf8', '#ffffff'],
['#fafaf9', '#78716c', '#1c1917'],
['#f0fdf4', '#16a34a', '#064e3b'],
['#fff7ed', '#ea580c', '#7c2d12'],
['#f5f3ff', '#7c3aed', '#2e1065'],
];

View File

@@ -0,0 +1,67 @@
'use client';
import * as React from 'react';
import { FormState } from '../types';
import { Checkbox } from '../components/Checkbox';
import { RepeatableList } from '../components/RepeatableList';
interface ApiStepProps {
state: FormState;
updateState: (updates: Partial<FormState>) => void;
toggleItem: (list: string[], id: string) => string[];
}
export function ApiStep({ state, updateState, toggleItem }: ApiStepProps) {
const isWebApp = state.projectType === 'web-app';
return (
<div className="space-y-12">
<div className="space-y-6">
<h4 className="text-2xl font-bold text-slate-900">
{isWebApp ? 'Integrationen & Datenquellen' : 'Schnittstellen (API)'}
</h4>
<p className="text-lg text-slate-500 leading-relaxed">
{isWebApp
? 'Mit welchen Systemen soll die Web App kommunizieren?'
: 'Datenaustausch mit Drittsystemen zur Automatisierung.'}
</p>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<Checkbox
label="CRM / ERP" desc="HubSpot, Salesforce, SAP, Xentral etc."
checked={state.apiSystems.includes('crm_erp')}
onChange={() => updateState({ apiSystems: toggleItem(state.apiSystems, 'crm_erp') })}
/>
<Checkbox
label="Payment" desc="Stripe, PayPal, Klarna Integration."
checked={state.apiSystems.includes('payment')}
onChange={() => updateState({ apiSystems: toggleItem(state.apiSystems, 'payment') })}
/>
<Checkbox
label="Marketing" desc="Newsletter (Mailchimp), Social Media Sync."
checked={state.apiSystems.includes('marketing')}
onChange={() => updateState({ apiSystems: toggleItem(state.apiSystems, 'marketing') })}
/>
<Checkbox
label="E-Commerce" desc="Shopify, WooCommerce, Lagerbestand-Sync."
checked={state.apiSystems.includes('ecommerce')}
onChange={() => updateState({ apiSystems: toggleItem(state.apiSystems, 'ecommerce') })}
/>
</div>
</div>
<div className="space-y-6">
<p className="text-lg font-bold text-slate-900">Weitere Systeme oder eigene APIs?</p>
<RepeatableList
items={state.otherTech}
onAdd={(v) => updateState({ otherTech: [...state.otherTech, v] })}
onRemove={(i) => updateTech(i)}
placeholder="z.B. Microsoft Graph, Google Maps, Custom REST API..."
/>
</div>
</div>
);
function updateTech(index: number) {
updateState({ otherTech: state.otherTech.filter((_, idx) => idx !== index) });
}
}

View File

@@ -0,0 +1,38 @@
'use client';
import * as React from 'react';
import { FormState } from '../types';
import { ASSET_OPTIONS } from '../constants';
import { Checkbox } from '../components/Checkbox';
import { RepeatableList } from '../components/RepeatableList';
interface AssetsStepProps {
state: FormState;
updateState: (updates: Partial<FormState>) => void;
toggleItem: (list: string[], id: string) => string[];
}
export function AssetsStep({ state, updateState, toggleItem }: AssetsStepProps) {
return (
<div className="space-y-12">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{ASSET_OPTIONS.map(opt => (
<Checkbox
key={opt.id} label={opt.label} desc={opt.desc}
checked={state.assets.includes(opt.id)}
onChange={() => updateState({ assets: toggleItem(state.assets, opt.id) })}
/>
))}
</div>
<div className="space-y-6">
<p className="text-lg font-bold text-slate-900">Weitere vorhandene Unterlagen?</p>
<RepeatableList
items={state.otherAssets}
onAdd={(v) => updateState({ otherAssets: [...state.otherAssets, v] })}
onRemove={(i) => updateState({ otherAssets: state.otherAssets.filter((_, idx) => idx !== i) })}
placeholder="z.B. Lastenheft, Wireframes, Bilddatenbank..."
/>
</div>
</div>
);
}

View File

@@ -0,0 +1,44 @@
'use client';
import * as React from 'react';
import { FormState } from '../types';
import { Checkbox } from '../components/Checkbox';
import { RepeatableList } from '../components/RepeatableList';
interface BaseStepProps {
state: FormState;
updateState: (updates: Partial<FormState>) => void;
toggleItem: (list: string[], id: string) => string[];
}
export function BaseStep({ state, updateState, toggleItem }: BaseStepProps) {
return (
<div className="space-y-12">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{[
{ id: 'Home', label: 'Startseite', desc: 'Der erste Eindruck Ihrer Marke.' },
{ id: 'About', label: 'Über uns', desc: 'Ihre Geschichte und Ihr Team.' },
{ id: 'Services', label: 'Leistungen', desc: 'Übersicht Ihres Angebots.' },
{ id: 'Contact', label: 'Kontakt', desc: 'Anlaufstelle für Ihre Kunden.' },
{ id: 'Landing', label: 'Landingpage', desc: 'Optimiert für Marketing-Kampagnen.' },
{ id: 'Legal', label: 'Rechtliches', desc: 'Impressum & Datenschutz.' },
].map(opt => (
<Checkbox
key={opt.id} label={opt.label} desc={opt.desc}
checked={state.selectedPages.includes(opt.id)}
onChange={() => updateState({ selectedPages: toggleItem(state.selectedPages, opt.id) })}
/>
))}
</div>
<div className="space-y-6">
<p className="text-lg font-bold text-slate-900">Weitere individuelle Seiten?</p>
<RepeatableList
items={state.otherPages}
onAdd={(v) => updateState({ otherPages: [...state.otherPages, v] })}
onRemove={(i) => updateState({ otherPages: state.otherPages.filter((_, idx) => idx !== i) })}
placeholder="z.B. Karriere, FAQ, Team-Detail..."
/>
</div>
</div>
);
}

View File

@@ -0,0 +1,103 @@
'use client';
import * as React from 'react';
import { FormState } from '../types';
import { FileText, Upload, X } from 'lucide-react';
interface ContactStepProps {
state: FormState;
updateState: (updates: Partial<FormState>) => void;
}
export function ContactStep({ state, updateState }: ContactStepProps) {
return (
<div className="space-y-10">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<input
type="text"
placeholder="Ihr Name"
required
value={state.name}
onChange={(e) => updateState({ name: e.target.value })}
className="w-full p-8 bg-white border border-slate-100 rounded-[2rem] focus:outline-none focus:border-slate-900 transition-colors text-lg"
/>
<input
type="email"
placeholder="Ihre Email"
required
value={state.email}
onChange={(e) => updateState({ email: e.target.value })}
className="w-full p-8 bg-white border border-slate-100 rounded-[2rem] focus:outline-none focus:border-slate-900 transition-colors text-lg"
/>
</div>
<input
type="text"
placeholder="Ihre Rolle (z.B. CEO, Marketing Manager...)"
value={state.role}
onChange={(e) => updateState({ role: e.target.value })}
className="w-full p-8 bg-white border border-slate-100 rounded-[2rem] focus:outline-none focus:border-slate-900 transition-colors text-lg"
/>
<textarea
placeholder="Erzählen Sie mir kurz von Ihrem Projekt..."
value={state.message}
onChange={(e) => updateState({ message: e.target.value })}
rows={5}
className="w-full p-8 bg-white border border-slate-100 rounded-[2rem] focus:outline-none focus:border-slate-900 transition-colors resize-none text-lg"
/>
<div className="space-y-6">
<p className="text-lg font-bold text-slate-900">Dateien hochladen (optional)</p>
<div
className={`relative group border-2 border-dashed rounded-[3rem] p-10 transition-all duration-300 flex flex-col items-center justify-center gap-6 cursor-pointer min-h-[200px] ${
state.contactFiles.length > 0 ? 'border-slate-900 bg-slate-50' : 'border-slate-200 hover:border-slate-400 bg-white'
}`}
onDragOver={(e) => { e.preventDefault(); e.stopPropagation(); }}
onDrop={(e) => {
e.preventDefault();
e.stopPropagation();
const files = Array.from(e.dataTransfer.files);
if (files.length > 0) updateState({ contactFiles: [...state.contactFiles, ...files] });
}}
onClick={() => document.getElementById('contact-upload')?.click()}
>
<input id="contact-upload" type="file" multiple className="hidden" onChange={(e) => {
const files = e.target.files ? Array.from(e.target.files) : [];
if (files.length > 0) updateState({ contactFiles: [...state.contactFiles, ...files] });
}} />
{state.contactFiles.length > 0 ? (
<div className="w-full space-y-3">
{state.contactFiles.map((file, idx) => (
<div key={idx} className="flex items-center justify-between p-4 bg-white border border-slate-100 rounded-2xl shadow-sm">
<div className="flex items-center gap-4 text-slate-900">
<FileText size={24} className="text-slate-400" />
<span className="font-bold text-base truncate max-w-[250px]">{file.name}</span>
</div>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
updateState({ contactFiles: state.contactFiles.filter((_, i) => i !== idx) });
}}
className="p-2 hover:bg-slate-100 rounded-full transition-colors focus:outline-none"
>
<X size={20} />
</button>
</div>
))}
<p className="text-xs text-slate-400 text-center mt-6">Klicken oder ziehen, um weitere Dateien hinzuzufügen</p>
</div>
) : (
<>
<Upload size={48} className="text-slate-400 group-hover:text-slate-900 transition-colors" />
<div className="text-center">
<p className="text-lg font-bold text-slate-900">Dateien hierher ziehen</p>
<p className="text-base text-slate-500 mt-1">oder klicken zum Auswählen</p>
</div>
</>
)}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,83 @@
'use client';
import * as React from 'react';
import { FormState } from '../types';
import { Zap, AlertCircle, Minus, Plus } from 'lucide-react';
interface ContentStepProps {
state: FormState;
updateState: (updates: Partial<FormState>) => void;
}
export function ContentStep({ state, updateState }: ContentStepProps) {
return (
<div className="space-y-12">
<div className="flex items-center justify-between p-10 bg-white border border-slate-100 rounded-[3rem]">
<div className="max-w-[70%]">
<h4 className="text-2xl font-bold text-slate-900">Inhalte selbst verwalten (CMS)</h4>
<p className="text-lg text-slate-500 mt-2">Möchten Sie Datensätze (z.B. Blogartikel, Produkte) selbst über eine einfache Oberfläche pflegen?</p>
</div>
<button
type="button"
onClick={() => updateState({ cmsSetup: !state.cmsSetup })}
className={`w-20 h-11 rounded-full transition-colors relative focus:outline-none ${state.cmsSetup ? 'bg-slate-900' : 'bg-slate-200'}`}
>
<div className={`absolute top-1.5 left-1.5 w-8 h-8 bg-white rounded-full transition-transform ${state.cmsSetup ? 'translate-x-9' : ''}`} />
</button>
</div>
<div className="p-10 bg-slate-50 rounded-[3rem] border border-slate-100 space-y-6">
<p className="text-lg font-bold text-slate-900">Wie oft ändern sich Ihre Inhalte?</p>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
{[
{ id: 'low', label: 'Selten', desc: 'Wenige Male im Jahr.' },
{ id: 'medium', label: 'Regelmäßig', desc: 'Monatliche Updates.' },
{ id: 'high', label: 'Häufig', desc: 'Wöchentlich oder täglich.' },
].map(opt => (
<button
key={opt.id}
type="button"
onClick={() => updateState({ expectedAdjustments: opt.id })}
className={`p-6 rounded-2xl border-2 text-left transition-all focus:outline-none ${
state.expectedAdjustments === opt.id ? 'border-slate-900 bg-slate-900 text-white' : 'border-slate-200 bg-white hover:border-slate-400'
}`}
>
<p className="font-bold text-lg">{opt.label}</p>
<p className={`text-sm mt-1 ${state.expectedAdjustments === opt.id ? 'text-slate-200' : 'text-slate-500'}`}>{opt.desc}</p>
</button>
))}
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-8 mt-8">
<div className="p-8 bg-white rounded-[2rem] border border-slate-100 space-y-3">
<div className="flex items-center gap-3 text-slate-900 font-bold text-sm uppercase tracking-wider">
<Zap size={18} /> Vorteil CMS
</div>
<p className="text-sm text-slate-500 leading-relaxed">
Volle Kontrolle über Ihre Inhalte und keine laufenden Kosten für kleine Textänderungen oder neue Blog-Beiträge.
</p>
</div>
<div className="p-8 bg-white rounded-[2rem] border border-slate-100 space-y-3">
<div className="flex items-center gap-3 text-slate-900 font-bold text-sm uppercase tracking-wider">
<AlertCircle size={18} /> Fokus Design
</div>
<p className="text-sm text-slate-500 leading-relaxed">
Ohne CMS bleibt die technische Komplexität geringer und das Design ist maximal geschützt vor ungewollten Änderungen.
</p>
</div>
</div>
</div>
<div className="flex flex-col gap-6 p-10 bg-white border border-slate-100 rounded-[3rem]">
<div>
<h4 className="text-2xl font-bold text-slate-900">Inhalte einpflegen</h4>
<p className="text-lg text-slate-500 mt-2 leading-relaxed">Für wie viele Datensätze soll ich die initiale Befüllung übernehmen?</p>
</div>
<div className="flex items-center gap-12 mt-2">
<button type="button" onClick={() => updateState({ newDatasets: Math.max(0, state.newDatasets - 1) })} className="w-16 h-16 rounded-full bg-slate-50 border border-slate-100 flex items-center justify-center hover:border-slate-900 transition-colors focus:outline-none"><Minus size={28} /></button>
<span className="text-5xl font-bold w-16 text-center">{state.newDatasets}</span>
<button type="button" onClick={() => updateState({ newDatasets: state.newDatasets + 1 })} className="w-16 h-16 rounded-full bg-slate-50 border border-slate-100 flex items-center justify-center hover:border-slate-900 transition-colors focus:outline-none"><Plus size={28} /></button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,70 @@
'use client';
import * as React from 'react';
import { FormState } from '../types';
import { DESIGN_VIBES, HARMONIOUS_PALETTES } from '../constants';
import { motion } from 'framer-motion';
interface DesignStepProps {
state: FormState;
updateState: (updates: Partial<FormState>) => void;
}
export function DesignStep({ state, updateState }: DesignStepProps) {
return (
<div className="space-y-12">
<div className="space-y-6">
<h4 className="text-2xl font-bold text-slate-900">Design-Richtung</h4>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{DESIGN_VIBES.map((vibe) => (
<button
key={vibe.id}
type="button"
onClick={() => updateState({ designVibe: vibe.id })}
className={`p-8 rounded-[2rem] border-2 text-left transition-all duration-300 focus:outline-none overflow-hidden relative ${
state.designVibe === vibe.id ? 'border-slate-900 bg-slate-900 text-white' : 'border-slate-100 bg-white hover:border-slate-200'
}`}
>
<div className={`w-16 h-10 mb-4 ${state.designVibe === vibe.id ? 'text-white' : 'text-slate-900'}`}>{vibe.illustration}</div>
<h4 className={`text-xl font-bold mb-2 ${state.designVibe === vibe.id ? 'text-white' : 'text-slate-900'}`}>{vibe.label}</h4>
<p className={`text-base leading-relaxed ${state.designVibe === vibe.id ? 'text-slate-200' : 'text-slate-500'}`}>{vibe.desc}</p>
</button>
))}
</div>
</div>
<div className="space-y-6">
<h4 className="text-2xl font-bold text-slate-900">Farbschema</h4>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
{HARMONIOUS_PALETTES.map((palette, i) => (
<button
key={i}
type="button"
onClick={() => updateState({ colorScheme: palette })}
className={`p-4 rounded-2xl border-2 transition-all ${
JSON.stringify(state.colorScheme) === JSON.stringify(palette) ? 'border-slate-900 bg-slate-50' : 'border-slate-100 bg-white hover:border-slate-200'
}`}
>
<div className="flex h-12 w-full rounded-lg overflow-hidden">
{palette.map((color, j) => (
<div key={j} className="flex-1" style={{ backgroundColor: color }} />
))}
</div>
</button>
))}
</div>
</div>
<div className="space-y-6">
<h4 className="text-2xl font-bold text-slate-900">Individuelle Wünsche</h4>
<textarea
placeholder="Haben Sie bereits konkrete Vorstellungen oder Referenzen?"
value={state.designWishes}
onChange={(e) => updateState({ designWishes: e.target.value })}
rows={4}
className="w-full p-8 bg-white border border-slate-100 rounded-[2rem] focus:outline-none focus:border-slate-900 transition-colors resize-none text-lg"
/>
</div>
</div>
);
}

View File

@@ -0,0 +1,38 @@
'use client';
import * as React from 'react';
import { FormState } from '../types';
import { Checkbox } from '../components/Checkbox';
import { RepeatableList } from '../components/RepeatableList';
import { FEATURE_OPTIONS } from '../constants';
interface FeaturesStepProps {
state: FormState;
updateState: (updates: Partial<FormState>) => void;
toggleItem: (list: string[], id: string) => string[];
}
export function FeaturesStep({ state, updateState, toggleItem }: FeaturesStepProps) {
return (
<div className="space-y-12">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{FEATURE_OPTIONS.map(opt => (
<Checkbox
key={opt.id} label={opt.label} desc={opt.desc}
checked={state.features.includes(opt.id)}
onChange={() => updateState({ features: toggleItem(state.features, opt.id) })}
/>
))}
</div>
<div className="space-y-6">
<p className="text-lg font-bold text-slate-900">Weitere inhaltliche Module?</p>
<RepeatableList
items={state.otherFeatures}
onAdd={(v) => updateState({ otherFeatures: [...state.otherFeatures, v] })}
onRemove={(i) => updateState({ otherFeatures: state.otherFeatures.filter((_, idx) => idx !== i) })}
placeholder="z.B. Glossar, Download-Center, Partner-Bereich..."
/>
</div>
</div>
);
}

View File

@@ -0,0 +1,102 @@
'use client';
import * as React from 'react';
import { FormState } from '../types';
import { Checkbox } from '../components/Checkbox';
import { RepeatableList } from '../components/RepeatableList';
import { Minus, Plus } from 'lucide-react';
interface FunctionsStepProps {
state: FormState;
updateState: (updates: Partial<FormState>) => void;
toggleItem: (list: string[], id: string) => string[];
}
export function FunctionsStep({ state, updateState, toggleItem }: FunctionsStepProps) {
const isWebApp = state.projectType === 'web-app';
return (
<div className="space-y-12">
<div className="space-y-6">
<h4 className="text-2xl font-bold text-slate-900">
{isWebApp ? 'Funktionale Anforderungen' : 'Erweiterte Funktionen'}
</h4>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{isWebApp ? (
<>
<Checkbox
label="Dashboard & Analytics" desc="Visualisierung von Daten und Kennzahlen."
checked={state.functions.includes('dashboard')}
onChange={() => updateState({ functions: toggleItem(state.functions, 'dashboard') })}
/>
<Checkbox
label="Dateiverwaltung" desc="Upload, Download und Organisation von Dokumenten."
checked={state.functions.includes('files')}
onChange={() => updateState({ functions: toggleItem(state.functions, 'files') })}
/>
<Checkbox
label="Benachrichtigungen" desc="E-Mail, Push oder In-App Alerts."
checked={state.functions.includes('notifications')}
onChange={() => updateState({ functions: toggleItem(state.functions, 'notifications') })}
/>
<Checkbox
label="Export-Funktionen" desc="CSV, Excel oder PDF Generierung."
checked={state.functions.includes('export')}
onChange={() => updateState({ functions: toggleItem(state.functions, 'export') })}
/>
</>
) : (
<>
<Checkbox
label="Suche" desc="Volltextsuche über alle Inhalte."
checked={state.functions.includes('search')}
onChange={() => updateState({ functions: toggleItem(state.functions, 'search') })}
/>
<Checkbox
label="Filter-Systeme" desc="Kategorisierung und Sortierung."
checked={state.functions.includes('filter')}
onChange={() => updateState({ functions: toggleItem(state.functions, 'filter') })}
/>
<Checkbox
label="PDF-Export" desc="Automatisierte PDF-Erstellung."
checked={state.functions.includes('pdf')}
onChange={() => updateState({ functions: toggleItem(state.functions, 'pdf') })}
/>
<Checkbox
label="Erweiterte Formulare" desc="Komplexe Abfragen & Logik."
checked={state.functions.includes('forms')}
onChange={() => updateState({ functions: toggleItem(state.functions, 'forms') })}
/>
</>
)}
</div>
</div>
<div className="space-y-6">
<p className="text-lg font-bold text-slate-900">Weitere spezifische Wünsche?</p>
<RepeatableList
items={state.otherFunctions}
onAdd={(v) => updateState({ otherFunctions: [...state.otherFunctions, v] })}
onRemove={(i) => updateState({ otherFunctions: state.otherFunctions.filter((_, idx) => idx !== i) })}
placeholder={isWebApp ? "z.B. Echtzeit-Chat, KI-Anbindung, Offline-Modus..." : "z.B. Login-Bereich, Buchungssystem..."}
/>
</div>
{!isWebApp && (
<div className="p-10 bg-white border border-slate-100 rounded-[3rem] space-y-6">
<div className="flex justify-between items-start">
<div>
<h4 className="text-2xl font-bold text-slate-900">Besondere Interaktionen</h4>
<p className="text-lg text-slate-500 mt-2">Aufwendige Animationen oder komplexe UI-Logik pro Abschnitt.</p>
</div>
<div className="flex items-center gap-8">
<button type="button" onClick={() => updateState({ complexInteractions: Math.max(0, state.complexInteractions - 1) })} className="w-12 h-12 rounded-full bg-slate-50 border border-slate-100 flex items-center justify-center hover:border-slate-900 transition-colors focus:outline-none"><Minus size={24} /></button>
<span className="text-4xl font-bold w-12 text-center">{state.complexInteractions}</span>
<button type="button" onClick={() => updateState({ complexInteractions: state.complexInteractions + 1 })} className="w-12 h-12 rounded-full bg-slate-50 border border-slate-100 flex items-center justify-center hover:border-slate-900 transition-colors focus:outline-none"><Plus size={24} /></button>
</div>
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,91 @@
'use client';
import * as React from 'react';
import { FormState } from '../types';
import { Globe, Minus, Plus, Info } from 'lucide-react';
import { motion } from 'framer-motion';
interface LanguageStepProps {
state: FormState;
updateState: (updates: Partial<FormState>) => void;
}
export function LanguageStep({ state, updateState }: LanguageStepProps) {
const basePriceExplanation = "Jede zusätzliche Sprache erhöht den Gesamtaufwand für Design, Entwicklung und Qualitätssicherung um ca. 20%. Dies deckt die technische Implementierung der Übersetzungsschicht sowie die Anpassung von Layouts für unterschiedliche Textlängen ab.";
return (
<div className="space-y-12">
<div className="p-10 bg-white border border-slate-100 rounded-[3rem] space-y-8">
<div className="flex items-center gap-6">
<div className="w-16 h-16 bg-slate-50 rounded-2xl flex items-center justify-center text-slate-900">
<Globe size={32} />
</div>
<div>
<h4 className="text-2xl font-bold text-slate-900">Mehrsprachigkeit</h4>
<p className="text-lg text-slate-500">In wie vielen Sprachen soll Ihre Website verfügbar sein?</p>
</div>
</div>
<div className="flex items-center gap-12 py-6">
<button
type="button"
onClick={() => updateState({ languagesCount: Math.max(1, state.languagesCount - 1) })}
className="w-20 h-20 rounded-full bg-slate-50 border border-slate-100 flex items-center justify-center hover:border-slate-900 transition-colors focus:outline-none"
>
<Minus size={32} />
</button>
<div className="flex flex-col items-center">
<span className="text-7xl font-bold text-slate-900">{state.languagesCount}</span>
<span className="text-sm font-bold uppercase tracking-widest text-slate-400 mt-3">
{state.languagesCount === 1 ? 'Sprache' : 'Sprachen'}
</span>
</div>
<button
type="button"
onClick={() => updateState({ languagesCount: state.languagesCount + 1 })}
className="w-20 h-20 rounded-full bg-slate-50 border border-slate-100 flex items-center justify-center hover:border-slate-900 transition-colors focus:outline-none"
>
<Plus size={32} />
</button>
</div>
</div>
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
className="p-10 bg-slate-900 text-white rounded-[3rem] space-y-6"
>
<div className="flex items-center gap-4 text-slate-400">
<Info size={24} />
<span className="text-sm font-bold uppercase tracking-widest">Warum dieser Faktor?</span>
</div>
<p className="text-lg leading-relaxed text-slate-300">
{basePriceExplanation}
</p>
{state.languagesCount > 1 && (
<div className="pt-6 border-t border-white/10">
<div className="flex justify-between items-center">
<span className="text-lg font-medium">Aktueller Aufschlagsfaktor:</span>
<span className="text-3xl font-bold text-white">+{((state.languagesCount - 1) * 20)}%</span>
</div>
</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>
);
}

View File

@@ -0,0 +1,45 @@
'use client';
import * as React from 'react';
import { FormState } from '../types';
import { AlertCircle } from 'lucide-react';
interface TimelineStepProps {
state: FormState;
updateState: (updates: Partial<FormState>) => void;
}
export function TimelineStep({ state, updateState }: TimelineStepProps) {
return (
<div className="space-y-12">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{[
{ id: 'asap', label: 'So schnell wie möglich', desc: 'Priorisierter Start gewünscht.' },
{ id: '2-3-months', label: 'In 2-3 Monaten', desc: 'Normaler Projektvorlauf.' },
{ id: '3-6-months', label: 'In 3-6 Monaten', desc: 'Langfristige Planung.' },
{ id: 'flexible', label: 'Flexibel', desc: 'Kein fester Termindruck.' },
].map(opt => (
<button
key={opt.id}
type="button"
onClick={() => updateState({ deadline: opt.id })}
className={`p-10 rounded-[2.5rem] border-2 text-left transition-all focus:outline-none overflow-hidden relative ${
state.deadline === opt.id ? 'border-slate-900 bg-slate-900 text-white' : 'border-slate-100 bg-white hover:border-slate-200'
}`}
>
<h4 className={`text-2xl font-bold mb-2 ${state.deadline === opt.id ? 'text-white' : 'text-slate-900'}`}>{opt.label}</h4>
<p className={`text-lg ${state.deadline === opt.id ? 'text-slate-200' : 'text-slate-500'}`}>{opt.desc}</p>
</button>
))}
</div>
{state.deadline === 'asap' && (
<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} />
<p className="text-base text-slate-600 leading-relaxed">
<strong>Hinweis:</strong> Bei sehr kurzfristigen Deadlines kann ein Express-Zuschlag anfallen, um die Kapazitäten entsprechend zu priorisieren.
</p>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,34 @@
'use client';
import * as React from 'react';
import { FormState, ProjectType } from '../types';
import { ConceptWebsite, ConceptSystem } from '../../Landing/ConceptIllustrations';
interface TypeStepProps {
state: FormState;
updateState: (updates: Partial<FormState>) => void;
}
export function TypeStep({ state, updateState }: TypeStepProps) {
return (
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{[
{ id: 'website', label: 'Website', desc: 'Klassische Webpräsenz, Portfolio oder Blog.', illustration: <ConceptWebsite className="w-16 h-16 mb-4" /> },
{ id: 'web-app', label: 'Web App', desc: 'Internes Tool, Dashboard oder Prozess-Logik.', illustration: <ConceptSystem className="w-16 h-16 mb-4" /> },
].map((type) => (
<button
key={type.id}
type="button"
onClick={() => updateState({ projectType: type.id as ProjectType })}
className={`p-10 rounded-[2.5rem] border-2 text-left transition-all duration-300 focus:outline-none overflow-hidden relative ${
state.projectType === type.id ? 'border-slate-900 bg-slate-900 text-white' : 'border-slate-100 bg-white hover:border-slate-200'
}`}
>
<div className={state.projectType === type.id ? 'text-white' : 'text-slate-900'}>{type.illustration}</div>
<h4 className={`text-3xl font-bold mb-3 ${state.projectType === type.id ? 'text-white' : 'text-slate-900'}`}>{type.label}</h4>
<p className={`text-xl leading-relaxed ${state.projectType === type.id ? 'text-slate-200' : 'text-slate-500'}`}>{type.desc}</p>
</button>
))}
</div>
);
}

View File

@@ -0,0 +1,139 @@
'use client';
import * as React from 'react';
import { FormState } from '../types';
import { Users, Shield, Monitor, Smartphone, Globe, Lock } from 'lucide-react';
interface WebAppStepProps {
state: FormState;
updateState: (updates: Partial<FormState>) => void;
}
export function WebAppStep({ state, updateState }: WebAppStepProps) {
const toggleUserRole = (role: string) => {
const current = state.userRoles || [];
const next = current.includes(role)
? current.filter(r => r !== role)
: [...current, role];
updateState({ userRoles: next });
};
return (
<div className="space-y-12">
{/* Target Audience */}
<div className="space-y-6">
<h4 className="text-2xl font-bold text-slate-900 flex items-center gap-3">
<Users size={24} /> Zielgruppe
</h4>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{[
{ id: 'internal', label: 'Internes Tool', desc: 'Für Mitarbeiter & Prozesse.' },
{ id: 'external', label: 'Kunden-Portal', desc: 'Für Ihre Endnutzer (B2B/B2C).' },
].map(opt => (
<button
key={opt.id}
type="button"
onClick={() => updateState({ targetAudience: opt.id })}
className={`p-8 rounded-[2rem] border-2 text-left transition-all ${
state.targetAudience === opt.id ? 'border-slate-900 bg-slate-900 text-white' : 'border-slate-100 bg-white hover:border-slate-200'
}`}
>
<p className="text-xl font-bold">{opt.label}</p>
<p className={`text-base mt-2 ${state.targetAudience === opt.id ? 'text-slate-200' : 'text-slate-500'}`}>{opt.desc}</p>
</button>
))}
</div>
</div>
{/* User Roles */}
<div className="space-y-6">
<h4 className="text-2xl font-bold text-slate-900">Benutzerrollen</h4>
<p className="text-lg text-slate-500">Wer wird das System nutzen?</p>
<div className="flex flex-wrap gap-4">
{['Administratoren', 'Manager', 'Standard-Nutzer', 'Gäste', 'Read-Only'].map(role => (
<button
key={role}
type="button"
onClick={() => toggleUserRole(role)}
className={`px-8 py-4 rounded-full border-2 font-bold text-base transition-all ${
(state.userRoles || []).includes(role) ? 'border-slate-900 bg-slate-900 text-white' : 'border-slate-100 bg-white hover:border-slate-200'
}`}
>
{role}
</button>
))}
</div>
</div>
{/* Platform Type */}
<div className="space-y-6">
<h4 className="text-2xl font-bold text-slate-900 flex items-center gap-3">
<Monitor size={24} /> Plattform-Fokus
</h4>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
{[
{ id: 'desktop', label: 'Desktop First', icon: <Monitor size={24} /> },
{ id: 'mobile', label: 'Mobile First', icon: <Smartphone size={24} /> },
{ id: 'pwa', label: 'PWA (Installierbar)', icon: <Globe size={24} /> },
].map(opt => (
<button
key={opt.id}
type="button"
onClick={() => updateState({ platformType: opt.id })}
className={`p-8 rounded-[2rem] border-2 flex flex-col items-center gap-4 transition-all ${
state.platformType === opt.id ? 'border-slate-900 bg-slate-900 text-white' : 'border-slate-100 bg-white hover:border-slate-200'
}`}
>
{opt.icon}
<span className="font-bold text-lg">{opt.label}</span>
</button>
))}
</div>
</div>
{/* Data Sensitivity */}
<div className="space-y-6">
<h4 className="text-2xl font-bold text-slate-900 flex items-center gap-3">
<Shield size={24} /> Datensicherheit
</h4>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{[
{ id: 'standard', label: 'Standard', desc: 'Normale Nutzerdaten & Profile.' },
{ id: 'high', label: 'Sensibel', desc: 'Finanzdaten, Gesundheitsdaten oder DSGVO-kritisch.' },
].map(opt => (
<button
key={opt.id}
type="button"
onClick={() => updateState({ dataSensitivity: opt.id })}
className={`p-8 rounded-[2rem] border-2 text-left transition-all ${
state.dataSensitivity === opt.id ? 'border-slate-900 bg-slate-900 text-white' : 'border-slate-100 bg-white hover:border-slate-200'
}`}
>
<p className="text-xl font-bold">{opt.label}</p>
<p className={`text-base mt-2 ${state.dataSensitivity === opt.id ? 'text-slate-200' : 'text-slate-500'}`}>{opt.desc}</p>
</button>
))}
</div>
</div>
{/* Authentication */}
<div className="p-10 bg-slate-50 rounded-[3rem] border border-slate-100 space-y-6">
<h4 className="text-2xl font-bold text-slate-900 flex items-center gap-3">
<Lock size={24} /> Authentifizierung
</h4>
<p className="text-lg text-slate-500">Wie sollen sich Nutzer anmelden?</p>
<div className="flex flex-wrap gap-4">
{['E-Mail / Passwort', 'Social Login', 'SSO / SAML', '2FA / MFA', 'Magic Links'].map(method => (
<div
key={method}
className="px-8 py-4 rounded-full border-2 border-white bg-white font-bold text-base text-slate-400"
>
{method}
</div>
))}
</div>
<p className="text-xs text-slate-400 italic">Details zur Authentifizierung besprechen wir im Erstgespräch.</p>
</div>
</div>
);
}

View File

@@ -0,0 +1,47 @@
export type ProjectType = 'website' | 'web-app';
export interface FormState {
projectType: ProjectType;
selectedPages: string[];
otherPages: string[];
features: string[];
otherFeatures: string[];
functions: string[];
otherFunctions: string[];
apiSystems: string[];
otherTech: string[];
assets: string[];
otherAssets: string[];
complexInteractions: number;
newDatasets: number;
cmsSetup: boolean;
storageExpansion: number;
name: string;
email: string;
role: string;
message: string;
sitemapFile: File | null;
contactFiles: File[];
// Design
designVibe: string;
colorScheme: string[];
references: string[];
designWishes: string;
// Maintenance
expectedAdjustments: string;
languagesCount: number;
// Timeline
deadline: string;
// Web App specific
targetAudience: string;
userRoles: string[];
dataSensitivity: string;
platformType: string;
}
export interface Step {
id: string;
title: string;
description: string;
illustration: React.ReactNode;
}

View File

@@ -0,0 +1,546 @@
'use client';
import * as React from 'react';
import { Document, Page, Text, View, StyleSheet, Image } from '@react-pdf/renderer';
const styles = StyleSheet.create({
page: {
padding: 40,
backgroundColor: '#ffffff',
fontFamily: 'Helvetica',
fontSize: 10,
color: '#1a1a1a',
},
header: {
flexDirection: 'row',
justifyContent: 'space-between',
marginBottom: 40,
borderBottom: 2,
borderBottomColor: '#000000',
paddingBottom: 20,
},
brand: {
fontSize: 18,
fontWeight: 'bold',
letterSpacing: -0.5,
},
quoteInfo: {
textAlign: 'right',
},
quoteTitle: {
fontSize: 14,
fontWeight: 'bold',
marginBottom: 4,
},
quoteDate: {
fontSize: 9,
color: '#666666',
},
recipientSection: {
marginBottom: 30,
},
recipientLabel: {
fontSize: 8,
color: '#999999',
textTransform: 'uppercase',
marginBottom: 4,
},
recipientName: {
fontSize: 12,
fontWeight: 'bold',
},
recipientRole: {
fontSize: 10,
color: '#666666',
},
table: {
display: 'flex',
width: 'auto',
marginBottom: 30,
},
tableHeader: {
flexDirection: 'row',
backgroundColor: '#f8fafc',
borderBottom: 1,
borderBottomColor: '#e2e8f0',
paddingVertical: 8,
paddingHorizontal: 12,
},
tableRow: {
flexDirection: 'row',
borderBottom: 1,
borderBottomColor: '#f1f5f9',
paddingVertical: 10,
paddingHorizontal: 12,
alignItems: 'flex-start',
},
colPos: { width: '8%' },
colDesc: { width: '62%' },
colQty: { width: '10%', textAlign: 'center' },
colPrice: { width: '20%', textAlign: 'right' },
headerText: {
fontSize: 8,
fontWeight: 'bold',
color: '#64748b',
textTransform: 'uppercase',
},
posText: { fontSize: 9, color: '#94a3b8' },
itemTitle: { fontSize: 10, fontWeight: 'bold', marginBottom: 2 },
itemDesc: { fontSize: 8, color: '#64748b', lineHeight: 1.4 },
priceText: { fontSize: 10, fontWeight: 'bold' },
summarySection: {
flexDirection: 'row',
justifyContent: 'flex-end',
marginTop: 10,
},
summaryTable: {
width: '40%',
},
summaryRow: {
flexDirection: 'row',
justifyContent: 'space-between',
paddingVertical: 4,
},
totalRow: {
flexDirection: 'row',
justifyContent: 'space-between',
paddingVertical: 12,
borderTop: 1,
borderTopColor: '#000000',
marginTop: 8,
},
totalLabel: { fontSize: 12, fontWeight: 'bold' },
totalValue: { fontSize: 16, fontWeight: 'bold' },
configSection: {
marginTop: 20,
padding: 20,
backgroundColor: '#f8fafc',
borderRadius: 8,
},
configTitle: {
fontSize: 10,
fontWeight: 'bold',
marginBottom: 10,
textTransform: 'uppercase',
color: '#475569',
},
configGrid: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: 20,
},
configItem: {
width: '45%',
marginBottom: 10,
},
configLabel: { fontSize: 8, color: '#94a3b8', marginBottom: 2 },
configValue: { fontSize: 9, color: '#1e293b' },
qrSection: {
marginTop: 30,
alignItems: 'center',
justifyContent: 'center',
},
qrImage: {
width: 80,
height: 80,
},
qrText: {
fontSize: 7,
color: '#94a3b8',
marginTop: 5,
},
footer: {
position: 'absolute',
bottom: 30,
left: 40,
right: 40,
borderTop: 1,
borderTopColor: '#f1f5f9',
paddingTop: 20,
flexDirection: 'row',
justifyContent: 'space-between',
fontSize: 8,
color: '#94a3b8',
},
pageNumber: {
position: 'absolute',
bottom: 30,
right: 40,
fontSize: 8,
color: '#94a3b8',
}
});
interface PDFProps {
state: any;
totalPrice: number;
monthlyPrice: number;
totalPagesCount: number;
pricing: any;
qrCodeData?: string;
}
export const EstimationPDF = ({ state, totalPrice, monthlyPrice, totalPagesCount, pricing, qrCodeData }: PDFProps) => {
const date = new Date().toLocaleDateString('de-DE', {
year: 'numeric',
month: 'long',
day: 'numeric',
});
const vibeLabels: Record<string, string> = {
minimal: 'Minimalistisch',
bold: 'Mutig & Laut',
nature: 'Natürlich',
tech: 'Technisch'
};
const deadlineLabels: Record<string, string> = {
asap: 'So schnell wie möglich',
'2-3-months': 'In 2-3 Monaten',
'3-6-months': 'In 3-6 Monaten',
flexible: 'Flexibel'
};
const assetLabels: Record<string, string> = {
logo: 'Logo',
styleguide: 'Styleguide',
content_concept: 'Inhalts-Konzept',
media: 'Bild/Video-Material',
icons: 'Icons',
illustrations: 'Illustrationen',
fonts: 'Fonts'
};
const featureLabels: Record<string, string> = {
blog_news: 'Blog / News',
products: 'Produktbereich',
jobs: 'Karriere / Jobs',
refs: 'Referenzen / Cases',
events: 'Events / Termine'
};
const functionLabels: Record<string, string> = {
search: 'Suche',
filter: 'Filter-Systeme',
pdf: 'PDF-Export',
forms: 'Erweiterte Formulare'
};
const apiLabels: Record<string, string> = {
crm: 'CRM System',
erp: 'ERP / Warenwirtschaft',
stripe: 'Stripe / Payment',
newsletter: 'Newsletter / Marketing',
ecommerce: 'E-Commerce / Shop',
hr: 'HR / Recruiting',
realestate: 'Immobilien',
calendar: 'Termine / Booking',
social: 'Social Media Sync'
};
const positions = [];
let pos = 1;
if (state.projectType === 'website') {
positions.push({
pos: pos++,
title: 'Basis Website Setup',
desc: 'Projekt-Setup, Infrastruktur, Hosting-Bereitstellung, Grundstruktur & Design-Vorlage, technisches SEO-Basics, Analytics.',
qty: 1,
price: pricing.BASE_WEBSITE
});
const allPages = [...state.selectedPages.map((p: string) => p === 'Home' ? 'Startseite' : p), ...state.otherPages];
positions.push({
pos: pos++,
title: 'Individuelle Seiten',
desc: `Gestaltung und Umsetzung von ${totalPagesCount} individuellen Seiten-Layouts (${allPages.join(', ')}).`,
qty: totalPagesCount,
price: totalPagesCount * pricing.PAGE
});
if (state.features.length > 0 || state.otherFeatures.length > 0) {
const allFeatures = [...state.features.map((f: string) => featureLabels[f] || f), ...state.otherFeatures];
positions.push({
pos: pos++,
title: 'System-Module',
desc: `Implementierung funktionaler Bereiche: ${allFeatures.join(', ')}. Inklusive Datenstruktur und Darstellung.`,
qty: allFeatures.length,
price: allFeatures.length * pricing.FEATURE
});
}
if (state.functions.length > 0 || state.otherFunctions.length > 0) {
const allFunctions = [...state.functions.map((f: string) => functionLabels[f] || f), ...state.otherFunctions];
positions.push({
pos: pos++,
title: 'Logik-Funktionen',
desc: `Erweiterte Funktionen: ${allFunctions.join(', ')}.`,
qty: allFunctions.length,
price: allFunctions.length * pricing.FUNCTION
});
}
if (state.apiSystems.length > 0 || state.otherTech.length > 0) {
const allApis = [...state.apiSystems.map((a: string) => apiLabels[a] || a), ...state.otherTech];
positions.push({
pos: pos++,
title: 'Schnittstellen (API)',
desc: `Anbindung externer Systeme zur Datensynchronisation: ${allApis.join(', ')}.`,
qty: allApis.length,
price: allApis.length * pricing.API_INTEGRATION
});
}
if (state.cmsSetup) {
positions.push({
pos: pos++,
title: 'Inhaltsverwaltung (CMS)',
desc: 'Einrichtung eines Systems zur eigenständigen Pflege von Inhalten und Datensätzen.',
qty: 1,
price: pricing.CMS_SETUP + (state.features.length + state.otherFeatures.length) * pricing.CMS_CONNECTION_PER_FEATURE
});
}
if (state.newDatasets > 0) {
positions.push({
pos: pos++,
title: 'Inhaltspflege (Initial)',
desc: `Manuelle Einpflege von ${state.newDatasets} Datensätzen (z.B. Produkte, Blogartikel).`,
qty: state.newDatasets,
price: state.newDatasets * pricing.NEW_DATASET
});
}
if (state.complexInteractions > 0) {
positions.push({
pos: pos++,
title: 'Besondere Interaktionen',
desc: `Umsetzung von ${state.complexInteractions} komplexen UI-Animationen oder interaktiven Logik-Abschnitten.`,
qty: state.complexInteractions,
price: state.complexInteractions * pricing.COMPLEX_INTERACTION
});
}
if (state.languagesCount > 1) {
const factorPrice = totalPrice - (totalPrice / (1 + (state.languagesCount - 1) * 0.2));
positions.push({
pos: pos++,
title: 'Mehrsprachigkeit',
desc: `Erweiterung des Systems auf ${state.languagesCount} Sprachen (Struktur & Logik).`,
qty: state.languagesCount,
price: Math.round(factorPrice)
});
}
} else {
positions.push({
pos: pos++,
title: 'Web App / Software Entwicklung',
desc: 'Individuelle Software-Entwicklung nach Aufwand. Abrechnung erfolgt auf Stundenbasis.',
qty: 1,
price: 0
});
}
return (
<Document>
<Page size="A4" style={styles.page}>
<View style={styles.header}>
<View>
<Text style={styles.brand}>marc mintel</Text>
<Text style={{ fontSize: 8, color: '#64748b', marginTop: 4 }}>Digital Systems & Design</Text>
</View>
<View style={styles.quoteInfo}>
<Text style={styles.quoteTitle}>Kostenschätzung</Text>
<Text style={styles.quoteDate}>{date}</Text>
<Text style={[styles.quoteDate, { marginTop: 2 }]}>Projekt: {state.projectType === 'website' ? 'Website' : 'Web App'}</Text>
</View>
</View>
<View style={styles.recipientSection}>
<Text style={styles.recipientLabel}>Ansprechpartner</Text>
<Text style={styles.recipientName}>{state.name || 'Interessent'}</Text>
{state.role && <Text style={styles.recipientRole}>{state.role}</Text>}
<Text style={styles.recipientRole}>{state.email}</Text>
</View>
<View style={styles.table}>
<View style={styles.tableHeader}>
<Text style={[styles.headerText, styles.colPos]}>Pos</Text>
<Text style={[styles.headerText, styles.colDesc]}>Beschreibung</Text>
<Text style={[styles.headerText, styles.colQty]}>Menge</Text>
<Text style={[styles.headerText, styles.colPrice]}>Betrag</Text>
</View>
{positions.map((item, i) => (
<View key={i} style={styles.tableRow}>
<Text style={[styles.posText, styles.colPos]}>{item.pos}</Text>
<View style={styles.colDesc}>
<Text style={styles.itemTitle}>{item.title}</Text>
<Text style={styles.itemDesc}>{item.desc}</Text>
</View>
<Text style={[styles.posText, styles.colQty]}>{item.qty}</Text>
<Text style={[styles.priceText, styles.colPrice]}>
{item.price > 0 ? `${item.price.toLocaleString()}` : 'n. A.'}
</Text>
</View>
))}
</View>
<View style={styles.summarySection}>
<View style={styles.summaryTable}>
<View style={styles.summaryRow}>
<Text style={{ color: '#64748b' }}>Zwischensumme (Netto)</Text>
<Text>{totalPrice.toLocaleString()} </Text>
</View>
<View style={styles.summaryRow}>
<Text style={{ color: '#64748b' }}>Umsatzsteuer (0%)*</Text>
<Text>0,00 </Text>
</View>
<View style={styles.totalRow}>
<Text style={styles.totalLabel}>Gesamtsumme</Text>
<Text style={styles.totalValue}>{totalPrice.toLocaleString()} </Text>
</View>
<Text style={{ fontSize: 7, color: '#94a3b8', textAlign: 'right', marginTop: -4 }}>
*Gemäß § 19 UStG wird keine Umsatzsteuer berechnet.
</Text>
{state.projectType === 'website' && (
<View style={[styles.summaryRow, { marginTop: 15, borderTop: 1, borderTopColor: '#f1f5f9', paddingTop: 10 }]}>
<Text style={{ color: '#64748b', fontSize: 9 }}>Betrieb & Hosting</Text>
<Text style={{ fontSize: 9, fontWeight: 'bold' }}>{monthlyPrice.toLocaleString()} / Monat</Text>
</View>
)}
</View>
</View>
<View style={styles.footer}>
<Text>marc@mintel.me</Text>
<Text>mintel.me</Text>
<Text>Digital Systems & Design</Text>
</View>
<Text style={styles.pageNumber} render={({ pageNumber, totalPages }) => `Seite ${pageNumber} von ${totalPages}`} fixed />
</Page>
<Page size="A4" style={styles.page}>
<View style={styles.header}>
<View>
<Text style={styles.brand}>marc mintel</Text>
</View>
<View style={styles.quoteInfo}>
<Text style={styles.quoteTitle}>Projektdetails</Text>
</View>
</View>
<View style={styles.configSection}>
<Text style={styles.configTitle}>Konfiguration & Wünsche</Text>
<View style={styles.configGrid}>
{state.projectType === 'website' ? (
<>
<View style={styles.configItem}>
<Text style={styles.configLabel}>Design-Vibe</Text>
<Text style={styles.configValue}>{vibeLabels[state.designVibe] || state.designVibe}</Text>
</View>
<View style={styles.configItem}>
<Text style={styles.configLabel}>Farbschema</Text>
<Text style={styles.configValue}>{state.colorScheme.join(', ')}</Text>
</View>
</>
) : (
<>
<View style={styles.configItem}>
<Text style={styles.configLabel}>Zielgruppe</Text>
<Text style={styles.configValue}>{state.targetAudience === 'internal' ? 'Internes Tool' : 'Kunden-Portal'}</Text>
</View>
<View style={styles.configItem}>
<Text style={styles.configLabel}>Plattform</Text>
<Text style={styles.configValue}>{state.platformType.toUpperCase()}</Text>
</View>
<View style={styles.configItem}>
<Text style={styles.configLabel}>Sicherheit</Text>
<Text style={styles.configValue}>{state.dataSensitivity === 'high' ? 'Sensibel' : 'Standard'}</Text>
</View>
<View style={styles.configItem}>
<Text style={styles.configLabel}>Rollen</Text>
<Text style={styles.configValue}>{state.userRoles.join(', ') || 'Keine'}</Text>
</View>
</>
)}
<View style={styles.configItem}>
<Text style={styles.configLabel}>Zeitplan</Text>
<Text style={styles.configValue}>{deadlineLabels[state.deadline] || state.deadline}</Text>
</View>
<View style={styles.configItem}>
<Text style={styles.configLabel}>Assets vorhanden</Text>
<Text style={styles.configValue}>{state.assets.map((a: string) => assetLabels[a] || a).join(', ') || 'Keine angegeben'}</Text>
</View>
{state.otherAssets.length > 0 && (
<View style={styles.configItem}>
<Text style={styles.configLabel}>Weitere Assets</Text>
<Text style={styles.configValue}>{state.otherAssets.join(', ')}</Text>
</View>
)}
<View style={styles.configItem}>
<Text style={styles.configLabel}>Sprachen</Text>
<Text style={styles.configValue}>{state.languagesCount}</Text>
</View>
{state.projectType === 'website' && (
<>
<View style={styles.configItem}>
<Text style={styles.configLabel}>CMS (Inhaltsverwaltung)</Text>
<Text style={styles.configValue}>{state.cmsSetup ? 'Ja' : 'Nein'}</Text>
</View>
<View style={styles.configItem}>
<Text style={styles.configLabel}>Änderungsfrequenz</Text>
<Text style={styles.configValue}>
{state.expectedAdjustments === 'low' ? 'Selten' :
state.expectedAdjustments === 'medium' ? 'Regelmäßig' : 'Häufig'}
</Text>
</View>
</>
)}
</View>
{state.designWishes && (
<View style={{ marginTop: 15 }}>
<Text style={styles.configLabel}>Design-Vorstellungen</Text>
<Text style={[styles.configValue, { lineHeight: 1.4 }]}>{state.designWishes}</Text>
</View>
)}
{state.references.length > 0 && (
<View style={{ marginTop: 15 }}>
<Text style={styles.configLabel}>Referenzen</Text>
<Text style={[styles.configValue, { lineHeight: 1.4 }]}>{state.references.join('\n')}</Text>
</View>
)}
{state.message && (
<View style={{ marginTop: 15 }}>
<Text style={styles.configLabel}>Nachricht / Anmerkungen</Text>
<Text style={[styles.configValue, { lineHeight: 1.4 }]}>{state.message}</Text>
</View>
)}
</View>
{qrCodeData && (
<View style={styles.qrSection}>
<Image src={qrCodeData} style={styles.qrImage} />
<Text style={styles.qrText}>QR-Code scannen, um Konfiguration online zu öffnen</Text>
</View>
)}
<View style={styles.footer}>
<Text>marc@mintel.me</Text>
<Text>mintel.me</Text>
<Text>Digital Systems & Design</Text>
</View>
<Text style={styles.pageNumber} render={({ pageNumber, totalPages }) => `Seite ${pageNumber} von ${totalPages}`} fixed />
</Page>
</Document>
);
};

View File

@@ -138,7 +138,7 @@ export const FileExample: React.FC<FileExampleProps> = ({
<div className="flex items-center gap-1 flex-shrink-0">
<button
type="button"
className={`copy-btn h-7 w-7 inline-flex items-center justify-center rounded-md border border-transparent hover:border-slate-200 hover:bg-white transition-colors ${isCopied ? 'copied' : ''}`}
className={`copy-btn h-8 w-8 inline-flex items-center justify-center rounded-full border border-slate-200 bg-white text-slate-400 hover:text-slate-900 hover:border-slate-400 hover:bg-slate-50 transition-all duration-500 ease-[cubic-bezier(0.23,1,0.32,1)] hover:-translate-y-0.5 hover:shadow-lg hover:shadow-slate-100 ${isCopied ? 'copied' : ''}`}
onClick={handleCopy}
title="Copy to clipboard"
aria-label={`Copy ${filename} to clipboard`}
@@ -156,7 +156,7 @@ export const FileExample: React.FC<FileExampleProps> = ({
<button
type="button"
className="download-btn h-7 w-7 inline-flex items-center justify-center rounded-md border border-transparent hover:border-slate-200 hover:bg-white transition-colors"
className="download-btn h-8 w-8 inline-flex items-center justify-center rounded-full border border-slate-200 bg-white text-slate-400 hover:text-slate-900 hover:border-slate-400 hover:bg-slate-50 transition-all duration-500 ease-[cubic-bezier(0.23,1,0.32,1)] hover:-translate-y-0.5 hover:shadow-lg hover:shadow-slate-100"
onClick={handleDownload}
title="Download file"
aria-label={`Download ${filename}`}

View File

@@ -49,7 +49,7 @@ export const FileExamplesList: React.FC<FileExamplesListProps> = ({ groups }) =>
<button
type="button"
className="toggle-all-btn h-7 w-7 inline-flex items-center justify-center rounded-md border border-transparent hover:border-slate-200 hover:bg-white text-slate-500 hover:text-slate-900 transition-colors"
className="toggle-all-btn h-8 w-8 inline-flex items-center justify-center rounded-full border border-slate-200 bg-white text-slate-400 hover:text-slate-900 hover:border-slate-400 hover:bg-slate-50 transition-all duration-500 ease-[cubic-bezier(0.23,1,0.32,1)] hover:-translate-y-0.5 hover:shadow-lg hover:shadow-slate-100"
title="Toggle all"
onClick={() => toggleAllInGroup(group.groupId, group.files)}
>

View File

@@ -1,5 +1,8 @@
import Image from 'next/image';
import * as React from 'react';
import LogoBlack from '../assets/logo/Logo Black Transparent.svg';
export const Footer: React.FC = () => {
const currentYear = new Date().getFullYear();
@@ -9,14 +12,12 @@ export const Footer: React.FC = () => {
<div className="grid grid-cols-1 md:grid-cols-2 gap-12 items-end">
<div className="space-y-8">
<div className="flex items-center gap-3">
<div className="w-8 h-8 bg-slate-900 rounded-lg flex items-center justify-center">
<span className="text-white text-sm font-bold">M</span>
</div>
<span className="text-xl font-bold text-slate-900 tracking-tighter">Marc Mintel</span>
<Image
src={LogoBlack}
alt="Marc Mintel"
height={72}
/>
</div>
<p className="text-2xl text-slate-400 font-serif italic leading-tight max-w-xs">
Digitale Systeme ohne Overhead.
</p>
</div>
<div className="flex flex-col md:items-end gap-4 text-sm font-mono text-slate-300 uppercase tracking-widest">

View File

@@ -1,22 +1,41 @@
'use client';
import * as React from 'react';
import Image from 'next/image';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import * as React from 'react';
import IconWhite from '../assets/logo/Icon White Transparent.svg';
export const Header: React.FC = () => {
const pathname = usePathname();
const [, setIsScrolled] = React.useState(false);
React.useEffect(() => {
const handleScroll = () => {
setIsScrolled(window.scrollY > 20);
};
window.addEventListener('scroll', handleScroll);
return () => window.removeEventListener('scroll', handleScroll);
}, []);
const isActive = (path: string) => pathname === path;
return (
<header className="bg-white/80 backdrop-blur-md sticky top-0 z-50 border-b border-slate-50">
<div className="narrow-container py-4 flex items-center justify-between">
<Link href="/" className="flex items-center gap-3 group">
<div className="w-10 h-10 bg-slate-900 rounded-xl flex items-center justify-center group-hover:bg-slate-800 group-hover:scale-105 transition-all duration-500 shadow-sm">
<span className="text-white text-lg font-bold">M</span>
<Link href="/" className="flex items-center gap-4 group">
<div className="flex items-center gap-3">
<div className="w-12 h-12 bg-black rounded-xl flex items-center justify-center group-hover:scale-105 transition-all duration-500 shadow-sm shrink-0">
<Image
src={IconWhite}
alt="Marc Mintel Icon"
width={32}
height={32}
className="w-8 h-8"
/>
</div>
</div>
<span className="text-slate-900 font-bold tracking-tighter text-2xl group-hover:tracking-tight transition-all duration-500">Marc Mintel</span>
</Link>
<nav className="flex items-center gap-8">
@@ -38,7 +57,7 @@ export const Header: React.FC = () => {
</Link>
<Link
href="/contact"
className="text-[10px] font-bold uppercase tracking-[0.2em] text-slate-900 border border-slate-200 px-5 py-2.5 rounded-full hover:border-slate-400 transition-all duration-300"
className="text-[10px] font-bold uppercase tracking-[0.2em] text-slate-900 border border-slate-200 px-5 py-2.5 rounded-full hover:border-slate-400 hover:bg-slate-50 transition-all duration-500 ease-[cubic-bezier(0.23,1,0.32,1)] hover:-translate-y-0.5 hover:shadow-lg hover:shadow-slate-100"
>
Anfrage
</Link>

View File

@@ -1,436 +1,3 @@
'use client';
import * as React from 'react';
import { motion } from 'framer-motion';
interface IllustrationProps {
className?: string;
delay?: number;
}
export const ConceptCommunication: React.FC<IllustrationProps> = ({ className = "", delay = 0 }) => (
<svg className={className} viewBox="0 0 120 120" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="20" cy="60" r="6" className="fill-slate-200 stroke-slate-300" strokeWidth="1" />
<circle cx="100" cy="60" r="6" className="fill-slate-900" />
<path d="M 26 60 H 94" stroke="currentColor" strokeWidth="1" className="text-slate-300" strokeDasharray="4 4" />
<motion.path
d="M 26 60 H 94"
stroke="currentColor"
strokeWidth="2"
className="text-slate-400"
initial={{ pathLength: 0 }}
animate={{ pathLength: [0, 1, 1, 0], opacity: [0, 1, 1, 0] }}
transition={{ duration: 3, repeat: Infinity, ease: "easeInOut", delay }}
/>
<motion.circle r="3" className="fill-slate-900">
<animateMotion
dur="3s"
repeatCount="indefinite"
path="M 26 60 H 94"
/>
</motion.circle>
</svg>
);
export const ConceptPrototyping: React.FC<IllustrationProps> = ({ className = "", delay = 0 }) => (
<svg className={className} viewBox="0 0 120 120" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="15" y="25" width="90" height="70" rx="4" stroke="currentColor" strokeWidth="1" className="text-slate-400" />
<path d="M 15 40 H 105" stroke="currentColor" strokeWidth="1" className="text-slate-300" />
<motion.rect
x="25" y="50" width="40" height="8" rx="1"
className="fill-slate-300"
animate={{ width: [0, 40, 40, 0] }}
transition={{ duration: 4, repeat: Infinity, delay }}
/>
<motion.rect
x="25" y="65" width="60" height="8" rx="1"
className="fill-slate-200"
animate={{ width: [0, 60, 60, 0] }}
transition={{ duration: 4, repeat: Infinity, delay: 0.5 }}
/>
<motion.circle
cx="85" cy="75" r="10"
className="fill-slate-900"
animate={{ scale: [0.8, 1.1, 0.8] }}
transition={{ duration: 2, repeat: Infinity }}
/>
</svg>
);
export const ConceptCode: React.FC<IllustrationProps> = ({ className = "", delay = 0 }) => (
<svg className={className} viewBox="0 0 120 120" fill="none" xmlns="http://www.w3.org/2000/svg">
{[40, 55, 70, 85].map((y, i) => (
<motion.path
key={y}
d={`M 25 ${y} H ${25 + Math.random() * 60 + 20}`}
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
className="text-slate-400"
initial={{ pathLength: 0 }}
animate={{ pathLength: [0, 1, 1, 0] }}
transition={{ duration: 4, repeat: Infinity, delay: i * 0.2 + delay }}
/>
))}
<motion.path
d="M 90 40 L 100 50 L 115 30"
stroke="currentColor"
strokeWidth="3"
strokeLinecap="round"
strokeLinejoin="round"
className="text-slate-900"
animate={{ opacity: [0, 1, 1, 0], scale: [0.8, 1, 1, 0.8] }}
transition={{ duration: 4, repeat: Infinity, delay: 1.5 }}
/>
</svg>
);
export const ConceptPrice: React.FC<IllustrationProps> = ({ className = "", delay = 0 }) => (
<svg className={className} viewBox="0 0 120 120" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="30" y="30" width="60" height="70" rx="2" stroke="currentColor" strokeWidth="1" className="text-slate-400" />
<motion.path
d="M 40 50 H 80 M 40 65 H 80 M 40 80 H 60"
stroke="currentColor"
strokeWidth="1"
className="text-slate-300"
initial={{ pathLength: 0 }}
animate={{ pathLength: [0, 1, 1, 0] }}
transition={{ duration: 5, repeat: Infinity, delay }}
/>
<motion.circle
cx="85" cy="35" r="15"
className="fill-white stroke-slate-900"
strokeWidth="1"
animate={{ y: [0, -5, 0], rotate: [0, 10, 0] }}
transition={{ duration: 4, repeat: Infinity }}
/>
</svg>
);
export const ConceptWebsite: React.FC<IllustrationProps> = ({ className = "", delay = 0 }) => (
<svg className={className} viewBox="0 0 120 120" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="10" y="20" width="100" height="80" rx="4" stroke="currentColor" strokeWidth="1" className="text-slate-400" />
<motion.rect
x="20" y="35" width="80" height="15" rx="2"
className="fill-slate-200"
animate={{ opacity: [0.3, 0.6, 0.3] }}
transition={{ duration: 3, repeat: Infinity }}
/>
<motion.g
animate={{ y: [0, 10, 0] }}
transition={{ duration: 4, repeat: Infinity }}
>
<rect x="20" y="55" width="35" height="35" rx="2" className="fill-slate-300" />
<rect x="65" y="55" width="35" height="35" rx="2" className="fill-slate-300" />
</motion.g>
</svg>
);
export const ConceptSystem: React.FC<IllustrationProps> = ({ className = "", delay = 0 }) => (
<svg className={className} viewBox="0 0 120 120" fill="none" xmlns="http://www.w3.org/2000/svg">
<motion.circle cx="60" cy="60" r="15" className="fill-slate-900"
animate={{ scale: [1, 1.1, 1] }} transition={{ duration: 4, repeat: Infinity }} />
{[0, 72, 144, 216, 288].map((angle, i) => {
const x = 60 + Math.cos((angle * Math.PI) / 180) * 40;
const y = 60 + Math.sin((angle * Math.PI) / 180) * 40;
return (
<React.Fragment key={i}>
<motion.line
x1="60" y1="60" x2={x} y2={y}
stroke="currentColor" strokeWidth="1" className="text-slate-400"
animate={{ strokeDashoffset: [0, 10] }}
strokeDasharray="2 2"
transition={{ duration: 2, repeat: Infinity, ease: "linear" }}
/>
<motion.circle
cx={x} cy={y} r="6"
className="fill-white stroke-slate-300"
animate={{ scale: [1, 1.2, 1] }}
transition={{ duration: 3, repeat: Infinity, delay: i * 0.4 }}
/>
</React.Fragment>
);
})}
</svg>
);
export const ConceptAutomation: React.FC<IllustrationProps> = ({ className = "", delay = 0 }) => (
<svg className={className} viewBox="0 0 120 120" fill="none" xmlns="http://www.w3.org/2000/svg">
<motion.g
animate={{ rotate: 360 }}
transition={{ duration: 10, repeat: Infinity, ease: "linear" }}
style={{ originX: "40px", originY: "60px" }}
>
<path d="M 40 45 L 50 60 L 40 75 L 30 60 Z" className="fill-slate-300" />
</motion.g>
<motion.g
animate={{ rotate: -360 }}
transition={{ duration: 10, repeat: Infinity, ease: "linear" }}
style={{ originX: "75px", originY: "65px" }}
>
<path d="M 75 50 L 85 65 L 75 80 L 65 65 Z" className="fill-slate-500" />
</motion.g>
<motion.path
d="M 10 60 H 110"
stroke="currentColor" strokeWidth="1" className="text-slate-300"
strokeDasharray="4 4"
animate={{ strokeDashoffset: [0, -20] }}
transition={{ duration: 2, repeat: Infinity, ease: "linear" }}
/>
</svg>
);
export const ConceptTarget: React.FC<IllustrationProps> = ({ className = "", delay = 0 }) => (
<svg className={className} viewBox="0 0 120 120" fill="none" xmlns="http://www.w3.org/2000/svg">
<motion.circle
cx="60" cy="60" r="50"
stroke="currentColor" strokeWidth="1" className="text-slate-300"
animate={{ scale: [1, 1.05, 1] }}
transition={{ duration: 4, repeat: Infinity }}
/>
<motion.circle
cx="60" cy="60" r="30"
stroke="currentColor" strokeWidth="1" className="text-slate-400"
/>
<motion.circle
cx="60" cy="60" r="10"
className="fill-slate-900"
animate={{ opacity: [0.5, 1, 0.5] }}
transition={{ duration: 2, repeat: Infinity }}
/>
</svg>
);
export const ConceptMessy: React.FC<IllustrationProps> = ({ className = "", delay = 0 }) => (
<svg className={className} viewBox="0 0 120 120" fill="none" xmlns="http://www.w3.org/2000/svg">
<motion.path
d="M 20 60 C 30 20, 40 100, 50 60 C 60 20, 70 100, 80 60 C 90 20, 100 100, 110 60"
stroke="currentColor" strokeWidth="1" className="text-slate-500"
animate={{ strokeDashoffset: [0, 20] }}
strokeDasharray="4 4"
transition={{ duration: 5, repeat: Infinity, ease: "linear" }}
/>
<motion.path
d="M 20 40 L 100 80 M 20 80 L 100 40"
stroke="currentColor" strokeWidth="1" className="text-slate-200 opacity-50"
animate={{ opacity: [0.2, 0.5, 0.2] }} transition={{ duration: 3, repeat: Infinity }}
/>
</svg>
);
export const HeroArchitecture: React.FC<IllustrationProps> = ({ className = "", delay = 0 }) => (
<svg className={className} viewBox="0 0 400 300" fill="none" xmlns="http://www.w3.org/2000/svg">
<motion.rect x="170" y="120" width="60" height="60" rx="8" className="stroke-slate-900 fill-white" strokeWidth="2"
animate={{ scale: [1, 1.05, 1] }} transition={{ duration: 4, repeat: Infinity }} />
{[
{ x: 80, y: 60 }, { x: 320, y: 60 },
{ x: 80, y: 240 }, { x: 320, y: 240 }
].map((node, i) => (
<React.Fragment key={i}>
<motion.path
d={`M 200 150 L ${node.x} ${node.y}`}
stroke="currentColor" strokeWidth="1" className="text-slate-400"
animate={{ strokeDashoffset: [0, -10] }} strokeDasharray="4 4"
transition={{ duration: 2, repeat: Infinity, ease: "linear" }}
/>
<motion.circle
cx={node.x} cy={node.y} r="12"
className="fill-white stroke-slate-300"
strokeWidth="1"
animate={{ scale: [1, 1.1, 1] }} transition={{ duration: 3, repeat: Infinity, delay: i * 0.5 }}
/>
</React.Fragment>
))}
</svg>
);
// Binary text component for reuse
const BinaryText: React.FC<{ x: number; y: number; delay?: number }> = ({ x, y, delay = 0 }) => (
<motion.text
x={x}
y={y}
className="fill-slate-300 font-mono"
style={{ fontSize: 10 }}
animate={{ opacity: [0.2, 0.5, 0.2] }}
transition={{ duration: 2, repeat: Infinity, delay }}
>
{Math.random() > 0.5 ? '0' : '1'}
</motion.text>
);
export const HeroMainIllustration: React.FC<IllustrationProps> = ({ className = "", delay = 0 }) => {
return (
<svg className={className} viewBox="0 0 800 700" fill="none" xmlns="http://www.w3.org/2000/svg">
{/* Matrix-style Binary Rain Background */}
<g className="opacity-[0.08]">
{Array.from({ length: 20 }).map((_, col) => {
const colX = 20 + col * 40;
const speed = 8 + Math.random() * 6;
const startDelay = Math.random() * 5;
return (
<motion.g
key={`rain-col-${col}`}
initial={{ y: -700 }}
animate={{ y: 700 }}
transition={{
duration: speed,
repeat: Infinity,
ease: "linear",
delay: startDelay,
}}
>
{Array.from({ length: 25 }).map((_, row) => (
<text
key={`${col}-${row}`}
x={colX}
y={row * 28}
className="fill-slate-900 font-mono"
style={{ fontSize: 12 }}
>
{Math.random() > 0.5 ? '1' : '0'}
</text>
))}
</motion.g>
);
})}
</g>
{/* Layer 1: Base Platform */}
<motion.g
animate={{ y: [0, 8, 0] }}
transition={{ duration: 5, repeat: Infinity, ease: "easeInOut" }}
>
<rect x="150" y="500" width="500" height="30" rx="4" className="fill-slate-100 stroke-slate-300" strokeWidth="1" />
<rect x="170" y="510" width="460" height="10" rx="2" className="fill-slate-200" />
{/* Binary on base */}
<text x="180" y="518" className="fill-slate-400 font-mono" style={{ fontSize: 8 }}>01010101010101010101010101010101010101</text>
</motion.g>
{/* Layer 2: Server/Database Layer */}
<motion.g
animate={{ y: [0, 6, 0] }}
transition={{ duration: 5, repeat: Infinity, ease: "easeInOut", delay: 0.3 }}
>
{/* Left Server Block */}
<g transform="translate(200, 400)">
<rect x="0" y="0" width="120" height="80" rx="6" className="fill-white stroke-slate-900" strokeWidth="2" />
<rect x="10" y="10" width="100" height="15" rx="2" className="fill-slate-100" />
<rect x="10" y="30" width="80" height="10" rx="2" className="fill-slate-200" />
<rect x="10" y="45" width="60" height="10" rx="2" className="fill-slate-200" />
<circle cx="100" cy="65" r="5" className="fill-slate-900" />
<text x="15" y="20" className="fill-slate-500 font-mono" style={{ fontSize: 8 }}>SERVER</text>
</g>
{/* Right Database Block */}
<g transform="translate(480, 400)">
<rect x="0" y="0" width="120" height="80" rx="6" className="fill-white stroke-slate-900" strokeWidth="2" />
<rect x="10" y="10" width="100" height="15" rx="2" className="fill-slate-100" />
<rect x="10" y="30" width="100" height="8" rx="2" className="fill-slate-200" />
<rect x="10" y="42" width="100" height="8" rx="2" className="fill-slate-200" />
<rect x="10" y="54" width="100" height="8" rx="2" className="fill-slate-200" />
<text x="15" y="20" className="fill-slate-500 font-mono" style={{ fontSize: 8 }}>DATABASE</text>
</g>
{/* Connection Lines */}
<motion.path
d="M 320 440 L 400 440 L 480 440"
stroke="currentColor"
strokeWidth="2"
className="text-slate-400"
strokeDasharray="6 4"
animate={{ strokeDashoffset: [0, -20] }}
transition={{ duration: 1, repeat: Infinity, ease: "linear" }}
/>
</motion.g>
{/* Layer 3: Browser/Website */}
<motion.g
animate={{ y: [0, 4, 0] }}
transition={{ duration: 5, repeat: Infinity, ease: "easeInOut", delay: 0.6 }}
>
{/* Browser Window */}
<rect x="180" y="100" width="440" height="280" rx="8" className="fill-white stroke-slate-900" strokeWidth="2" />
{/* Browser Chrome */}
<rect x="180" y="100" width="440" height="30" rx="8" className="fill-slate-900" />
<rect x="180" y="120" width="440" height="10" className="fill-slate-900" />
{/* Browser Dots */}
<circle cx="200" cy="115" r="5" className="fill-slate-600" />
<circle cx="218" cy="115" r="5" className="fill-slate-600" />
<circle cx="236" cy="115" r="5" className="fill-slate-600" />
{/* Address Bar */}
<rect x="260" y="108" width="200" height="14" rx="3" className="fill-slate-700" />
{/* Website Content */}
<g transform="translate(200, 150)">
{/* Navigation */}
<rect x="0" y="0" width="400" height="20" className="fill-slate-50" />
<rect x="10" y="5" width="60" height="10" rx="2" className="fill-slate-900" />
<rect x="280" y="5" width="30" height="10" rx="2" className="fill-slate-300" />
<rect x="320" y="5" width="30" height="10" rx="2" className="fill-slate-300" />
<rect x="360" y="5" width="30" height="10" rx="2" className="fill-slate-300" />
{/* Hero Section */}
<rect x="0" y="30" width="400" height="100" className="fill-slate-100" />
<rect x="20" y="50" width="180" height="16" rx="2" className="fill-slate-900" />
<rect x="20" y="72" width="140" height="10" rx="2" className="fill-slate-400" />
<rect x="20" y="88" width="100" height="10" rx="2" className="fill-slate-400" />
<rect x="20" y="108" width="80" height="16" rx="4" className="fill-slate-900" />
{/* Hero Image Placeholder */}
<rect x="240" y="40" width="140" height="80" rx="4" className="fill-slate-200" />
<path d="M 280 80 L 310 60 L 340 80 L 310 100 Z" className="fill-slate-300" />
{/* Cards Section */}
<g transform="translate(0, 140)">
<rect x="0" y="0" width="125" height="70" rx="4" className="fill-slate-50 stroke-slate-200" strokeWidth="1" />
<rect x="10" y="10" width="105" height="30" rx="2" className="fill-slate-200" />
<rect x="10" y="48" width="80" height="8" rx="2" className="fill-slate-300" />
<rect x="137" y="0" width="125" height="70" rx="4" className="fill-slate-50 stroke-slate-200" strokeWidth="1" />
<rect x="147" y="10" width="105" height="30" rx="2" className="fill-slate-200" />
<rect x="147" y="48" width="80" height="8" rx="2" className="fill-slate-300" />
<rect x="274" y="0" width="125" height="70" rx="4" className="fill-slate-50 stroke-slate-200" strokeWidth="1" />
<rect x="284" y="10" width="105" height="30" rx="2" className="fill-slate-200" />
<rect x="284" y="48" width="80" height="8" rx="2" className="fill-slate-300" />
</g>
</g>
</motion.g>
{/* Connecting Lines from Browser to Infrastructure */}
<motion.g>
<motion.path
d="M 400 380 L 400 400"
stroke="currentColor"
strokeWidth="2"
className="text-slate-400"
strokeDasharray="4 4"
animate={{ strokeDashoffset: [0, -16] }}
transition={{ duration: 0.8, repeat: Infinity, ease: "linear" }}
/>
<motion.path
d="M 260 480 L 260 500"
stroke="currentColor"
strokeWidth="2"
className="text-slate-400"
strokeDasharray="4 4"
animate={{ strokeDashoffset: [0, -16] }}
transition={{ duration: 0.8, repeat: Infinity, ease: "linear" }}
/>
<motion.path
d="M 540 480 L 540 500"
stroke="currentColor"
strokeWidth="2"
className="text-slate-400"
strokeDasharray="4 4"
animate={{ strokeDashoffset: [0, -16] }}
transition={{ duration: 0.8, repeat: Infinity, ease: "linear" }}
/>
</motion.g>
</svg>
);
};
export * from './Illustrations';

View File

@@ -0,0 +1,31 @@
'use client';
import * as React from 'react';
import { motion } from 'framer-motion';
import { IllustrationProps } from './types';
export const ConceptAutomation: React.FC<IllustrationProps> = ({ className = "", delay = 0 }) => (
<svg className={className} viewBox="0 0 120 120" fill="none" xmlns="http://www.w3.org/2000/svg">
<motion.g
animate={{ rotate: 360 }}
transition={{ duration: 10, repeat: Infinity, ease: "linear" }}
style={{ originX: "40px", originY: "60px" }}
>
<path d="M 40 45 L 50 60 L 40 75 L 30 60 Z" className="fill-slate-300" />
</motion.g>
<motion.g
animate={{ rotate: -360 }}
transition={{ duration: 10, repeat: Infinity, ease: "linear" }}
style={{ originX: "75px", originY: "65px" }}
>
<path d="M 75 50 L 85 65 L 75 80 L 65 65 Z" className="fill-slate-500" />
</motion.g>
<motion.path
d="M 10 60 H 110"
stroke="currentColor" strokeWidth="1" className="text-slate-300"
strokeDasharray="4 4"
animate={{ strokeDashoffset: [0, -20] }}
transition={{ duration: 2, repeat: Infinity, ease: "linear" }}
/>
</svg>
);

View File

@@ -0,0 +1,33 @@
'use client';
import * as React from 'react';
import { motion } from 'framer-motion';
import { IllustrationProps } from './types';
export const ConceptCode: React.FC<IllustrationProps> = ({ className = "", delay = 0 }) => (
<svg className={className} viewBox="0 0 120 120" fill="none" xmlns="http://www.w3.org/2000/svg">
{[40, 55, 70, 85].map((y, i) => (
<motion.path
key={y}
d={`M 25 ${y} H ${25 + ((i * 17) % 50) + 20}`}
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
className="text-slate-400"
initial={{ pathLength: 0 }}
animate={{ pathLength: [0, 1, 1, 0] }}
transition={{ duration: 4, repeat: Infinity, delay: i * 0.2 + delay }}
/>
))}
<motion.path
d="M 90 40 L 100 50 L 115 30"
stroke="currentColor"
strokeWidth="3"
strokeLinecap="round"
strokeLinejoin="round"
className="text-slate-900"
animate={{ opacity: [0, 1, 1, 0], scale: [0.8, 1, 1, 0.8] }}
transition={{ duration: 4, repeat: Infinity, delay: 1.5 }}
/>
</svg>
);

View File

@@ -0,0 +1,29 @@
'use client';
import * as React from 'react';
import { motion } from 'framer-motion';
import { IllustrationProps } from './types';
export const ConceptCommunication: React.FC<IllustrationProps> = ({ className = "", delay = 0 }) => (
<svg className={className} viewBox="0 0 120 120" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="20" cy="60" r="6" className="fill-slate-200 stroke-slate-300" strokeWidth="1" />
<circle cx="100" cy="60" r="6" className="fill-slate-900" />
<path d="M 26 60 H 94" stroke="currentColor" strokeWidth="1" className="text-slate-300" strokeDasharray="4 4" />
<motion.path
d="M 26 60 H 94"
stroke="currentColor"
strokeWidth="2"
className="text-slate-400"
initial={{ pathLength: 0 }}
animate={{ pathLength: [0, 1, 1, 0], opacity: [0, 1, 1, 0] }}
transition={{ duration: 3, repeat: Infinity, ease: "easeInOut", delay }}
/>
<motion.circle r="3" className="fill-slate-900">
<animateMotion
dur="3s"
repeatCount="indefinite"
path="M 26 60 H 94"
/>
</motion.circle>
</svg>
);

View File

@@ -0,0 +1,22 @@
'use client';
import * as React from 'react';
import { motion } from 'framer-motion';
import { IllustrationProps } from './types';
export const ConceptMessy: React.FC<IllustrationProps> = ({ className = "", delay = 0 }) => (
<svg className={className} viewBox="0 0 120 120" fill="none" xmlns="http://www.w3.org/2000/svg">
<motion.path
d="M 20 60 C 30 20, 40 100, 50 60 C 60 20, 70 100, 80 60 C 90 20, 100 100, 110 60"
stroke="currentColor" strokeWidth="1" className="text-slate-500"
animate={{ strokeDashoffset: [0, 20] }}
strokeDasharray="4 4"
transition={{ duration: 5, repeat: Infinity, ease: "linear" }}
/>
<motion.path
d="M 20 40 L 100 80 M 20 80 L 100 40"
stroke="currentColor" strokeWidth="1" className="text-slate-200 opacity-50"
animate={{ opacity: [0.2, 0.5, 0.2] }} transition={{ duration: 3, repeat: Infinity }}
/>
</svg>
);

View File

@@ -0,0 +1,27 @@
'use client';
import * as React from 'react';
import { motion } from 'framer-motion';
import { IllustrationProps } from './types';
export const ConceptPrice: React.FC<IllustrationProps> = ({ className = "", delay = 0 }) => (
<svg className={className} viewBox="0 0 120 120" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="30" y="30" width="60" height="70" rx="2" stroke="currentColor" strokeWidth="1" className="text-slate-400" />
<motion.path
d="M 40 50 H 80 M 40 65 H 80 M 40 80 H 60"
stroke="currentColor"
strokeWidth="1"
className="text-slate-300"
initial={{ pathLength: 0 }}
animate={{ pathLength: [0, 1, 1, 0] }}
transition={{ duration: 5, repeat: Infinity, delay }}
/>
<motion.circle
cx="85" cy="35" r="15"
className="fill-white stroke-slate-900"
strokeWidth="1"
animate={{ y: [0, -5, 0], rotate: [0, 10, 0] }}
transition={{ duration: 4, repeat: Infinity }}
/>
</svg>
);

View File

@@ -0,0 +1,30 @@
'use client';
import * as React from 'react';
import { motion } from 'framer-motion';
import { IllustrationProps } from './types';
export const ConceptPrototyping: React.FC<IllustrationProps> = ({ className = "", delay = 0 }) => (
<svg className={className} viewBox="0 0 120 120" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="15" y="25" width="90" height="70" rx="4" stroke="currentColor" strokeWidth="1" className="text-slate-400" />
<path d="M 15 40 H 105" stroke="currentColor" strokeWidth="1" className="text-slate-300" />
<motion.rect
x="25" y="50" width="40" height="8" rx="1"
className="fill-slate-300"
animate={{ width: [0, 40, 40, 0] }}
transition={{ duration: 4, repeat: Infinity, delay }}
/>
<motion.rect
x="25" y="65" width="60" height="8" rx="1"
className="fill-slate-200"
animate={{ width: [0, 60, 60, 0] }}
transition={{ duration: 4, repeat: Infinity, delay: 0.5 }}
/>
<motion.circle
cx="85" cy="75" r="10"
className="fill-slate-900"
animate={{ scale: [0.8, 1.1, 0.8] }}
transition={{ duration: 2, repeat: Infinity }}
/>
</svg>
);

View File

@@ -0,0 +1,33 @@
'use client';
import * as React from 'react';
import { motion } from 'framer-motion';
import { IllustrationProps } from './types';
export const ConceptSystem: React.FC<IllustrationProps> = ({ className = "", delay = 0 }) => (
<svg className={className} viewBox="0 0 120 120" fill="none" xmlns="http://www.w3.org/2000/svg">
<motion.circle cx="60" cy="60" r="15" className="fill-slate-900"
animate={{ scale: [1, 1.1, 1] }} transition={{ duration: 4, repeat: Infinity }} />
{[0, 72, 144, 216, 288].map((angle, i) => {
const x = 60 + Math.cos((angle * Math.PI) / 180) * 40;
const y = 60 + Math.sin((angle * Math.PI) / 180) * 40;
return (
<React.Fragment key={i}>
<motion.line
x1="60" y1="60" x2={x} y2={y}
stroke="currentColor" strokeWidth="1" className="text-slate-400"
animate={{ strokeDashoffset: [0, 10] }}
strokeDasharray="2 2"
transition={{ duration: 2, repeat: Infinity, ease: "linear" }}
/>
<motion.circle
cx={x} cy={y} r="6"
className="fill-white stroke-slate-300"
animate={{ scale: [1, 1.2, 1] }}
transition={{ duration: 3, repeat: Infinity, delay: i * 0.4 }}
/>
</React.Fragment>
);
})}
</svg>
);

View File

@@ -0,0 +1,26 @@
'use client';
import * as React from 'react';
import { motion } from 'framer-motion';
import { IllustrationProps } from './types';
export const ConceptTarget: React.FC<IllustrationProps> = ({ className = "", delay = 0 }) => (
<svg className={className} viewBox="0 0 120 120" fill="none" xmlns="http://www.w3.org/2000/svg">
<motion.circle
cx="60" cy="60" r="50"
stroke="currentColor" strokeWidth="1" className="text-slate-300"
animate={{ scale: [1, 1.05, 1] }}
transition={{ duration: 4, repeat: Infinity }}
/>
<motion.circle
cx="60" cy="60" r="30"
stroke="currentColor" strokeWidth="1" className="text-slate-400"
/>
<motion.circle
cx="60" cy="60" r="10"
className="fill-slate-900"
animate={{ opacity: [0.5, 1, 0.5] }}
transition={{ duration: 2, repeat: Infinity }}
/>
</svg>
);

View File

@@ -0,0 +1,24 @@
'use client';
import * as React from 'react';
import { motion } from 'framer-motion';
import { IllustrationProps } from './types';
export const ConceptWebsite: React.FC<IllustrationProps> = ({ className = "", delay = 0 }) => (
<svg className={className} viewBox="0 0 120 120" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="10" y="20" width="100" height="80" rx="4" stroke="currentColor" strokeWidth="1" className="text-slate-400" />
<motion.rect
x="20" y="35" width="80" height="15" rx="2"
className="fill-slate-200"
animate={{ opacity: [0.3, 0.6, 0.3] }}
transition={{ duration: 3, repeat: Infinity }}
/>
<motion.g
animate={{ y: [0, 10, 0] }}
transition={{ duration: 4, repeat: Infinity }}
>
<rect x="20" y="55" width="35" height="35" rx="2" className="fill-slate-300" />
<rect x="65" y="55" width="35" height="35" rx="2" className="fill-slate-300" />
</motion.g>
</svg>
);

View File

@@ -0,0 +1,31 @@
'use client';
import * as React from 'react';
import { motion } from 'framer-motion';
import { IllustrationProps } from './types';
export const HeroArchitecture: React.FC<IllustrationProps> = ({ className = "", delay = 0 }) => (
<svg className={className} viewBox="0 0 400 300" fill="none" xmlns="http://www.w3.org/2000/svg">
<motion.rect x="170" y="120" width="60" height="60" rx="8" className="stroke-slate-900 fill-white" strokeWidth="2"
animate={{ scale: [1, 1.05, 1] }} transition={{ duration: 4, repeat: Infinity }} />
{[
{ x: 80, y: 60 }, { x: 320, y: 60 },
{ x: 80, y: 240 }, { x: 320, y: 240 }
].map((node, i) => (
<React.Fragment key={i}>
<motion.path
d={`M 200 150 L ${node.x} ${node.y}`}
stroke="currentColor" strokeWidth="1" className="text-slate-400"
animate={{ strokeDashoffset: [0, -10] }} strokeDasharray="4 4"
transition={{ duration: 2, repeat: Infinity, ease: "linear" }}
/>
<motion.circle
cx={node.x} cy={node.y} r="12"
className="fill-white stroke-slate-300"
strokeWidth="1"
animate={{ scale: [1, 1.1, 1] }} transition={{ duration: 3, repeat: Infinity, delay: i * 0.5 }}
/>
</React.Fragment>
))}
</svg>
);

View File

@@ -0,0 +1,182 @@
'use client';
import * as React from 'react';
import { motion } from 'framer-motion';
import { IllustrationProps } from './types';
export const HeroMainIllustration: React.FC<IllustrationProps> = ({ className = "", delay = 0 }) => {
return (
<svg className={className} viewBox="0 0 800 700" fill="none" xmlns="http://www.w3.org/2000/svg">
{/* Matrix-style Binary Rain Background */}
<g className="opacity-[0.08]">
{Array.from({ length: 20 }).map((_, col) => {
const colX = 20 + col * 40;
const speed = 8 + (col % 6);
const startDelay = (col % 5);
return (
<motion.g
key={`rain-col-${col}`}
initial={{ y: -700 }}
animate={{ y: 700 }}
transition={{
duration: speed,
repeat: Infinity,
ease: "linear",
delay: startDelay,
}}
>
{Array.from({ length: 25 }).map((_, row) => (
<text
key={`${col}-${row}`}
x={colX}
y={row * 28}
className="fill-slate-900 font-mono"
style={{ fontSize: 12 }}
>
{(col + row) % 2 === 0 ? '1' : '0'}
</text>
))}
</motion.g>
);
})}
</g>
{/* Layer 1: Base Platform */}
<motion.g
animate={{ y: [0, 8, 0] }}
transition={{ duration: 5, repeat: Infinity, ease: "easeInOut" }}
>
<rect x="150" y="500" width="500" height="30" rx="4" className="fill-slate-100 stroke-slate-300" strokeWidth="1" />
<rect x="170" y="510" width="460" height="10" rx="2" className="fill-slate-200" />
{/* Binary on base */}
<text x="180" y="518" className="fill-slate-400 font-mono" style={{ fontSize: 8 }}>01010101010101010101010101010101010101</text>
</motion.g>
{/* Layer 2: Server/Database Layer */}
<motion.g
animate={{ y: [0, 6, 0] }}
transition={{ duration: 5, repeat: Infinity, ease: "easeInOut", delay: 0.3 }}
>
{/* Left Server Block */}
<g transform="translate(200, 400)">
<rect x="0" y="0" width="120" height="80" rx="6" className="fill-white stroke-slate-900" strokeWidth="2" />
<rect x="10" y="10" width="100" height="15" rx="2" className="fill-slate-100" />
<rect x="10" y="30" width="80" height="10" rx="2" className="fill-slate-200" />
<rect x="10" y="45" width="60" height="10" rx="2" className="fill-slate-200" />
<circle cx="100" cy="65" r="5" className="fill-slate-900" />
<text x="15" y="20" className="fill-slate-500 font-mono" style={{ fontSize: 8 }}>SERVER</text>
</g>
{/* Right Database Block */}
<g transform="translate(480, 400)">
<rect x="0" y="0" width="120" height="80" rx="6" className="fill-white stroke-slate-900" strokeWidth="2" />
<rect x="10" y="10" width="100" height="15" rx="2" className="fill-slate-100" />
<rect x="10" y="30" width="100" height="8" rx="2" className="fill-slate-200" />
<rect x="10" y="42" width="100" height="8" rx="2" className="fill-slate-200" />
<rect x="10" y="54" width="100" height="8" rx="2" className="fill-slate-200" />
<text x="15" y="20" className="fill-slate-500 font-mono" style={{ fontSize: 8 }}>DATABASE</text>
</g>
{/* Connection Lines */}
<motion.path
d="M 320 440 L 400 440 L 480 440"
stroke="currentColor"
strokeWidth="2"
className="text-slate-400"
strokeDasharray="6 4"
animate={{ strokeDashoffset: [0, -20] }}
transition={{ duration: 1, repeat: Infinity, ease: "linear" }}
/>
</motion.g>
{/* Layer 3: Browser/Website */}
<motion.g
animate={{ y: [0, 4, 0] }}
transition={{ duration: 5, repeat: Infinity, ease: "easeInOut", delay: 0.6 }}
>
{/* Browser Window */}
<rect x="180" y="100" width="440" height="280" rx="8" className="fill-white stroke-slate-900" strokeWidth="2" />
{/* Browser Chrome */}
<rect x="180" y="100" width="440" height="30" rx="8" className="fill-slate-900" />
<rect x="180" y="120" width="440" height="10" className="fill-slate-900" />
{/* Browser Dots */}
<circle cx="200" cy="115" r="5" className="fill-slate-600" />
<circle cx="218" cy="115" r="5" className="fill-slate-600" />
<circle cx="236" cy="115" r="5" className="fill-slate-600" />
{/* Address Bar */}
<rect x="260" y="108" width="200" height="14" rx="3" className="fill-slate-700" />
{/* Website Content */}
<g transform="translate(200, 150)">
{/* Navigation */}
<rect x="0" y="0" width="400" height="20" className="fill-slate-50" />
<rect x="10" y="5" width="60" height="10" rx="2" className="fill-slate-900" />
<rect x="280" y="5" width="30" height="10" rx="2" className="fill-slate-300" />
<rect x="320" y="5" width="30" height="10" rx="2" className="fill-slate-300" />
<rect x="360" y="5" width="30" height="10" rx="2" className="fill-slate-300" />
{/* Hero Section */}
<rect x="0" y="30" width="400" height="100" className="fill-slate-100" />
<rect x="20" y="50" width="180" height="16" rx="2" className="fill-slate-900" />
<rect x="20" y="72" width="140" height="10" rx="2" className="fill-slate-400" />
<rect x="20" y="88" width="100" height="10" rx="2" className="fill-slate-400" />
<rect x="20" y="108" width="80" height="16" rx="4" className="fill-slate-900" />
{/* Hero Image Placeholder */}
<rect x="240" y="40" width="140" height="80" rx="4" className="fill-slate-200" />
<path d="M 280 80 L 310 60 L 340 80 L 310 100 Z" className="fill-slate-300" />
{/* Cards Section */}
<g transform="translate(0, 140)">
<rect x="0" y="0" width="125" height="70" rx="4" className="fill-slate-50 stroke-slate-200" strokeWidth="1" />
<rect x="10" y="10" width="105" height="30" rx="2" className="fill-slate-200" />
<rect x="10" y="48" width="80" height="8" rx="2" className="fill-slate-300" />
<rect x="137" y="0" width="125" height="70" rx="4" className="fill-slate-50 stroke-slate-200" strokeWidth="1" />
<rect x="147" y="10" width="105" height="30" rx="2" className="fill-slate-200" />
<rect x="147" y="48" width="80" height="8" rx="2" className="fill-slate-300" />
<rect x="274" y="0" width="125" height="70" rx="4" className="fill-slate-50 stroke-slate-200" strokeWidth="1" />
<rect x="284" y="10" width="105" height="30" rx="2" className="fill-slate-200" />
<rect x="284" y="48" width="80" height="8" rx="2" className="fill-slate-300" />
</g>
</g>
</motion.g>
{/* Connecting Lines from Browser to Infrastructure */}
<motion.g>
<motion.path
d="M 400 380 L 400 400"
stroke="currentColor"
strokeWidth="2"
className="text-slate-400"
strokeDasharray="4 4"
animate={{ strokeDashoffset: [0, -16] }}
transition={{ duration: 0.8, repeat: Infinity, ease: "linear" }}
/>
<motion.path
d="M 260 480 L 260 500"
stroke="currentColor"
strokeWidth="2"
className="text-slate-400"
strokeDasharray="4 4"
animate={{ strokeDashoffset: [0, -16] }}
transition={{ duration: 0.8, repeat: Infinity, ease: "linear" }}
/>
<motion.path
d="M 540 480 L 540 500"
stroke="currentColor"
strokeWidth="2"
className="text-slate-400"
strokeDasharray="4 4"
animate={{ strokeDashoffset: [0, -16] }}
transition={{ duration: 0.8, repeat: Infinity, ease: "linear" }}
/>
</motion.g>
</svg>
);
};

View File

@@ -0,0 +1,12 @@
export * from './types';
export * from './ConceptCommunication';
export * from './ConceptPrototyping';
export * from './ConceptCode';
export * from './ConceptPrice';
export * from './ConceptWebsite';
export * from './ConceptSystem';
export * from './ConceptAutomation';
export * from './ConceptTarget';
export * from './ConceptMessy';
export * from './HeroArchitecture';
export * from './HeroMainIllustration';

View File

@@ -0,0 +1,8 @@
'use client';
import * as React from 'react';
export interface IllustrationProps {
className?: string;
delay?: number;
}

60
src/components/Modal.tsx Normal file
View File

@@ -0,0 +1,60 @@
'use client';
import * as React from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { X } from 'lucide-react';
interface ModalProps {
isOpen: boolean;
onClose: () => void;
title: string;
children: React.ReactNode;
}
export function Modal({ isOpen, onClose, title, children }: ModalProps) {
// Close on escape key
React.useEffect(() => {
const handleEsc = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
window.addEventListener('keydown', handleEsc);
return () => window.removeEventListener('keydown', handleEsc);
}, [onClose]);
return (
<AnimatePresence>
{isOpen && (
<>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={onClose}
className="fixed inset-0 bg-slate-900/40 backdrop-blur-sm z-[100]"
/>
<div className="fixed inset-0 flex items-center justify-center p-4 z-[101] pointer-events-none">
<motion.div
initial={{ opacity: 0, scale: 0.9, y: 20 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.9, y: 20 }}
className="bg-white w-full max-w-lg rounded-[2.5rem] shadow-2xl pointer-events-auto overflow-hidden"
>
<div className="p-8 border-b border-slate-50 flex items-center justify-between">
<h3 className="text-2xl font-bold text-slate-900">{title}</h3>
<button
onClick={onClose}
className="p-2 hover:bg-slate-50 rounded-full transition-colors text-slate-400 hover:text-slate-900"
>
<X size={24} />
</button>
</div>
<div className="p-8">
{children}
</div>
</motion.div>
</div>
</>
)}
</AnimatePresence>
);
}

View File

@@ -28,7 +28,10 @@ export const PageHeader: React.FC<PageHeaderProps> = ({
)}
{backLink && (
<Link href={backLink.href} className="inline-flex items-center gap-3 text-slate-400 hover:text-slate-900 mb-12 transition-all duration-300 font-bold text-[10px] uppercase tracking-[0.3em] group">
<Link
href={backLink.href}
className="inline-flex items-center gap-3 px-4 py-2 border border-slate-200 rounded-full text-slate-400 hover:text-slate-900 hover:border-slate-400 hover:bg-slate-50 mb-12 transition-all duration-500 ease-[cubic-bezier(0.23,1,0.32,1)] hover:-translate-y-0.5 hover:shadow-lg hover:shadow-slate-100 font-bold text-[10px] uppercase tracking-[0.3em] group"
>
<ArrowLeft className="w-4 h-4 group-hover:-translate-x-1 transition-transform" /> {backLink.label}
</Link>
)}

View File

@@ -65,7 +65,7 @@ export const SearchBar: React.FC<SearchBarProps> = ({ value: propValue, onChange
{value && (
<button
onClick={clearSearch}
className="absolute right-6 top-1/2 -translate-y-1/2 text-[10px] font-bold uppercase tracking-widest text-slate-400 hover:text-slate-900 transition-colors"
className="absolute right-4 top-1/2 -translate-y-1/2 px-3 py-1 border border-slate-200 rounded-full text-[10px] font-bold uppercase tracking-widest text-slate-400 hover:text-slate-900 hover:border-slate-400 hover:bg-slate-50 transition-all duration-500 ease-[cubic-bezier(0.23,1,0.32,1)] hover:-translate-y-[calc(50%+2px)] hover:shadow-lg hover:shadow-slate-100"
aria-label="Clear search"
type="button"
>

View File

@@ -10,6 +10,7 @@ interface SectionProps {
variant?: 'white' | 'gray';
borderTop?: boolean;
connector?: React.ReactNode;
containerVariant?: 'narrow' | 'normal' | 'wide';
}
export const Section: React.FC<SectionProps> = ({
@@ -21,49 +22,84 @@ export const Section: React.FC<SectionProps> = ({
variant = 'white',
borderTop = false,
connector,
containerVariant = 'narrow',
}) => {
const bgClass = variant === 'gray' ? 'bg-slate-50' : 'bg-white';
const borderClass = borderTop ? 'border-t border-slate-100' : '';
const containerClass = containerVariant === 'wide' ? 'wide-container' : containerVariant === 'normal' ? 'container' : 'narrow-container';
// If no number and title, or if we want to force a simple layout, we could add a prop.
// But let's make it smart: if it's wide, maybe we want the title on top anyway?
// The user specifically asked to move it above for the configurator.
const isTopTitle = containerVariant === 'wide';
return (
<section className={`relative py-24 md:py-32 group ${bgClass} ${borderClass} ${className}`}>
<div className="narrow-container">
<div className="grid grid-cols-1 md:grid-cols-12 gap-12 md:gap-16">
{/* Sidebar: Number & Title */}
<div className="md:col-span-3 relative">
{/* Connector Line */}
{connector && (
<div className="absolute left-[2.5rem] top-0 bottom-0 w-24 hidden md:block -z-10 pointer-events-none">
{connector}
</div>
<div className={containerClass}>
{isTopTitle ? (
<div className="space-y-16">
{(number || title) && (
<div className="flex flex-col md:flex-row md:items-end gap-6 md:gap-12">
{number && (
<Reveal delay={delay}>
<span className="block text-6xl md:text-8xl font-bold text-slate-100 leading-none select-none">
{number}
</span>
</Reveal>
)}
{title && (
<Reveal delay={delay + 0.1}>
<div className="flex items-center gap-3 mb-2 md:mb-4">
<div className="h-px w-6 bg-slate-900"></div>
<h2 className="text-xs font-bold uppercase tracking-[0.3em] text-slate-900">
{title}
</h2>
</div>
</Reveal>
)}
</div>
)}
<div className="md:sticky md:top-32 space-y-6">
{number && (
<Reveal delay={delay}>
<span className="block text-6xl md:text-8xl font-bold text-slate-100 leading-none select-none relative bg-white/0 backdrop-blur-[2px]">
{number}
</span>
</Reveal>
)}
{title && (
<Reveal delay={delay + 0.1}>
<div className="flex items-center gap-3">
<div className="h-px w-6 bg-slate-900"></div>
<h2 className="text-xs font-bold uppercase tracking-[0.3em] text-slate-900">
{title}
</h2>
<div>{children}</div>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-12 gap-12 md:gap-16">
{/* Sidebar: Number & Title */}
<div className="md:col-span-3 relative">
{/* Connector Line */}
{connector && (
<div className="absolute left-[2.5rem] top-0 bottom-0 w-24 hidden md:block -z-10 pointer-events-none">
{connector}
</div>
</Reveal>
)}
<div className="md:sticky md:top-32 space-y-6">
{number && (
<Reveal delay={delay}>
<span className="block text-6xl md:text-8xl font-bold text-slate-100 leading-none select-none relative bg-white/0 backdrop-blur-[2px]">
{number}
</span>
</Reveal>
)}
{title && (
<Reveal delay={delay + 0.1}>
<div className="flex items-center gap-3">
<div className="h-px w-6 bg-slate-900"></div>
<h2 className="text-xs font-bold uppercase tracking-[0.3em] text-slate-900">
{title}
</h2>
</div>
</Reveal>
)}
</div>
</div>
{/* Main Content */}
<div className="md:col-span-9">
{children}
</div>
</div>
{/* Main Content */}
<div className="md:col-span-9">
{children}
</div>
</div>
)}
</div>
</section>
);

View File

@@ -0,0 +1,81 @@
'use client';
import * as React from 'react';
import { Modal } from './Modal';
import { Copy, Check, Share2 } from 'lucide-react';
import { useState } from 'react';
interface ShareModalProps {
isOpen: boolean;
onClose: () => void;
url: string;
qrCodeData?: string;
}
export function ShareModal({ isOpen, onClose, url, qrCodeData }: ShareModalProps) {
const [copied, setCopied] = useState(false);
const handleCopy = () => {
navigator.clipboard.writeText(url);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
const handleNativeShare = async () => {
if (navigator.share) {
try {
await navigator.share({
title: 'Meine Projekt-Konfiguration',
url: url
});
} catch (e) {
console.error("Share failed", e);
}
}
};
return (
<Modal isOpen={isOpen} onClose={onClose} title="Konfiguration teilen">
<div className="space-y-8">
<p className="text-slate-500 leading-relaxed">
Speichern Sie diesen Link, um Ihre Konfiguration später fortzusetzen oder teilen Sie ihn mit anderen.
</p>
{qrCodeData && (
<div className="flex flex-col items-center gap-4 p-8 bg-slate-50 rounded-[2rem]">
<img src={qrCodeData} alt="QR Code" className="w-48 h-48 rounded-xl shadow-sm" />
<p className="text-[10px] font-bold uppercase tracking-widest text-slate-400">QR-Code scannen</p>
</div>
)}
<div className="space-y-4">
<div className="relative">
<input
type="text"
readOnly
value={url}
className="w-full p-6 pr-20 bg-slate-50 border border-slate-100 rounded-2xl text-sm font-mono text-slate-600 focus:outline-none"
/>
<button
onClick={handleCopy}
className="absolute right-2 top-2 bottom-2 px-4 bg-white border border-slate-100 rounded-xl text-slate-900 hover:bg-slate-900 hover:text-white transition-all flex items-center gap-2"
>
{copied ? <Check size={18} /> : <Copy size={18} />}
<span className="text-xs font-bold uppercase tracking-wider">{copied ? 'Kopiert' : 'Kopieren'}</span>
</button>
</div>
{typeof navigator !== 'undefined' && navigator.share && (
<button
onClick={handleNativeShare}
className="w-full p-6 bg-slate-900 text-white rounded-2xl font-bold flex items-center justify-center gap-3 hover:bg-slate-800 transition-all"
>
<Share2 size={20} />
<span>System-Dialog öffnen</span>
</button>
)}
</div>
</div>
</Modal>
);
}

View File

@@ -11,7 +11,7 @@ export const Tag: React.FC<TagProps> = ({ tag, className = '' }) => {
return (
<Link
href={`/tags/${tag}`}
className={`inline-block text-[10px] font-bold uppercase tracking-[0.2em] text-slate-500 bg-white border border-slate-200 px-4 py-2 rounded-full hover:border-slate-400 hover:text-slate-900 transition-all duration-300 ${className}`}
className={`inline-block text-[10px] font-bold uppercase tracking-[0.2em] text-slate-500 bg-white border border-slate-200 px-4 py-2 rounded-full hover:border-slate-400 hover:text-slate-900 hover:bg-slate-50 transition-all duration-500 ease-[cubic-bezier(0.23,1,0.32,1)] hover:-translate-y-0.5 hover:shadow-lg hover:shadow-slate-100 ${className}`}
>
{tag}
</Link>

View File

@@ -178,15 +178,15 @@
/* Buttons */
.btn {
@apply inline-block px-4 py-2 bg-slate-900 text-white font-sans font-medium hover:bg-slate-700 transition-colors rounded;
@apply inline-flex items-center justify-center px-6 py-3 border border-slate-200 bg-white text-slate-600 font-sans font-bold text-sm uppercase tracking-widest rounded-full transition-all duration-500 ease-[cubic-bezier(0.23,1,0.32,1)] hover:border-slate-400 hover:text-slate-900 hover:bg-slate-50 hover:-translate-y-0.5 hover:shadow-xl hover:shadow-slate-100 active:translate-y-0 active:shadow-sm;
}
.btn-primary {
@apply bg-blue-600 hover:bg-blue-700 text-white px-3 py-1.5 rounded transition-colors;
@apply border-slate-900 text-slate-900 hover:bg-slate-900 hover:text-white;
}
.btn-secondary {
@apply bg-white text-slate-700 hover:bg-slate-100 border border-slate-300 px-3 py-1.5 rounded transition-colors;
@apply border-slate-200 text-slate-500 hover:border-slate-400 hover:text-slate-900;
}
/* Hide scrollbars */

4
src/types/svg.d.ts vendored Normal file
View File

@@ -0,0 +1,4 @@
declare module '*.svg' {
const content: any;
export default content;
}

View File

@@ -5,6 +5,7 @@
import type { AnalyticsAdapter, AnalyticsEvent, AnalyticsConfig } from './interfaces';
import { PlausibleAdapter } from './plausible-adapter';
import { UmamiAdapter, type UmamiConfig } from './umami-adapter';
export class AnalyticsService {
private adapter: AnalyticsAdapter;
@@ -65,20 +66,33 @@ export class AnalyticsService {
}
}
// Factory function
// Factory functions
export function createPlausibleAnalytics(config: AnalyticsConfig): AnalyticsService {
return new AnalyticsService(new PlausibleAdapter(config));
}
export function createUmamiAnalytics(config: UmamiConfig): AnalyticsService {
return new AnalyticsService(new UmamiAdapter(config));
}
// Default singleton
let defaultAnalytics: AnalyticsService | null = null;
export function getDefaultAnalytics(): AnalyticsService {
if (!defaultAnalytics) {
defaultAnalytics = createPlausibleAnalytics({
domain: 'mintel.me',
scriptUrl: 'https://plausible.yourdomain.com/js/script.js'
});
const provider = process.env.NEXT_PUBLIC_ANALYTICS_PROVIDER || 'plausible';
if (provider === 'umami') {
defaultAnalytics = createUmamiAnalytics({
websiteId: process.env.NEXT_PUBLIC_UMAMI_WEBSITE_ID || '',
hostUrl: process.env.NEXT_PUBLIC_UMAMI_HOST_URL,
});
} else {
defaultAnalytics = createPlausibleAnalytics({
domain: process.env.NEXT_PUBLIC_PLAUSIBLE_DOMAIN || 'mintel.me',
scriptUrl: process.env.NEXT_PUBLIC_PLAUSIBLE_SCRIPT_URL || 'https://plausible.yourdomain.com/js/script.js'
});
}
}
return defaultAnalytics;
}

View File

@@ -0,0 +1,54 @@
/**
* Umami Analytics Adapter
* Decoupled implementation
*/
import type { AnalyticsAdapter, AnalyticsEvent, AnalyticsConfig } from './interfaces';
export interface UmamiConfig extends AnalyticsConfig {
websiteId: string;
hostUrl?: string;
}
export class UmamiAdapter implements AnalyticsAdapter {
private websiteId: string;
private hostUrl: string;
constructor(config: UmamiConfig) {
this.websiteId = config.websiteId;
this.hostUrl = config.hostUrl || 'https://cloud.umami.is';
}
async track(event: AnalyticsEvent): Promise<void> {
if (typeof window === 'undefined') return;
const w = window as any;
if (w.umami) {
w.umami.track(event.name, event.props);
}
}
async page(path: string, props?: Record<string, any>): Promise<void> {
// Umami tracks pageviews automatically by default,
// but we can manually trigger it if needed.
if (typeof window === 'undefined') return;
const w = window as any;
if (w.umami) {
w.umami.track(props?.name || 'pageview', { url: path, ...props });
}
}
async identify(userId: string, traits?: Record<string, any>): Promise<void> {
// Umami doesn't have a direct 'identify' like Segment,
// but we can track it as an event or session property if supported by the instance.
await this.track({
name: 'identify',
props: { userId, ...traits }
});
}
getScriptTag(): string {
return `<script async src="${this.hostUrl}/script.js" data-website-id="${this.websiteId}"></script>`;
}
}

View File

@@ -0,0 +1,76 @@
/**
* GlitchTip Error Tracking Adapter
* GlitchTip is Sentry-compatible.
*/
import type { ErrorTrackingAdapter, ErrorContext, ErrorTrackingConfig } from './interfaces';
export class GlitchTipAdapter implements ErrorTrackingAdapter {
private dsn: string;
constructor(config: ErrorTrackingConfig) {
this.dsn = config.dsn;
this.init(config);
}
private init(config: ErrorTrackingConfig) {
if (typeof window === 'undefined') return;
// In a real scenario, we would import @sentry/nextjs or @sentry/browser
// For this implementation, we assume Sentry is available globally or
// we provide the structure that would call the SDK.
const w = window as any;
if (w.Sentry) {
w.Sentry.init({
dsn: this.dsn,
environment: config.environment || 'production',
release: config.release,
debug: config.debug || false,
});
}
}
captureException(error: any, context?: ErrorContext): void {
if (typeof window === 'undefined') return;
const w = window as any;
if (w.Sentry) {
w.Sentry.captureException(error, context);
} else {
console.error('[GlitchTip] Exception captured (Sentry not loaded):', error, context);
}
}
captureMessage(message: string, context?: ErrorContext): void {
if (typeof window === 'undefined') return;
const w = window as any;
if (w.Sentry) {
w.Sentry.captureMessage(message, context);
} else {
console.log('[GlitchTip] Message captured (Sentry not loaded):', message, context);
}
}
setUser(user: ErrorContext['user']): void {
if (typeof window === 'undefined') return;
const w = window as any;
if (w.Sentry) {
w.Sentry.setUser(user);
}
}
setTag(key: string, value: string): void {
if (typeof window === 'undefined') return;
const w = window as any;
if (w.Sentry) {
w.Sentry.setTag(key, value);
}
}
setExtra(key: string, value: any): void {
if (typeof window === 'undefined') return;
const w = window as any;
if (w.Sentry) {
w.Sentry.setExtra(key, value);
}
}
}

View File

@@ -0,0 +1,61 @@
/**
* Error Tracking Service - Main entry point with DI
*/
import type { ErrorTrackingAdapter, ErrorContext, ErrorTrackingConfig } from './interfaces';
import { GlitchTipAdapter } from './glitchtip-adapter';
export class ErrorTrackingService {
private adapter: ErrorTrackingAdapter;
constructor(adapter: ErrorTrackingAdapter) {
this.adapter = adapter;
}
captureException(error: any, context?: ErrorContext): void {
this.adapter.captureException(error, context);
}
captureMessage(message: string, context?: ErrorContext): void {
this.adapter.captureMessage(message, context);
}
setUser(user: ErrorContext['user']): void {
this.adapter.setUser(user);
}
setTag(key: string, value: string): void {
this.adapter.setTag(key, value);
}
setExtra(key: string, value: any): void {
this.adapter.setExtra(key, value);
}
}
// Factory function
export function createGlitchTipErrorTracking(config: ErrorTrackingConfig): ErrorTrackingService {
return new ErrorTrackingService(new GlitchTipAdapter(config));
}
// Default singleton
let defaultErrorTracking: ErrorTrackingService | null = null;
export function getDefaultErrorTracking(): ErrorTrackingService {
if (!defaultErrorTracking) {
defaultErrorTracking = createGlitchTipErrorTracking({
dsn: process.env.NEXT_PUBLIC_GLITCHTIP_DSN || '',
environment: process.env.NODE_ENV,
});
}
return defaultErrorTracking;
}
// Convenience functions
export function captureException(error: any, context?: ErrorContext): void {
getDefaultErrorTracking().captureException(error, context);
}
export function captureMessage(message: string, context?: ErrorContext): void {
getDefaultErrorTracking().captureMessage(message, context);
}

View File

@@ -0,0 +1,29 @@
/**
* Error Tracking interfaces - decoupled contracts
*/
export interface ErrorContext {
extra?: Record<string, any>;
tags?: Record<string, string>;
user?: {
id?: string;
email?: string;
username?: string;
};
level?: 'fatal' | 'error' | 'warning' | 'info' | 'debug';
}
export interface ErrorTrackingAdapter {
captureException(error: any, context?: ErrorContext): void;
captureMessage(message: string, context?: ErrorContext): void;
setUser(user: ErrorContext['user']): void;
setTag(key: string, value: string): void;
setExtra(key: string, value: any): void;
}
export interface ErrorTrackingConfig {
dsn: string;
environment?: string;
release?: string;
debug?: boolean;
}