Compare commits
10 Commits
5587317e9f
...
1f57bae339
| Author | SHA1 | Date | |
|---|---|---|---|
| 1f57bae339 | |||
| 1468a48281 | |||
| a466309ee7 | |||
| f8df944bd7 | |||
| 316e4b6fe9 | |||
| cea56ac58d | |||
| ba08724a52 | |||
| 520be462f0 | |||
| 36bb12f656 | |||
| f536765b6c |
146
.gitea/workflows/deploy.yml
Normal 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"
|
||||
@@ -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>
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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({
|
||||
|
||||
46
app/page.tsx
@@ -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" />
|
||||
|
||||
@@ -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="€"
|
||||
/>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
reactStrictMode: true,
|
||||
output: 'standalone',
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
|
||||
833
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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
|
After Width: | Height: | Size: 3.5 MiB |
BIN
src/assets/logo/Favicon.png
Normal file
|
After Width: | Height: | Size: 54 KiB |
BIN
src/assets/logo/Favicon@2x.png
Normal file
|
After Width: | Height: | Size: 133 KiB |
BIN
src/assets/logo/Icon Black Transparent.png
Normal file
|
After Width: | Height: | Size: 26 KiB |
12
src/assets/logo/Icon Black Transparent.svg
Normal file
|
After Width: | Height: | Size: 24 KiB |
BIN
src/assets/logo/Icon Black Transparent@2x.png
Normal file
|
After Width: | Height: | Size: 58 KiB |
BIN
src/assets/logo/Icon Black.png
Normal file
|
After Width: | Height: | Size: 54 KiB |
BIN
src/assets/logo/Icon Black@2x.png
Normal file
|
After Width: | Height: | Size: 129 KiB |
BIN
src/assets/logo/Icon White Transparent.png
Normal file
|
After Width: | Height: | Size: 32 KiB |
12
src/assets/logo/Icon White Transparent.svg
Normal file
|
After Width: | Height: | Size: 29 KiB |
BIN
src/assets/logo/Icon White Transparent@2x.png
Normal file
|
After Width: | Height: | Size: 77 KiB |
BIN
src/assets/logo/Icon White.png
Normal file
|
After Width: | Height: | Size: 52 KiB |
BIN
src/assets/logo/Icon White@2x.png
Normal file
|
After Width: | Height: | Size: 128 KiB |
BIN
src/assets/logo/Logo Black Transparent.png
Normal file
|
After Width: | Height: | Size: 13 KiB |
12
src/assets/logo/Logo Black Transparent.svg
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
src/assets/logo/Logo Black Transparent@2x.png
Normal file
|
After Width: | Height: | Size: 29 KiB |
BIN
src/assets/logo/Logo Black.png
Normal file
|
After Width: | Height: | Size: 26 KiB |
8
src/assets/logo/Logo Black.svg
Normal file
|
After Width: | Height: | Size: 30 KiB |
BIN
src/assets/logo/Logo Black@2x.png
Normal file
|
After Width: | Height: | Size: 59 KiB |
BIN
src/assets/logo/Logo White Transparent.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
12
src/assets/logo/Logo White Transparent.svg
Normal file
|
After Width: | Height: | Size: 19 KiB |
BIN
src/assets/logo/Logo White Transparent@2x.png
Normal file
|
After Width: | Height: | Size: 32 KiB |
BIN
src/assets/logo/Logo White.png
Normal file
|
After Width: | Height: | Size: 28 KiB |
BIN
src/assets/logo/Logo White@2x.png
Normal file
|
After Width: | Height: | Size: 65 KiB |
@@ -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' }}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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" />
|
||||
|
||||
365
src/components/ContactForm.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
23
src/components/ContactForm/components/AnimatedNumber.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useSpring, useTransform } from 'framer-motion';
|
||||
|
||||
export function AnimatedNumber({ value }: { value: number }) {
|
||||
const spring = useSpring(value, { stiffness: 50, damping: 20 });
|
||||
const display = useTransform(spring, (v) => Math.round(v).toLocaleString());
|
||||
const ref = useRef<HTMLSpanElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
spring.set(value);
|
||||
}, [value, spring]);
|
||||
|
||||
useEffect(() => {
|
||||
return display.on('change', (v) => {
|
||||
if (ref.current) ref.current.textContent = v;
|
||||
});
|
||||
}, [display]);
|
||||
|
||||
return <span ref={ref}>{value.toLocaleString()}</span>;
|
||||
}
|
||||
31
src/components/ContactForm/components/Checkbox.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { Check } from 'lucide-react';
|
||||
|
||||
interface CheckboxProps {
|
||||
label: string;
|
||||
desc: string;
|
||||
checked: boolean;
|
||||
onChange: () => void;
|
||||
}
|
||||
|
||||
export function Checkbox({ label, desc, checked, onChange }: CheckboxProps) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onChange}
|
||||
className={`p-6 rounded-[2rem] border-2 text-left transition-all duration-300 flex items-start gap-4 focus:outline-none overflow-hidden relative ${
|
||||
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>
|
||||
);
|
||||
}
|
||||
114
src/components/ContactForm/components/PriceCalculation.tsx
Normal file
@@ -0,0 +1,114 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { FormState } from '../types';
|
||||
import { PRICING } from '../constants';
|
||||
import { AnimatedNumber } from './AnimatedNumber';
|
||||
import { ConceptPrice, ConceptAutomation } from '../../Landing/ConceptIllustrations';
|
||||
import { Info, Download, Share2 } from 'lucide-react';
|
||||
import dynamic from 'next/dynamic';
|
||||
import { EstimationPDF } from '../../EstimationPDF';
|
||||
|
||||
// Dynamically import PDF components to avoid SSR issues
|
||||
const PDFDownloadLink = dynamic(
|
||||
() => import('@react-pdf/renderer').then((mod) => mod.PDFDownloadLink),
|
||||
{ ssr: false }
|
||||
);
|
||||
|
||||
interface PriceCalculationProps {
|
||||
state: FormState;
|
||||
totalPrice: number;
|
||||
monthlyPrice: number;
|
||||
totalPagesCount: number;
|
||||
isClient: boolean;
|
||||
qrCodeData: string;
|
||||
onShare?: () => void;
|
||||
}
|
||||
|
||||
export function PriceCalculation({
|
||||
state,
|
||||
totalPrice,
|
||||
monthlyPrice,
|
||||
totalPagesCount,
|
||||
isClient,
|
||||
qrCodeData,
|
||||
onShare
|
||||
}: PriceCalculationProps) {
|
||||
return (
|
||||
<div className="lg:col-span-4 lg:sticky lg:top-24">
|
||||
<div className="p-10 bg-slate-50 border border-slate-100 rounded-[3rem] space-y-10">
|
||||
<div className="space-y-3"><div className="flex items-center gap-3"><ConceptPrice className="w-8 h-8" /><h3 className="text-2xl font-bold text-slate-900">Kalkulation</h3></div><p className="text-sm text-slate-500 leading-relaxed">Unverbindliche Schätzung.</p></div>
|
||||
<div className="space-y-6">
|
||||
{state.projectType === 'website' ? (
|
||||
<>
|
||||
<div className="flex justify-between items-center py-4 border-b border-slate-200"><span className="text-slate-600 font-medium">Basis Website</span><span className="font-bold text-lg text-slate-900">{PRICING.BASE_WEBSITE.toLocaleString()} €</span></div>
|
||||
<div className="space-y-4 max-h-[400px] overflow-y-auto pr-2 hide-scrollbar">
|
||||
{totalPagesCount > 0 && (<div className="flex justify-between items-center text-sm"><span className="text-slate-500">{totalPagesCount}x Seite</span><span className="font-medium text-slate-900">{(totalPagesCount * PRICING.PAGE).toLocaleString()} €</span></div>)}
|
||||
{state.features.length + state.otherFeatures.length > 0 && (<div className="flex justify-between items-center text-sm"><span className="text-slate-500">{state.features.length + state.otherFeatures.length}x System-Modul</span><span className="font-medium text-slate-900">{((state.features.length + state.otherFeatures.length) * PRICING.FEATURE).toLocaleString()} €</span></div>)}
|
||||
{state.functions.length + state.otherFunctions.length > 0 && (<div className="flex justify-between items-center text-sm"><span className="text-slate-500">{state.functions.length + state.otherFunctions.length}x Logik-Funktion</span><span className="font-medium text-slate-900">{((state.functions.length + state.otherFunctions.length) * PRICING.FUNCTION).toLocaleString()} €</span></div>)}
|
||||
{state.complexInteractions > 0 && (<div className="flex justify-between items-center text-sm"><span className="text-slate-500">{state.complexInteractions}x Komplexes UI/Animation</span><span className="font-medium text-slate-900">{(state.complexInteractions * PRICING.COMPLEX_INTERACTION).toLocaleString()} €</span></div>)}
|
||||
{state.apiSystems.length + state.otherTech.length > 0 && (<div className="flex justify-between items-center text-sm"><span className="text-slate-500">{state.apiSystems.length + state.otherTech.length}x API Sync</span><span className="font-medium text-slate-900">{((state.apiSystems.length + state.otherTech.length) * PRICING.API_INTEGRATION).toLocaleString()} €</span></div>)}
|
||||
{state.cmsSetup && (<div className="flex justify-between items-center text-sm"><span className="text-slate-500">CMS Setup & Anbindung</span><span className="font-medium text-slate-900">{(PRICING.CMS_SETUP + (state.features.length + state.otherFeatures.length) * PRICING.CMS_CONNECTION_PER_FEATURE).toLocaleString()} €</span></div>)}
|
||||
{state.newDatasets > 0 && (<div className="flex justify-between items-center text-sm"><span className="text-slate-500">{state.newDatasets}x Inhalte einpflegen</span><span className="font-medium text-slate-900">{(state.newDatasets * PRICING.NEW_DATASET).toLocaleString()} €</span></div>)}
|
||||
{state.languagesCount > 1 && (<div className="flex justify-between items-center text-sm text-slate-900 font-bold pt-2 border-t border-slate-100"><span className="text-slate-500">Mehrsprachigkeit ({state.languagesCount}x)</span><span>+{(totalPrice - (totalPrice / (1 + (state.languagesCount - 1) * 0.2))).toLocaleString()} €</span></div>)}
|
||||
</div>
|
||||
<div className="pt-8 space-y-2"><div className="flex justify-between items-end"><span className="text-2xl font-bold text-slate-900">Gesamt</span><div className="text-right"><div className="text-4xl font-bold tracking-tighter text-slate-900"><AnimatedNumber value={totalPrice} /> €</div><p className="text-[10px] text-slate-400 mt-1 uppercase tracking-widest font-bold">Einmalig / Netto</p></div></div></div>
|
||||
<div className="pt-8 border-t border-slate-200 space-y-4"><div className="flex justify-between items-center"><span className="text-slate-600 font-medium">Betrieb & Hosting</span><span className="font-bold text-lg text-slate-900">{monthlyPrice.toLocaleString()} € / Monat</span></div><div className="p-6 bg-white rounded-[2rem] text-xs text-slate-500 flex gap-4 leading-relaxed border border-slate-100"><Info size={18} className="shrink-0 text-slate-300" /><p>Inklusive Hosting, Sicherheitsupdates, Backups und Analytics-Reports.</p></div></div>
|
||||
|
||||
<div className="pt-6 space-y-4">
|
||||
{isClient && (
|
||||
<PDFDownloadLink
|
||||
document={<EstimationPDF state={state} totalPrice={totalPrice} monthlyPrice={monthlyPrice} totalPagesCount={totalPagesCount} pricing={PRICING} qrCodeData={qrCodeData} />}
|
||||
fileName={`kalkulation-${state.name.toLowerCase().replace(/\s+/g, '-') || 'projekt'}.pdf`}
|
||||
className="w-full flex items-center justify-center gap-3 px-8 py-4 rounded-full border border-slate-200 text-slate-900 font-bold text-sm uppercase tracking-widest hover:bg-white hover:border-slate-900 transition-all focus:outline-none overflow-hidden relative rounded-full"
|
||||
>
|
||||
{({ loading }) => (
|
||||
<div className="flex items-center gap-3">
|
||||
<Download size={18} />
|
||||
<span>{loading ? 'PDF wird erstellt...' : 'Als PDF speichern'}</span>
|
||||
</div>
|
||||
)}
|
||||
</PDFDownloadLink>
|
||||
)}
|
||||
|
||||
{onShare && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onShare}
|
||||
className="w-full flex items-center justify-center gap-3 px-8 py-4 rounded-full bg-slate-900 text-white font-bold text-sm uppercase tracking-widest hover:bg-slate-800 transition-all shadow-xl shadow-slate-200 focus:outline-none"
|
||||
>
|
||||
<Share2 size={18} />
|
||||
<span>Konfiguration teilen</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
<div className="py-12 text-center space-y-6">
|
||||
<div className="w-20 h-20 bg-white rounded-full flex items-center justify-center mx-auto shadow-sm">
|
||||
<ConceptAutomation className="w-12 h-12" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<p className="text-slate-600 text-sm leading-relaxed">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>
|
||||
);
|
||||
}
|
||||
74
src/components/ContactForm/components/RepeatableList.tsx
Normal file
@@ -0,0 +1,74 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { useState } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { Plus, X } from 'lucide-react';
|
||||
|
||||
interface RepeatableListProps {
|
||||
items: string[];
|
||||
onAdd: (val: string) => void;
|
||||
onRemove: (index: number) => void;
|
||||
placeholder: string;
|
||||
}
|
||||
|
||||
export function RepeatableList({
|
||||
items,
|
||||
onAdd,
|
||||
onRemove,
|
||||
placeholder
|
||||
}: RepeatableListProps) {
|
||||
const [input, setInput] = useState('');
|
||||
return (
|
||||
<div className="space-y-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>
|
||||
);
|
||||
}
|
||||
158
src/components/ContactForm/constants.tsx
Normal 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'],
|
||||
];
|
||||
67
src/components/ContactForm/steps/ApiStep.tsx
Normal 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) });
|
||||
}
|
||||
}
|
||||
38
src/components/ContactForm/steps/AssetsStep.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { FormState } from '../types';
|
||||
import { ASSET_OPTIONS } from '../constants';
|
||||
import { Checkbox } from '../components/Checkbox';
|
||||
import { RepeatableList } from '../components/RepeatableList';
|
||||
|
||||
interface AssetsStepProps {
|
||||
state: FormState;
|
||||
updateState: (updates: Partial<FormState>) => void;
|
||||
toggleItem: (list: string[], id: string) => string[];
|
||||
}
|
||||
|
||||
export function AssetsStep({ state, updateState, toggleItem }: AssetsStepProps) {
|
||||
return (
|
||||
<div className="space-y-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>
|
||||
);
|
||||
}
|
||||
44
src/components/ContactForm/steps/BaseStep.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
103
src/components/ContactForm/steps/ContactStep.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
83
src/components/ContactForm/steps/ContentStep.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { FormState } from '../types';
|
||||
import { Zap, AlertCircle, Minus, Plus } from 'lucide-react';
|
||||
|
||||
interface ContentStepProps {
|
||||
state: FormState;
|
||||
updateState: (updates: Partial<FormState>) => void;
|
||||
}
|
||||
|
||||
export function ContentStep({ state, updateState }: ContentStepProps) {
|
||||
return (
|
||||
<div className="space-y-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>
|
||||
);
|
||||
}
|
||||
70
src/components/ContactForm/steps/DesignStep.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
38
src/components/ContactForm/steps/FeaturesStep.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { FormState } from '../types';
|
||||
import { 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>
|
||||
);
|
||||
}
|
||||
102
src/components/ContactForm/steps/FunctionsStep.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
91
src/components/ContactForm/steps/LanguageStep.tsx
Normal file
@@ -0,0 +1,91 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { FormState } from '../types';
|
||||
import { Globe, Minus, Plus, Info } from 'lucide-react';
|
||||
import { motion } from 'framer-motion';
|
||||
|
||||
interface LanguageStepProps {
|
||||
state: FormState;
|
||||
updateState: (updates: Partial<FormState>) => void;
|
||||
}
|
||||
|
||||
export function LanguageStep({ state, updateState }: LanguageStepProps) {
|
||||
const basePriceExplanation = "Jede zusätzliche Sprache erhöht den Gesamtaufwand für Design, Entwicklung und Qualitätssicherung um ca. 20%. Dies deckt die technische Implementierung der Übersetzungsschicht sowie die Anpassung von Layouts für unterschiedliche Textlängen ab.";
|
||||
|
||||
return (
|
||||
<div className="space-y-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>
|
||||
);
|
||||
}
|
||||
45
src/components/ContactForm/steps/TimelineStep.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { FormState } from '../types';
|
||||
import { AlertCircle } from 'lucide-react';
|
||||
|
||||
interface TimelineStepProps {
|
||||
state: FormState;
|
||||
updateState: (updates: Partial<FormState>) => void;
|
||||
}
|
||||
|
||||
export function TimelineStep({ state, updateState }: TimelineStepProps) {
|
||||
return (
|
||||
<div className="space-y-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>
|
||||
);
|
||||
}
|
||||
34
src/components/ContactForm/steps/TypeStep.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { FormState, ProjectType } from '../types';
|
||||
import { ConceptWebsite, ConceptSystem } from '../../Landing/ConceptIllustrations';
|
||||
|
||||
interface TypeStepProps {
|
||||
state: FormState;
|
||||
updateState: (updates: Partial<FormState>) => void;
|
||||
}
|
||||
|
||||
export function TypeStep({ state, updateState }: TypeStepProps) {
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{[
|
||||
{ id: 'website', label: 'Website', desc: 'Klassische Webpräsenz, Portfolio oder Blog.', illustration: <ConceptWebsite className="w-16 h-16 mb-4" /> },
|
||||
{ id: '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>
|
||||
);
|
||||
}
|
||||
139
src/components/ContactForm/steps/WebAppStep.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
47
src/components/ContactForm/types.ts
Normal 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;
|
||||
}
|
||||
546
src/components/EstimationPDF.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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}`}
|
||||
|
||||
@@ -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)}
|
||||
>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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';
|
||||
|
||||
31
src/components/Landing/Illustrations/ConceptAutomation.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { IllustrationProps } from './types';
|
||||
|
||||
export const ConceptAutomation: React.FC<IllustrationProps> = ({ className = "", delay = 0 }) => (
|
||||
<svg className={className} viewBox="0 0 120 120" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<motion.g
|
||||
animate={{ rotate: 360 }}
|
||||
transition={{ duration: 10, repeat: Infinity, ease: "linear" }}
|
||||
style={{ originX: "40px", originY: "60px" }}
|
||||
>
|
||||
<path d="M 40 45 L 50 60 L 40 75 L 30 60 Z" className="fill-slate-300" />
|
||||
</motion.g>
|
||||
<motion.g
|
||||
animate={{ rotate: -360 }}
|
||||
transition={{ duration: 10, repeat: Infinity, ease: "linear" }}
|
||||
style={{ originX: "75px", originY: "65px" }}
|
||||
>
|
||||
<path d="M 75 50 L 85 65 L 75 80 L 65 65 Z" className="fill-slate-500" />
|
||||
</motion.g>
|
||||
<motion.path
|
||||
d="M 10 60 H 110"
|
||||
stroke="currentColor" strokeWidth="1" className="text-slate-300"
|
||||
strokeDasharray="4 4"
|
||||
animate={{ strokeDashoffset: [0, -20] }}
|
||||
transition={{ duration: 2, repeat: Infinity, ease: "linear" }}
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
33
src/components/Landing/Illustrations/ConceptCode.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { IllustrationProps } from './types';
|
||||
|
||||
export const ConceptCode: React.FC<IllustrationProps> = ({ className = "", delay = 0 }) => (
|
||||
<svg className={className} viewBox="0 0 120 120" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
{[40, 55, 70, 85].map((y, i) => (
|
||||
<motion.path
|
||||
key={y}
|
||||
d={`M 25 ${y} H ${25 + ((i * 17) % 50) + 20}`}
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
className="text-slate-400"
|
||||
initial={{ pathLength: 0 }}
|
||||
animate={{ pathLength: [0, 1, 1, 0] }}
|
||||
transition={{ duration: 4, repeat: Infinity, delay: i * 0.2 + delay }}
|
||||
/>
|
||||
))}
|
||||
<motion.path
|
||||
d="M 90 40 L 100 50 L 115 30"
|
||||
stroke="currentColor"
|
||||
strokeWidth="3"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="text-slate-900"
|
||||
animate={{ opacity: [0, 1, 1, 0], scale: [0.8, 1, 1, 0.8] }}
|
||||
transition={{ duration: 4, repeat: Infinity, delay: 1.5 }}
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
@@ -0,0 +1,29 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { IllustrationProps } from './types';
|
||||
|
||||
export const ConceptCommunication: React.FC<IllustrationProps> = ({ className = "", delay = 0 }) => (
|
||||
<svg className={className} viewBox="0 0 120 120" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle cx="20" cy="60" r="6" className="fill-slate-200 stroke-slate-300" strokeWidth="1" />
|
||||
<circle cx="100" cy="60" r="6" className="fill-slate-900" />
|
||||
<path d="M 26 60 H 94" stroke="currentColor" strokeWidth="1" className="text-slate-300" strokeDasharray="4 4" />
|
||||
<motion.path
|
||||
d="M 26 60 H 94"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
className="text-slate-400"
|
||||
initial={{ pathLength: 0 }}
|
||||
animate={{ pathLength: [0, 1, 1, 0], opacity: [0, 1, 1, 0] }}
|
||||
transition={{ duration: 3, repeat: Infinity, ease: "easeInOut", delay }}
|
||||
/>
|
||||
<motion.circle r="3" className="fill-slate-900">
|
||||
<animateMotion
|
||||
dur="3s"
|
||||
repeatCount="indefinite"
|
||||
path="M 26 60 H 94"
|
||||
/>
|
||||
</motion.circle>
|
||||
</svg>
|
||||
);
|
||||
22
src/components/Landing/Illustrations/ConceptMessy.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { IllustrationProps } from './types';
|
||||
|
||||
export const ConceptMessy: React.FC<IllustrationProps> = ({ className = "", delay = 0 }) => (
|
||||
<svg className={className} viewBox="0 0 120 120" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<motion.path
|
||||
d="M 20 60 C 30 20, 40 100, 50 60 C 60 20, 70 100, 80 60 C 90 20, 100 100, 110 60"
|
||||
stroke="currentColor" strokeWidth="1" className="text-slate-500"
|
||||
animate={{ strokeDashoffset: [0, 20] }}
|
||||
strokeDasharray="4 4"
|
||||
transition={{ duration: 5, repeat: Infinity, ease: "linear" }}
|
||||
/>
|
||||
<motion.path
|
||||
d="M 20 40 L 100 80 M 20 80 L 100 40"
|
||||
stroke="currentColor" strokeWidth="1" className="text-slate-200 opacity-50"
|
||||
animate={{ opacity: [0.2, 0.5, 0.2] }} transition={{ duration: 3, repeat: Infinity }}
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
27
src/components/Landing/Illustrations/ConceptPrice.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { IllustrationProps } from './types';
|
||||
|
||||
export const ConceptPrice: React.FC<IllustrationProps> = ({ className = "", delay = 0 }) => (
|
||||
<svg className={className} viewBox="0 0 120 120" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="30" y="30" width="60" height="70" rx="2" stroke="currentColor" strokeWidth="1" className="text-slate-400" />
|
||||
<motion.path
|
||||
d="M 40 50 H 80 M 40 65 H 80 M 40 80 H 60"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1"
|
||||
className="text-slate-300"
|
||||
initial={{ pathLength: 0 }}
|
||||
animate={{ pathLength: [0, 1, 1, 0] }}
|
||||
transition={{ duration: 5, repeat: Infinity, delay }}
|
||||
/>
|
||||
<motion.circle
|
||||
cx="85" cy="35" r="15"
|
||||
className="fill-white stroke-slate-900"
|
||||
strokeWidth="1"
|
||||
animate={{ y: [0, -5, 0], rotate: [0, 10, 0] }}
|
||||
transition={{ duration: 4, repeat: Infinity }}
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
30
src/components/Landing/Illustrations/ConceptPrototyping.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { IllustrationProps } from './types';
|
||||
|
||||
export const ConceptPrototyping: React.FC<IllustrationProps> = ({ className = "", delay = 0 }) => (
|
||||
<svg className={className} viewBox="0 0 120 120" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="15" y="25" width="90" height="70" rx="4" stroke="currentColor" strokeWidth="1" className="text-slate-400" />
|
||||
<path d="M 15 40 H 105" stroke="currentColor" strokeWidth="1" className="text-slate-300" />
|
||||
<motion.rect
|
||||
x="25" y="50" width="40" height="8" rx="1"
|
||||
className="fill-slate-300"
|
||||
animate={{ width: [0, 40, 40, 0] }}
|
||||
transition={{ duration: 4, repeat: Infinity, delay }}
|
||||
/>
|
||||
<motion.rect
|
||||
x="25" y="65" width="60" height="8" rx="1"
|
||||
className="fill-slate-200"
|
||||
animate={{ width: [0, 60, 60, 0] }}
|
||||
transition={{ duration: 4, repeat: Infinity, delay: 0.5 }}
|
||||
/>
|
||||
<motion.circle
|
||||
cx="85" cy="75" r="10"
|
||||
className="fill-slate-900"
|
||||
animate={{ scale: [0.8, 1.1, 0.8] }}
|
||||
transition={{ duration: 2, repeat: Infinity }}
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
33
src/components/Landing/Illustrations/ConceptSystem.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { IllustrationProps } from './types';
|
||||
|
||||
export const ConceptSystem: React.FC<IllustrationProps> = ({ className = "", delay = 0 }) => (
|
||||
<svg className={className} viewBox="0 0 120 120" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<motion.circle cx="60" cy="60" r="15" className="fill-slate-900"
|
||||
animate={{ scale: [1, 1.1, 1] }} transition={{ duration: 4, repeat: Infinity }} />
|
||||
{[0, 72, 144, 216, 288].map((angle, i) => {
|
||||
const x = 60 + Math.cos((angle * Math.PI) / 180) * 40;
|
||||
const y = 60 + Math.sin((angle * Math.PI) / 180) * 40;
|
||||
return (
|
||||
<React.Fragment key={i}>
|
||||
<motion.line
|
||||
x1="60" y1="60" x2={x} y2={y}
|
||||
stroke="currentColor" strokeWidth="1" className="text-slate-400"
|
||||
animate={{ strokeDashoffset: [0, 10] }}
|
||||
strokeDasharray="2 2"
|
||||
transition={{ duration: 2, repeat: Infinity, ease: "linear" }}
|
||||
/>
|
||||
<motion.circle
|
||||
cx={x} cy={y} r="6"
|
||||
className="fill-white stroke-slate-300"
|
||||
animate={{ scale: [1, 1.2, 1] }}
|
||||
transition={{ duration: 3, repeat: Infinity, delay: i * 0.4 }}
|
||||
/>
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</svg>
|
||||
);
|
||||
26
src/components/Landing/Illustrations/ConceptTarget.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { IllustrationProps } from './types';
|
||||
|
||||
export const ConceptTarget: React.FC<IllustrationProps> = ({ className = "", delay = 0 }) => (
|
||||
<svg className={className} viewBox="0 0 120 120" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<motion.circle
|
||||
cx="60" cy="60" r="50"
|
||||
stroke="currentColor" strokeWidth="1" className="text-slate-300"
|
||||
animate={{ scale: [1, 1.05, 1] }}
|
||||
transition={{ duration: 4, repeat: Infinity }}
|
||||
/>
|
||||
<motion.circle
|
||||
cx="60" cy="60" r="30"
|
||||
stroke="currentColor" strokeWidth="1" className="text-slate-400"
|
||||
/>
|
||||
<motion.circle
|
||||
cx="60" cy="60" r="10"
|
||||
className="fill-slate-900"
|
||||
animate={{ opacity: [0.5, 1, 0.5] }}
|
||||
transition={{ duration: 2, repeat: Infinity }}
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
24
src/components/Landing/Illustrations/ConceptWebsite.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { IllustrationProps } from './types';
|
||||
|
||||
export const ConceptWebsite: React.FC<IllustrationProps> = ({ className = "", delay = 0 }) => (
|
||||
<svg className={className} viewBox="0 0 120 120" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="10" y="20" width="100" height="80" rx="4" stroke="currentColor" strokeWidth="1" className="text-slate-400" />
|
||||
<motion.rect
|
||||
x="20" y="35" width="80" height="15" rx="2"
|
||||
className="fill-slate-200"
|
||||
animate={{ opacity: [0.3, 0.6, 0.3] }}
|
||||
transition={{ duration: 3, repeat: Infinity }}
|
||||
/>
|
||||
<motion.g
|
||||
animate={{ y: [0, 10, 0] }}
|
||||
transition={{ duration: 4, repeat: Infinity }}
|
||||
>
|
||||
<rect x="20" y="55" width="35" height="35" rx="2" className="fill-slate-300" />
|
||||
<rect x="65" y="55" width="35" height="35" rx="2" className="fill-slate-300" />
|
||||
</motion.g>
|
||||
</svg>
|
||||
);
|
||||
31
src/components/Landing/Illustrations/HeroArchitecture.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { IllustrationProps } from './types';
|
||||
|
||||
export const HeroArchitecture: React.FC<IllustrationProps> = ({ className = "", delay = 0 }) => (
|
||||
<svg className={className} viewBox="0 0 400 300" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<motion.rect x="170" y="120" width="60" height="60" rx="8" className="stroke-slate-900 fill-white" strokeWidth="2"
|
||||
animate={{ scale: [1, 1.05, 1] }} transition={{ duration: 4, repeat: Infinity }} />
|
||||
{[
|
||||
{ x: 80, y: 60 }, { x: 320, y: 60 },
|
||||
{ x: 80, y: 240 }, { x: 320, y: 240 }
|
||||
].map((node, i) => (
|
||||
<React.Fragment key={i}>
|
||||
<motion.path
|
||||
d={`M 200 150 L ${node.x} ${node.y}`}
|
||||
stroke="currentColor" strokeWidth="1" className="text-slate-400"
|
||||
animate={{ strokeDashoffset: [0, -10] }} strokeDasharray="4 4"
|
||||
transition={{ duration: 2, repeat: Infinity, ease: "linear" }}
|
||||
/>
|
||||
<motion.circle
|
||||
cx={node.x} cy={node.y} r="12"
|
||||
className="fill-white stroke-slate-300"
|
||||
strokeWidth="1"
|
||||
animate={{ scale: [1, 1.1, 1] }} transition={{ duration: 3, repeat: Infinity, delay: i * 0.5 }}
|
||||
/>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</svg>
|
||||
);
|
||||
182
src/components/Landing/Illustrations/HeroMainIllustration.tsx
Normal file
@@ -0,0 +1,182 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { IllustrationProps } from './types';
|
||||
|
||||
export const HeroMainIllustration: React.FC<IllustrationProps> = ({ className = "", delay = 0 }) => {
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 800 700" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
{/* Matrix-style Binary Rain Background */}
|
||||
<g className="opacity-[0.08]">
|
||||
{Array.from({ length: 20 }).map((_, col) => {
|
||||
const colX = 20 + col * 40;
|
||||
const speed = 8 + (col % 6);
|
||||
const startDelay = (col % 5);
|
||||
return (
|
||||
<motion.g
|
||||
key={`rain-col-${col}`}
|
||||
initial={{ y: -700 }}
|
||||
animate={{ y: 700 }}
|
||||
transition={{
|
||||
duration: speed,
|
||||
repeat: Infinity,
|
||||
ease: "linear",
|
||||
delay: startDelay,
|
||||
}}
|
||||
>
|
||||
{Array.from({ length: 25 }).map((_, row) => (
|
||||
<text
|
||||
key={`${col}-${row}`}
|
||||
x={colX}
|
||||
y={row * 28}
|
||||
className="fill-slate-900 font-mono"
|
||||
style={{ fontSize: 12 }}
|
||||
>
|
||||
{(col + row) % 2 === 0 ? '1' : '0'}
|
||||
</text>
|
||||
))}
|
||||
</motion.g>
|
||||
);
|
||||
})}
|
||||
</g>
|
||||
|
||||
{/* Layer 1: Base Platform */}
|
||||
<motion.g
|
||||
animate={{ y: [0, 8, 0] }}
|
||||
transition={{ duration: 5, repeat: Infinity, ease: "easeInOut" }}
|
||||
>
|
||||
<rect x="150" y="500" width="500" height="30" rx="4" className="fill-slate-100 stroke-slate-300" strokeWidth="1" />
|
||||
<rect x="170" y="510" width="460" height="10" rx="2" className="fill-slate-200" />
|
||||
{/* Binary on base */}
|
||||
<text x="180" y="518" className="fill-slate-400 font-mono" style={{ fontSize: 8 }}>01010101010101010101010101010101010101</text>
|
||||
</motion.g>
|
||||
|
||||
{/* Layer 2: Server/Database Layer */}
|
||||
<motion.g
|
||||
animate={{ y: [0, 6, 0] }}
|
||||
transition={{ duration: 5, repeat: Infinity, ease: "easeInOut", delay: 0.3 }}
|
||||
>
|
||||
{/* Left Server Block */}
|
||||
<g transform="translate(200, 400)">
|
||||
<rect x="0" y="0" width="120" height="80" rx="6" className="fill-white stroke-slate-900" strokeWidth="2" />
|
||||
<rect x="10" y="10" width="100" height="15" rx="2" className="fill-slate-100" />
|
||||
<rect x="10" y="30" width="80" height="10" rx="2" className="fill-slate-200" />
|
||||
<rect x="10" y="45" width="60" height="10" rx="2" className="fill-slate-200" />
|
||||
<circle cx="100" cy="65" r="5" className="fill-slate-900" />
|
||||
<text x="15" y="20" className="fill-slate-500 font-mono" style={{ fontSize: 8 }}>SERVER</text>
|
||||
</g>
|
||||
|
||||
{/* Right Database Block */}
|
||||
<g transform="translate(480, 400)">
|
||||
<rect x="0" y="0" width="120" height="80" rx="6" className="fill-white stroke-slate-900" strokeWidth="2" />
|
||||
<rect x="10" y="10" width="100" height="15" rx="2" className="fill-slate-100" />
|
||||
<rect x="10" y="30" width="100" height="8" rx="2" className="fill-slate-200" />
|
||||
<rect x="10" y="42" width="100" height="8" rx="2" className="fill-slate-200" />
|
||||
<rect x="10" y="54" width="100" height="8" rx="2" className="fill-slate-200" />
|
||||
<text x="15" y="20" className="fill-slate-500 font-mono" style={{ fontSize: 8 }}>DATABASE</text>
|
||||
</g>
|
||||
|
||||
{/* Connection Lines */}
|
||||
<motion.path
|
||||
d="M 320 440 L 400 440 L 480 440"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
className="text-slate-400"
|
||||
strokeDasharray="6 4"
|
||||
animate={{ strokeDashoffset: [0, -20] }}
|
||||
transition={{ duration: 1, repeat: Infinity, ease: "linear" }}
|
||||
/>
|
||||
</motion.g>
|
||||
|
||||
{/* Layer 3: Browser/Website */}
|
||||
<motion.g
|
||||
animate={{ y: [0, 4, 0] }}
|
||||
transition={{ duration: 5, repeat: Infinity, ease: "easeInOut", delay: 0.6 }}
|
||||
>
|
||||
{/* Browser Window */}
|
||||
<rect x="180" y="100" width="440" height="280" rx="8" className="fill-white stroke-slate-900" strokeWidth="2" />
|
||||
|
||||
{/* Browser Chrome */}
|
||||
<rect x="180" y="100" width="440" height="30" rx="8" className="fill-slate-900" />
|
||||
<rect x="180" y="120" width="440" height="10" className="fill-slate-900" />
|
||||
|
||||
{/* Browser Dots */}
|
||||
<circle cx="200" cy="115" r="5" className="fill-slate-600" />
|
||||
<circle cx="218" cy="115" r="5" className="fill-slate-600" />
|
||||
<circle cx="236" cy="115" r="5" className="fill-slate-600" />
|
||||
|
||||
{/* Address Bar */}
|
||||
<rect x="260" y="108" width="200" height="14" rx="3" className="fill-slate-700" />
|
||||
|
||||
{/* Website Content */}
|
||||
<g transform="translate(200, 150)">
|
||||
{/* Navigation */}
|
||||
<rect x="0" y="0" width="400" height="20" className="fill-slate-50" />
|
||||
<rect x="10" y="5" width="60" height="10" rx="2" className="fill-slate-900" />
|
||||
<rect x="280" y="5" width="30" height="10" rx="2" className="fill-slate-300" />
|
||||
<rect x="320" y="5" width="30" height="10" rx="2" className="fill-slate-300" />
|
||||
<rect x="360" y="5" width="30" height="10" rx="2" className="fill-slate-300" />
|
||||
|
||||
{/* Hero Section */}
|
||||
<rect x="0" y="30" width="400" height="100" className="fill-slate-100" />
|
||||
<rect x="20" y="50" width="180" height="16" rx="2" className="fill-slate-900" />
|
||||
<rect x="20" y="72" width="140" height="10" rx="2" className="fill-slate-400" />
|
||||
<rect x="20" y="88" width="100" height="10" rx="2" className="fill-slate-400" />
|
||||
<rect x="20" y="108" width="80" height="16" rx="4" className="fill-slate-900" />
|
||||
|
||||
{/* Hero Image Placeholder */}
|
||||
<rect x="240" y="40" width="140" height="80" rx="4" className="fill-slate-200" />
|
||||
<path d="M 280 80 L 310 60 L 340 80 L 310 100 Z" className="fill-slate-300" />
|
||||
|
||||
{/* Cards Section */}
|
||||
<g transform="translate(0, 140)">
|
||||
<rect x="0" y="0" width="125" height="70" rx="4" className="fill-slate-50 stroke-slate-200" strokeWidth="1" />
|
||||
<rect x="10" y="10" width="105" height="30" rx="2" className="fill-slate-200" />
|
||||
<rect x="10" y="48" width="80" height="8" rx="2" className="fill-slate-300" />
|
||||
|
||||
<rect x="137" y="0" width="125" height="70" rx="4" className="fill-slate-50 stroke-slate-200" strokeWidth="1" />
|
||||
<rect x="147" y="10" width="105" height="30" rx="2" className="fill-slate-200" />
|
||||
<rect x="147" y="48" width="80" height="8" rx="2" className="fill-slate-300" />
|
||||
|
||||
<rect x="274" y="0" width="125" height="70" rx="4" className="fill-slate-50 stroke-slate-200" strokeWidth="1" />
|
||||
<rect x="284" y="10" width="105" height="30" rx="2" className="fill-slate-200" />
|
||||
<rect x="284" y="48" width="80" height="8" rx="2" className="fill-slate-300" />
|
||||
</g>
|
||||
</g>
|
||||
</motion.g>
|
||||
|
||||
{/* Connecting Lines from Browser to Infrastructure */}
|
||||
<motion.g>
|
||||
<motion.path
|
||||
d="M 400 380 L 400 400"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
className="text-slate-400"
|
||||
strokeDasharray="4 4"
|
||||
animate={{ strokeDashoffset: [0, -16] }}
|
||||
transition={{ duration: 0.8, repeat: Infinity, ease: "linear" }}
|
||||
/>
|
||||
<motion.path
|
||||
d="M 260 480 L 260 500"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
className="text-slate-400"
|
||||
strokeDasharray="4 4"
|
||||
animate={{ strokeDashoffset: [0, -16] }}
|
||||
transition={{ duration: 0.8, repeat: Infinity, ease: "linear" }}
|
||||
/>
|
||||
<motion.path
|
||||
d="M 540 480 L 540 500"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
className="text-slate-400"
|
||||
strokeDasharray="4 4"
|
||||
animate={{ strokeDashoffset: [0, -16] }}
|
||||
transition={{ duration: 0.8, repeat: Infinity, ease: "linear" }}
|
||||
/>
|
||||
</motion.g>
|
||||
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
12
src/components/Landing/Illustrations/index.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
export * from './types';
|
||||
export * from './ConceptCommunication';
|
||||
export * from './ConceptPrototyping';
|
||||
export * from './ConceptCode';
|
||||
export * from './ConceptPrice';
|
||||
export * from './ConceptWebsite';
|
||||
export * from './ConceptSystem';
|
||||
export * from './ConceptAutomation';
|
||||
export * from './ConceptTarget';
|
||||
export * from './ConceptMessy';
|
||||
export * from './HeroArchitecture';
|
||||
export * from './HeroMainIllustration';
|
||||
8
src/components/Landing/Illustrations/types.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
|
||||
export interface IllustrationProps {
|
||||
className?: string;
|
||||
delay?: number;
|
||||
}
|
||||
60
src/components/Modal.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { X } from 'lucide-react';
|
||||
|
||||
interface ModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
title: string;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function Modal({ isOpen, onClose, title, children }: ModalProps) {
|
||||
// Close on escape key
|
||||
React.useEffect(() => {
|
||||
const handleEsc = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose();
|
||||
};
|
||||
window.addEventListener('keydown', handleEsc);
|
||||
return () => window.removeEventListener('keydown', handleEsc);
|
||||
}, [onClose]);
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
<>
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
onClick={onClose}
|
||||
className="fixed inset-0 bg-slate-900/40 backdrop-blur-sm z-[100]"
|
||||
/>
|
||||
<div className="fixed inset-0 flex items-center justify-center p-4 z-[101] pointer-events-none">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.9, y: 20 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.9, y: 20 }}
|
||||
className="bg-white w-full max-w-lg rounded-[2.5rem] shadow-2xl pointer-events-auto overflow-hidden"
|
||||
>
|
||||
<div className="p-8 border-b border-slate-50 flex items-center justify-between">
|
||||
<h3 className="text-2xl font-bold text-slate-900">{title}</h3>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-2 hover:bg-slate-50 rounded-full transition-colors text-slate-400 hover:text-slate-900"
|
||||
>
|
||||
<X size={24} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="p-8">
|
||||
{children}
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
81
src/components/ShareModal.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { Modal } from './Modal';
|
||||
import { Copy, Check, Share2 } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
|
||||
interface ShareModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
url: string;
|
||||
qrCodeData?: string;
|
||||
}
|
||||
|
||||
export function ShareModal({ isOpen, onClose, url, qrCodeData }: ShareModalProps) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const handleCopy = () => {
|
||||
navigator.clipboard.writeText(url);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
};
|
||||
|
||||
const handleNativeShare = async () => {
|
||||
if (navigator.share) {
|
||||
try {
|
||||
await navigator.share({
|
||||
title: 'Meine Projekt-Konfiguration',
|
||||
url: url
|
||||
});
|
||||
} catch (e) {
|
||||
console.error("Share failed", e);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={onClose} title="Konfiguration teilen">
|
||||
<div className="space-y-8">
|
||||
<p className="text-slate-500 leading-relaxed">
|
||||
Speichern Sie diesen Link, um Ihre Konfiguration später fortzusetzen oder teilen Sie ihn mit anderen.
|
||||
</p>
|
||||
|
||||
{qrCodeData && (
|
||||
<div className="flex flex-col items-center gap-4 p-8 bg-slate-50 rounded-[2rem]">
|
||||
<img src={qrCodeData} alt="QR Code" className="w-48 h-48 rounded-xl shadow-sm" />
|
||||
<p className="text-[10px] font-bold uppercase tracking-widest text-slate-400">QR-Code scannen</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="relative">
|
||||
<input
|
||||
type="text"
|
||||
readOnly
|
||||
value={url}
|
||||
className="w-full p-6 pr-20 bg-slate-50 border border-slate-100 rounded-2xl text-sm font-mono text-slate-600 focus:outline-none"
|
||||
/>
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
className="absolute right-2 top-2 bottom-2 px-4 bg-white border border-slate-100 rounded-xl text-slate-900 hover:bg-slate-900 hover:text-white transition-all flex items-center gap-2"
|
||||
>
|
||||
{copied ? <Check size={18} /> : <Copy size={18} />}
|
||||
<span className="text-xs font-bold uppercase tracking-wider">{copied ? 'Kopiert' : 'Kopieren'}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{typeof navigator !== 'undefined' && navigator.share && (
|
||||
<button
|
||||
onClick={handleNativeShare}
|
||||
className="w-full p-6 bg-slate-900 text-white rounded-2xl font-bold flex items-center justify-center gap-3 hover:bg-slate-800 transition-all"
|
||||
>
|
||||
<Share2 size={20} />
|
||||
<span>System-Dialog öffnen</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -0,0 +1,4 @@
|
||||
declare module '*.svg' {
|
||||
const content: any;
|
||||
export default content;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
54
src/utils/analytics/umami-adapter.ts
Normal 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>`;
|
||||
}
|
||||
}
|
||||
76
src/utils/error-tracking/glitchtip-adapter.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
61
src/utils/error-tracking/index.ts
Normal 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);
|
||||
}
|
||||
29
src/utils/error-tracking/interfaces.ts
Normal 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;
|
||||
}
|
||||