Compare commits
1 Commits
main
...
feature/ai
| Author | SHA1 | Date | |
|---|---|---|---|
| 85d2d2c069 |
@@ -1,13 +1,9 @@
|
||||
# Exclude all binary/dependency folders recursively
|
||||
**/node_modules
|
||||
**/.pnpm-store
|
||||
**/.git
|
||||
**/.next
|
||||
**/dist
|
||||
**/out
|
||||
**/*.log
|
||||
|
||||
# Specific exclusions for this project
|
||||
node_modules
|
||||
.next
|
||||
out
|
||||
dist
|
||||
*.log
|
||||
.git
|
||||
.DS_Store
|
||||
cloned-websites
|
||||
storage
|
||||
@@ -15,11 +11,3 @@ storage
|
||||
verify_ci
|
||||
pnpm_install_log.txt
|
||||
full_tree.json
|
||||
backups
|
||||
data
|
||||
|
||||
# Ensure we don't copy the sibling's build artifacts either
|
||||
_at-mintel/**/node_modules
|
||||
_at-mintel/**/dist
|
||||
_at-mintel/**/.next
|
||||
_at-mintel/.git
|
||||
|
||||
@@ -118,7 +118,151 @@ jobs:
|
||||
echo "target=skip" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
# (JOB 2: QA was removed to reduce pipeline noise)
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# JOB 2: QA (Lint, Typecheck, Test)
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
qa:
|
||||
name: 🧪 QA
|
||||
needs: [prepare, deploy]
|
||||
if: needs.prepare.outputs.target != 'skip'
|
||||
runs-on: docker
|
||||
container:
|
||||
image: catthehacker/ubuntu:act-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v3
|
||||
with:
|
||||
version: 10
|
||||
- name: Provide sibling monorepo
|
||||
run: |
|
||||
git clone https://git.infra.mintel.me/mmintel/at-mintel.git _at-mintel
|
||||
|
||||
# Force ALL @mintel packages to use the local clone instead of the registry
|
||||
# This handles root package.json
|
||||
perl -pi -e 's/"\@mintel\/([^"]+)"\s*:\s*"[^"]+"/"\@mintel\/$1": "link:.\/_at-mintel\/packages\/$1"/g' package.json
|
||||
# Special case for pdf -> pdf-library
|
||||
perl -pi -e 's/link:\.\/_at-mintel\/packages\/pdf"/link:.\/_at-mintel\/packages\/pdf-library"/g' package.json
|
||||
|
||||
# Handle apps/web/package.json
|
||||
perl -pi -e 's/"\@mintel\/([^"]+)"\s*:\s*"[^"]+"/"\@mintel\/$1": "link:..\/\.\.\/_at-mintel\/packages\/$1"/g' apps/web/package.json
|
||||
# Special case for pdf -> pdf-library
|
||||
perl -pi -e 's/link:\.\.\/\.\.\/_at-mintel\/packages\/pdf"/link:..\/\.\.\/_at-mintel\/packages\/pdf-library"/g' apps/web/package.json
|
||||
|
||||
# Fix tsconfig paths if they exist
|
||||
sed -i 's|../../../at-mintel|../../_at-mintel|g' apps/web/tsconfig.json || true
|
||||
|
||||
# Fix tsconfig paths if they exist
|
||||
sed -i 's|../../../at-mintel|../../_at-mintel|g' apps/web/tsconfig.json || true
|
||||
- name: 🔐 Registry Auth
|
||||
run: |
|
||||
echo "Testing available secrets against git.infra.mintel.me Docker registry..."
|
||||
TOKENS="${{ secrets.GITHUB_TOKEN }} ${{ secrets.GITEA_PAT }} ${{ secrets.MINTEL_PRIVATE_TOKEN }} ${{ secrets.NPM_TOKEN }}"
|
||||
USERS="${{ github.repository_owner }} ${{ github.actor }} marcmintel mintel mmintel"
|
||||
|
||||
VALID_TOKEN=""
|
||||
VALID_USER=""
|
||||
|
||||
for T_RAW in $TOKENS; do
|
||||
if [ -n "$T_RAW" ]; then
|
||||
T=$(echo "$T_RAW" | tr -d ' ' | tr -d '\n' | tr -d '\r')
|
||||
|
||||
echo "Testing API with token..."
|
||||
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" -H "Authorization: token $T" https://git.infra.mintel.me/api/v1/user || echo "failed")
|
||||
echo "API returned: $HTTP_CODE"
|
||||
|
||||
for U in $USERS; do
|
||||
if [ -n "$U" ]; then
|
||||
echo "Attempting docker login for a token with user $U..."
|
||||
if echo "$T" | docker login git.infra.mintel.me -u "$U" --password-stdin > /dev/null 2>&1; then
|
||||
echo "✅ Successfully authenticated with a token."
|
||||
VALID_TOKEN="$T"
|
||||
VALID_USER="$U"
|
||||
break 2
|
||||
fi
|
||||
fi
|
||||
done
|
||||
fi
|
||||
done
|
||||
|
||||
if [ -z "$VALID_TOKEN" ]; then
|
||||
echo "❌ All token/user combinations failed to authenticate!"
|
||||
T=$(echo "$TOKENS" | awk '{print $1}')
|
||||
echo "Attempting open diagnostic login with first token and user mmintel..."
|
||||
echo "$T" | docker login git.infra.mintel.me -u "mmintel" --password-stdin || true
|
||||
exit 1
|
||||
fi
|
||||
|
||||
TOKEN="$VALID_TOKEN"
|
||||
echo "::add-mask::$TOKEN"
|
||||
echo "token=$TOKEN" >> $GITHUB_OUTPUT
|
||||
echo "user=$VALID_USER" >> $GITHUB_OUTPUT
|
||||
|
||||
echo "Configuring .npmrc for git.infra.mintel.me..."
|
||||
echo "@mintel:registry=https://git.infra.mintel.me/api/packages/mmintel/npm/" > .npmrc
|
||||
echo "//git.infra.mintel.me/api/packages/mmintel/npm/:_authToken=${TOKEN}" >> .npmrc
|
||||
echo "always-auth=true" >> .npmrc
|
||||
|
||||
# Also export for pnpm to pick it up from env if needed
|
||||
echo "NPM_TOKEN=${TOKEN}" >> $GITHUB_ENV
|
||||
- name: 🏗️ Compile Sibling Monorepo
|
||||
timeout-minutes: 15
|
||||
run: |
|
||||
mkdir -p ci-logs
|
||||
echo "=== Compile Sibling Monorepo ===" >> ci-logs/summary.txt
|
||||
cp .npmrc _at-mintel/
|
||||
cd _at-mintel
|
||||
pnpm install --no-frozen-lockfile --loglevel info 2>&1 | tee -a ../ci-logs/summary.txt
|
||||
pnpm --filter "...@mintel/payload-ai" \
|
||||
--filter @mintel/pdf... \
|
||||
--filter @mintel/concept-engine... \
|
||||
--filter @mintel/estimation-engine... \
|
||||
--filter @mintel/meme-generator... \
|
||||
build --loglevel info 2>&1 | tee -a ../ci-logs/summary.txt
|
||||
- name: Install dependencies
|
||||
timeout-minutes: 10
|
||||
run: |
|
||||
echo "=== Install dependencies (Root) ===" >> ci-logs/summary.txt
|
||||
pnpm install --no-frozen-lockfile --loglevel info 2>&1 | tee -a ci-logs/summary.txt
|
||||
- name: 🧪 Test
|
||||
if: github.event.inputs.skip_checks != 'true'
|
||||
timeout-minutes: 10
|
||||
run: |
|
||||
echo "=== Test (@mintel/web) ===" >> ci-logs/summary.txt
|
||||
pnpm --filter @mintel/web test --loglevel info 2>&1 | tee -a ci-logs/summary.txt
|
||||
- name: Inspect on Failure
|
||||
if: failure()
|
||||
run: |
|
||||
echo "==== runner state ===="
|
||||
ls -la
|
||||
echo "==== _at-mintel state ===="
|
||||
ls -la _at-mintel || true
|
||||
echo "==== .npmrc check ===="
|
||||
cat .npmrc | sed -E 's/authToken=[a-f0-9]{5}.*/authToken=REDACTED/'
|
||||
echo "==== pnpm debug logs ===="
|
||||
[ -f pnpm-debug.log ] && tail -n 100 pnpm-debug.log || echo "No root pnpm-debug.log"
|
||||
[ -f _at-mintel/pnpm-debug.log ] && tail -n 100 _at-mintel/pnpm-debug.log || echo "No sibling pnpm-debug.log"
|
||||
- name: Extract QA Error Logs
|
||||
if: failure()
|
||||
run: |
|
||||
mkdir -p ci-logs
|
||||
echo "QA Failure Report" > ci-logs/summary.txt
|
||||
ls -R >> ci-logs/summary.txt
|
||||
[ -f pnpm-debug.log ] && cp pnpm-debug.log ci-logs/ || true
|
||||
[ -f _at-mintel/pnpm-debug.log ] && cp _at-mintel/pnpm-debug.log ci-logs/at-mintel-pnpm-debug.log || true
|
||||
|
||||
SSH_KEY_FILE=$(mktemp)
|
||||
echo "${{ secrets.ALPHA_SSH_KEY }}" > "$SSH_KEY_FILE"
|
||||
chmod 600 "$SSH_KEY_FILE"
|
||||
|
||||
ssh -o StrictHostKeyChecking=no -i "$SSH_KEY_FILE" root@alpha.mintel.me "mkdir -p ~/logs"
|
||||
scp -r -o StrictHostKeyChecking=no -i "$SSH_KEY_FILE" ci-logs/* root@alpha.mintel.me:~/logs/ || true
|
||||
rm "$SSH_KEY_FILE"
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# JOB 3: Build & Push
|
||||
@@ -154,33 +298,6 @@ jobs:
|
||||
run: |
|
||||
echo "${{ secrets.REGISTRY_PASS }}" | docker login registry.infra.mintel.me -u "${{ secrets.REGISTRY_USER }}" --password-stdin
|
||||
|
||||
- name: 🧹 Nuclear Runner Cleanup
|
||||
run: |
|
||||
echo "Disk space before nuclear prune:"
|
||||
df -h
|
||||
|
||||
# Massive cleanup of pre-installed runner software to free up ~5GB+
|
||||
# These are standard on VM-based runners and often unnecessary
|
||||
sudo rm -rf /usr/share/dotnet || true
|
||||
sudo rm -rf /usr/local/lib/android || true
|
||||
sudo rm -rf /opt/ghc || true
|
||||
sudo rm -rf /opt/hostedtoolcache/CodeQL || true
|
||||
|
||||
# Comprehensive Docker purge
|
||||
docker buildx stop || true
|
||||
docker buildx rm -a || true
|
||||
docker rm -fv $(docker ps -aq --filter "ancestor=moby/buildkit") || true
|
||||
docker system prune -af --volumes
|
||||
docker builder prune -af
|
||||
docker buildx prune -af
|
||||
|
||||
# Clean temp build artifacts
|
||||
rm -rf /tmp/docker-actions-toolkit-* || true
|
||||
|
||||
echo "Disk space after nuclear prune:"
|
||||
df -h
|
||||
continue-on-error: true
|
||||
|
||||
- name: 🏗️ Build and Push
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
@@ -193,18 +310,9 @@ jobs:
|
||||
NEXT_PUBLIC_TARGET=${{ needs.prepare.outputs.target }}
|
||||
DIRECTUS_URL=${{ needs.prepare.outputs.directus_url }}
|
||||
NPM_TOKEN=${{ secrets.NPM_TOKEN }}
|
||||
S3_ENDPOINT=${{ secrets.S3_ENDPOINT || vars.S3_ENDPOINT || 'https://fsn1.your-objectstorage.com' }}
|
||||
S3_ACCESS_KEY=${{ secrets.S3_ACCESS_KEY || vars.S3_ACCESS_KEY }}
|
||||
S3_SECRET_KEY=${{ secrets.S3_SECRET_KEY || vars.S3_SECRET_KEY }}
|
||||
S3_BUCKET=${{ secrets.S3_BUCKET || vars.S3_BUCKET || 'mintel' }}
|
||||
S3_REGION=${{ secrets.S3_REGION || vars.S3_REGION || 'fsn1' }}
|
||||
S3_PREFIX=${{ secrets.S3_PREFIX || vars.S3_PREFIX || 'mintel.me' }}
|
||||
|
||||
BUILD_ID=${{ github.sha }}
|
||||
tags: registry.infra.mintel.me/mintel/mintel.me:${{ needs.prepare.outputs.image_tag }}
|
||||
# Temporarily disable registry cache export to save runner disk/bandwidth
|
||||
cache-from: type=registry,ref=registry.infra.mintel.me/mintel/mintel.me:buildcache-${{ needs.prepare.outputs.target }}
|
||||
# cache-to: type=registry,ref=registry.infra.mintel.me/mintel/mintel.me:buildcache-${{ needs.prepare.outputs.target }},mode=max
|
||||
cache-to: type=registry,ref=registry.infra.mintel.me/mintel/mintel.me:buildcache-${{ needs.prepare.outputs.target }},mode=max
|
||||
secrets: |
|
||||
NPM_TOKEN=${{ secrets.NPM_TOKEN }}
|
||||
|
||||
@@ -217,16 +325,17 @@ jobs:
|
||||
chmod 600 ~/.ssh/id_ed25519
|
||||
ssh-keyscan -H alpha.mintel.me >> ~/.ssh/known_hosts 2>/dev/null
|
||||
echo "Re-running docker build with plain progress to capture exact logs..."
|
||||
echo "${{ secrets.NPM_TOKEN }}" > /tmp/npm_token.txt
|
||||
echo "${{ steps.discover_token.outputs.token }}" | docker login git.infra.mintel.me -u "${{ steps.discover_token.outputs.user }}" --password-stdin > login.log 2>&1
|
||||
echo "${{ steps.discover_token.outputs.token }}" > /tmp/npm_token.txt
|
||||
docker build \
|
||||
--build-arg NEXT_PUBLIC_BASE_URL=${{ needs.prepare.outputs.next_public_url }} \
|
||||
--build-arg NEXT_PUBLIC_TARGET=${{ needs.prepare.outputs.target }} \
|
||||
--build-arg DIRECTUS_URL=${{ needs.prepare.outputs.directus_url }} \
|
||||
--build-arg NPM_TOKEN=${{ secrets.NPM_TOKEN }} \
|
||||
--build-arg NPM_TOKEN=${{ steps.discover_token.outputs.token }} \
|
||||
--secret id=NPM_TOKEN,src=/tmp/npm_token.txt \
|
||||
--progress plain \
|
||||
-t temp-image . > docker_build_failed.log 2>&1
|
||||
cat login.log >> docker_build_failed.log || true
|
||||
cat login.log >> docker_build_failed.log
|
||||
scp docker_build_failed.log root@alpha.mintel.me:/root/docker_build_failed.log
|
||||
# JOB 4: Deploy
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
@@ -244,6 +353,13 @@ jobs:
|
||||
DIRECTUS_URL: ${{ needs.prepare.outputs.directus_url }}
|
||||
DIRECTUS_HOST: cms.${{ needs.prepare.outputs.traefik_host }}
|
||||
|
||||
# Database configuration
|
||||
postgres_DB_NAME: ${{ secrets.DIRECTUS_DB_NAME || vars.DIRECTUS_DB_NAME || 'directus' }}
|
||||
postgres_DB_USER: ${{ secrets.DIRECTUS_DB_USER || vars.DIRECTUS_DB_USER || 'directus' }}
|
||||
postgres_DB_PASSWORD: ${{ (needs.prepare.outputs.target == 'testing' && secrets.TESTING_DIRECTUS_DB_PASSWORD) || (needs.prepare.outputs.target == 'staging' && secrets.STAGING_DIRECTUS_DB_PASSWORD) || secrets.DIRECTUS_DB_PASSWORD || vars.DIRECTUS_DB_PASSWORD || 'directus' }}
|
||||
DATABASE_URI: postgres://${{ secrets.DIRECTUS_DB_USER || vars.DIRECTUS_DB_USER || 'directus' }}:${{ (needs.prepare.outputs.target == 'testing' && secrets.TESTING_DIRECTUS_DB_PASSWORD) || (needs.prepare.outputs.target == 'staging' && secrets.STAGING_DIRECTUS_DB_PASSWORD) || secrets.DIRECTUS_DB_PASSWORD || vars.DIRECTUS_DB_PASSWORD || 'directus' }}@postgres-db:5432/${{ secrets.DIRECTUS_DB_NAME || vars.DIRECTUS_DB_NAME || 'directus' }}
|
||||
PAYLOAD_SECRET: ${{ secrets.PAYLOAD_SECRET || vars.PAYLOAD_SECRET || 'secret' }}
|
||||
|
||||
# Mail
|
||||
MAIL_HOST: ${{ secrets.SMTP_HOST || vars.SMTP_HOST }}
|
||||
MAIL_PORT: ${{ secrets.SMTP_PORT || vars.SMTP_PORT || '587' }}
|
||||
@@ -306,7 +422,11 @@ jobs:
|
||||
SENTRY_DSN=$SENTRY_DSN
|
||||
PROJECT_COLOR=$PROJECT_COLOR
|
||||
LOG_LEVEL=$LOG_LEVEL
|
||||
|
||||
postgres_DB_NAME=$postgres_DB_NAME
|
||||
postgres_DB_USER=$postgres_DB_USER
|
||||
postgres_DB_PASSWORD=$postgres_DB_PASSWORD
|
||||
DATABASE_URI=$DATABASE_URI
|
||||
PAYLOAD_SECRET=$PAYLOAD_SECRET
|
||||
MAIL_HOST=$MAIL_HOST
|
||||
MAIL_PORT=$MAIL_PORT
|
||||
MAIL_USERNAME=$MAIL_USERNAME
|
||||
@@ -326,7 +446,6 @@ jobs:
|
||||
S3_REGION=$S3_REGION
|
||||
S3_PREFIX=$S3_PREFIX
|
||||
TARGET=$TARGET
|
||||
SKIP_RUNTIME_ENV_VALIDATION=true
|
||||
SENTRY_ENVIRONMENT=$TARGET
|
||||
PROJECT_NAME=$PROJECT_NAME
|
||||
ENV_FILE=$ENV_FILE
|
||||
@@ -405,32 +524,9 @@ jobs:
|
||||
docker volume create 'mintel-me_payload-db-data' || true
|
||||
cd $SITE_DIR
|
||||
docker compose -p '${{ needs.prepare.outputs.project_name }}' --env-file $ENV_FILE pull
|
||||
docker compose -p '${{ needs.prepare.outputs.project_name }}' --env-file $ENV_FILE up -d --wait --remove-orphans
|
||||
docker compose -p '${{ needs.prepare.outputs.project_name }}' --env-file $ENV_FILE up -d --remove-orphans
|
||||
"
|
||||
|
||||
- name: 🧹 Purge S3 Cache
|
||||
shell: bash
|
||||
run: |
|
||||
echo "Installing rclone..."
|
||||
curl -s -O https://downloads.rclone.org/rclone-current-linux-amd64.deb
|
||||
sudo dpkg -i rclone-current-linux-amd64.deb > /dev/null 2>&1
|
||||
|
||||
echo "Configuring rclone..."
|
||||
cat > rclone.conf <<EOF
|
||||
[mintel-s3]
|
||||
type = s3
|
||||
provider = Other
|
||||
access_key_id = ${{ secrets.S3_ACCESS_KEY || vars.S3_ACCESS_KEY }}
|
||||
secret_access_key = ${{ secrets.S3_SECRET_KEY || vars.S3_SECRET_KEY }}
|
||||
endpoint = ${{ secrets.S3_ENDPOINT || vars.S3_ENDPOINT || 'https://fsn1.your-objectstorage.com' }}
|
||||
region = ${{ secrets.S3_REGION || vars.S3_REGION || 'fsn1' }}
|
||||
EOF
|
||||
|
||||
echo "Purging S3 cache for ${{ env.S3_PREFIX }} ..."
|
||||
rclone --config rclone.conf delete mintel-s3:${{ env.S3_BUCKET }}/${{ env.S3_PREFIX }}/cache/ --include "*" || true
|
||||
|
||||
rm rclone.conf rclone-current-linux-amd64.deb
|
||||
|
||||
- name: 🧹 Post-Deploy Cleanup (Runner)
|
||||
if: always()
|
||||
run: docker builder prune -f --filter "until=1h"
|
||||
@@ -439,69 +535,112 @@ jobs:
|
||||
# JOB 5: Post-Deploy Verification
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
post_deploy_checks:
|
||||
name: 🩺 Smoke Test
|
||||
needs: [prepare, deploy]
|
||||
name: 🧪 Post-Deploy Verification
|
||||
needs: [prepare, deploy, qa]
|
||||
if: success() || failure() # Run even if QA fails (due to E2E noise)
|
||||
runs-on: docker
|
||||
if: needs.deploy.result == 'success'
|
||||
container:
|
||||
image: alpine:latest
|
||||
image: catthehacker/ubuntu:act-latest
|
||||
steps:
|
||||
- name: 🌐 Check Production URL
|
||||
shell: sh
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v3
|
||||
with:
|
||||
version: 10
|
||||
- name: Provide sibling monorepo
|
||||
run: |
|
||||
apk add --no-cache curl
|
||||
# Wait longer (up to 2 minutes) for Next.js to fully prime
|
||||
COUNT=0
|
||||
MAX=24
|
||||
URL="${{ needs.prepare.outputs.next_public_url }}"
|
||||
git clone https://git.infra.mintel.me/mmintel/at-mintel.git _at-mintel
|
||||
|
||||
echo "Verifying $URL is responsive..."
|
||||
while [ $COUNT -lt $MAX ]; do
|
||||
STATUS=$(curl -s -o /dev/null -w "%{http_code}" "$URL/" || echo "000")
|
||||
if [ "$STATUS" = "200" ]; then
|
||||
echo "✅ Site is UP (200 OK)"
|
||||
break
|
||||
# Force ALL @mintel packages to use the local clone instead of the registry
|
||||
perl -pi -e 's/"\@mintel\/([^"]+)"\s*:\s*"[^"]+"/"\@mintel\/$1": "link:.\/_at-mintel\/packages\/$1"/g' package.json
|
||||
perl -pi -e 's/link:\.\/_at-mintel\/packages\/pdf"/link:.\/_at-mintel\/packages\/pdf-library"/g' package.json
|
||||
perl -pi -e 's/"\@mintel\/([^"]+)"\s*:\s*"[^"]+"/"\@mintel\/$1": "link:..\/\.\.\/_at-mintel\/packages\/$1"/g' apps/web/package.json
|
||||
perl -pi -e 's/link:\.\.\/\.\.\/_at-mintel\/packages\/pdf"/link:..\/\.\.\/_at-mintel\/packages\/pdf-library"/g' apps/web/package.json
|
||||
|
||||
# Fix tsconfig paths if they exist
|
||||
sed -i 's|../../../at-mintel|../../_at-mintel|g' apps/web/tsconfig.json || true
|
||||
- name: 🔐 Registry Auth
|
||||
run: |
|
||||
echo "Testing available secrets against git.infra.mintel.me Docker registry..."
|
||||
TOKENS="${{ secrets.GITHUB_TOKEN }} ${{ secrets.GITEA_PAT }} ${{ secrets.MINTEL_PRIVATE_TOKEN }} ${{ secrets.NPM_TOKEN }}"
|
||||
USERS="${{ github.repository_owner }} ${{ github.actor }} marcmintel mintel mmintel"
|
||||
|
||||
VALID_TOKEN=""
|
||||
for TOKEN_RAW in $TOKENS; do
|
||||
if [ -n "$TOKEN_RAW" ]; then
|
||||
TOKEN=$(echo "$TOKEN_RAW" | tr -d '[:space:]' | tr -d '\n' | tr -d '\r')
|
||||
for U in $USERS; do
|
||||
if [ -n "$U" ]; then
|
||||
if echo "$TOKEN" | docker login git.infra.mintel.me -u "$U" --password-stdin > /dev/null 2>&1; then
|
||||
echo "✅ Successfully authenticated with a token."
|
||||
VALID_TOKEN="$TOKEN"
|
||||
break 2
|
||||
fi
|
||||
fi
|
||||
done
|
||||
fi
|
||||
echo "⏳ Wait for 200 OK (Status: $STATUS)..."
|
||||
sleep 5
|
||||
COUNT=$((COUNT + 1))
|
||||
done
|
||||
|
||||
if [ "$STATUS" != "200" ]; then
|
||||
echo "❌ Site failed smoke test after 2 minutes! (Status: $STATUS)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: 🌐 Check Case Study Assets and Rewrites
|
||||
shell: sh
|
||||
if [ -z "$VALID_TOKEN" ]; then echo "❌ All tokens failed to authenticate!"; exit 1; fi
|
||||
TOKEN="$VALID_TOKEN"
|
||||
echo "Configuring .npmrc for git.infra.mintel.me..."
|
||||
echo "@mintel:registry=https://git.infra.mintel.me/api/packages/mmintel/npm/" > .npmrc
|
||||
echo "//git.infra.mintel.me/api/packages/mmintel/npm/:_authToken=${TOKEN}" >> .npmrc
|
||||
echo "always-auth=true" >> .npmrc
|
||||
echo "NPM_TOKEN=${TOKEN}" >> $GITHUB_ENV
|
||||
- name: Install dependencies
|
||||
run: pnpm install --no-frozen-lockfile
|
||||
- name: 🏥 App Health Check
|
||||
shell: bash
|
||||
env:
|
||||
DEPLOY_URL: ${{ needs.prepare.outputs.next_public_url }}
|
||||
run: |
|
||||
BASE_URL="${{ needs.prepare.outputs.next_public_url }}"
|
||||
echo "Waiting for app to start at $DEPLOY_URL ..."
|
||||
for i in {1..30}; do
|
||||
HTTP_CODE=$(curl -sk -o /dev/null -w '%{http_code}' "$DEPLOY_URL" 2>&1) || true
|
||||
echo "Attempt $i: HTTP $HTTP_CODE"
|
||||
if [[ "$HTTP_CODE" =~ ^2 ]]; then
|
||||
echo "✅ App is up (HTTP $HTTP_CODE)"
|
||||
exit 0
|
||||
fi
|
||||
echo "⏳ Waiting... (got $HTTP_CODE)"
|
||||
sleep 10
|
||||
done
|
||||
echo "❌ App health check failed after 30 attempts"
|
||||
exit 1
|
||||
- name: 🚀 OG Image Check
|
||||
continue-on-error: true
|
||||
env:
|
||||
TEST_URL: ${{ needs.prepare.outputs.next_public_url }}
|
||||
run: pnpm --filter @mintel/web check:og
|
||||
- name: 📝 E2E Smoke Test
|
||||
continue-on-error: true
|
||||
env:
|
||||
TEST_URL: ${{ needs.prepare.outputs.next_public_url }}
|
||||
GATEKEEPER_PASSWORD: ${{ secrets.GATEKEEPER_PASSWORD }}
|
||||
PUPPETEER_SKIP_DOWNLOAD: "true"
|
||||
PUPPETEER_EXECUTABLE_PATH: /usr/bin/chromium
|
||||
run: |
|
||||
# Install system Chromium + dependencies (KLZ pattern)
|
||||
# Ubuntu's default 'chromium' is a snap wrapper, so we use xtradeb PPA for native binary
|
||||
sudo apt-get update && sudo apt-get install -y gnupg wget ca-certificates
|
||||
|
||||
echo "Verifying case study page..."
|
||||
STATUS=$(curl -s -o /dev/null -w "%{http_code}" "$BASE_URL/case-studies/klz-cables")
|
||||
if [ "$STATUS" != "200" ]; then
|
||||
echo "❌ Case study page failed! (Status: $STATUS)"
|
||||
exit 1
|
||||
fi
|
||||
echo "✅ Case study page UP"
|
||||
# Setup xtradeb PPA for native chromium
|
||||
CODENAME=$(. /etc/os-release && echo $VERSION_CODENAME)
|
||||
sudo mkdir -p /etc/apt/keyrings
|
||||
wget -qO- "https://keyserver.ubuntu.com/pks/lookup?op=get&search=0x82BB6851C64F6880" | sudo gpg --dearmor -o /etc/apt/keyrings/xtradeb.gpg || true
|
||||
echo "deb [signed-by=/etc/apt/keyrings/xtradeb.gpg] http://ppa.launchpad.net/xtradeb/apps/ubuntu $CODENAME main" | sudo tee /etc/apt/sources.list.d/xtradeb-ppa.list
|
||||
printf "Package: *\nPin: release o=LP-PPA-xtradeb-apps\nPin-Priority: 1001\n" | sudo tee /etc/apt/preferences.d/xtradeb
|
||||
|
||||
echo "Verifying breeze CSS (root path)..."
|
||||
CSS_PATH="/assets/klz-cables.com/wp-content/cache/breeze-minification/css/breeze_klz-cables-com-1-10895.css"
|
||||
STATUS=$(curl -s -o /dev/null -w "%{http_code}" "$BASE_URL$CSS_PATH")
|
||||
if [ "$STATUS" != "200" ]; then
|
||||
echo "❌ Root asset path failed! (Status: $STATUS)"
|
||||
exit 1
|
||||
fi
|
||||
echo "✅ Root asset path OK"
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y --allow-downgrades chromium libnss3 libatk1.0-0 libatk-bridge2.0-0 libcups2 libdrm2 libxkbcommon0 libxcomposite1 libxdamage1 libxrandr2 libgbm1 libasound2t64 || sudo apt-get install -y --allow-downgrades chromium libnss3 libatk1.0-0 libatk-bridge2.0-0 libcups2 libdrm2 libxkbcommon0 libxcomposite1 libxdamage1 libxrandr2 libgbm1 libasound2
|
||||
|
||||
echo "Verifying breeze CSS (relative path from case-study)..."
|
||||
REL_CSS_PATH="/case-studies/assets/klz-cables.com/wp-content/cache/breeze-minification/css/breeze_klz-cables-com-1-10895.css"
|
||||
STATUS=$(curl -s -o /dev/null -w "%{http_code}" "$BASE_URL$REL_CSS_PATH")
|
||||
if [ "$STATUS" != "200" ]; then
|
||||
echo "❌ Relative asset path failed! (Status: $STATUS)"
|
||||
exit 1
|
||||
fi
|
||||
echo "✅ Relative asset path OK"
|
||||
[ -f /usr/bin/chromium ] && sudo ln -sf /usr/bin/chromium /usr/bin/google-chrome
|
||||
pnpm --filter @mintel/web check:forms
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# JOB 6: Notifications
|
||||
@@ -521,7 +660,7 @@ jobs:
|
||||
TARGET="${{ needs.prepare.outputs.target }}"
|
||||
VERSION="${{ needs.prepare.outputs.image_tag }}"
|
||||
|
||||
if [[ "$DEPLOY" == "success" ]]; then
|
||||
if [[ "$DEPLOY" == "success" ]] && [[ "$SMOKE" == "success" || "$SMOKE" == "skipped" ]]; then
|
||||
PRIORITY=5
|
||||
EMOJI="✅"
|
||||
else
|
||||
@@ -531,5 +670,5 @@ jobs:
|
||||
|
||||
curl -s -k -X POST "${{ secrets.GOTIFY_URL }}/message?token=${{ secrets.GOTIFY_TOKEN }}" \
|
||||
-F "title=$EMOJI mintel.me $VERSION -> $TARGET" \
|
||||
-F "message=Deploy: $DEPLOY" \
|
||||
-F "message=Deploy: $DEPLOY | Smoke: $SMOKE" \
|
||||
-F "priority=$PRIORITY" || true
|
||||
|
||||
232
.gitea/workflows/qa.yml
Normal file
232
.gitea/workflows/qa.yml
Normal file
@@ -0,0 +1,232 @@
|
||||
name: Nightly QA
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: ["Build & Deploy"]
|
||||
branches: [main]
|
||||
types:
|
||||
- completed
|
||||
schedule:
|
||||
- cron: "0 3 * * *"
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
TARGET_URL: "https://testing.mintel.me"
|
||||
PROJECT_NAME: "mintel.me"
|
||||
|
||||
jobs:
|
||||
# ────────────────────────────────────────────────────
|
||||
# 1. Static Checks (HTML, Assets, HTTP)
|
||||
# ────────────────────────────────────────────────────
|
||||
static:
|
||||
name: 🔍 Static Analysis
|
||||
runs-on: docker
|
||||
container:
|
||||
image: catthehacker/ubuntu:act-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: pnpm/action-setup@v3
|
||||
with:
|
||||
version: 10
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
- name: 🔐 Registry Auth
|
||||
run: |
|
||||
echo "@mintel:registry=https://git.infra.mintel.me/api/packages/mmintel/npm" > .npmrc
|
||||
echo "//git.infra.mintel.me/api/packages/mmintel/npm/:_authToken=${{ secrets.NPM_TOKEN }}" >> .npmrc
|
||||
- name: 📦 Cache node_modules
|
||||
uses: actions/cache@v4
|
||||
id: cache-deps
|
||||
with:
|
||||
path: node_modules
|
||||
key: pnpm-${{ hashFiles('pnpm-lock.yaml') }}
|
||||
- name: Install
|
||||
if: steps.cache-deps.outputs.cache-hit != 'true'
|
||||
run: |
|
||||
pnpm store prune
|
||||
pnpm install --no-frozen-lockfile
|
||||
- name: 🌐 Install Chrome & Dependencies
|
||||
run: |
|
||||
apt-get update && apt-get install -y --fix-missing \
|
||||
libnss3 libnspr4 libatk1.0-0 libatk-bridge2.0-0 libcups2 libdrm2 \
|
||||
libxkbcommon0 libxcomposite1 libxdamage1 libxext6 libxfixes3 \
|
||||
libxrandr2 libgbm1 libpango-1.0-0 libcairo2 || true
|
||||
apt-get install -y libasound2t64 || apt-get install -y libasound2 || true
|
||||
npx puppeteer browsers install chrome || true
|
||||
- name: 🖼️ OG Images
|
||||
continue-on-error: true
|
||||
env:
|
||||
TEST_URL: ${{ env.TARGET_URL }}
|
||||
GATEKEEPER_PASSWORD: ${{ secrets.GATEKEEPER_PASSWORD }}
|
||||
run: pnpm --filter @mintel/web run check:og
|
||||
|
||||
# ────────────────────────────────────────────────────
|
||||
# 2. E2E (Forms)
|
||||
# ────────────────────────────────────────────────────
|
||||
e2e:
|
||||
name: 📝 E2E
|
||||
runs-on: docker
|
||||
container:
|
||||
image: catthehacker/ubuntu:act-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: pnpm/action-setup@v3
|
||||
with:
|
||||
version: 10
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
- name: 🔐 Registry Auth
|
||||
run: |
|
||||
echo "@mintel:registry=https://git.infra.mintel.me/api/packages/mmintel/npm" > .npmrc
|
||||
echo "//git.infra.mintel.me/api/packages/mmintel/npm/:_authToken=${{ secrets.NPM_TOKEN }}" >> .npmrc
|
||||
- name: 📦 Cache node_modules
|
||||
uses: actions/cache@v4
|
||||
id: cache-deps
|
||||
with:
|
||||
path: node_modules
|
||||
key: pnpm-${{ hashFiles('pnpm-lock.yaml') }}
|
||||
- name: Install
|
||||
if: steps.cache-deps.outputs.cache-hit != 'true'
|
||||
run: |
|
||||
pnpm store prune
|
||||
pnpm install --no-frozen-lockfile
|
||||
- name: 🌐 Install Chrome & Dependencies
|
||||
run: |
|
||||
apt-get update && apt-get install -y libnss3 libnspr4 libatk1.0-0 libatk-bridge2.0-0 libcups2 libdrm2 libxkbcommon0 libxcomposite1 libxdamage1 libxext6 libxfixes3 libxrandr2 libgbm1 libasound2t64 libpango-1.0-0 libcairo2
|
||||
npx puppeteer browsers install chrome || true
|
||||
- name: 📝 E2E Form Submission Test
|
||||
continue-on-error: true
|
||||
env:
|
||||
TEST_URL: ${{ env.TARGET_URL }}
|
||||
NEXT_PUBLIC_BASE_URL: ${{ env.TARGET_URL }}
|
||||
GATEKEEPER_PASSWORD: ${{ secrets.GATEKEEPER_PASSWORD }}
|
||||
run: pnpm --filter @mintel/web run check:forms
|
||||
|
||||
# ────────────────────────────────────────────────────
|
||||
# 3. Performance (Lighthouse)
|
||||
# ────────────────────────────────────────────────────
|
||||
lighthouse:
|
||||
name: 🎭 Lighthouse
|
||||
runs-on: docker
|
||||
container:
|
||||
image: catthehacker/ubuntu:act-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: pnpm/action-setup@v3
|
||||
with:
|
||||
version: 10
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
- name: 🔐 Registry Auth
|
||||
run: |
|
||||
echo "@mintel:registry=https://git.infra.mintel.me/api/packages/mmintel/npm" > .npmrc
|
||||
echo "//git.infra.mintel.me/api/packages/mmintel/npm/:_authToken=${{ secrets.NPM_TOKEN }}" >> .npmrc
|
||||
- name: 📦 Cache node_modules
|
||||
uses: actions/cache@v4
|
||||
id: cache-deps
|
||||
with:
|
||||
path: node_modules
|
||||
key: pnpm-${{ hashFiles('pnpm-lock.yaml') }}
|
||||
- name: Install
|
||||
if: steps.cache-deps.outputs.cache-hit != 'true'
|
||||
run: |
|
||||
pnpm store prune
|
||||
pnpm install --no-frozen-lockfile
|
||||
- name: 🌐 Install Chrome & Dependencies
|
||||
run: |
|
||||
apt-get update && apt-get install -y libnss3 libnspr4 libatk1.0-0 libatk-bridge2.0-0 libcups2 libdrm2 libxkbcommon0 libxcomposite1 libxdamage1 libxext6 libxfixes3 libxrandr2 libgbm1 libasound2t64 libpango-1.0-0 libcairo2
|
||||
npx puppeteer browsers install chrome || true
|
||||
- name: 🎭 Desktop
|
||||
env:
|
||||
NEXT_PUBLIC_BASE_URL: ${{ env.TARGET_URL }}
|
||||
GATEKEEPER_PASSWORD: ${{ secrets.GATEKEEPER_PASSWORD }}
|
||||
PAGESPEED_LIMIT: 5
|
||||
run: pnpm --filter @mintel/web run pagespeed:test
|
||||
- name: 📱 Mobile
|
||||
env:
|
||||
NEXT_PUBLIC_BASE_URL: ${{ env.TARGET_URL }}
|
||||
GATEKEEPER_PASSWORD: ${{ secrets.GATEKEEPER_PASSWORD }}
|
||||
PAGESPEED_LIMIT: 5
|
||||
run: pnpm --filter @mintel/web run pagespeed:test
|
||||
|
||||
# ────────────────────────────────────────────────────
|
||||
# 4. Link Check & Dependency Audit
|
||||
# ────────────────────────────────────────────────────
|
||||
links:
|
||||
name: 🔗 Links & Deps
|
||||
runs-on: docker
|
||||
container:
|
||||
image: catthehacker/ubuntu:act-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: pnpm/action-setup@v3
|
||||
with:
|
||||
version: 10
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
- name: 🔐 Registry Auth
|
||||
run: |
|
||||
echo "@mintel:registry=https://git.infra.mintel.me/api/packages/mmintel/npm" > .npmrc
|
||||
echo "//git.infra.mintel.me/api/packages/mmintel/npm/:_authToken=${{ secrets.NPM_TOKEN }}" >> .npmrc
|
||||
- name: 📦 Cache node_modules
|
||||
uses: actions/cache@v4
|
||||
id: cache-deps
|
||||
with:
|
||||
path: node_modules
|
||||
key: pnpm-${{ hashFiles('pnpm-lock.yaml') }}
|
||||
- name: Install
|
||||
if: steps.cache-deps.outputs.cache-hit != 'true'
|
||||
run: |
|
||||
pnpm store prune
|
||||
pnpm install --no-frozen-lockfile
|
||||
- name: 📦 Depcheck
|
||||
continue-on-error: true
|
||||
run: pnpm dlx depcheck --ignores="*eslint*,*typescript*,*tailwindcss*,*postcss*,*prettier*,*@types/*,*husky*,*lint-staged*,*@next/*,*@lhci/*,*commitlint*,*cspell*,*rimraf*,*@payloadcms/*,*start-server-and-test*,*html-validate*,*critters*,*dotenv*,*turbo*" || true
|
||||
- name: 🔗 Lychee Link Check
|
||||
uses: lycheeverse/lychee-action@v2
|
||||
continue-on-error: true
|
||||
with:
|
||||
args: --accept 200,204,429 --timeout 10 --insecure --exclude "file://*" --exclude "https://logs.infra.mintel.me/*" --exclude "https://git.infra.mintel.me/*" --exclude "https://mintel.me/*" '*.md' 'docs/*.md'
|
||||
fail: false
|
||||
|
||||
# ────────────────────────────────────────────────────
|
||||
# 5. Notification
|
||||
# ────────────────────────────────────────────────────
|
||||
notify:
|
||||
name: 🔔 Notify
|
||||
needs: [static, e2e, lighthouse, links]
|
||||
if: failure()
|
||||
runs-on: docker
|
||||
container:
|
||||
image: catthehacker/ubuntu:act-latest
|
||||
steps:
|
||||
- name: 🔔 Gotify
|
||||
shell: bash
|
||||
run: |
|
||||
STATIC="${{ needs.static.result }}"
|
||||
E2E="${{ needs.e2e.result }}"
|
||||
LIGHTHOUSE="${{ needs.lighthouse.result }}"
|
||||
LINKS="${{ needs.links.result }}"
|
||||
|
||||
if [[ "$STATIC" != "success" || "$LIGHTHOUSE" != "success" ]]; then
|
||||
PRIORITY=8
|
||||
EMOJI="🚨"
|
||||
STATUS="Failed"
|
||||
else
|
||||
PRIORITY=2
|
||||
EMOJI="✅"
|
||||
STATUS="Passed"
|
||||
fi
|
||||
|
||||
TITLE="$EMOJI ${{ env.PROJECT_NAME }} QA $STATUS"
|
||||
MESSAGE="Static: $STATIC | E2E: $E2E | Lighthouse: $LIGHTHOUSE | Links: $LINKS
|
||||
${{ env.TARGET_URL }}"
|
||||
|
||||
curl -s -k -X POST "${{ secrets.GOTIFY_URL }}/message?token=${{ secrets.GOTIFY_TOKEN }}" \
|
||||
-F "title=$TITLE" \
|
||||
-F "message=$MESSAGE" \
|
||||
-F "priority=$PRIORITY" || true
|
||||
6
.gitignore
vendored
6
.gitignore
vendored
@@ -56,8 +56,4 @@ apps/web/out/estimations/
|
||||
# Backups
|
||||
backups/
|
||||
|
||||
.turbo
|
||||
# Manual build artifacts
|
||||
_at-mintel/
|
||||
local_build_*.log
|
||||
*.tar
|
||||
.turbo
|
||||
55
Dockerfile
55
Dockerfile
@@ -7,65 +7,37 @@ ARG NEXT_PUBLIC_BASE_URL
|
||||
ARG NEXT_PUBLIC_TARGET
|
||||
ARG UMAMI_API_ENDPOINT
|
||||
ARG NPM_TOKEN
|
||||
ARG S3_ENDPOINT
|
||||
ARG S3_ACCESS_KEY
|
||||
ARG S3_SECRET_KEY
|
||||
ARG S3_BUCKET
|
||||
ARG S3_REGION
|
||||
ARG S3_PREFIX
|
||||
|
||||
ARG BUILD_ID
|
||||
|
||||
# Environment variables for Next.js build
|
||||
ENV NEXT_PUBLIC_BASE_URL=$NEXT_PUBLIC_BASE_URL
|
||||
ENV NEXT_PUBLIC_TARGET=$NEXT_PUBLIC_TARGET
|
||||
ENV UMAMI_API_ENDPOINT=$UMAMI_API_ENDPOINT
|
||||
ENV S3_ENDPOINT=$S3_ENDPOINT
|
||||
ENV S3_ACCESS_KEY=$S3_ACCESS_KEY
|
||||
ENV S3_SECRET_KEY=$S3_SECRET_KEY
|
||||
ENV S3_BUCKET=$S3_BUCKET
|
||||
ENV S3_REGION=$S3_REGION
|
||||
ENV S3_PREFIX=$S3_PREFIX
|
||||
|
||||
ENV SKIP_RUNTIME_ENV_VALIDATION=true
|
||||
ENV NEXT_BUILD_WORKERS=1
|
||||
ENV NEXT_DISABLE_SOURCEMAPS=true
|
||||
ENV SENTRY_SKIP_LOCAL_SOURCES=true
|
||||
ENV NEXT_PRIVATE_LOCAL_WEBPACK=true
|
||||
ENV CI=true
|
||||
|
||||
# Copy manifest files specifically for better layer caching
|
||||
COPY pnpm-lock.yaml pnpm-workspace.yaml package.json .npmrc* ./
|
||||
COPY apps/web/package.json ./apps/web/package.json
|
||||
# Install build dependencies for native modules (like canvas)
|
||||
RUN apk add --no-cache python3 make g++ pkgconfig pixman-dev cairo-dev pango-dev libjpeg-turbo-dev giflib-dev librsvg-dev
|
||||
|
||||
# Copy sibling monorepo for linked dependencies (cloned during CI)
|
||||
# Placing it inside /app so relative links like ../../_at-mintel resolve correctly!
|
||||
COPY _at-mintel* /app/_at-mintel/
|
||||
|
||||
COPY _at-mintel* ./_at-mintel/
|
||||
|
||||
# Install dependencies with cache mount and dynamic .npmrc (High Fidelity pattern)
|
||||
RUN --mount=type=cache,id=pnpm,target=/pnpm/store \
|
||||
--mount=type=secret,id=NPM_TOKEN \
|
||||
export NPM_TOKEN=$(cat /run/secrets/NPM_TOKEN 2>/dev/null || echo $NPM_TOKEN) && \
|
||||
echo "@mintel:registry=https://npm.infra.mintel.me" > .npmrc && \
|
||||
echo "//npm.infra.mintel.me/:_authToken=\"\${NPM_TOKEN}\"" >> .npmrc && \
|
||||
echo "@mintel:registry=https://git.infra.mintel.me/api/packages/mmintel/npm/" > .npmrc && \
|
||||
echo "//git.infra.mintel.me/api/packages/mmintel/npm/:_authToken=\${NPM_TOKEN}" >> .npmrc && \
|
||||
echo "always-auth=true" >> .npmrc && \
|
||||
cd /app/_at-mintel && pnpm install --no-frozen-lockfile && pnpm build && \
|
||||
cd _at-mintel && pnpm install --no-frozen-lockfile && pnpm build && \
|
||||
cd /app && pnpm install --no-frozen-lockfile && \
|
||||
rm .npmrc
|
||||
|
||||
# Copy source code
|
||||
# We use BUILD_ID here to ensure that if the commit changes, we always COPY the latest files
|
||||
# even if Docker's metadata-based fingerprinting for the public directory fails.
|
||||
ARG BUILD_ID
|
||||
RUN echo "Building with ID: ${BUILD_ID}"
|
||||
COPY . .
|
||||
|
||||
# Build application (monorepo filter)
|
||||
ENV NODE_OPTIONS="--max_old_space_size=4096"
|
||||
RUN NEXT_BUILD_WORKERS=1 pnpm --filter @mintel/web build
|
||||
RUN pnpm --filter @mintel/web build
|
||||
|
||||
# Stage 2: Runner
|
||||
FROM git.infra.mintel.me/mmintel/runtime:latest AS runner
|
||||
@@ -73,18 +45,11 @@ WORKDIR /app
|
||||
|
||||
# Copy standalone output and static files (Monorepo paths)
|
||||
# Note: Base image already handles the non-root user and basic env
|
||||
COPY --from=builder --chown=1001:65533 /app/apps/web/public ./apps/web/public
|
||||
# Fallback target: Next.js sometimes resolves from the process root depending on version
|
||||
COPY --from=builder --chown=1001:65533 /app/apps/web/public ./public
|
||||
COPY --from=builder --chown=1001:65533 /app/apps/web/.next/standalone ./
|
||||
COPY --from=builder --chown=1001:65533 /app/apps/web/.next/static ./apps/web/.next/static
|
||||
COPY --from=builder /app/apps/web/public ./apps/web/public
|
||||
COPY --from=builder /app/apps/web/.next/standalone ./
|
||||
COPY --from=builder /app/apps/web/.next/static ./apps/web/.next/static
|
||||
|
||||
|
||||
|
||||
# Fix permissions for the non-root user (Standard uid/gid from base image)
|
||||
USER nextjs
|
||||
|
||||
# Start from the app directory to ensure references solve correctly
|
||||
# In Standalone mode, Next.js expects node_modules and public relative to the server.js
|
||||
WORKDIR /app
|
||||
CMD ["node", "apps/web/server.js"]
|
||||
WORKDIR /app/apps/web
|
||||
CMD ["node", "server.js"]
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
FROM node:20-alpine
|
||||
|
||||
# Install essential build tools if needed (e.g., for node-gyp)
|
||||
RUN apk add --no-cache libc6-compat git python3 make g++
|
||||
RUN apk add --no-cache libc6-compat python3 make g++
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
|
||||
13
apps/web/app/(payload)/actions.ts
Normal file
13
apps/web/app/(payload)/actions.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
"use server";
|
||||
import { handleServerFunctions as payloadHandleServerFunctions } from "@payloadcms/next/layouts";
|
||||
import config from "@payload-config";
|
||||
// @ts-ignore - Payload generates this file during the build process
|
||||
import { importMap } from "./admin/importMap";
|
||||
|
||||
export const handleServerFunctions = async (args: any) => {
|
||||
return payloadHandleServerFunctions({
|
||||
...args,
|
||||
config,
|
||||
importMap,
|
||||
});
|
||||
};
|
||||
26
apps/web/app/(payload)/admin/[[...segments]]/page.tsx
Normal file
26
apps/web/app/(payload)/admin/[[...segments]]/page.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import type { Metadata } from "next";
|
||||
|
||||
import configPromise from "@payload-config";
|
||||
import { RootPage, generatePageMetadata } from "@payloadcms/next/views";
|
||||
// @ts-ignore - Payload generates this file during the build process
|
||||
import { importMap } from "../importMap";
|
||||
|
||||
type Args = {
|
||||
params: Promise<{
|
||||
segments: string[];
|
||||
}>;
|
||||
searchParams: Promise<{
|
||||
[key: string]: string | string[];
|
||||
}>;
|
||||
};
|
||||
|
||||
export const generateMetadata = async ({
|
||||
params,
|
||||
searchParams,
|
||||
}: Args): Promise<Metadata> =>
|
||||
generatePageMetadata({ config: configPromise, params, searchParams });
|
||||
|
||||
const Page = async ({ params, searchParams }: Args) =>
|
||||
RootPage({ config: configPromise, importMap, params, searchParams });
|
||||
|
||||
export default Page;
|
||||
1
apps/web/app/(payload)/admin/importMap.js
Normal file
1
apps/web/app/(payload)/admin/importMap.js
Normal file
@@ -0,0 +1 @@
|
||||
export const importMap = {};
|
||||
16
apps/web/app/(payload)/api/[...slug]/route.ts
Normal file
16
apps/web/app/(payload)/api/[...slug]/route.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import config from "@payload-config";
|
||||
import {
|
||||
REST_DELETE,
|
||||
REST_GET,
|
||||
REST_OPTIONS,
|
||||
REST_PATCH,
|
||||
REST_POST,
|
||||
REST_PUT,
|
||||
} from "@payloadcms/next/routes";
|
||||
|
||||
export const GET = REST_GET(config);
|
||||
export const POST = REST_POST(config);
|
||||
export const DELETE = REST_DELETE(config);
|
||||
export const OPTIONS = REST_OPTIONS(config);
|
||||
export const PATCH = REST_PATCH(config);
|
||||
export const PUT = REST_PUT(config);
|
||||
20
apps/web/app/(payload)/layout.tsx
Normal file
20
apps/web/app/(payload)/layout.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import configPromise from "@payload-config";
|
||||
import "@payloadcms/next/css";
|
||||
import { RootLayout } from "@payloadcms/next/layouts";
|
||||
import React from "react";
|
||||
|
||||
import { handleServerFunctions } from "./actions";
|
||||
// @ts-ignore - Payload generates this file during the build process
|
||||
import { importMap } from "./admin/importMap";
|
||||
|
||||
export default function Layout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<RootLayout
|
||||
config={configPromise}
|
||||
importMap={importMap}
|
||||
serverFunction={handleServerFunctions}
|
||||
>
|
||||
{children}
|
||||
</RootLayout>
|
||||
);
|
||||
}
|
||||
@@ -1,150 +1,217 @@
|
||||
"use client";
|
||||
|
||||
import { Reveal } from "@/src/components/Reveal";
|
||||
import Image from "next/image";
|
||||
import { Section } from "@/src/components/Section";
|
||||
import { Reveal } from "@/src/components/Reveal";
|
||||
import {
|
||||
ExperienceIllustration,
|
||||
ResponsibilityIllustration,
|
||||
ResultIllustration,
|
||||
ContactIllustration,
|
||||
HeroLines,
|
||||
ParticleNetwork,
|
||||
GridLines,
|
||||
} from "@/src/components/Landing";
|
||||
import { Signature } from "@/src/components/Signature";
|
||||
import { Check } from "lucide-react";
|
||||
import {
|
||||
H1,
|
||||
H3,
|
||||
H4,
|
||||
LeadText,
|
||||
BodyText,
|
||||
Label,
|
||||
MonoLabel,
|
||||
} from "@/src/components/Typography";
|
||||
import { Card, Container } from "@/src/components/Layout";
|
||||
import { Button } from "@/src/components/Button";
|
||||
import { AbstractCircuit, GradientMesh } from "@/src/components/Effects";
|
||||
import { GlitchText } from "@/src/components/GlitchText";
|
||||
import { IconList, IconListItem } from "@/src/components/IconList";
|
||||
import {
|
||||
GradientMesh,
|
||||
CodeSnippet,
|
||||
AbstractCircuit,
|
||||
} from "@/src/components/Effects";
|
||||
|
||||
import { Marker } from "@/src/components/Marker";
|
||||
|
||||
export default function AboutPage() {
|
||||
return (
|
||||
<div className="flex flex-col bg-white overflow-hidden relative">
|
||||
<AbstractCircuit />
|
||||
{/* Background decoration removed per user request */}
|
||||
|
||||
{/* Hero Section */}
|
||||
<section className="relative pt-24 md:pt-40 pb-12 md:pb-24 overflow-hidden border-b border-slate-50">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 w-full relative z-10">
|
||||
<div className="flex flex-col items-center text-center space-y-12">
|
||||
<section className="relative pt-12 md:pt-32 pb-8 md:pb-24 overflow-hidden border-b border-slate-50">
|
||||
<Container variant="narrow" className="relative z-10">
|
||||
<div className="flex flex-col items-center text-center space-y-6 md:space-y-12">
|
||||
<Reveal width="fit-content">
|
||||
<div className="w-40 h-40 md:w-56 md:h-56 rounded-full overflow-hidden grayscale hover:grayscale-0 transition-all duration-1000">
|
||||
<img
|
||||
src="/marc-mintel.png"
|
||||
alt="Marc Mintel"
|
||||
className="object-cover w-full h-full"
|
||||
/>
|
||||
<div className="relative">
|
||||
{/* Structural rings around avatar */}
|
||||
{/* Structural rings removed per user request */}
|
||||
|
||||
<div className="relative w-32 h-32 md:w-40 md:h-40 rounded-full overflow-hidden border border-slate-200 shadow-xl bg-white p-1 group">
|
||||
<div className="w-full h-full rounded-full overflow-hidden relative aspect-square">
|
||||
<img
|
||||
src="/marc-mintel.png"
|
||||
alt="Marc Mintel"
|
||||
className="object-cover grayscale transition-all duration-1000 ease-in-out scale-110 group-hover:scale-100 group-hover:grayscale-0 w-full h-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Reveal>
|
||||
|
||||
<div className="space-y-6 max-w-4xl">
|
||||
<div className="space-y-3 md:space-y-6 max-w-3xl">
|
||||
<Reveal delay={0.1}>
|
||||
<MonoLabel className="text-slate-400 tracking-[0.2em] text-xs">
|
||||
WEBENTWICKLER
|
||||
</MonoLabel>
|
||||
<div className="flex items-center justify-center gap-2 md:gap-4 mb-1 md:mb-4">
|
||||
<div className="h-px w-6 md:w-8 bg-slate-900"></div>
|
||||
<MonoLabel className="text-slate-900 text-[10px] md:text-sm">
|
||||
Digital Architect
|
||||
</MonoLabel>
|
||||
<div className="h-px w-6 md:w-8 bg-slate-900"></div>
|
||||
</div>
|
||||
</Reveal>
|
||||
<Reveal delay={0.2}>
|
||||
<H1 className="text-6xl md:text-8xl lg:text-9xl leading-none tracking-tighter">
|
||||
<H1 className="text-4xl md:text-8xl leading-none tracking-tighter">
|
||||
Über <span className="text-slate-400">mich.</span>
|
||||
</H1>
|
||||
</Reveal>
|
||||
<Reveal delay={0.3}>
|
||||
<p className="text-slate-500 max-w-2xl mx-auto text-xl md:text-3xl leading-snug">
|
||||
15 Jahre Erfahrung. Ein Ziel: Ihnen den Frust mit Websites zu
|
||||
ersparen.
|
||||
<p className="text-slate-400 font-medium max-w-xl mx-auto text-sm md:text-xl">
|
||||
15 Jahre Erfahrung. Ein Ziel: Websites, die ihre Versprechen
|
||||
halten.
|
||||
</p>
|
||||
</Reveal>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Container>
|
||||
|
||||
{/* Connector to first section */}
|
||||
<div className="absolute bottom-0 left-1/2 -translate-x-1/2 w-px h-12 md:h-16 bg-gradient-to-b from-transparent to-slate-200" />
|
||||
</section>
|
||||
|
||||
{/* Section 01: Story */}
|
||||
<Section number="01" title="Erfahrung" borderTop>
|
||||
<div className="space-y-16 md:space-y-32">
|
||||
<Section
|
||||
number="01"
|
||||
title="Erfahrung"
|
||||
borderTop
|
||||
illustration={<ExperienceIllustration className="w-24 h-24" />}
|
||||
>
|
||||
<div className="space-y-8 md:space-y-12">
|
||||
<Reveal>
|
||||
<H3 className="text-4xl md:text-7xl lg:text-8xl leading-none tracking-tighter max-w-4xl">
|
||||
Aus der Praxis. <br />
|
||||
<span className="text-slate-400">Für die Praxis.</span>
|
||||
<H3 className="text-2xl md:text-5xl leading-tight max-w-3xl">
|
||||
Vom Designer <br />
|
||||
<span className="text-slate-400">zum Architekten.</span>
|
||||
</H3>
|
||||
</Reveal>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-12 md:gap-24">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-8 md:gap-12">
|
||||
<Reveal delay={0.1}>
|
||||
<LeadText className="text-2xl md:text-4xl text-slate-900 leading-snug">
|
||||
Ich habe in Agenturen, Konzernen und Startups gearbeitet. Dabei
|
||||
habe ich gesehen, woran Projekte scheitern: zu viele Leute, zu
|
||||
viel Blabla.
|
||||
</LeadText>
|
||||
<div className="space-y-6 md:space-y-8">
|
||||
<LeadText className="text-xl md:text-2xl text-slate-400">
|
||||
Agenturen, Konzerne, Startups – ich habe die Branche von allen
|
||||
Seiten kennengelernt. Was hängen geblieben ist:{" "}
|
||||
<span className="text-slate-900">
|
||||
<Marker delay={0.2}>Ergebnisse</Marker> zählen. Nicht der
|
||||
Weg dorthin.
|
||||
</span>
|
||||
</LeadText>
|
||||
<IconList className="space-y-4">
|
||||
{[
|
||||
"Frontend, Backend, Infrastruktur – Fullstack",
|
||||
"Komplexe Systeme auf das Wesentliche reduziert",
|
||||
"Performance-Probleme systematisch gelöst",
|
||||
].map((item, i) => (
|
||||
<IconListItem key={i} bullet>
|
||||
<BodyText className="text-lg">{item}</BodyText>
|
||||
</IconListItem>
|
||||
))}
|
||||
</IconList>
|
||||
</div>
|
||||
</Reveal>
|
||||
<Reveal delay={0.2}>
|
||||
<div className="space-y-12">
|
||||
<BodyText className="text-slate-500 text-lg md:text-xl">
|
||||
Das mache ich anders. Keine Hierarchien, kein Fachchinesisch,
|
||||
sondern klare Kommunikation. Fokus auf Ergebnisse statt auf
|
||||
endlose Meetings.
|
||||
</BodyText>
|
||||
<div className="space-y-6 border-l border-slate-100 pl-8">
|
||||
<div>
|
||||
<Label className="text-slate-900 text-lg">Direkt</Label>
|
||||
<BodyText className="text-slate-500">
|
||||
Sie sprechen immer mit mir.
|
||||
</BodyText>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-slate-900 text-lg">
|
||||
Unkompliziert
|
||||
</Label>
|
||||
<BodyText className="text-slate-500">
|
||||
Wir beschränken uns auf das Wesentliche.
|
||||
</BodyText>
|
||||
</div>
|
||||
<Card
|
||||
variant="gray"
|
||||
hover={false}
|
||||
padding="normal"
|
||||
className="group"
|
||||
>
|
||||
<H4 className="text-xl mb-6">
|
||||
Heute: Direkte Zusammenarbeit ohne Reibungsverluste.
|
||||
</H4>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{["Effizient", "Pragmatisch", "Verlässlich"].map((tag, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className="px-4 py-2 bg-white border border-slate-200 rounded-full shadow-sm"
|
||||
>
|
||||
<Label className="text-slate-900">{tag}</Label>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</Reveal>
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{/* Section 02: Ablauf */}
|
||||
<Section number="02" title="Ablauf" borderTop>
|
||||
<div className="space-y-16 md:space-y-32">
|
||||
{/* Section 02: Arbeitsweise – HOW I work */}
|
||||
<Section
|
||||
number="02"
|
||||
title="Arbeitsweise"
|
||||
variant="gray"
|
||||
borderTop
|
||||
illustration={<ResponsibilityIllustration className="w-24 h-24" />}
|
||||
effects={<GradientMesh variant="subtle" className="opacity-60" />}
|
||||
>
|
||||
<div className="space-y-12">
|
||||
<Reveal>
|
||||
<H3 className="text-4xl md:text-7xl lg:text-8xl leading-none tracking-tighter max-w-4xl">
|
||||
So einfach läuft <br />
|
||||
<span className="text-slate-400">ein Projekt ab.</span>
|
||||
<H3 className="text-2xl md:text-5xl leading-tight max-w-3xl">
|
||||
So läuft ein Projekt <br />
|
||||
<span className="text-slate-400">bei mir ab.</span>
|
||||
</H3>
|
||||
</Reveal>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-8 md:gap-12">
|
||||
{/* Timeline Steps */}
|
||||
<div className="space-y-1 relative">
|
||||
{/* Connecting line */}
|
||||
<div className="absolute left-[15px] top-8 bottom-8 w-px bg-slate-200 hidden md:block" />
|
||||
|
||||
{[
|
||||
{
|
||||
step: "01",
|
||||
title: "Kurzes Gespräch",
|
||||
desc: "Kein Workshop, nur die Fakten.",
|
||||
title: "Briefing",
|
||||
desc: "Sie beschreiben Ihr Vorhaben. Ich höre zu und stelle die richtigen Fragen.",
|
||||
},
|
||||
{
|
||||
step: "02",
|
||||
title: "Klares Angebot",
|
||||
desc: "Fixpreis ohne Sternchen.",
|
||||
title: "Angebot",
|
||||
desc: "Ein Fixpreis-Angebot mit klarem Leistungsumfang. Keine Überraschungen.",
|
||||
},
|
||||
{
|
||||
step: "03",
|
||||
title: "Umsetzung",
|
||||
desc: "Ich baue, Sie entspannen.",
|
||||
desc: "Schnelle Iterationen. Sie sehen regelmäßig den Fortschritt und geben Feedback.",
|
||||
},
|
||||
{
|
||||
step: "04",
|
||||
title: "Go-Live & Support",
|
||||
desc: "Ich übernehme die Wartung.",
|
||||
title: "Launch",
|
||||
desc: "Go-Live mit automatisiertem Deployment. Dokumentiert und übergabereif.",
|
||||
},
|
||||
].map((item, i) => (
|
||||
<Reveal key={i} delay={0.1 * i}>
|
||||
<div className="space-y-6 pt-6 border-t border-slate-900">
|
||||
<Label className="text-slate-400 font-mono text-sm">
|
||||
{item.step}
|
||||
</Label>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-slate-900 text-xl">
|
||||
<Reveal key={i} delay={0.1 + i * 0.1}>
|
||||
<div className="flex gap-4 md:gap-6 py-2 md:py-6 group">
|
||||
<div className="relative z-10 shrink-0">
|
||||
<div className="w-8 h-8 rounded-full bg-white border border-slate-200 flex items-center justify-center group-hover:border-slate-400 group-hover:shadow-md transition-all duration-500">
|
||||
<span className="text-[9px] font-mono font-bold text-slate-400 group-hover:text-slate-900 transition-colors">
|
||||
{item.step}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1 md:space-y-2 pt-1">
|
||||
<H4 className="text-base md:text-xl font-bold">
|
||||
{item.title}
|
||||
</Label>
|
||||
<BodyText className="text-slate-500 text-lg">
|
||||
</H4>
|
||||
<BodyText className="text-slate-500 text-sm md:text-base">
|
||||
{item.desc}
|
||||
</BodyText>
|
||||
</div>
|
||||
@@ -155,36 +222,68 @@ export default function AboutPage() {
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{/* Section 03: Garantie */}
|
||||
{/* Section 03: Garantie – The Pledge */}
|
||||
<Section number="03" title="Garantie" borderTop>
|
||||
<div className="space-y-16 md:space-y-32">
|
||||
<div className="relative">
|
||||
<Reveal>
|
||||
<H3 className="text-4xl md:text-7xl lg:text-8xl leading-none tracking-tighter max-w-4xl">
|
||||
Ich stehe für <br />
|
||||
<span className="text-slate-400">meine Arbeit gerade.</span>
|
||||
</H3>
|
||||
</Reveal>
|
||||
<div className="max-w-4xl text-left space-y-12 md:space-y-16 py-8 md:py-16">
|
||||
<H3 className="text-3xl md:text-6xl leading-tight">
|
||||
Ich stehe für <br />
|
||||
<span className="text-slate-400">meine Arbeit gerade.</span>
|
||||
</H3>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-12 md:gap-24 items-end">
|
||||
<Reveal delay={0.1}>
|
||||
<div className="space-y-8">
|
||||
<LeadText className="text-2xl md:text-4xl text-slate-900 leading-snug">
|
||||
Wenn etwas nicht passt, liegt die Verantwortung allein bei
|
||||
mir.
|
||||
</LeadText>
|
||||
<BodyText className="text-slate-500 text-lg md:text-xl">
|
||||
Sie beauftragen mich, damit Sie Ruhe haben. Fixpreis bedeutet
|
||||
Fixpreis. Wir gehen erst live, wenn Sie zu 100% zufrieden
|
||||
sind.
|
||||
</BodyText>
|
||||
<div className="prose prose-lg md:prose-2xl text-slate-500 leading-relaxed">
|
||||
<p>
|
||||
Keine Hierarchien. Keine Ausreden. Wenn etwas nicht passt,
|
||||
liegt die Verantwortung bei mir.
|
||||
</p>
|
||||
<p>
|
||||
Ich liefere nicht nur Code, sondern{" "}
|
||||
<span className="text-slate-900 font-medium relative inline-block">
|
||||
Ergebnisse
|
||||
<svg
|
||||
className="absolute -bottom-2 left-0 w-full h-3 text-blue-500/30"
|
||||
viewBox="0 0 100 10"
|
||||
preserveAspectRatio="none"
|
||||
>
|
||||
<path
|
||||
d="M0 5 Q 50 10 100 5"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
fill="none"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
, auf die Sie bauen können.
|
||||
</p>
|
||||
</div>
|
||||
</Reveal>
|
||||
<Reveal delay={0.2}>
|
||||
<div className="pb-8">
|
||||
<Signature />
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-8 max-w-2xl text-left">
|
||||
<div className="p-6 bg-slate-50 rounded-2xl border border-slate-100">
|
||||
<h4 className="font-bold text-slate-900 mb-2">
|
||||
Fixpreis-Garantie
|
||||
</h4>
|
||||
<p className="text-slate-500 text-sm">
|
||||
Keine versteckten Kosten. Der vereinbarte Preis ist final.
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-6 bg-slate-50 rounded-2xl border border-slate-100">
|
||||
<h4 className="font-bold text-slate-900 mb-2">
|
||||
Satisfaction Guarantee
|
||||
</h4>
|
||||
<p className="text-slate-500 text-sm">
|
||||
Wir gehen erst live, wenn Sie zu 100% zufrieden sind.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Reveal>
|
||||
</div>
|
||||
|
||||
<div className="pt-8 md:pt-12 flex flex-col items-start">
|
||||
<div className="w-64 md:w-80">
|
||||
<Signature delay={0.5} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Reveal>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
@@ -192,37 +291,41 @@ export default function AboutPage() {
|
||||
<Section
|
||||
number="04"
|
||||
title="Kontakt"
|
||||
variant="gray"
|
||||
borderTop
|
||||
illustration={<ContactIllustration className="w-24 h-24" />}
|
||||
effects={<GradientMesh variant="metallic" className="opacity-60" />}
|
||||
>
|
||||
<div className="py-12 md:py-24 relative z-10">
|
||||
<div className="space-y-16 md:space-y-24">
|
||||
<Reveal>
|
||||
<H3 className="text-5xl md:text-8xl lg:text-9xl leading-none tracking-tighter">
|
||||
Bereit, das Thema <br />
|
||||
<span className="text-slate-400">
|
||||
<GlitchText delay={0.5} duration={1}>
|
||||
abzuhaken?
|
||||
</GlitchText>
|
||||
</span>
|
||||
</H3>
|
||||
</Reveal>
|
||||
<div className="space-y-10 md:space-y-12">
|
||||
<Reveal>
|
||||
<H3 className="text-2xl md:text-5xl leading-tight max-w-3xl">
|
||||
Bereit für eine <br />
|
||||
<span className="text-slate-400">Zusammenarbeit?</span>
|
||||
</H3>
|
||||
</Reveal>
|
||||
|
||||
<Reveal delay={0.2}>
|
||||
<div className="flex flex-col md:flex-row justify-between items-start md:items-center gap-12">
|
||||
<LeadText className="text-xl md:text-3xl text-slate-500 max-w-xl">
|
||||
Schreiben Sie mir einfach – wir finden die beste Lösung für
|
||||
Ihre Website.
|
||||
</LeadText>
|
||||
<Button
|
||||
href="/contact"
|
||||
className="w-full md:w-auto px-12 py-6 text-xl"
|
||||
>
|
||||
Sagen Sie Hallo
|
||||
<Card
|
||||
variant="glass"
|
||||
hover={false}
|
||||
padding="normal"
|
||||
techBorder
|
||||
className="rounded-3xl shadow-xl relative overflow-hidden group"
|
||||
>
|
||||
<div className="relative z-10 space-y-6 md:space-y-8">
|
||||
<LeadText className="text-lg md:text-4xl leading-tight max-w-2xl text-slate-400">
|
||||
Lassen Sie uns gemeinsam etwas bauen, das{" "}
|
||||
<span className="text-slate-900">
|
||||
wirklich <Marker delay={0.3}>funktioniert.</Marker>
|
||||
</span>
|
||||
</LeadText>
|
||||
|
||||
<div className="pt-2 md:pt-4">
|
||||
<Button href="/contact" className="w-full md:w-auto">
|
||||
Projekt anfragen
|
||||
</Button>
|
||||
</div>
|
||||
</Reveal>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</Section>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import * as React from "react";
|
||||
import type { Metadata } from "next";
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
|
||||
import { getPayloadHMR } from "@payloadcms/next/utilities";
|
||||
import configPromise from "@payload-config";
|
||||
import { getAllPosts } from "@/src/lib/posts";
|
||||
import { BlogPostHeader } from "@/src/components/blog/BlogPostHeader";
|
||||
import { Section } from "@/src/components/Section";
|
||||
@@ -10,7 +11,7 @@ import { BlogPostClient } from "@/src/components/BlogPostClient";
|
||||
import { TextSelectionShare } from "@/src/components/TextSelectionShare";
|
||||
import { BlogPostStickyBar } from "@/src/components/blog/BlogPostStickyBar";
|
||||
import { MDXContent } from "@/src/components/MDXContent";
|
||||
|
||||
import { PayloadRichText } from "@/src/components/PayloadRichText";
|
||||
import { TableOfContents } from "@/src/components/TableOfContents";
|
||||
|
||||
export async function generateStaticParams() {
|
||||
@@ -57,6 +58,18 @@ export default async function BlogPostPage({
|
||||
const post = allPosts.find((p) => p.slug === slug);
|
||||
|
||||
if (!post) {
|
||||
const payload = await getPayloadHMR({ config: configPromise });
|
||||
const redirectDoc = await payload.find({
|
||||
collection: "redirects",
|
||||
where: {
|
||||
from: { equals: slug },
|
||||
},
|
||||
});
|
||||
|
||||
if (redirectDoc.docs.length > 0) {
|
||||
redirect(`/blog/${redirectDoc.docs[0].to}`);
|
||||
}
|
||||
|
||||
notFound();
|
||||
}
|
||||
|
||||
@@ -106,7 +119,11 @@ export default async function BlogPostPage({
|
||||
|
||||
<div className="article-content max-w-none">
|
||||
<TableOfContents />
|
||||
<MDXContent code={post.mdxContent || ""} />
|
||||
{post.lexicalContent ? (
|
||||
<PayloadRichText data={post.lexicalContent} />
|
||||
) : (
|
||||
<MDXContent code={post.body.code} />
|
||||
)}
|
||||
</div>
|
||||
</Reveal>
|
||||
</div>
|
||||
|
||||
@@ -2,8 +2,6 @@ import { getAllPosts } from "@/src/lib/posts";
|
||||
import { BlogClient } from "@/src/components/blog/BlogClient";
|
||||
import type { Metadata } from "next";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Blog | Mintel.me",
|
||||
description:
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Section } from "@/src/components/Section";
|
||||
import { ContactForm } from "@/src/components/ContactForm";
|
||||
import { AgentChat } from "@/src/components/agent/AgentChat";
|
||||
import { AbstractCircuit } from "@/src/components/Effects";
|
||||
|
||||
export default function ContactPage() {
|
||||
@@ -12,9 +12,10 @@ export default function ContactPage() {
|
||||
effects={<></>}
|
||||
className="pt-24 pb-12 md:pt-32 md:pb-20"
|
||||
>
|
||||
{/* Full-width Form */}
|
||||
<ContactForm />
|
||||
{/* AI Agent Chat */}
|
||||
<AgentChat />
|
||||
</Section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -24,19 +24,19 @@
|
||||
}
|
||||
|
||||
h1 {
|
||||
@apply text-4xl md:text-6xl lg:text-8xl leading-[1.1] md:leading-[1] lg:leading-[0.95] mb-6 md:mb-8 lg:mb-12;
|
||||
@apply text-4xl md:text-8xl leading-[1.1] md:leading-[0.95] mb-6 md:mb-12;
|
||||
}
|
||||
|
||||
h2 {
|
||||
@apply text-2xl md:text-4xl lg:text-6xl leading-tight mb-4 md:mb-6 lg:mb-8 mt-12 lg:mt-16;
|
||||
@apply text-2xl md:text-6xl leading-tight mb-4 md:mb-8 mt-12 md:mt-16;
|
||||
}
|
||||
|
||||
h3 {
|
||||
@apply text-xl md:text-3xl lg:text-5xl leading-tight mb-3 md:mb-4 lg:mb-6 mt-8 lg:mt-12;
|
||||
@apply text-xl md:text-5xl leading-tight mb-3 md:mb-6 mt-8 md:mt-12;
|
||||
}
|
||||
|
||||
h4 {
|
||||
@apply text-lg md:text-2xl lg:text-3xl leading-tight mb-3 lg:mb-4 mt-6 lg:mt-8;
|
||||
@apply text-lg md:text-3xl leading-tight mb-3 md:mb-4 mt-6 md:mt-8;
|
||||
}
|
||||
|
||||
p {
|
||||
@@ -44,7 +44,7 @@
|
||||
}
|
||||
|
||||
.lead {
|
||||
@apply text-base md:text-xl lg:text-2xl text-slate-600 mb-6 leading-relaxed;
|
||||
@apply text-base md:text-2xl text-slate-600 mb-6 leading-relaxed;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
@@ -78,28 +78,6 @@
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
/* Interactive elements pointer */
|
||||
a,
|
||||
button,
|
||||
[role="button"],
|
||||
input[type="button"],
|
||||
input[type="submit"],
|
||||
input[type="reset"],
|
||||
select,
|
||||
summary,
|
||||
label[for] {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Ensure disabled elements don't show pointer */
|
||||
button:disabled,
|
||||
[role="button"]:disabled,
|
||||
input:disabled,
|
||||
select:disabled,
|
||||
button[disabled] {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Remove default tap highlight on mobile */
|
||||
* {
|
||||
-webkit-tap-highlight-color: transparent !important;
|
||||
@@ -114,15 +92,15 @@
|
||||
}
|
||||
|
||||
.container {
|
||||
@apply max-w-6xl mx-auto px-5 md:px-6 lg:px-8 py-8 md:py-10 lg:py-12;
|
||||
@apply max-w-6xl mx-auto px-5 md:px-6 py-8 md:py-12;
|
||||
}
|
||||
|
||||
.wide-container {
|
||||
@apply max-w-7xl mx-auto px-5 md:px-6 lg:px-8 py-10 md:py-12 lg:py-16;
|
||||
@apply max-w-7xl mx-auto px-5 md:px-6 py-10 md:py-16;
|
||||
}
|
||||
|
||||
.narrow-container {
|
||||
@apply max-w-4xl mx-auto px-5 md:px-6 lg:px-8 py-6 md:py-8 lg:py-10;
|
||||
@apply max-w-4xl mx-auto px-5 md:px-6 py-6 md:py-10;
|
||||
}
|
||||
|
||||
.highlighter-tag {
|
||||
|
||||
@@ -16,11 +16,11 @@ const newsreader = Newsreader({
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: {
|
||||
default: "Marc Mintel — Professionelle Websites für Unternehmen",
|
||||
default: "Marc Mintel",
|
||||
template: "%s | Marc Mintel",
|
||||
},
|
||||
description:
|
||||
"Professionelle Websites zum Fixpreis. Ein Entwickler, ein Ansprechpartner — von der Idee bis zum fertigen Ergebnis. Inklusive Sorglos-Paket: Änderungen, Wartung und Sicherheit komplett abgedeckt.",
|
||||
"Technical problem solver's blog - practical insights and learning notes",
|
||||
metadataBase: new URL("https://mintel.me"),
|
||||
icons: {
|
||||
icon: "/favicon.svg",
|
||||
@@ -33,7 +33,7 @@ export default function RootLayout({
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<html lang="de" className={`${inter.variable} ${newsreader.variable}`}>
|
||||
<html lang="en" className={`${inter.variable} ${newsreader.variable}`}>
|
||||
<body className="min-h-screen bg-white">
|
||||
<Header />
|
||||
<main>{children}</main>
|
||||
|
||||
@@ -1,303 +1,319 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
ComparisonRow,
|
||||
ConceptCode,
|
||||
ConceptCommunication,
|
||||
ConceptPrice,
|
||||
ConceptPrototyping,
|
||||
ConceptWebsite,
|
||||
} from "@/src/components/Landing";
|
||||
import { Reveal } from "@/src/components/Reveal";
|
||||
import { Section } from "@/src/components/Section";
|
||||
import { H1, H3, LeadText, BodyText, Label } from "@/src/components/Typography";
|
||||
import {
|
||||
H1,
|
||||
H3,
|
||||
LeadText,
|
||||
BodyText,
|
||||
MonoLabel,
|
||||
Label,
|
||||
} from "@/src/components/Typography";
|
||||
import { Card, Container } from "@/src/components/Layout";
|
||||
import { Button } from "@/src/components/Button";
|
||||
import { GradientMesh, CodeSnippet } from "@/src/components/Effects";
|
||||
import { IconList, IconListItem } from "@/src/components/IconList";
|
||||
import { HeroSection } from "@/src/components/HeroSection";
|
||||
import { Availability } from "@/src/components/Availability";
|
||||
import { AbstractCircuit, GradientMesh } from "@/src/components/Effects";
|
||||
import { Marker } from "@/src/components/Marker";
|
||||
import { GlitchText } from "@/src/components/GlitchText";
|
||||
import { Marker } from "@/src/components/Marker";
|
||||
import { PenCircle } from "@/src/components/PenCircle";
|
||||
|
||||
export default function LandingPage() {
|
||||
return (
|
||||
<div className="flex flex-col bg-white overflow-hidden relative">
|
||||
<AbstractCircuit />
|
||||
{/* Dark Hero */}
|
||||
<HeroSection />
|
||||
|
||||
{/* Section 02: Ihr Vorteil */}
|
||||
<Section number="02" title="Ihr Vorteil" borderTop>
|
||||
<div className="space-y-16 md:space-y-32 relative z-10">
|
||||
{/* Rest of page on white */}
|
||||
|
||||
{/* Section 02: The Promise – Streamlined */}
|
||||
<Section
|
||||
number="02"
|
||||
title="Das Versprechen"
|
||||
borderTop
|
||||
effects={<GradientMesh variant="metallic" className="opacity-70" />}
|
||||
>
|
||||
<div className="space-y-10 md:space-y-16 relative">
|
||||
<Reveal>
|
||||
<H3 className="text-3xl md:text-6xl lg:text-8xl leading-none tracking-tighter">
|
||||
Sie kümmern sich um Ihr Geschäft.{" "}
|
||||
<span className="text-slate-400">
|
||||
Ich mich um Ihre <Marker delay={0.3}>Website.</Marker>
|
||||
</span>
|
||||
<H3 className="max-w-3xl">
|
||||
Kein Agentur-Zirkus. <br />
|
||||
<Marker delay={0.3}>Ergebnisse.</Marker>
|
||||
</H3>
|
||||
</Reveal>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-12 md:gap-24">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-8 relative z-10">
|
||||
{[
|
||||
{
|
||||
title: "Ein Ansprechpartner",
|
||||
text: "Direkte Kommunikation. Keine Agentur, keine Projektmanager, kein Stille-Post-Effekt.",
|
||||
icon: <ConceptCommunication className="w-8 h-8" />,
|
||||
title: "Direkte Kommunikation",
|
||||
text: "Sie sprechen mit dem Entwickler. Keine Stille Post, keine Umwege.",
|
||||
},
|
||||
{
|
||||
title: "Fixpreis",
|
||||
text: "Keine versteckten Kosten, keine Stundenzettel. Sie wissen exakt, was es kostet.",
|
||||
icon: <ConceptPrototyping className="w-8 h-8" />,
|
||||
title: "Schnelle Umsetzung",
|
||||
text: "Sichtbare Fortschritte in Tagen. Prototypen statt Konzeptpapiere.",
|
||||
},
|
||||
{
|
||||
title: "Schnelle Ergebnisse",
|
||||
text: "Kein monatelanges Warten. Erste Entwürfe innerhalb weniger Tage.",
|
||||
icon: <ConceptCode className="w-8 h-8" />,
|
||||
title: "Sauberer Code",
|
||||
text: "Maßgeschneiderte Architektur. Kein Baukasten, kein Plugin-Chaos.",
|
||||
},
|
||||
{
|
||||
title: "Null Arbeit für Sie",
|
||||
text: "Keine CMS-Einarbeitung. Änderungen oder Updates? Eine kurze E-Mail genügt.",
|
||||
icon: <ConceptPrice className="w-8 h-8" />,
|
||||
title: "Klare Fixpreise",
|
||||
text: "Volle Budgetsicherheit. Keine versteckten Kosten.",
|
||||
},
|
||||
].map((item, i) => (
|
||||
<Reveal key={i} delay={0.1 * i}>
|
||||
<div className="space-y-4">
|
||||
<div className="h-px w-full bg-slate-100 mb-6" />
|
||||
<Label className="text-slate-900 text-lg md:text-xl font-medium tracking-tight">
|
||||
{item.title}
|
||||
</Label>
|
||||
<BodyText className="text-slate-500 text-base md:text-lg">
|
||||
{item.text}
|
||||
</BodyText>
|
||||
</div>
|
||||
</Reveal>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{/* Section 03: Der Unterschied */}
|
||||
<Section number="03" title="Der Unterschied" borderTop>
|
||||
<div className="space-y-16 md:space-y-32">
|
||||
<Reveal>
|
||||
<H3 className="text-3xl md:text-6xl lg:text-8xl leading-none tracking-tighter">
|
||||
Warum nicht einfach{" "}
|
||||
<span className="text-slate-400">eine Agentur?</span>
|
||||
</H3>
|
||||
</Reveal>
|
||||
|
||||
<div className="space-y-12 md:space-y-0">
|
||||
{[
|
||||
{
|
||||
label: "Agentur",
|
||||
text: "Projektmanager, Designer, Entwickler — Sie erklären alles drei Mal. Stundenabrechnungen, unklare Endkosten.",
|
||||
},
|
||||
{
|
||||
label: "Baukasten (Wix, etc.)",
|
||||
text: "Sie bauen selbst, kämpfen mit Templates und sind auf sich allein gestellt, wenn etwas nicht funktioniert.",
|
||||
},
|
||||
{
|
||||
label: "Bei mir",
|
||||
text: "Ein Ansprechpartner. Fixpreis. Ich baue, Sie lehnen sich zurück. Sie brauchen eine Änderung? Kurze Nachricht genügt.",
|
||||
highlight: true,
|
||||
},
|
||||
].map((item, i) => (
|
||||
<Reveal key={i} delay={0.1 * i}>
|
||||
<div
|
||||
className={`grid grid-cols-1 md:grid-cols-4 gap-4 md:gap-8 py-8 md:py-16 border-t ${item.highlight ? "border-slate-900" : "border-slate-100"}`}
|
||||
<Reveal key={i} delay={0.1 + i * 0.1}>
|
||||
<Card
|
||||
variant="glass"
|
||||
padding="normal"
|
||||
techBorder
|
||||
className="group"
|
||||
>
|
||||
<div className="md:col-span-1">
|
||||
<Label
|
||||
className={`text-lg md:text-xl ${item.highlight ? "text-slate-900 font-bold" : "text-slate-400"}`}
|
||||
>
|
||||
{item.label}
|
||||
</Label>
|
||||
<div className="space-y-4 relative z-10">
|
||||
<div className="w-12 h-12 rounded-xl bg-slate-50 border border-slate-100 flex items-center justify-center group-hover:scale-110 transition-transform duration-500">
|
||||
{item.icon}
|
||||
</div>
|
||||
<Label className="text-slate-900">{item.title}</Label>
|
||||
<BodyText className="text-slate-500">{item.text}</BodyText>
|
||||
</div>
|
||||
<div className="md:col-span-3">
|
||||
<LeadText
|
||||
className={`text-xl md:text-3xl leading-snug ${item.highlight ? "text-slate-900" : "text-slate-500"}`}
|
||||
>
|
||||
{item.text}
|
||||
</LeadText>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</Reveal>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{/* Section 04: Zielgruppe */}
|
||||
<Section number="04" title="Für wen" borderTop>
|
||||
<div className="space-y-16 md:space-y-32">
|
||||
{/* Section 03: The Difference – Visual Comparison */}
|
||||
<Section number="03" title="Der Unterschied" variant="white" borderTop>
|
||||
<div className="space-y-10 md:space-y-16 relative">
|
||||
<Reveal>
|
||||
<H3 className="text-3xl md:text-6xl lg:text-8xl leading-none tracking-tighter">
|
||||
Fokus auf das <span className="text-slate-400">Wesentliche.</span>
|
||||
<H3 className="max-w-3xl">
|
||||
Ich arbeite für das Ergebnis, <br />
|
||||
nicht gegen die <Marker delay={0.4}>Uhr.</Marker>
|
||||
</H3>
|
||||
</Reveal>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-12 md:gap-24">
|
||||
<Reveal>
|
||||
<div className="space-y-8">
|
||||
<H3 className="text-2xl md:text-4xl">
|
||||
Unternehmer & Geschäftsführer
|
||||
</H3>
|
||||
<LeadText className="text-slate-500">
|
||||
Sie brauchen eine professionelle Website, die Ihr Unternehmen
|
||||
repräsentiert — ohne sich selbst mit Technik beschäftigen zu
|
||||
müssen. Ich übernehme alles.
|
||||
</LeadText>
|
||||
</div>
|
||||
</Reveal>
|
||||
<Reveal delay={0.2}>
|
||||
<div className="space-y-8">
|
||||
<H3 className="text-2xl md:text-4xl">
|
||||
Selbstständige & kleine Teams
|
||||
</H3>
|
||||
<LeadText className="text-slate-500">
|
||||
Sie wollen online sichtbar sein, aber keine Zeit in eine
|
||||
Website stecken. Ich liefere eine fertige Lösung, damit Sie
|
||||
sich auf Ihre Kunden konzentrieren können.
|
||||
</LeadText>
|
||||
</div>
|
||||
</Reveal>
|
||||
<div className="grid grid-cols-1 gap-8 relative z-20">
|
||||
<ComparisonRow
|
||||
negativeLabel="Klassisch"
|
||||
negativeText="Wochen in Planung, bevor eine einzige Zeile Code geschrieben wird."
|
||||
positiveLabel="Mein Weg"
|
||||
positiveText={
|
||||
<>
|
||||
Schnelle Prototypen. Ergebnisse in{" "}
|
||||
<PenCircle delay={0.5}>Tagen</PenCircle>, nicht Monaten.
|
||||
</>
|
||||
}
|
||||
delay={0.1}
|
||||
/>
|
||||
<ComparisonRow
|
||||
negativeLabel="Klassisch"
|
||||
negativeText="Unvorhersehbare Kosten durch Stundenabrechnungen."
|
||||
positiveLabel="Mein Weg"
|
||||
positiveText={
|
||||
<>
|
||||
<PenCircle delay={0.5}>Fixpreise.</PenCircle> Sie wissen von
|
||||
Anfang an, was es kostet.
|
||||
</>
|
||||
}
|
||||
reverse
|
||||
delay={0.2}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{/* Section 05: Leistungen */}
|
||||
<Section number="05" title="Leistungen" borderTop>
|
||||
<div className="space-y-0">
|
||||
{/* Section 04: Target Group */}
|
||||
<Section number="04" title="Für wen" borderTop>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 md:gap-6 relative z-10">
|
||||
<Reveal>
|
||||
<Card variant="glass" padding="normal" techBorder className="group">
|
||||
<div className="space-y-4 md:space-y-6 relative overflow-hidden">
|
||||
<div className="w-12 h-12 md:w-16 md:h-16 bg-slate-50 border border-slate-100 rounded-xl flex items-center justify-center">
|
||||
<ConceptPrice className="w-6 h-6 md:w-8 md:h-8" />
|
||||
</div>
|
||||
<H3 className="text-xl md:text-3xl">
|
||||
Unternehmer & <br />
|
||||
Geschäftsführer
|
||||
</H3>
|
||||
<LeadText className="text-slate-400 text-base md:text-lg">
|
||||
Sie wollen eine Website, die funktioniert – ohne sich mit
|
||||
Technik beschäftigen zu müssen.
|
||||
</LeadText>
|
||||
</div>
|
||||
<div className="pt-6 md:pt-8 border-t border-slate-50 mt-6 md:mt-8">
|
||||
<Label className="group-hover:text-slate-900 transition-colors">
|
||||
Perfekt für Sie
|
||||
</Label>
|
||||
</div>
|
||||
</Card>
|
||||
</Reveal>
|
||||
<Reveal delay={0.2}>
|
||||
<Card variant="glass" padding="normal" techBorder className="group">
|
||||
<div className="space-y-6 relative overflow-hidden">
|
||||
<div className="w-12 h-12 md:w-16 md:h-16 bg-slate-50 border border-slate-100 rounded-xl flex items-center justify-center">
|
||||
{/* Icon placeholder or same as above if needed */}
|
||||
<ConceptWebsite className="w-6 h-6 md:w-8 md:h-8" />
|
||||
</div>
|
||||
<H3 className="text-xl md:text-3xl">
|
||||
Marketing & <br />
|
||||
Vertrieb
|
||||
</H3>
|
||||
<LeadText className="text-slate-400 text-base md:text-lg">
|
||||
Sie brauchen Landingpages und Tools, die Ergebnisse liefern.
|
||||
Schnell und zuverlässig.
|
||||
</LeadText>
|
||||
</div>
|
||||
<div className="pt-6 md:pt-8 border-t border-slate-50 mt-6 md:mt-8">
|
||||
<Label className="group-hover:text-slate-900 transition-colors">
|
||||
Perfekt für Sie
|
||||
</Label>
|
||||
</div>
|
||||
</Card>
|
||||
</Reveal>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{/* Section 05: Leistungen — Interactive Service Rows */}
|
||||
<Section number="05" title="Leistungen" variant="gray" borderTop>
|
||||
<div className="space-y-0 relative z-20">
|
||||
{[
|
||||
{
|
||||
num: "01",
|
||||
binary: "00000001",
|
||||
title: "Websites",
|
||||
text: "Professionelle, schnelle Websites — individuell für Ihr Unternehmen gestaltet. Ohne Overhead.",
|
||||
text: "High-Performance Websites mit maßgeschneiderter Architektur. Von der Konzeption bis zum Go-Live — individuell, schnell, messbar.",
|
||||
tags: ["Next.js", "React", "TypeScript", "Performance"],
|
||||
href: "/websites",
|
||||
},
|
||||
{
|
||||
title: "Sorglos-Paket",
|
||||
text: "Änderungen, Wartung, Sicherheit — alles inklusive. Sie sagen Bescheid, ich erledige das.",
|
||||
num: "02",
|
||||
binary: "00000010",
|
||||
title: "Systeme",
|
||||
text: "Web-Applikationen und interne Tools, wenn Standard-Software nicht reicht. Dashboards, Portale, Automatisierungen.",
|
||||
tags: ["Full-Stack", "APIs", "Datenbanken", "Auth"],
|
||||
href: "/contact",
|
||||
},
|
||||
{
|
||||
title: "Web-Anwendungen",
|
||||
text: "Kundenportale, interne Tools oder individuelle Lösungen für Ihr Business.",
|
||||
href: "/showcase",
|
||||
num: "03",
|
||||
binary: "00000011",
|
||||
title: "Automatisierung",
|
||||
text: "Verbindung von Tools, automatische Prozesse, Daten-Synchronisation. Weniger manuelle Arbeit, mehr Effizienz.",
|
||||
tags: ["CI/CD", "Workflows", "Integrationen", "Monitoring"],
|
||||
href: "/contact",
|
||||
},
|
||||
].map((service, i) => (
|
||||
<Reveal key={i} delay={0.1 * i}>
|
||||
<a href={service.href} className="group block">
|
||||
<div className="flex flex-col md:flex-row justify-between items-start md:items-end gap-6 py-12 md:py-20 border-b border-slate-100 transition-colors hover:border-slate-300">
|
||||
<div className="space-y-4 max-w-2xl">
|
||||
<H3 className="text-3xl md:text-5xl group-hover:translate-x-2 transition-transform duration-500">
|
||||
{service.title}
|
||||
</H3>
|
||||
<LeadText className="text-slate-400 group-hover:text-slate-600 transition-colors">
|
||||
{service.text}
|
||||
</LeadText>
|
||||
</div>
|
||||
<div className="text-slate-300 group-hover:text-slate-900 transition-colors pb-1">
|
||||
<svg
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
<Reveal key={i} delay={0.1 + i * 0.15}>
|
||||
<div className="group py-8 md:py-16 border-b border-slate-100 last:border-b-0 cursor-pointer transition-all duration-500">
|
||||
<div className="flex flex-col md:flex-row md:items-start gap-6 md:gap-16">
|
||||
{/* Number + Binary */}
|
||||
<div className="shrink-0 flex md:block items-baseline gap-4">
|
||||
<span className="text-4xl md:text-6xl font-black text-slate-100 group-hover:text-slate-200 transition-colors duration-500 tracking-tighter block leading-none">
|
||||
{service.num}
|
||||
</span>
|
||||
<span
|
||||
className="text-[8px] font-mono text-slate-200 tracking-[0.3em] mt-2 block select-none group-hover:text-blue-300 transition-colors duration-700 leading-none"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<line x1="5" y1="12" x2="19" y2="12"></line>
|
||||
<polyline points="12 5 19 12 12 19"></polyline>
|
||||
</svg>
|
||||
{service.binary}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 space-y-4 md:space-y-6">
|
||||
<H3 className="text-xl md:text-4xl group-hover:translate-x-2 transition-transform duration-500">
|
||||
<GlitchText
|
||||
trigger="inView"
|
||||
delay={0.2 + i * 0.15}
|
||||
duration={0.6}
|
||||
>
|
||||
{service.title}
|
||||
</GlitchText>
|
||||
</H3>
|
||||
<BodyText className="text-slate-400 text-sm md:text-base max-w-xl group-hover:text-slate-500 transition-colors duration-500">
|
||||
{service.text}
|
||||
</BodyText>
|
||||
{/* Tags */}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{service.tags.map((tag, j) => (
|
||||
<span
|
||||
key={j}
|
||||
className="px-3 py-1 text-[8px] md:text-[9px] font-mono uppercase tracking-widest text-slate-400 border border-slate-100 rounded-full bg-white/50 group-hover:border-slate-200 group-hover:text-slate-500 transition-all duration-500"
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Arrow */}
|
||||
<div className="md:self-center shrink-0 pt-4 md:pt-0">
|
||||
<Button
|
||||
href={service.href}
|
||||
variant="ghost"
|
||||
size="normal"
|
||||
showArrow
|
||||
className="w-full md:w-auto"
|
||||
>
|
||||
Details
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
</Reveal>
|
||||
))}
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{/* Section 06: Blog / Insights */}
|
||||
<Section number="06" title="Einblicke" borderTop>
|
||||
<div className="space-y-16 md:space-y-32">
|
||||
{/* Section 06: Contact */}
|
||||
<Section number="06" title="Kontakt" borderTop>
|
||||
<div className="relative py-4 md:py-12" id="contact">
|
||||
<Reveal>
|
||||
<H3 className="text-3xl md:text-6xl lg:text-8xl leading-none tracking-tighter">
|
||||
Wissen statt <span className="text-slate-400">Werbung.</span>
|
||||
</H3>
|
||||
</Reveal>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-12 md:gap-24">
|
||||
<Reveal>
|
||||
<div className="space-y-8">
|
||||
<Label className="text-slate-400">Aktueller Artikel</Label>
|
||||
<H3 className="text-2xl md:text-4xl leading-tight">
|
||||
Warum Websites nach Updates oft brechen – und wie man das
|
||||
verhindert.
|
||||
</H3>
|
||||
<div>
|
||||
<Button
|
||||
href="/blog/why-websites-break-after-updates"
|
||||
variant="outline"
|
||||
>
|
||||
Artikel lesen
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Reveal>
|
||||
<Reveal delay={0.2}>
|
||||
<div className="space-y-8 border-l border-slate-100 pl-8 md:pl-12 flex flex-col justify-end">
|
||||
<LeadText className="text-slate-500">
|
||||
Ich teile meine Gedanken zu digitaler Architektur, Fixpreisen
|
||||
und warum weniger oft mehr ist.
|
||||
</LeadText>
|
||||
<a
|
||||
href="/blog"
|
||||
className="text-slate-900 font-bold hover:underline underline-offset-4"
|
||||
>
|
||||
Alle Artikel ansehen →
|
||||
</a>
|
||||
</div>
|
||||
</Reveal>
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{/* Section 07: Contact */}
|
||||
<Section
|
||||
number="07"
|
||||
title="Kontakt"
|
||||
borderTop
|
||||
effects={<GradientMesh variant="metallic" className="opacity-60" />}
|
||||
>
|
||||
<div className="relative py-12 md:py-32" id="contact">
|
||||
<Reveal>
|
||||
<div className="space-y-12 md:space-y-24 relative z-10">
|
||||
<H1 className="text-5xl md:text-8xl lg:text-9xl leading-none tracking-tighter">
|
||||
Bereit für den Start? <br />
|
||||
<span className="text-slate-400">
|
||||
<GlitchText delay={0.5} duration={1}>
|
||||
Sagen Sie Hallo.
|
||||
</GlitchText>
|
||||
</span>
|
||||
<div className="space-y-8 md:space-y-16">
|
||||
<H1 className="text-3xl md:text-8xl">
|
||||
Lassen Sie uns <br />
|
||||
<span className="text-slate-400">starten.</span>
|
||||
</H1>
|
||||
|
||||
<div className="flex flex-col md:flex-row gap-12 md:gap-24 items-start">
|
||||
<div className="space-y-8 flex-1">
|
||||
<LeadText className="text-2xl md:text-4xl text-slate-500 leading-snug">
|
||||
Schreiben Sie mir kurz, was Sie vorhaben. Ich melde mich
|
||||
innerhalb von 24 Stunden bei Ihnen.
|
||||
<div className="flex flex-col md:flex-row gap-6 md:gap-16 items-start relative z-10">
|
||||
<div className="space-y-4 md:space-y-8 flex-1">
|
||||
<LeadText className="text-lg md:text-3xl text-slate-400">
|
||||
Beschreiben Sie kurz Ihr Vorhaben. Ich melde mich{" "}
|
||||
<span className="text-slate-900 border-b-2 border-slate-900/10">
|
||||
<Marker>zeitnah</Marker>
|
||||
</span>{" "}
|
||||
bei Ihnen.
|
||||
</LeadText>
|
||||
<div className="pt-4">
|
||||
<div className="pt-2 md:pt-4">
|
||||
<Button
|
||||
href="/contact"
|
||||
size="large"
|
||||
className="text-lg px-8 py-4"
|
||||
className="w-full md:w-auto"
|
||||
>
|
||||
Jetzt Nachricht schreiben
|
||||
Projekt anfragen
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="w-full md:w-80 space-y-4 pt-4 md:pt-0">
|
||||
<div className="w-full md:w-72 space-y-4 md:space-y-6 p-6 glass rounded-2xl border border-slate-100">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-2 h-2 bg-slate-900 rounded-full"></div>
|
||||
<Label className="text-slate-900 text-sm">
|
||||
Verfügbarkeit
|
||||
</Label>
|
||||
<div className="w-2 h-2 bg-green-500 rounded-full animate-pulse"></div>
|
||||
<Label className="text-slate-900">Verfügbarkeit</Label>
|
||||
</div>
|
||||
<BodyText className="text-slate-500">
|
||||
<BodyText className="text-sm md:text-base leading-snug">
|
||||
Aktuell nehme ich Projekte für{" "}
|
||||
<span className="font-bold text-slate-900">
|
||||
<Availability />
|
||||
</span>{" "}
|
||||
<span className="font-bold text-slate-900">Q2 2026</span>{" "}
|
||||
an.
|
||||
</BodyText>
|
||||
</div>
|
||||
|
||||
@@ -3,241 +3,371 @@
|
||||
import { Reveal } from "@/src/components/Reveal";
|
||||
import { Section } from "@/src/components/Section";
|
||||
import {
|
||||
H1,
|
||||
SpeedPerformance,
|
||||
SolidFoundation,
|
||||
LayerSeparation,
|
||||
TaskDone,
|
||||
} from "@/src/components/Landing";
|
||||
import {
|
||||
H3,
|
||||
LeadText,
|
||||
BodyText,
|
||||
Label,
|
||||
MonoLabel,
|
||||
} from "@/src/components/Typography";
|
||||
import { Card } from "@/src/components/Layout";
|
||||
import { Button } from "@/src/components/Button";
|
||||
import { IconList, IconListItem } from "@/src/components/IconList";
|
||||
import {
|
||||
GradientMesh,
|
||||
CodeSnippet,
|
||||
AbstractCircuit,
|
||||
CMSVisualizer,
|
||||
ArchitectureVisualizer,
|
||||
ResultVisualizer,
|
||||
} from "@/src/components/Effects";
|
||||
import { Marker } from "@/src/components/Marker";
|
||||
import { AbstractCircuit, GradientMesh } from "@/src/components/Effects";
|
||||
import { GlitchText } from "@/src/components/GlitchText";
|
||||
|
||||
export default function WebsitesPage() {
|
||||
return (
|
||||
<div className="flex flex-col bg-white overflow-hidden relative">
|
||||
<AbstractCircuit />
|
||||
|
||||
<Section className="pt-24 pb-12 md:pt-40 md:pb-24">
|
||||
<div className="space-y-16 md:space-y-32">
|
||||
<div className="space-y-10 max-w-4xl">
|
||||
<div className="space-y-12 md:space-y-24">
|
||||
<div className="space-y-6 md:space-y-10 max-w-5xl">
|
||||
<Reveal>
|
||||
<div className="space-y-6">
|
||||
<MonoLabel className="text-slate-400 tracking-[0.2em] text-xs">
|
||||
PROFESSIONELLE WEBSITES
|
||||
<div className="space-y-4">
|
||||
<MonoLabel className="text-blue-500 tracking-[0.2em] text-[10px] md:text-xs">
|
||||
SYSTEM ENGINEERING
|
||||
</MonoLabel>
|
||||
<H1 className="text-5xl md:text-8xl lg:text-9xl leading-[0.9] tracking-tighter">
|
||||
Ihre Website. <br />
|
||||
<span className="text-slate-400">Komplett sorgenfrei.</span>
|
||||
</H1>
|
||||
<H3 className="text-4xl md:text-8xl leading-[1.0] tracking-tighter">
|
||||
Websites, die einfach <br />
|
||||
<span className="text-slate-400">
|
||||
<Marker>funktionieren.</Marker>
|
||||
</span>
|
||||
</H3>
|
||||
</div>
|
||||
</Reveal>
|
||||
<Reveal delay={0.2}>
|
||||
<LeadText className="text-xl md:text-3xl text-slate-500 leading-snug">
|
||||
Kein WordPress-Chaos. Kein Baukasten-Frust. Ich baue Ihnen eine
|
||||
maßgeschneiderte Website, mit der Sie{" "}
|
||||
<LeadText className="text-lg md:text-2xl max-w-2xl text-slate-500 md:text-slate-400 leading-relaxed">
|
||||
Kein Baukasten. Kein Plugin-Chaos. Maßgeschneiderte Architektur
|
||||
für{" "}
|
||||
<span className="text-slate-900 font-bold underline decoration-slate-200 underline-offset-8">
|
||||
null Arbeit
|
||||
</span>{" "}
|
||||
haben.
|
||||
maximale Performance
|
||||
</span>
|
||||
.
|
||||
</LeadText>
|
||||
</Reveal>
|
||||
</div>
|
||||
|
||||
<Reveal delay={0.3}>
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-8 md:gap-12 py-12 md:py-20 border-y border-slate-100">
|
||||
<div className="space-y-12">
|
||||
<Reveal delay={0.3} direction="up">
|
||||
<ArchitectureVisualizer />
|
||||
</Reveal>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{[
|
||||
{
|
||||
label: "Kein CMS-Zwang",
|
||||
desc: "Sie müssen kein System lernen. Ich übernehme alles.",
|
||||
label: "Next.js",
|
||||
sub: "Architecture",
|
||||
desc: "React-Framework für maximale SEO & Speed.",
|
||||
},
|
||||
{
|
||||
label: "Fixpreis",
|
||||
desc: "Klare Kosten von Anfang an. Keine versteckten Gebühren.",
|
||||
label: "Docker",
|
||||
sub: "Infrastructure",
|
||||
desc: "Reproduzierbare Umgebungen überall.",
|
||||
},
|
||||
{
|
||||
label: "Pfeilschnell",
|
||||
desc: "Keine langen Ladezeiten. Kunden bleiben auf der Seite.",
|
||||
label: "Directus",
|
||||
sub: "Management",
|
||||
desc: "Headless CMS für flexible Datenabfrage.",
|
||||
},
|
||||
{
|
||||
label: "Sorglos",
|
||||
desc: "Hosting, Sicherheit, Backups? Darum kümmere ich mich.",
|
||||
label: "Gitea",
|
||||
sub: "Pipeline",
|
||||
desc: "Self-hosted Git & CI/CD Pipelines.",
|
||||
},
|
||||
].map((item, i) => (
|
||||
<div key={i} className="space-y-4">
|
||||
<Label className="text-slate-900 text-lg">{item.label}</Label>
|
||||
<BodyText className="text-slate-500">{item.desc}</BodyText>
|
||||
</div>
|
||||
<Reveal key={i} delay={0.4 + i * 0.1}>
|
||||
<div className="space-y-2 p-6 rounded-2xl border border-slate-50 bg-white shadow-sm hover:border-slate-200 transition-all group">
|
||||
<Label className="text-slate-900 group-hover:text-blue-600 transition-colors uppercase tracking-widest text-[10px]">
|
||||
{item.label}
|
||||
</Label>
|
||||
<BodyText className="text-xs text-slate-400">
|
||||
{item.desc}
|
||||
</BodyText>
|
||||
</div>
|
||||
</Reveal>
|
||||
))}
|
||||
</div>
|
||||
</Reveal>
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{/* 02: Performance */}
|
||||
<Section number="02" title="Schnelligkeit" borderTop>
|
||||
<div className="space-y-16 md:space-y-32">
|
||||
<Section
|
||||
number="02"
|
||||
title="Performance"
|
||||
borderTop
|
||||
variant="gray"
|
||||
illustration={<SpeedPerformance className="w-24 h-24" />}
|
||||
effects={<GradientMesh variant="metallic" className="opacity-60" />}
|
||||
>
|
||||
<div className="space-y-8 md:space-y-12">
|
||||
<Reveal>
|
||||
<H3 className="text-4xl md:text-7xl lg:text-8xl leading-none tracking-tighter max-w-4xl">
|
||||
Niemand wartet gerne. <br />
|
||||
<H3 className="text-2xl md:text-5xl leading-tight max-w-3xl">
|
||||
Geschwindigkeit ist <br />
|
||||
<span className="text-slate-400">
|
||||
Ihre Kunden erst recht nicht.
|
||||
kein Extra. Sie ist <Marker delay={0.3}>Standard.</Marker>
|
||||
</span>
|
||||
</H3>
|
||||
</Reveal>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-12 md:gap-24">
|
||||
<Reveal delay={0.2}>
|
||||
<LeadText className="text-xl md:text-2xl text-slate-500">
|
||||
Langsame Websites kosten bares Geld. Wenn Ihre Seite nicht
|
||||
sofort lädt, ist der Besucher schon bei der Konkurrenz. Meine
|
||||
Websites sind auf maximale Geschwindigkeit optimiert.
|
||||
</LeadText>
|
||||
</Reveal>
|
||||
<Reveal delay={0.3}>
|
||||
<div className="space-y-8 border-l border-slate-100 pl-8 md:pl-12">
|
||||
{[
|
||||
"Sofortiger Seitenaufbau ohne Verzögerung",
|
||||
"Automatische Bild-Optimierung für Smartphones",
|
||||
"Bessere Google-Platzierung (SEO) durch Top-Speed",
|
||||
].map((item, i) => (
|
||||
<div key={i} className="space-y-2">
|
||||
<Label className="text-slate-900">0{i + 1}</Label>
|
||||
<BodyText className="text-slate-500 text-lg">
|
||||
{item}
|
||||
</BodyText>
|
||||
<div className="grid grid-cols-1 md:grid-cols-12 gap-8 md:gap-12 items-center">
|
||||
<div className="md:col-span-12 lg:col-span-7 space-y-6 md:space-y-8">
|
||||
<Reveal delay={0.2}>
|
||||
<LeadText className="text-lg md:text-xl text-slate-500 md:text-slate-400">
|
||||
Jede Seite wird vorab gerendert und über ein CDN ausgeliefert.
|
||||
Das Ergebnis: Ladezeiten unter einer Sekunde. Messbar.{" "}
|
||||
<span className="text-slate-900">Reproduzierbar.</span>
|
||||
</LeadText>
|
||||
</Reveal>
|
||||
<Reveal delay={0.4}>
|
||||
<IconList className="space-y-2 md:space-y-4">
|
||||
{[
|
||||
"Server-Side Rendering für sofortige Inhalte",
|
||||
"Automatische Bild-Optimierung (WebP, AVIF)",
|
||||
"Lighthouse-Score 90+ als Mindeststandard",
|
||||
"Core Web Vitals im grünen Bereich",
|
||||
].map((item, i) => (
|
||||
<IconListItem key={i} bullet>
|
||||
<LeadText className="text-base md:text-xl">
|
||||
{item}
|
||||
</LeadText>
|
||||
</IconListItem>
|
||||
))}
|
||||
</IconList>
|
||||
</Reveal>
|
||||
</div>
|
||||
<div className="md:col-span-5">
|
||||
<Reveal delay={0.6}>
|
||||
<Card
|
||||
variant="glass"
|
||||
padding="normal"
|
||||
techBorder
|
||||
className="text-center group py-10 md:py-12"
|
||||
>
|
||||
<div className="text-5xl md:text-8xl font-bold text-slate-900 tracking-tighter group-hover:scale-110 transition-transform duration-700">
|
||||
90+
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Reveal>
|
||||
<Label className="mt-4">Lighthouse Score</Label>
|
||||
<span className="block text-[8px] md:text-[9px] font-mono text-slate-300 mt-2 tracking-wider">
|
||||
PERFORMANCE · ACCESSIBILITY · SEO
|
||||
</span>
|
||||
</Card>
|
||||
</Reveal>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{/* 03: Code-Qualität */}
|
||||
<Section number="03" title="Qualität" borderTop>
|
||||
<div className="space-y-16 md:space-y-32">
|
||||
<Section
|
||||
number="03"
|
||||
title="Code"
|
||||
borderTop
|
||||
illustration={<SolidFoundation className="w-24 h-24" />}
|
||||
>
|
||||
<div className="space-y-8 md:space-y-12">
|
||||
<Reveal>
|
||||
<H3 className="text-4xl md:text-7xl lg:text-8xl leading-none tracking-tighter max-w-4xl">
|
||||
Handarbeit statt <br />
|
||||
<span className="text-slate-400">Fertigbausatz.</span>
|
||||
<H3 className="text-2xl md:text-5xl leading-tight max-w-3xl">
|
||||
Keine Plugins. <br />
|
||||
<span className="text-slate-400">Keine Abhängigkeiten.</span>
|
||||
</H3>
|
||||
</Reveal>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-12 md:gap-24">
|
||||
<Reveal delay={0.2}>
|
||||
<LeadText className="text-xl md:text-2xl text-slate-500">
|
||||
Vergessen Sie Sicherheitslücken durch veraltete Plugins oder
|
||||
Themes, die ständig aktualisiert werden müssen.
|
||||
</LeadText>
|
||||
</Reveal>
|
||||
<Reveal delay={0.3}>
|
||||
<div className="space-y-12">
|
||||
<div>
|
||||
<Label className="text-slate-900 text-xl mb-4 block">
|
||||
Saubere Programmierung
|
||||
</Label>
|
||||
<BodyText className="text-slate-500 text-lg">
|
||||
Ihre Website wird von Grund auf solide entwickelt. Kein
|
||||
unnötiger Ballast, der die Seite verlangsamt oder angreifbar
|
||||
macht.
|
||||
</BodyText>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-slate-900 text-xl mb-4 block">
|
||||
Zukunftssicher
|
||||
</Label>
|
||||
<BodyText className="text-slate-500 text-lg">
|
||||
Moderne Technologien garantieren, dass Ihre Website auch in
|
||||
ein paar Jahren noch perfekt und fehlerfrei läuft.
|
||||
</BodyText>
|
||||
</div>
|
||||
</div>
|
||||
</Reveal>
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{/* 04: Sorglos Paket */}
|
||||
<Section number="04" title="Sorglos-Paket" borderTop>
|
||||
<div className="space-y-16 md:space-y-32">
|
||||
<Reveal>
|
||||
<H3 className="text-4xl md:text-7xl lg:text-8xl leading-none tracking-tighter max-w-4xl">
|
||||
Das Prinzip: <br />
|
||||
<span className="text-slate-400">Null Aufwand.</span>
|
||||
</H3>
|
||||
<Reveal delay={0.2}>
|
||||
<LeadText className="text-xl md:text-2xl max-w-2xl text-slate-500 md:text-slate-400">
|
||||
Ihre Website besteht aus{" "}
|
||||
<span className="text-slate-900">Ihrem Code</span>. Kein
|
||||
WordPress, kein Wix, keine Blackbox. Alles versioniert, alles
|
||||
nachvollziehbar.
|
||||
</LeadText>
|
||||
</Reveal>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-12 md:gap-24 items-start">
|
||||
<Reveal delay={0.2}>
|
||||
<LeadText className="text-2xl md:text-4xl text-slate-900 leading-snug">
|
||||
Sie haben Besseres zu tun, als Bilder zu tauschen oder Updates
|
||||
zu installieren.
|
||||
</LeadText>
|
||||
{/* Git Branch Visualization */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
|
||||
<Reveal delay={0.4}>
|
||||
<CodeSnippet variant="git" />
|
||||
</Reveal>
|
||||
|
||||
<Reveal delay={0.3}>
|
||||
<div className="space-y-8">
|
||||
<BodyText className="text-slate-500 text-lg md:text-xl leading-relaxed">
|
||||
Mein Sorglos-Paket deckt alles für ein ganzes Jahr ab. Wenn
|
||||
Sie eine Änderung brauchen, schreiben Sie mir einfach eine
|
||||
E-Mail. Kein kompliziertes System, keine Schulungen. Ich setze
|
||||
es für Sie um.
|
||||
</BodyText>
|
||||
<div className="pt-8 border-t border-slate-100 flex gap-8">
|
||||
<div>
|
||||
<Label className="text-slate-900">Inklusive</Label>
|
||||
<BodyText className="text-slate-500 mt-2">
|
||||
Änderungen & Wartung
|
||||
<Reveal delay={0.5}>
|
||||
<div className="space-y-4 md:space-y-6">
|
||||
<Card variant="glass" padding="normal" className="group">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-slate-900">Versionskontrolle</Label>
|
||||
<BodyText>
|
||||
Jede Änderung ist dokumentiert. Rollbacks in Sekunden.
|
||||
Kein „wer hat das kaputt gemacht?".
|
||||
</BodyText>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-slate-900">Ihr Stresslevel</Label>
|
||||
<BodyText className="text-slate-500 mt-2">0%</BodyText>
|
||||
</Card>
|
||||
<Card variant="glass" padding="normal" className="group">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-slate-900">
|
||||
Automatisches Deployment
|
||||
</Label>
|
||||
<BodyText>
|
||||
Code wird geprüft, getestet und automatisch live
|
||||
geschaltet. Ohne manuellen Eingriff.
|
||||
</BodyText>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</Reveal>
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{/* 05: CTA */}
|
||||
{/* 04: Content-System */}
|
||||
<Section
|
||||
number="04"
|
||||
title="Inhalte"
|
||||
borderTop
|
||||
variant="gray"
|
||||
illustration={<LayerSeparation className="w-24 h-24" />}
|
||||
effects={<GradientMesh variant="subtle" className="opacity-60" />}
|
||||
>
|
||||
<div className="space-y-12 md:space-y-20">
|
||||
<div className="space-y-6 md:space-y-10 max-w-5xl">
|
||||
<Reveal>
|
||||
<div className="space-y-4">
|
||||
<MonoLabel className="text-blue-500 tracking-[0.2em] text-[10px] md:text-xs">
|
||||
ARCHITECTURAL SEPARATION
|
||||
</MonoLabel>
|
||||
<H3 className="text-4xl md:text-7xl leading-[1.1] tracking-tighter">
|
||||
Inhalte pflegen <br />
|
||||
<span className="text-slate-400 italic font-serif">
|
||||
ohne Angst.
|
||||
</span>
|
||||
</H3>
|
||||
</div>
|
||||
</Reveal>
|
||||
|
||||
<Reveal delay={0.2}>
|
||||
<div className="space-y-6">
|
||||
<LeadText className="text-lg md:text-2xl text-slate-500 md:text-slate-400 leading-relaxed max-w-3xl">
|
||||
Vergessen Sie zerschossene Layouts nach einem Textupdate.
|
||||
Meine Websites trennen{" "}
|
||||
<span className="text-slate-900 font-bold underline decoration-blue-500/30 underline-offset-8">
|
||||
Daten von Design
|
||||
</span>
|
||||
.
|
||||
</LeadText>
|
||||
</div>
|
||||
</Reveal>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-12 gap-12 items-start">
|
||||
<div className="lg:col-span-8 relative">
|
||||
<Reveal delay={0.4} direction="up">
|
||||
<CMSVisualizer className="w-full mx-auto" />
|
||||
</Reveal>
|
||||
</div>
|
||||
|
||||
<div className="lg:col-span-4 space-y-8">
|
||||
<Reveal delay={0.5}>
|
||||
<div className="space-y-4">
|
||||
<BodyText className="text-slate-500 leading-relaxed">
|
||||
Durch eine krisenfeste Headless-Architektur (Directus)
|
||||
bewegen Sie sich in einem geschützten Sandkasten – während
|
||||
das Frontend-System die visuelle Integrität Ihrer Marke
|
||||
garantiert.
|
||||
</BodyText>
|
||||
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{["Layout-Schutz", "Live-Vorschau", "Role-RBAC"].map(
|
||||
(tag, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="px-3 py-1 bg-white border border-slate-100 rounded-full text-[10px] font-mono text-slate-400"
|
||||
>
|
||||
{tag}
|
||||
</div>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Reveal>
|
||||
|
||||
<Reveal delay={0.6}>
|
||||
<div className="p-6 bg-slate-900 rounded-2xl shadow-xl text-[10px] font-mono text-white/50 space-y-3">
|
||||
<div className="flex justify-between items-center text-white/90">
|
||||
<span>PROTOCOL</span>
|
||||
<span className="text-green-500 font-bold">ENFORCED</span>
|
||||
</div>
|
||||
<p className="leading-tight">
|
||||
Website architecture validates all CMS payloads against the
|
||||
design schema before rendering.
|
||||
</p>
|
||||
<div className="pt-2 border-t border-white/10 flex items-center justify-between">
|
||||
<span>INTEGRITY</span>
|
||||
<span className="text-white">100%</span>
|
||||
</div>
|
||||
</div>
|
||||
</Reveal>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Reveal delay={0.7}>
|
||||
<div className="p-px w-full bg-gradient-to-r from-transparent via-slate-100 to-transparent" />
|
||||
</Reveal>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{/* 05: Was Sie bekommen */}
|
||||
<Section
|
||||
number="05"
|
||||
title="Ergebnis"
|
||||
borderTop
|
||||
effects={<GradientMesh variant="metallic" className="opacity-60" />}
|
||||
illustration={<TaskDone className="w-24 h-24" />}
|
||||
>
|
||||
<div className="py-12 md:py-24 relative z-10">
|
||||
<div className="space-y-16 md:space-y-24">
|
||||
<div className="space-y-12 md:space-y-24">
|
||||
<div className="max-w-4xl space-y-6">
|
||||
<Reveal>
|
||||
<H3 className="text-5xl md:text-8xl lg:text-9xl leading-none tracking-tighter">
|
||||
Ihre fertige <br />
|
||||
<span className="text-slate-400">
|
||||
<GlitchText delay={0.5} duration={1}>
|
||||
Website.
|
||||
</GlitchText>
|
||||
</span>
|
||||
<H3 className="text-4xl md:text-7xl leading-[1.1] tracking-tighter">
|
||||
Was Sie konkret <br />
|
||||
<span className="text-slate-400">bekommen.</span>
|
||||
</H3>
|
||||
</Reveal>
|
||||
|
||||
<Reveal delay={0.2}>
|
||||
<div className="flex flex-col md:flex-row justify-between items-start md:items-center gap-12">
|
||||
<LeadText className="text-xl md:text-3xl text-slate-500 max-w-xl">
|
||||
Kein Stress, kein Overhead. Schreiben Sie mir einfach eine
|
||||
Nachricht.
|
||||
</LeadText>
|
||||
<Button
|
||||
href="/contact"
|
||||
className="w-full md:w-auto px-12 py-6 text-xl"
|
||||
>
|
||||
Sagen Sie Hallo
|
||||
</Button>
|
||||
</div>
|
||||
<LeadText className="text-lg md:text-2xl text-slate-500 md:text-slate-400 max-w-2xl">
|
||||
Keine halben Sachen. Ich liefere Ihnen ein schlüsselfertiges
|
||||
System mit voller Kontrolle und Transparenz.
|
||||
</LeadText>
|
||||
</Reveal>
|
||||
</div>
|
||||
|
||||
<Reveal delay={0.3} direction="up">
|
||||
<ResultVisualizer />
|
||||
</Reveal>
|
||||
|
||||
<Reveal delay={0.5}>
|
||||
<div className="pt-10 md:pt-16 border-t border-slate-100 flex flex-col md:flex-row justify-between items-start md:items-center gap-12">
|
||||
<div className="space-y-3">
|
||||
<MonoLabel className="text-blue-500">
|
||||
BEREIT FÜR DEN NÄCHSTEN SCHRITT?
|
||||
</MonoLabel>
|
||||
<div className="text-xl md:text-3xl font-bold tracking-tight text-slate-900">
|
||||
Lassen Sie uns über Ihr Projekt sprechen.
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
href="/contact"
|
||||
className="w-full md:w-auto h-16 px-10 text-lg rounded-2xl shadow-2xl shadow-blue-500/10"
|
||||
>
|
||||
Projekt anfragen
|
||||
</Button>
|
||||
</div>
|
||||
</Reveal>
|
||||
</div>
|
||||
</Section>
|
||||
</div>
|
||||
|
||||
381
apps/web/app/api/agent-chat/route.ts
Normal file
381
apps/web/app/api/agent-chat/route.ts
Normal file
@@ -0,0 +1,381 @@
|
||||
import { NextResponse, NextRequest } from 'next/server';
|
||||
import redis from '../../../src/lib/redis';
|
||||
import * as Sentry from '@sentry/nextjs';
|
||||
import {
|
||||
PRICING,
|
||||
initialState,
|
||||
PAGE_SAMPLES,
|
||||
FEATURE_OPTIONS,
|
||||
FUNCTION_OPTIONS,
|
||||
API_OPTIONS,
|
||||
ASSET_OPTIONS,
|
||||
DESIGN_OPTIONS,
|
||||
EMPLOYEE_OPTIONS,
|
||||
DEADLINE_LABELS,
|
||||
} from '../../../src/logic/pricing/constants';
|
||||
|
||||
// Rate limiting
|
||||
const RATE_LIMIT_POINTS = 10;
|
||||
const RATE_LIMIT_DURATION = 60;
|
||||
|
||||
// Tool definitions for Mistral
|
||||
const TOOLS = [
|
||||
{
|
||||
type: 'function' as const,
|
||||
function: {
|
||||
name: 'update_company_info',
|
||||
description: 'Aktualisiert Firmen-/Kontaktinformationen des Kunden. Nutze dieses Tool wenn der Nutzer seinen Namen, seine Firma oder Mitarbeiterzahl nennt.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
companyName: { type: 'string', description: 'Firmenname' },
|
||||
name: { type: 'string', description: 'Name des Ansprechpartners' },
|
||||
employeeCount: {
|
||||
type: 'string',
|
||||
enum: EMPLOYEE_OPTIONS.map((e) => e.id),
|
||||
description: 'Mitarbeiterzahl',
|
||||
},
|
||||
existingWebsite: { type: 'string', description: 'URL der bestehenden Website' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'function' as const,
|
||||
function: {
|
||||
name: 'update_project_type',
|
||||
description: 'Setzt den Projekttyp. Nutze dieses Tool wenn klar wird ob es eine Website oder Web-App wird.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
projectType: {
|
||||
type: 'string',
|
||||
enum: ['website', 'web-app'],
|
||||
description: 'Art des Projekts',
|
||||
},
|
||||
},
|
||||
required: ['projectType'],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'function' as const,
|
||||
function: {
|
||||
name: 'show_page_selector',
|
||||
description: 'Zeigt dem Nutzer eine interaktive Auswahl der verfügbaren Seiten-Typen. Nutze dieses Tool wenn über die Struktur/Seiten der Website gesprochen wird.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
preselected: {
|
||||
type: 'array',
|
||||
items: { type: 'string' },
|
||||
description: 'Bereits ausgewählte Seiten-IDs basierend auf dem Gespräch',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'function' as const,
|
||||
function: {
|
||||
name: 'show_feature_selector',
|
||||
description: 'Zeigt dem Nutzer eine interaktive Auswahl der verfügbaren Features (Blog, Produkte, Jobs, Cases, Events). Nutze dieses Tool wenn über Inhalts-Bereiche gesprochen wird.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
preselected: {
|
||||
type: 'array',
|
||||
items: { type: 'string' },
|
||||
description: 'Vorausgewählte Feature-IDs',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'function' as const,
|
||||
function: {
|
||||
name: 'show_function_selector',
|
||||
description: 'Zeigt dem Nutzer eine interaktive Auswahl der technischen Funktionen (Suche, Filter, PDF, Formulare). Nutze dieses Tool wenn über technische Anforderungen gesprochen wird.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
preselected: {
|
||||
type: 'array',
|
||||
items: { type: 'string' },
|
||||
description: 'Vorausgewählte Funktions-IDs',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'function' as const,
|
||||
function: {
|
||||
name: 'show_api_selector',
|
||||
description: 'Zeigt dem Nutzer eine interaktive Auswahl der System-Integrationen (CRM, ERP, Payment, etc.). Nutze dieses Tool wenn über Drittanbieter-Anbindungen gesprochen wird.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
preselected: {
|
||||
type: 'array',
|
||||
items: { type: 'string' },
|
||||
description: 'Vorausgewählte API-IDs',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'function' as const,
|
||||
function: {
|
||||
name: 'show_asset_selector',
|
||||
description: 'Zeigt dem Nutzer eine Auswahl welche Assets bereits vorhanden sind (Logo, Styleguide, Bilder etc.). Nutze dieses Tool wenn über vorhandenes Material gesprochen wird.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
preselected: {
|
||||
type: 'array',
|
||||
items: { type: 'string' },
|
||||
description: 'Vorausgewählte Asset-IDs',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'function' as const,
|
||||
function: {
|
||||
name: 'show_design_picker',
|
||||
description: 'Zeigt dem Nutzer eine visuelle Design-Stil-Auswahl. Nutze dieses Tool wenn über das Design oder den visuellen Stil gesprochen wird.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
preselected: { type: 'string', description: 'Vorausgewählter Design-Stil' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'function' as const,
|
||||
function: {
|
||||
name: 'show_timeline_picker',
|
||||
description: 'Zeigt dem Nutzer eine Timeline/Deadline-Auswahl. Nutze dieses Tool wenn über Zeitrahmen oder Deadlines gesprochen wird.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
preselected: { type: 'string', description: 'Vorausgewählte Deadline' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'function' as const,
|
||||
function: {
|
||||
name: 'show_contact_fields',
|
||||
description: 'Zeigt dem Nutzer Eingabefelder für E-Mail-Adresse und optionale Nachricht. Nutze dieses Tool wenn es Zeit ist die Kontaktdaten zu sammeln, typischerweise gegen Ende des Gesprächs.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'function' as const,
|
||||
function: {
|
||||
name: 'request_file_upload',
|
||||
description: 'Zeigt dem Nutzer einen Datei-Upload-Bereich. Nutze dieses Tool wenn der Nutzer Dateien teilen möchte (Briefing, Sitemap, Design-Referenzen etc.).',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
label: { type: 'string', description: 'Beschriftung des Upload-Bereichs' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'function' as const,
|
||||
function: {
|
||||
name: 'show_estimate_preview',
|
||||
description: 'Zeigt dem Nutzer eine Live-Kostenübersicht basierend auf dem aktuellen Konfigurationsstand. Nutze dieses Tool wenn genügend Informationen gesammelt wurden oder wenn der Nutzer nach Kosten fragt.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'function' as const,
|
||||
function: {
|
||||
name: 'generate_estimate_pdf',
|
||||
description: 'Generiert ein PDF-Angebot basierend auf dem aktuellen Konfigurationsstand. Nutze dieses Tool wenn der Nutzer ein Angebot/PDF möchte oder das Gespräch abgeschlossen wird.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'function' as const,
|
||||
function: {
|
||||
name: 'submit_inquiry',
|
||||
description: 'Sendet die Anfrage ab und benachrichtigt Marc Mintel. Nutze dieses Tool wenn der Nutzer explizit absenden möchte und mindestens Name + Email vorhanden sind.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
// Available options for the system prompt
|
||||
const availableOptions = `
|
||||
VERFÜGBARE SEITEN: ${PAGE_SAMPLES.map((p) => `${p.id} (${p.label})`).join(', ')}
|
||||
VERFÜGBARE FEATURES: ${FEATURE_OPTIONS.map((f) => `${f.id} (${f.label})`).join(', ')}
|
||||
VERFÜGBARE FUNKTIONEN: ${FUNCTION_OPTIONS.map((f) => `${f.id} (${f.label})`).join(', ')}
|
||||
VERFÜGBARE API-INTEGRATIONEN: ${API_OPTIONS.map((a) => `${a.id} (${a.label})`).join(', ')}
|
||||
VERFÜGBARE ASSETS: ${ASSET_OPTIONS.map((a) => `${a.id} (${a.label})`).join(', ')}
|
||||
VERFÜGBARE DESIGN-STILE: ${DESIGN_OPTIONS.map((d) => `${d.id} (${d.label})`).join(', ')}
|
||||
DEADLINES: ${Object.entries(DEADLINE_LABELS).map(([k, v]) => `${k} (${v})`).join(', ')}
|
||||
MITARBEITER: ${EMPLOYEE_OPTIONS.map((e) => `${e.id} (${e.label})`).join(', ')}
|
||||
|
||||
PREISE (netto):
|
||||
- Basis Website: ${PRICING.BASE_WEBSITE}€
|
||||
- Pro Seite: ${PRICING.PAGE}€
|
||||
- Pro Feature: ${PRICING.FEATURE}€
|
||||
- Pro Funktion: ${PRICING.FUNCTION}€
|
||||
- API-Integration: ${PRICING.API_INTEGRATION}€
|
||||
- CMS Setup: ${PRICING.CMS_SETUP}€
|
||||
- Hosting monatlich: ${PRICING.HOSTING_MONTHLY}€
|
||||
`;
|
||||
|
||||
const SYSTEM_PROMPT = `Du bist ein professioneller Projektberater der Digitalagentur "Mintel" – spezialisiert auf Next.js, Payload CMS und moderne Web-Infrastruktur.
|
||||
|
||||
DEINE AUFGABE:
|
||||
Du führst ein natürliches Beratungsgespräch, um alle Informationen für eine Website-/Web-App-Projektschätzung zu sammeln. Du bist freundlich, kompetent und effizient.
|
||||
|
||||
GESPRÄCHSFÜHRUNG:
|
||||
1. Begrüße den Nutzer und frage nach seinem Namen und Unternehmen.
|
||||
2. Finde heraus, was für ein Projekt es wird (Website oder Web-App).
|
||||
3. Sammle schrittweise die Anforderungen – NICHT alle auf einmal fragen!
|
||||
4. Pro Nachricht maximal 1-2 Themen ansprechen.
|
||||
5. Nutze die verfügbaren Tools um interaktive Auswahl-Widgets zu zeigen.
|
||||
6. Wenn du genug Informationen hast, zeige eine Kostenübersicht.
|
||||
7. Biete an, ein PDF-Angebot zu generieren.
|
||||
8. Sammle am Ende Kontaktdaten und biete an die Anfrage abzusenden.
|
||||
|
||||
WICHTIGE REGELN:
|
||||
- ANTWORTE IN DER SPRACHE DES NUTZERS (Deutsch/Englisch).
|
||||
- Halte Antworten kurz und natürlich (2-4 Sätze pro Nachricht).
|
||||
- Zeige Widgets über Tool-Calls – nicht als Text-Listen.
|
||||
- Wenn der Nutzer eine konkrete Auswahl trifft müssen wir das über die passenden UI-Tools machen, bestätige kurz und gehe zum nächsten Thema.
|
||||
- Du darfst mehrere Tools gleichzeitig aufrufen wenn es sinnvoll ist.
|
||||
- Sei proaktiv: Wenn der Nutzer sagt "ich brauche eine Website für mein Restaurant", sag nicht nur "ok", sondern schlage direkt passende Seiten vor (Home, About, Speisekarte, Kontakt, Impressum) und zeige den Seiten-Selektor.
|
||||
|
||||
${availableOptions}
|
||||
|
||||
AKTUELLER FORMSTATE (wird vom Frontend mitgeliefert):
|
||||
Wird in jeder Nachricht als JSON übergeben.`;
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const { messages, formState, visitorId, honeypot } = await req.json();
|
||||
|
||||
// Validation
|
||||
if (!messages || !Array.isArray(messages) || messages.length === 0) {
|
||||
return NextResponse.json({ error: 'Valid messages array is required' }, { status: 400 });
|
||||
}
|
||||
|
||||
const latestMessage = messages[messages.length - 1].content;
|
||||
|
||||
// Honeypot
|
||||
if (honeypot && honeypot.length > 0) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 3000));
|
||||
return NextResponse.json({
|
||||
message: 'Vielen Dank für Ihre Anfrage.',
|
||||
toolCalls: [],
|
||||
});
|
||||
}
|
||||
|
||||
// Rate Limiting
|
||||
try {
|
||||
if (visitorId) {
|
||||
const requestCount = await redis.incr(`agent_chat_rate_limit:${visitorId}`);
|
||||
if (requestCount === 1) {
|
||||
await redis.expire(`agent_chat_rate_limit:${visitorId}`, RATE_LIMIT_DURATION);
|
||||
}
|
||||
if (requestCount > RATE_LIMIT_POINTS) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Rate limit exceeded. Please try again later.' },
|
||||
{ status: 429 },
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (redisError) {
|
||||
console.error('Redis Rate Limiting Error:', redisError);
|
||||
Sentry.captureException(redisError, { tags: { context: 'agent-chat-rate-limit' } });
|
||||
}
|
||||
|
||||
// Build messages for OpenRouter
|
||||
const systemMessage = {
|
||||
role: 'system',
|
||||
content: `${SYSTEM_PROMPT}\n\nAKTUELLER FORMSTATE:\n${JSON.stringify(formState || initialState, null, 2)}`,
|
||||
};
|
||||
|
||||
const openRouterKey = process.env.OPENROUTER_API_KEY;
|
||||
if (!openRouterKey) {
|
||||
throw new Error('OPENROUTER_API_KEY is not set');
|
||||
}
|
||||
|
||||
const fetchRes = await fetch('https://openrouter.ai/api/v1/chat/completions', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${openRouterKey}`,
|
||||
'Content-Type': 'application/json',
|
||||
'HTTP-Referer': process.env.NEXT_PUBLIC_BASE_URL || 'https://mintel.me',
|
||||
'X-Title': 'Mintel.me Project Agent',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: 'mistralai/mistral-large-2407',
|
||||
temperature: 0.4,
|
||||
tools: TOOLS,
|
||||
tool_choice: 'auto',
|
||||
messages: [
|
||||
systemMessage,
|
||||
...messages.map((m: any) => ({
|
||||
role: m.role,
|
||||
content: typeof m.content === 'string' ? m.content : JSON.stringify(m.content),
|
||||
...(m.tool_calls ? { tool_calls: m.tool_calls } : {}),
|
||||
...(m.tool_call_id ? { tool_call_id: m.tool_call_id } : {}),
|
||||
})),
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
if (!fetchRes.ok) {
|
||||
const errBody = await fetchRes.text();
|
||||
throw new Error(`OpenRouter API Error: ${errBody}`);
|
||||
}
|
||||
|
||||
const data = await fetchRes.json();
|
||||
const choice = data.choices[0];
|
||||
const responseMessage = choice.message;
|
||||
|
||||
// Extract tool calls
|
||||
const toolCalls = responseMessage.tool_calls?.map((tc: any) => ({
|
||||
id: tc.id,
|
||||
name: tc.function.name,
|
||||
arguments: JSON.parse(tc.function.arguments || '{}'),
|
||||
})) || [];
|
||||
|
||||
return NextResponse.json({
|
||||
message: responseMessage.content || '',
|
||||
toolCalls,
|
||||
rawToolCalls: responseMessage.tool_calls || [],
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Agent Chat API Error:', error);
|
||||
Sentry.captureException(error, { tags: { context: 'agent-chat-api' } });
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
140
apps/web/app/api/ai-search/route.ts
Normal file
140
apps/web/app/api/ai-search/route.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
import { NextResponse, NextRequest } from 'next/server';
|
||||
import { searchPosts } from '../../../src/lib/qdrant';
|
||||
import redis from '../../../src/lib/redis';
|
||||
import * as Sentry from '@sentry/nextjs';
|
||||
|
||||
// Rate limiting constants
|
||||
const RATE_LIMIT_POINTS = 5; // 5 requests
|
||||
const RATE_LIMIT_DURATION = 60; // per 1 minute
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const { messages, visitorId, honeypot } = await req.json();
|
||||
|
||||
// 1. Basic Validation
|
||||
if (!messages || !Array.isArray(messages) || messages.length === 0) {
|
||||
return NextResponse.json({ error: 'Valid messages array is required' }, { status: 400 });
|
||||
}
|
||||
|
||||
const latestMessage = messages[messages.length - 1].content;
|
||||
const isBot = honeypot && honeypot.length > 0;
|
||||
|
||||
if (latestMessage.length > 500) {
|
||||
return NextResponse.json({ error: 'Message too long' }, { status: 400 });
|
||||
}
|
||||
|
||||
// 2. Honeypot check
|
||||
if (isBot) {
|
||||
console.warn('Honeypot triggered in AI search');
|
||||
await new Promise((resolve) => setTimeout(resolve, 3000));
|
||||
return NextResponse.json({
|
||||
answerText: 'Vielen Dank für Ihre Anfrage.',
|
||||
posts: [],
|
||||
});
|
||||
}
|
||||
|
||||
// 3. Rate Limiting via Redis
|
||||
try {
|
||||
if (visitorId) {
|
||||
const requestCount = await redis.incr(`ai_search_rate_limit:${visitorId}`);
|
||||
if (requestCount === 1) {
|
||||
await redis.expire(`ai_search_rate_limit:${visitorId}`, RATE_LIMIT_DURATION);
|
||||
}
|
||||
|
||||
if (requestCount > RATE_LIMIT_POINTS) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Rate limit exceeded. Please try again later.' },
|
||||
{ status: 429 },
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (redisError) {
|
||||
console.error('Redis Rate Limiting Error:', redisError);
|
||||
Sentry.captureException(redisError, { tags: { context: 'ai-search-rate-limit' } });
|
||||
// Fail open if Redis is down
|
||||
}
|
||||
|
||||
// 4. Fetch Context from Qdrant
|
||||
let contextStr = '';
|
||||
let foundPosts: any[] = [];
|
||||
|
||||
try {
|
||||
const searchResults = await searchPosts(latestMessage, 5);
|
||||
|
||||
if (searchResults && searchResults.length > 0) {
|
||||
const postDescriptions = searchResults
|
||||
.map((p: any) => p.payload?.content)
|
||||
.join('\n\n');
|
||||
|
||||
contextStr = `BLOG-POSTS & WISSEN:\n${postDescriptions}`;
|
||||
|
||||
foundPosts = searchResults
|
||||
.filter((p: any) => p.payload?.data)
|
||||
.map((p: any) => p.payload?.data);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Qdrant Search Error:', e);
|
||||
Sentry.captureException(e, { tags: { context: 'ai-search-qdrant' } });
|
||||
}
|
||||
|
||||
// 5. Generate AI Response via OpenRouter (Mistral)
|
||||
const systemPrompt = `Du bist ein professioneller technischer Berater der Agentur "Mintel" – einer Full-Stack Digitalagentur spezialisiert auf Next.js, Payload CMS und moderne Web-Infrastruktur.
|
||||
Deine Aufgabe ist es, Besuchern bei technischen Fragen zu helfen, basierend auf den Blog-Artikeln und dem Fachwissen der Agentur.
|
||||
|
||||
WICHTIGE REGELN:
|
||||
1. ANTWORTE IMMER IN DER SPRACHE DES BENUTZERS. Wenn der Benutzer Deutsch spricht, antworte auf Deutsch. Bei Englisch, antworte auf Englisch.
|
||||
2. Nutze das bereitgestellte BLOG-WISSEN unten, um deine Antworten zu fundieren. Verweise auf relevante Blog-Posts.
|
||||
3. Sei hilfreich, präzise und technisch versiert. Du kannst Code-Beispiele geben wenn sinnvoll.
|
||||
4. Wenn du keine passende Information findest, gib das offen zu und schlage vor, über das Kontaktformular direkt Kontakt aufzunehmen.
|
||||
5. Antworte in Markdown-Format (Überschriften, Listen, Code-Blöcke sind erlaubt).
|
||||
6. Halte Antworten kompakt aber informativ – maximal 3-4 Absätze.
|
||||
7. Oute dich als AI-Assistent von Mintel.
|
||||
|
||||
VERFÜGBARER KONTEXT:
|
||||
${contextStr ? contextStr : 'Keine spezifischen Blog-Daten für diese Anfrage gefunden.'}
|
||||
`;
|
||||
|
||||
const openRouterKey = process.env.OPENROUTER_API_KEY;
|
||||
if (!openRouterKey) {
|
||||
throw new Error('OPENROUTER_API_KEY is not set');
|
||||
}
|
||||
|
||||
const fetchRes = await fetch('https://openrouter.ai/api/v1/chat/completions', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${openRouterKey}`,
|
||||
'Content-Type': 'application/json',
|
||||
'HTTP-Referer': process.env.NEXT_PUBLIC_BASE_URL || 'https://mintel.me',
|
||||
'X-Title': 'Mintel.me AI Search',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: 'mistralai/mistral-large-2407',
|
||||
temperature: 0.3,
|
||||
messages: [
|
||||
{ role: 'system', content: systemPrompt },
|
||||
...messages.map((m: any) => ({
|
||||
role: m.role,
|
||||
content: typeof m.content === 'string' ? m.content : JSON.stringify(m.content),
|
||||
})),
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
if (!fetchRes.ok) {
|
||||
const errBody = await fetchRes.text();
|
||||
throw new Error(`OpenRouter API Error: ${errBody}`);
|
||||
}
|
||||
|
||||
const data = await fetchRes.json();
|
||||
const text = data.choices[0].message.content;
|
||||
|
||||
return NextResponse.json({
|
||||
answerText: text,
|
||||
posts: foundPosts,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('AI Search API Error:', error);
|
||||
Sentry.captureException(error, { tags: { context: 'ai-search-api' } });
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const POSTS_DIR = path.join(process.cwd(), "content/blog");
|
||||
let files = [];
|
||||
if (fs.existsSync(POSTS_DIR)) {
|
||||
files = fs.readdirSync(POSTS_DIR);
|
||||
}
|
||||
|
||||
// Revalidate the two problematic pages
|
||||
revalidatePath("/blog/website-as-a-service");
|
||||
revalidatePath("/blog/zero-overhead-agencies");
|
||||
revalidatePath("/blog"); // also bust the blog list cache
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
cwd: process.cwd(),
|
||||
postsDir: POSTS_DIR,
|
||||
postsDirExists: fs.existsSync(POSTS_DIR),
|
||||
files: files,
|
||||
message: "Cache revalidated for specific paths",
|
||||
});
|
||||
} catch (err: any) {
|
||||
return NextResponse.json({ success: false, error: err.message });
|
||||
}
|
||||
}
|
||||
42
apps/web/app/api/health/cms/route.ts
Normal file
42
apps/web/app/api/health/cms/route.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getPayload } from "payload";
|
||||
import configPromise from "@payload-config";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/**
|
||||
* Deep CMS Health Check
|
||||
* Validates that Payload CMS can actually query the database.
|
||||
* Used by post-deploy smoke tests to catch migration/schema issues.
|
||||
*/
|
||||
export async function GET() {
|
||||
const checks: Record<string, string> = {};
|
||||
|
||||
try {
|
||||
const payload = await getPayload({ config: configPromise });
|
||||
checks.init = "ok";
|
||||
|
||||
// Verify each collection can be queried (catches missing locale tables, broken migrations)
|
||||
// Adjusted for mintel.me collections
|
||||
const collections = ["posts", "projects", "media", "inquiries"] as const;
|
||||
for (const collection of collections) {
|
||||
try {
|
||||
await payload.find({ collection, limit: 1 });
|
||||
checks[collection] = "ok";
|
||||
} catch (e: any) {
|
||||
checks[collection] = `error: ${e.message?.substring(0, 100)}`;
|
||||
}
|
||||
}
|
||||
|
||||
const hasErrors = Object.values(checks).some((v) => v.startsWith("error"));
|
||||
return NextResponse.json(
|
||||
{ status: hasErrors ? "degraded" : "ok", checks },
|
||||
{ status: hasErrors ? 503 : 200 },
|
||||
);
|
||||
} catch (e: any) {
|
||||
return NextResponse.json(
|
||||
{ status: "error", message: e.message?.substring(0, 200), checks },
|
||||
{ status: 503 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
import { NextResponse, NextRequest } from "next/server";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const searchParams = request.nextUrl.searchParams;
|
||||
const pathParam = searchParams.get("path") || "/";
|
||||
const typeParam =
|
||||
(searchParams.get("type") as "page" | "layout") || "layout";
|
||||
|
||||
// Revalidate the requested path (defaults to entire app via layout)
|
||||
revalidatePath(pathParam, typeParam);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
revalidatedPath: pathParam,
|
||||
revalidatedType: typeParam,
|
||||
message: `Cache revalidated for ${pathParam} (${typeParam})`,
|
||||
});
|
||||
} catch (err: any) {
|
||||
return NextResponse.json({ success: false, error: err.message });
|
||||
}
|
||||
}
|
||||
@@ -172,7 +172,10 @@ sequenceDiagram
|
||||
Gartner schätzt, dass **40% aller CRM-Fehlschläge** auf Prozessprobleme oder menschliches Versagen zurückzuführen sind. Eine automatisierte Integration ist die wirksamste Versicherung gegen dieses Risiko. [Vermeiden Sie technische Altlasten](/blog/slow-loading-costs-customers) und machen Sie Ihre Website zu einem integralen Bestandteil Ihres Sales-Motors.
|
||||
</Paragraph>
|
||||
|
||||
|
||||
<PortfolioHeader
|
||||
title="Bereit für echte Automatisierung?"
|
||||
sub="Präzision in der Schnittstelle, Klarheit im Ergebnis."
|
||||
/>
|
||||
|
||||
<FAQSection>
|
||||
<H3>Wie lange dauert die Integration eines CRMs?</H3>
|
||||
|
||||
@@ -18,7 +18,7 @@ tags: ["management", "business"]
|
||||
|
||||
<Section>
|
||||
<H2>Inhaltsverzeichnis</H2>
|
||||
<TableOfContents />
|
||||
<TOC />
|
||||
</Section>
|
||||
|
||||
<H2>Die Falle der unendlichen Stunden</H2>
|
||||
|
||||
@@ -53,7 +53,10 @@ tags: ["performance", "seo", "conversion-optimization"]
|
||||
Google hat bestätigt, dass <ExternalLink href="https://developers.google.com/search/docs/appearance/core-web-vitals">Core Web Vitals</ExternalLink> als Ranking-Signale für Suchergebnisse genutzt werden. Wer hier rote Zahlen schreibt, wird vom Algorithmus abgestraft. Webseiten, die den Schwellenwert „Gut“ in allen Kategorien erreichen, verzeichnen laut Deloitte eine deutlich höhere Interaktion. Dennoch zeigt das HTTP Archive, dass ein signifikanter Prozentsatz der Websites die empfohlenen Grenzwerte weiterhin massiv verfehlt.
|
||||
</Paragraph>
|
||||
|
||||
<WebVitalsScore values={{ lcp: 2.1, inp: 180, cls: 0.05 }} description="Exzellente Werte signalisieren Google eine hohe Nutzerzufriedenheit und fördern das Ranking." />
|
||||
<WebVitalsScore
|
||||
values={{ lcp: 2.1, inp: 180, cls: 0.05 }}
|
||||
description="Exzellente Werte signalisieren Google eine hohe Nutzerzufriedenheit und fördern das Ranking."
|
||||
/>
|
||||
|
||||
<Paragraph>
|
||||
Dabei fokussiert sich das Framework auf drei wesentliche Säulen der User Experience, für die Google im PageSpeed Insights Tool spezifische Optimierungsvorschläge liefert:
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
title: "Wartungsfrei durch Verzicht: Warum 'No-CMS' die überlegene B2B-Architektur ist"
|
||||
thumbnail: "/blog/maintenance-for-headless-systems.png"
|
||||
title: "Wartungsfrei durch Verzicht: Warum '
|
||||
thumbnail: "/blog/maintenance-for-headless-systems.png"No-CMS' die überlegene B2B-Architektur ist"
|
||||
description: "Sicherheit und Geschwindigkeit durch architektonische Reduktion: Warum Git-basierte Workflows klassische CMS-Backends in Performance und ROI schlagen."
|
||||
date: "2026-02-01"
|
||||
tags: ["maintenance", "architecture"]
|
||||
|
||||
@@ -1,110 +0,0 @@
|
||||
---
|
||||
title: "Website as a Service: Warum das Sorglos-Paket der einzig logische Weg für Unternehmer ist"
|
||||
thumbnail: "/blog/website-as-a-service.png"
|
||||
description: "Vergessen Sie Plugin-Updates, Sicherheitslücken und endlose Stundenabrechnungen. Ein Plädoyer für Websites ohne Overhead und warum Fixpreise den Markt revolutionieren."
|
||||
date: "2026-05-06"
|
||||
tags: ["business", "management", "strategy"]
|
||||
---
|
||||
|
||||
<LeadParagraph>
|
||||
Die meisten Unternehmer wollen keine Website besitzen – sie wollen die Ergebnisse, die eine professionelle Website liefert: Sichtbarkeit, Vertrauen, qualifizierte Anfragen.
|
||||
</LeadParagraph>
|
||||
<LeadParagraph>
|
||||
Trotzdem verbringen unzählige Geschäftsführer und Marketing-Teams Stunden damit, Sicherheits-Updates einzuspielen, sich in Baukästen einzuarbeiten oder mit Support-Hotlines zu telefonieren, weil ein simples Plugin die Seite zerschossen hat. Das ist betriebswirtschaftlicher Wahnsinn.
|
||||
</LeadParagraph>
|
||||
<LeadParagraph>
|
||||
In meiner Arbeit als Digital Architect habe ich das Modell "Website as a Service" (WaaS) perfektioniert. Es ist die radikalste und fairste Antwort auf die Dysfunktionen der klassischen Webentwicklung.
|
||||
</LeadParagraph>
|
||||
|
||||
|
||||
|
||||
<H2>Der Mythos der "kostenlosen" Website</H2>
|
||||
|
||||
<Paragraph>
|
||||
Ein Baukasten-System kostet vielleicht nur 15 Euro im Monat. Ein Premium-WordPress-Theme gibt es für 50 Euro. Doch die wahren Kosten verstecken sich in der Zeit. Wenn ein Geschäftsführer oder Head of Marketing, dessen Zeit konservativ geschätzt 150 Euro pro Stunde wert ist, sich nur fünf Stunden im Monat mit technischen Problemen seiner Website beschäftigt, kostet diese Seite in Wahrheit 750 Euro im Monat.
|
||||
</Paragraph>
|
||||
|
||||
<Paragraph>
|
||||
Dazu kommt das <Marker>Opportunitätsrisiko</Marker>: Was hätte das Unternehmen in diesen fünf Stunden erreichen können? Ein entscheidendes Verkaufsgespräch mehr? Eine strategische Prozessoptimierung? Die Verwaltung von IT-Infrastruktur sollte niemals die Kernaufgabe eines Nicht-IT-Unternehmens sein.
|
||||
</Paragraph>
|
||||
|
||||
<ArticleQuote quote="The most expensive software is the one that requires your constant attention to keep it running safely." author="Digital Economics" isCompany={true} source="SaaS Industry Report" translated={false} />
|
||||
|
||||
<H2>Website as a Service: Das Ende des Overheads</H2>
|
||||
|
||||
<Paragraph>
|
||||
Der Ansatz "Website as a Service" (WaaS) – bei mir das "Sorglos-Paket" genannt – dreht dieses Modell komplett um. Sie mieten keine Software, Sie buchen eine fortlaufende Dienstleistung mit absoluter Kostensicherheit.
|
||||
</Paragraph>
|
||||
|
||||
<div className="my-12">
|
||||
<Mermaid id="waas-logic" title="Klassisches vs. WaaS Modell" showShare={true}>
|
||||
graph LR
|
||||
A["Klassisch (WordPress/Baukasten)"] --> B["Versteckte Zeitkosten"]
|
||||
B --> C["Sicherheitsrisiken"]
|
||||
C --> D["Frust bei Änderungen"]
|
||||
|
||||
E["Sorglos-Paket (WaaS)"] --> F["Fixer Jahresbeitrag"]
|
||||
F --> G["Zero Overhead"]
|
||||
G --> H["Skalierbares Business"]
|
||||
|
||||
style A fill:#f87171,stroke:#333
|
||||
style E fill:#4ade80,stroke:#333
|
||||
</Mermaid>
|
||||
</div>
|
||||
|
||||
<Paragraph>
|
||||
Bei meinem Sorglos-Paket ist die Prämisse kompromisslos einfach: <Marker>Zero Overhead für die Technik.</Marker> Sie kümmern sich um Ihr Geschäft und schreiben Ihre Fachartikel oder News ganz entspannt im Headless CMS. Alles andere – Server-Wartung, Security-Updates, Performance-Monitoring oder Layout-Anpassungen – läuft unsichtbar im Hintergrund ab. Keine zerschossenen Plugins, keine Angst vor fehlerhaften Updates.
|
||||
</Paragraph>
|
||||
|
||||
<H2>Sicherheit durch Architektur (Security by Design)</H2>
|
||||
|
||||
<Paragraph>
|
||||
Warum sind klassische CMS wie WordPress so angreifbar? Weil sie dynamisch sind. Jeder Seitenaufruf triggert eine Datenbankabfrage, und Millionen von Websites nutzen dieselben veralteten Plugins, was sie zu einem perfekten Ziel für automatisierte Hacker-Skripte macht.
|
||||
</Paragraph>
|
||||
|
||||
<Paragraph>
|
||||
In einem WaaS-Modell übernehme ich als Entwickler nicht nur die inhaltlichen Änderungen, sondern baue die Seite von Grund auf auf einer modernen, statischen Architektur (Jamstack / Next.js). Das bedeutet: Die Seite wird im Vorfeld generiert. Es gibt keine Datenbank, die live am Netz hängt und gehackt werden könnte.
|
||||
</Paragraph>
|
||||
|
||||
<ComparisonRow description="Wie unterscheidet sich WaaS von klassischem Hosting?" negativeLabel="Klassisches Hosting & CMS" negativeText="Sie sind selbst für Updates, Backups und Plugin-Kompatibilität verantwortlich. Die Seite ist ein Sicherheitsrisiko und veraltet technologisch schnell." positiveLabel="Sorglos-Paket (WaaS)" positiveText="Proaktives Monitoring, unsichtbare Updates und sofortige strukturelle Anpassungen durch einen Experten. Kein Stress, absolute Sicherheit und volle redaktionelle Kontrolle." showShare={true} />
|
||||
|
||||
<Paragraph>
|
||||
Das Ergebnis: Die Seite kann faktisch nicht gehackt werden. [Updates und Wartung](/blog/why-websites-break-after-updates) laufen unsichtbar im Hintergrund ab, und die Ladezeiten sind extrem optimiert, da nur statische Dateien (HTML/CSS/JS) vom Server ausgeliefert werden.
|
||||
</Paragraph>
|
||||
|
||||
<StatsGrid stats="0%|Einarbeitungszeit|für Sie oder Ihr Team~100%|Kostensicherheit|durch garantierte Fixpreise~99.9%|Uptime|durch statische Architektur" />
|
||||
|
||||
<H2>Finanzielle Vorhersehbarkeit statt Blackbox</H2>
|
||||
|
||||
<Paragraph>
|
||||
"Wir haben 2 Stunden gebraucht, um das Kontaktformular wieder zum Laufen zu bringen." – Solche Rechnungen gehören mit einem Service-Paket der Vergangenheit an. Ein festes Jahresbudget erlaubt Ihnen eine exakte Finanzplanung.
|
||||
</Paragraph>
|
||||
|
||||
<Paragraph>
|
||||
Diese <Marker>Budgetsicherheit</Marker> ist für B2B-Unternehmen extrem wertvoll. Unvorhergesehene Serverausfälle oder nötige Sicherheits-Patches sind nicht mehr Ihr finanzielles Risiko, sondern mein technisches. Es liegt also in meinem absoluten Eigeninteresse, die Architektur von Tag eins an so robust und fehlerfrei wie möglich zu bauen. Das ist das Prinzip des [strategischen Festpreises](/blog/fixed-price-digital-projects).
|
||||
</Paragraph>
|
||||
|
||||
<LeadMagnet title="Bereit für Zero Overhead?" description="Lassen Sie uns Ihre aktuelle Website analysieren. Ich zeige Ihnen, wie wir den Aufwand für Sie auf null reduzieren können." buttonText="Sorglos-Paket anfragen" href="/contact" variant="standard" />
|
||||
|
||||
<H2>Fazit: Kaufen Sie Zeit, keine Software</H2>
|
||||
|
||||
<Paragraph>
|
||||
Eine Website sollte ein stiller Mitarbeiter sein, der 24/7 funktioniert, Leads generiert und Ihr Unternehmen professionell repräsentiert – kein IT-Projekt, das ständig Ihre Aufmerksamkeit fordert. Ein Sorglos-Paket ist nicht einfach nur ein Wartungsvertrag. Es ist die strategische Entscheidung, technischen Ballast abzuwerfen und sich wieder zu 100% auf das eigene Kerngeschäft zu konzentrieren.
|
||||
</Paragraph>
|
||||
|
||||
<FAQSection>
|
||||
<H3>Welche Änderungen sind im Sorglos-Paket enthalten?</H3>
|
||||
<Paragraph>
|
||||
Das Paket umfasst alle **nicht-redaktionellen** Anpassungen, Wartungsarbeiten und Security-Updates. Wenn sich Ihr Angebot ändert, ein neues technisches Feature integriert werden muss oder Layout-Anpassungen nötig sind, setze ich das zeitnah um. Reine redaktionelle Aufgaben (wie das wöchentliche Schreiben neuer Blog-Posts) sind nicht inkludiert, da Sie hierfür über das Headless CMS volle Autonomie besitzen.
|
||||
</Paragraph>
|
||||
|
||||
<H3>Was passiert, wenn wir doch komplett neue Funktionen brauchen?</H3>
|
||||
<Paragraph>
|
||||
Große strukturelle Erweiterungen (z.B. der Bau eines komplett neuen Karriere-Portals oder die Integration eines komplexen CRM-Systems) werden als separates Festpreis-Projekt behandelt. Alles, was den bestehenden Rahmen nutzt, ist abgedeckt.
|
||||
</Paragraph>
|
||||
|
||||
<H3>Verlieren wir nicht die Kontrolle über die Technik?</H3>
|
||||
<Paragraph>
|
||||
Im Gegenteil: Sie gewinnen Qualitätskontrolle und Sicherheit. Sie haben über das Headless CMS die volle redaktionelle Freiheit für Ihre Inhalte. Was ich Ihnen abnehme, ist die technische Last. Sie können das Layout nicht versehentlich zerschießen und müssen keine Angst vor Datenbank-Fehlern haben. Die Technik bleibt professionell betreut und zu 100% "on brand".
|
||||
</Paragraph>
|
||||
</FAQSection>
|
||||
@@ -1,110 +0,0 @@
|
||||
---
|
||||
title: "Zero Overhead: Warum klassische Agenturen oft zu langsam (und zu teuer) sind"
|
||||
thumbnail: "/blog/zero-overhead-agencies.png"
|
||||
description: "Projektmanager, Account Manager, Junior Designer – bei klassischen Agenturen zahlen Sie oft den Wasserkopf mit. Wie ein Lean-Ansatz bessere und schnellere Ergebnisse liefert."
|
||||
date: "2026-05-06"
|
||||
tags: ["management", "strategy", "efficiency"]
|
||||
---
|
||||
|
||||
<LeadParagraph>
|
||||
Sie kennen den Ablauf: Ein Kick-off-Meeting mit fünf Personen. Ein Account Manager, ein Projektmanager, ein Designer, ein Entwickler und vielleicht noch ein Stratege.
|
||||
</LeadParagraph>
|
||||
<LeadParagraph>
|
||||
Was sich auf dem Papier nach geballter Kompetenz anhört, ist in der Realität oft der Startschuss für den <Marker>Stille-Post-Effekt</Marker>. Ressourcen werden verschwendet, Deadlines reißen und Budgets explodieren.
|
||||
</LeadParagraph>
|
||||
<LeadParagraph>
|
||||
Die Alternative ist "Lean Engineering". Ein Ansatz, der Hierarchien eliminiert, technische Exzellenz in den Vordergrund stellt und den administrativen Wasserkopf radikal abschneidet.
|
||||
</LeadParagraph>
|
||||
|
||||
|
||||
|
||||
<H2>Der Preis des Wasserkopfs</H2>
|
||||
|
||||
<Paragraph>
|
||||
Klassische Agenturen haben einen hohen administrativen Overhead. Großraumbüros in Top-Lagen, repräsentative Empfangsbereiche und Gehälter für Mitarbeiter, die keinen einzigen direkten Beitrag zu Ihrem Produkt leisten (Projektmanagement, HR, Sales). All das muss über Ihren Stundensatz finanziert werden.
|
||||
</Paragraph>
|
||||
|
||||
<Paragraph>
|
||||
Das Problem dabei ist nicht nur der aufgeblähte Preis, sondern die **Geschwindigkeit**. Wenn Sie eine einfache inhaltliche oder funktionale Anpassung anfordern, geht die E-Mail an den Projektmanager. Dieser schreibt ein Ticket für den Designer. Der Designer macht einen Entwurf. Der Projektmanager schickt Ihnen den Entwurf. Sie geben Feedback. Das Ticket geht zurück zum Designer, dann zum Entwickler, dann in die QA, dann wieder zu Ihnen.
|
||||
</Paragraph>
|
||||
|
||||
<ArticleQuote quote="Every layer of communication halves the precision and doubles the time required to execute a task. In software, middle management is often a bug, not a feature." author="Lean Engineering Principles" isCompany={false} source="The Minimalist Developer" translated={false} />
|
||||
|
||||
<H2>Der Lean-Ansatz: Ein Ansprechpartner, volle Verantwortung</H2>
|
||||
|
||||
<Paragraph>
|
||||
Der Gegensatz dazu ist der Lean-Ansatz. Als unabhängiger Digital Architect bin ich Ihr einziger Ansprechpartner. Ich analysiere Ihre Business-Ziele, entwerfe die Systemarchitektur, designe das Interface und schreibe den Code.
|
||||
</Paragraph>
|
||||
|
||||
<div className="my-12">
|
||||
<Mermaid id="agency-vs-lean" title="Kommunikationswege im Vergleich" showShare={true}>
|
||||
graph TD
|
||||
A[Kunde] --> B[Projektmanager]
|
||||
B --> C[Designer]
|
||||
B --> D[Entwickler]
|
||||
B --> E[Copywriter]
|
||||
|
||||
F[Kunde] --> G[Marc Mintel (Architect)]
|
||||
G --> H[Direktes Ergebnis (Code/Design)]
|
||||
|
||||
style A fill:#f87171,stroke:#333
|
||||
style F fill:#4ade80,stroke:#333
|
||||
</Mermaid>
|
||||
</div>
|
||||
|
||||
<Paragraph>
|
||||
Dieser direkte Weg reduziert Missverständnisse auf null. Wenn wir in einem Call über eine neue Funktion sprechen, weiß ich sofort, wie diese technisch im Hintergrund umsetzbar ist und ob sie in den [strategischen Festpreis](/blog/fixed-price-digital-projects) passt. Es gibt keine unrealistischen Versprechungen im Vertrieb, die die Technik später mühsam ausbaden muss.
|
||||
</Paragraph>
|
||||
|
||||
<H2>Weniger ist technologisch schneller</H2>
|
||||
|
||||
<Paragraph>
|
||||
Ein oft unterschätzter Faktor ist die technologische Reibung innerhalb großer Teams. In Agenturen müssen Technologien oft so gewählt werden, dass auch der Junior-Entwickler oder der Praktikant sie versteht. Es werden schwere, veraltete Frameworks oder Baukasten-CMS-Systeme (wie WordPress) eingesetzt, weil sie der kleinste gemeinsame Nenner sind.
|
||||
</Paragraph>
|
||||
|
||||
<Paragraph>
|
||||
Wenn man den Overhead eliminiert, kann man auf [hochperformante, moderne Stacks (Jamstack, Next.js)](/blog/digital-longevity-architecture) setzen. Diese wären für ein heterogenes Team oft zu komplex im Setup, können von einem Senior-Entwickler aber in einem Bruchteil der Zeit fehlerfrei und in industrieller Qualität gebaut werden.
|
||||
</Paragraph>
|
||||
|
||||
<ComparisonRow description="Wofür bezahlen Sie wirklich?" negativeLabel="Klassische Agentur" negativeText="Sie finanzieren Meetings, interne Abstimmungen, Projektmanagement-Tools, Büromieten und Junior-Gehälter." positiveLabel="Lean Engineer (Zero Overhead)" positiveText="Sie investieren 100% Ihres Budgets in pure Code-Qualität, High-End Design und technologische Langlebigkeit." showShare={true} />
|
||||
|
||||
<StatsGrid stats="60%|Weniger Meetings|durch direkten Kontakt~3x|Schnellere Umsetzung|durch fehlende Feedback-Schleifen~100%|Verantwortung|bei einer einzigen Person" />
|
||||
|
||||
<H2>Agile vs. Waterfall: Warum Agenturen oft stecken bleiben</H2>
|
||||
|
||||
<Paragraph>
|
||||
Viele Agenturen behaupten, agil zu arbeiten, praktizieren aber eigentlich einen "Wasserfall-Prozess" in Sprints. Das Design wird komplett finalisiert (und abgerechnet), bevor die erste Zeile Code geschrieben wird. Wenn in der Entwicklung auffällt, dass eine Interaktion technisch unsauber ist, fängt der Kreislauf von vorne an.
|
||||
</Paragraph>
|
||||
|
||||
<Paragraph>
|
||||
Im Zero-Overhead-Modell verschmelzen Design und Entwicklung. Ich gestalte oft direkt im Code. Das bedeutet, wir können echte Klick-Prototypen im Browser testen, statt statische Bilder anzustarren. Das spart nicht nur Wochen an Projektzeit, sondern führt zu digitalen Produkten, die sich von Grund auf flüssiger anfühlen.
|
||||
</Paragraph>
|
||||
|
||||
<H2>Wann eine Agentur Sinn macht</H2>
|
||||
|
||||
<Paragraph>
|
||||
Um fair zu bleiben: Es gibt Projekte, für die eine Agentur zwingend notwendig ist. Wenn Sie eine globale 360-Grad-Kampagne planen, physische Plakatwände buchen, TV-Spots produzieren und 50 verschiedene Landingpages gleichzeitig in 10 Sprachen ausrollen müssen, brauchen Sie schlichtweg Manpower.
|
||||
</Paragraph>
|
||||
|
||||
<Paragraph>
|
||||
Aber für 90% der B2B-Unternehmen, die eine hochprofessionelle, blitzschnelle und zukunftssichere Plattform brauchen, um digitale Dominanz auszustrahlen und Leads zu generieren? Dafür ist eine Agentur meistens Overkill – und ein massiver Rendite-Killer.
|
||||
</Paragraph>
|
||||
|
||||
<LeadMagnet title="Machen wir es unkompliziert." description="Lassen Sie uns den administrativen Overhead streichen. In einem kurzen Call klären wir die Fakten, danach erhalten Sie ein glasklares Angebot." buttonText="Jetzt Blueprint-Call anfragen" href="/contact" variant="standard" />
|
||||
|
||||
<FAQSection>
|
||||
<H3>Was passiert, wenn der alleinige Entwickler ausfällt?</H3>
|
||||
<Paragraph>
|
||||
Das ist eine berechtigte Frage (der sogenannte Bus-Faktor). Die Antwort liegt in der Code-Qualität. Durch strikte, industrieweite Standards, extrem saubere Dokumentation und den Verzicht auf obskure proprietäre Plugins kann sich jeder erfahrene React/Next.js-Entwickler innerhalb von Stunden in meine Architektur einarbeiten.
|
||||
</Paragraph>
|
||||
|
||||
<H3>Fehlt ohne Agentur nicht die strategische Beratung?</H3>
|
||||
<Paragraph>
|
||||
Ganz im Gegenteil. Als Digital Architect übernehme ich genau diesen strategischen Part. Da ich nicht versuchen muss, meine 20-köpfige Design-Abteilung auszulasten, ist meine Beratung 100% objektiv und auf Ihren geschäftlichen Erfolg fokussiert, nicht auf meine interne Auslastung.
|
||||
</Paragraph>
|
||||
|
||||
<H3>Können große Projekte überhaupt alleine gestemmt werden?</H3>
|
||||
<Paragraph>
|
||||
Ja, durch moderne Tooling-Ketten, KI-Unterstützung und hochgradig wiederverwendbare Architektur-Komponenten. Ein Senior Architect baut heute allein Systeme, für die man vor 5 Jahren noch ein Team aus 5 Leuten gebraucht hätte. Qualität schlägt in der Softwareentwicklung immer Quantität.
|
||||
</Paragraph>
|
||||
</FAQSection>
|
||||
54
apps/web/migrate-docs.ts
Normal file
54
apps/web/migrate-docs.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { getPayload } from "payload";
|
||||
import configPromise from "./payload.config";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
|
||||
async function run() {
|
||||
try {
|
||||
const payload = await getPayload({ config: configPromise });
|
||||
console.log("Payload initialized.");
|
||||
|
||||
const docsDir = path.resolve(process.cwd(), "docs");
|
||||
|
||||
if (!fs.existsSync(docsDir)) {
|
||||
console.log(`Docs directory not found at ${docsDir}`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const files = fs.readdirSync(docsDir);
|
||||
let count = 0;
|
||||
|
||||
for (const file of files) {
|
||||
if (file.endsWith(".md")) {
|
||||
const content = fs.readFileSync(path.join(docsDir, file), "utf8");
|
||||
|
||||
// Check if already exists
|
||||
const existing = await payload.find({
|
||||
collection: "context-files",
|
||||
where: { filename: { equals: file } },
|
||||
});
|
||||
|
||||
if (existing.totalDocs === 0) {
|
||||
await payload.create({
|
||||
collection: "context-files",
|
||||
data: {
|
||||
filename: file,
|
||||
content: content,
|
||||
},
|
||||
});
|
||||
count++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log(
|
||||
`Migration successful! Added ${count} new context files to the database.`,
|
||||
);
|
||||
process.exit(0);
|
||||
} catch (e) {
|
||||
console.error("Migration failed:", e);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
run();
|
||||
34
apps/web/migrate-drafts.ts
Normal file
34
apps/web/migrate-drafts.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { getPayload } from "payload";
|
||||
import configPromise from "./payload.config";
|
||||
|
||||
async function run() {
|
||||
const payload = await getPayload({ config: configPromise });
|
||||
const { docs } = await payload.find({
|
||||
collection: "posts",
|
||||
limit: 1000,
|
||||
});
|
||||
|
||||
console.log(`Found ${docs.length} posts. Checking status...`);
|
||||
|
||||
for (const doc of docs) {
|
||||
if (doc._status !== "published") {
|
||||
try {
|
||||
await payload.update({
|
||||
collection: "posts",
|
||||
id: doc.id,
|
||||
data: {
|
||||
_status: "published",
|
||||
},
|
||||
});
|
||||
console.log(`Updated "${doc.title}" to published.`);
|
||||
} catch (e) {
|
||||
console.error(`Failed to update ${doc.title}:`, e.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log("Migration complete.");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
run();
|
||||
@@ -1,5 +1,5 @@
|
||||
import withMintelConfig from "@mintel/next-config";
|
||||
|
||||
import { withPayload } from '@payloadcms/next/withPayload';
|
||||
import createMDX from '@next/mdx';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
@@ -10,20 +10,16 @@ const dirname = path.dirname(filename);
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
serverExternalPackages: [
|
||||
'@mintel/content-engine',
|
||||
'@mintel/concept-engine',
|
||||
'@mintel/estimation-engine',
|
||||
'@mintel/payload-ai',
|
||||
'@mintel/pdf',
|
||||
'canvas',
|
||||
'sharp',
|
||||
'puppeteer',
|
||||
'require-in-the-middle',
|
||||
'import-in-the-middle'
|
||||
],
|
||||
transpilePackages: [
|
||||
'@mintel/content-engine',
|
||||
'@mintel/concept-engine',
|
||||
'@mintel/estimation-engine',
|
||||
'@mintel/meme-generator',
|
||||
|
||||
'@mintel/pdf',
|
||||
'@mintel/thumbnail-generator'
|
||||
'import-in-the-middle' // Sentry 10+ instrumentation dependencies
|
||||
],
|
||||
images: {
|
||||
remotePatterns: [
|
||||
@@ -37,6 +33,12 @@ const nextConfig = {
|
||||
},
|
||||
],
|
||||
},
|
||||
async rewrites() {
|
||||
return [
|
||||
// Umami proxy rewrite handled in app/stats/api/send/route.ts
|
||||
// Sentry relay handled in app/errors/api/relay/route.ts
|
||||
];
|
||||
},
|
||||
async redirects() {
|
||||
return [
|
||||
{
|
||||
@@ -46,46 +48,10 @@ const nextConfig = {
|
||||
},
|
||||
];
|
||||
},
|
||||
async rewrites() {
|
||||
return {
|
||||
beforeFiles: [
|
||||
// Map Patttern: Global assets moved into KLZ showcase folder
|
||||
{
|
||||
source: '/assets/klz-cables.com/:path*',
|
||||
destination: '/showcase/klz-cables.com/assets/klz-cables.com/:path*',
|
||||
},
|
||||
{
|
||||
source: '/wp-content/:path*',
|
||||
destination: '/showcase/klz-cables.com/wp-content/:path*',
|
||||
},
|
||||
{
|
||||
source: '/wp-includes/:path*',
|
||||
destination: '/showcase/klz-cables.com/wp-includes/:path*',
|
||||
},
|
||||
{
|
||||
source: '/case-studies/assets/:directory/:path*',
|
||||
destination: '/showcase/:directory/assets/:directory/:path*',
|
||||
},
|
||||
]
|
||||
};
|
||||
},
|
||||
// In Standalone mode, Next.js expects the tracing root to be the monorepo root
|
||||
outputFileTracingRoot: path.join(dirname, '../../'),
|
||||
outputFileTracingIncludes: {
|
||||
'/(site)/blog/[slug]': ['./content/blog/**/*'],
|
||||
'/blog/[slug]': ['./content/blog/**/*'],
|
||||
},
|
||||
};
|
||||
|
||||
const withMDX = createMDX({});
|
||||
|
||||
// Clean, standard wrapper application
|
||||
// Rewrites are now handled by src/middleware.ts for maximum robustness
|
||||
const config = withMintelConfig(withMDX(nextConfig));
|
||||
|
||||
// Cleanup config to prevent Next.js 16 warnings about deprecated keys
|
||||
if (config.serverActions) {
|
||||
delete config.serverActions;
|
||||
}
|
||||
|
||||
export default config;
|
||||
const withMDX = createMDX({
|
||||
// Add markdown plugins here, as desired
|
||||
});
|
||||
export default withPayload(withMintelConfig(withMDX(nextConfig)));
|
||||
|
||||
@@ -4,8 +4,9 @@
|
||||
"version": "0.1.0",
|
||||
"description": "Technical problem solver's blog - practical insights and learning notes",
|
||||
"scripts": {
|
||||
"dev": "next dev --webpack --hostname 0.0.0.0",
|
||||
"dev:native": "next dev --webpack",
|
||||
"dev": "pnpm run seed:context && next dev --webpack --hostname 0.0.0.0",
|
||||
"dev:native": "DATABASE_URI=postgres://payload:payload@127.0.0.1:54321/payload PAYLOAD_SECRET=dev-secret pnpm run seed:context && DATABASE_URI=postgres://payload:payload@127.0.0.1:54321/payload PAYLOAD_SECRET=dev-secret next dev --webpack",
|
||||
"seed:context": "node --import tsx --experimental-loader ./ignore-css.mjs ./seed-context.ts",
|
||||
"build": "next build --webpack",
|
||||
"start": "next start",
|
||||
"lint": "eslint app src scripts video",
|
||||
@@ -20,9 +21,17 @@
|
||||
"video:render:button": "remotion render video/index.ts ButtonShowcase out/button-showcase.mp4 --concurrency=1 --codec=h264 --crf=16 --pixel-format=yuv420p --overwrite",
|
||||
"video:render:all": "npm run video:render:contact && npm run video:render:button",
|
||||
"pagespeed:test": "npx tsx ./scripts/pagespeed-sitemap.ts",
|
||||
"index:posts": "node --import tsx --experimental-loader ./ignore-css.mjs ./scripts/index-posts.ts",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"check:og": "tsx scripts/check-og-images.ts",
|
||||
"check:forms": "tsx scripts/check-forms.ts"
|
||||
"check:forms": "tsx scripts/check-forms.ts",
|
||||
"cms:push:testing": "bash ./scripts/cms-sync.sh push testing",
|
||||
"cms:pull:testing": "bash ./scripts/cms-sync.sh pull testing",
|
||||
"cms:push:staging": "bash ./scripts/cms-sync.sh push staging",
|
||||
"cms:pull:staging": "bash ./scripts/cms-sync.sh pull staging",
|
||||
"cms:push:prod": "bash ./scripts/cms-sync.sh push prod",
|
||||
"cms:pull:prod": "bash ./scripts/cms-sync.sh pull prod",
|
||||
"db:restore": "bash ./scripts/restore-db.sh"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.750.0",
|
||||
@@ -33,6 +42,7 @@
|
||||
"@mintel/content-engine": "link:../../../at-mintel/packages/content-engine",
|
||||
"@mintel/estimation-engine": "link:../../../at-mintel/packages/estimation-engine",
|
||||
"@mintel/meme-generator": "link:../../../at-mintel/packages/meme-generator",
|
||||
"@mintel/payload-ai": "^1.9.15",
|
||||
"@mintel/pdf": "link:../../../at-mintel/packages/pdf-library",
|
||||
"@mintel/thumbnail-generator": "link:../../../at-mintel/packages/thumbnail-generator",
|
||||
"@next/mdx": "^16.1.6",
|
||||
@@ -41,6 +51,13 @@
|
||||
"@opentelemetry/context-async-hooks": "^2.1.0",
|
||||
"@opentelemetry/core": "^2.1.0",
|
||||
"@opentelemetry/sdk-trace-base": "^2.1.0",
|
||||
"@payloadcms/db-postgres": "^3.77.0",
|
||||
"@payloadcms/email-nodemailer": "^3.77.0",
|
||||
"@payloadcms/next": "^3.77.0",
|
||||
"@payloadcms/richtext-lexical": "^3.77.0",
|
||||
"@payloadcms/storage-s3": "^3.77.0",
|
||||
"@payloadcms/ui": "^3.77.0",
|
||||
"@qdrant/js-client-rest": "^1.17.0",
|
||||
"@react-pdf/renderer": "^4.3.2",
|
||||
"@remotion/bundler": "^4.0.414",
|
||||
"@remotion/cli": "^4.0.414",
|
||||
@@ -62,7 +79,6 @@
|
||||
"esbuild": "^0.27.3",
|
||||
"framer-motion": "^12.29.2",
|
||||
"graphql": "^16.12.0",
|
||||
"gray-matter": "^4.0.3",
|
||||
"html-to-image": "^1.11.13",
|
||||
"import-in-the-middle": "^1.11.0",
|
||||
"ioredis": "^5.9.1",
|
||||
@@ -71,15 +87,18 @@
|
||||
"next": "^16.1.6",
|
||||
"next-mdx-remote": "^6.0.0",
|
||||
"nodemailer": "^8.0.1",
|
||||
"payload": "^3.77.0",
|
||||
"playwright": "^1.58.1",
|
||||
"prismjs": "^1.30.0",
|
||||
"puppeteer": "^24.36.1",
|
||||
"qrcode": "^1.5.4",
|
||||
"react": "^19.2.3",
|
||||
"react-dom": "^19.2.3",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-social-media-embed": "^2.5.18",
|
||||
"react-tweet": "^3.3.0",
|
||||
"recharts": "^3.7.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"remotion": "^4.0.414",
|
||||
"replicate": "^1.4.0",
|
||||
"require-in-the-middle": "^8.0.1",
|
||||
@@ -127,4 +146,4 @@
|
||||
"type": "git",
|
||||
"url": "git@git.infra.mintel.me:mmintel/mintel.me.git"
|
||||
}
|
||||
}
|
||||
}
|
||||
1015
apps/web/payload-types.ts
Normal file
1015
apps/web/payload-types.ts
Normal file
File diff suppressed because it is too large
Load Diff
119
apps/web/payload.config.ts
Normal file
119
apps/web/payload.config.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
import { buildConfig } from "payload";
|
||||
// Triggering config re-analysis for blocks visibility - V4
|
||||
import { postgresAdapter } from "@payloadcms/db-postgres";
|
||||
import { lexicalEditor, BlocksFeature } from "@payloadcms/richtext-lexical";
|
||||
import { payloadBlocks } from "./src/payload/blocks/allBlocks";
|
||||
import { nodemailerAdapter } from "@payloadcms/email-nodemailer";
|
||||
import { s3Storage } from "@payloadcms/storage-s3";
|
||||
import path from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
import sharp from "sharp";
|
||||
|
||||
import { Users } from "./src/payload/collections/Users";
|
||||
import { Media } from "./src/payload/collections/Media";
|
||||
import { Posts } from "./src/payload/collections/Posts";
|
||||
import { emailWebhookHandler } from "./src/payload/endpoints/emailWebhook";
|
||||
import { aiEndpointHandler } from "./src/payload/endpoints/aiEndpoint";
|
||||
import { Inquiries } from "./src/payload/collections/Inquiries";
|
||||
import { Redirects } from "./src/payload/collections/Redirects";
|
||||
import { ContextFiles } from "./src/payload/collections/ContextFiles";
|
||||
import { CrmAccounts } from "./src/payload/collections/CrmAccounts";
|
||||
import { CrmContacts } from "./src/payload/collections/CrmContacts";
|
||||
import { CrmInteractions } from "./src/payload/collections/CrmInteractions";
|
||||
import { CrmTopics } from "./src/payload/collections/CrmTopics";
|
||||
import { Projects } from "./src/payload/collections/Projects";
|
||||
import { payloadChatPlugin } from "@mintel/payload-ai";
|
||||
|
||||
const filename = fileURLToPath(import.meta.url);
|
||||
const dirname = path.dirname(filename);
|
||||
|
||||
export default buildConfig({
|
||||
admin: {
|
||||
user: Users.slug,
|
||||
importMap: {
|
||||
baseDir: path.resolve(dirname),
|
||||
},
|
||||
},
|
||||
collections: [
|
||||
Users,
|
||||
Media,
|
||||
Posts,
|
||||
Inquiries,
|
||||
Redirects,
|
||||
ContextFiles,
|
||||
CrmAccounts,
|
||||
CrmContacts,
|
||||
CrmTopics,
|
||||
CrmInteractions,
|
||||
Projects,
|
||||
],
|
||||
globals: [
|
||||
/* AiSettings as any */
|
||||
],
|
||||
email: nodemailerAdapter({
|
||||
defaultFromAddress: process.env.MAIL_FROM || "info@mintel.me",
|
||||
defaultFromName: "Mintel.me",
|
||||
transportOptions: {
|
||||
host: process.env.MAIL_HOST || "localhost",
|
||||
port: parseInt(process.env.MAIL_PORT || "587", 10),
|
||||
auth: {
|
||||
user: process.env.MAIL_USERNAME || "user",
|
||||
pass: process.env.MAIL_PASSWORD || "pass",
|
||||
},
|
||||
...(process.env.MAIL_HOST ? {} : { ignoreTLS: true }),
|
||||
},
|
||||
}),
|
||||
editor: lexicalEditor({
|
||||
features: ({ defaultFeatures }) => [
|
||||
...defaultFeatures,
|
||||
BlocksFeature({
|
||||
blocks: payloadBlocks,
|
||||
}),
|
||||
],
|
||||
}),
|
||||
secret: process.env.PAYLOAD_SECRET || "fallback-secret-for-dev",
|
||||
typescript: {
|
||||
outputFile: path.resolve(dirname, "payload-types.ts"),
|
||||
},
|
||||
db: postgresAdapter({
|
||||
pool: {
|
||||
connectionString:
|
||||
process.env.DATABASE_URI || process.env.POSTGRES_URI || "",
|
||||
},
|
||||
}),
|
||||
sharp,
|
||||
plugins: [
|
||||
...(process.env.S3_ENDPOINT
|
||||
? [
|
||||
s3Storage({
|
||||
collections: {
|
||||
media: {
|
||||
prefix: `${process.env.S3_PREFIX || "mintel-me"}/media`,
|
||||
},
|
||||
},
|
||||
bucket: process.env.S3_BUCKET || "",
|
||||
config: {
|
||||
credentials: {
|
||||
accessKeyId: process.env.S3_ACCESS_KEY || "",
|
||||
secretAccessKey: process.env.S3_SECRET_KEY || "",
|
||||
},
|
||||
region: process.env.S3_REGION || "fsn1",
|
||||
endpoint: process.env.S3_ENDPOINT,
|
||||
forcePathStyle: true,
|
||||
},
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
payloadChatPlugin({
|
||||
enabled: true,
|
||||
mcpServers: [],
|
||||
}),
|
||||
],
|
||||
endpoints: [
|
||||
{
|
||||
path: "/crm/incoming-email",
|
||||
method: "post",
|
||||
handler: emailWebhookHandler,
|
||||
},
|
||||
],
|
||||
});
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 456 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 528 KiB |
87
apps/web/remove-toc.ts
Normal file
87
apps/web/remove-toc.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import { getPayload } from "payload";
|
||||
import configPromise from "./payload.config";
|
||||
|
||||
async function run() {
|
||||
const payload = await getPayload({ config: configPromise });
|
||||
const { docs } = await payload.find({
|
||||
collection: "posts",
|
||||
limit: 1000,
|
||||
});
|
||||
|
||||
console.log(
|
||||
`Found ${docs.length} posts. Checking for <TableOfContents />...`,
|
||||
);
|
||||
|
||||
let updatedCount = 0;
|
||||
|
||||
const removeTOC = (node: any): boolean => {
|
||||
let modified = false;
|
||||
if (node.children && Array.isArray(node.children)) {
|
||||
// Filter out raw text nodes or paragraph nodes that are exactly TableOfContents
|
||||
const originalLength = node.children.length;
|
||||
node.children = node.children.filter((child: any) => {
|
||||
if (
|
||||
child.type === "text" &&
|
||||
child.text &&
|
||||
child.text.includes("<TableOfContents />")
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
child.type === "paragraph" &&
|
||||
child.children &&
|
||||
child.children.length === 1 &&
|
||||
child.children[0].text === "<TableOfContents />"
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
if (node.children.length !== originalLength) {
|
||||
modified = true;
|
||||
}
|
||||
|
||||
// Also clean up any substrings in remaining text nodes
|
||||
for (const child of node.children) {
|
||||
if (
|
||||
child.type === "text" &&
|
||||
child.text &&
|
||||
child.text.includes("<TableOfContents />")
|
||||
) {
|
||||
child.text = child.text.replace("<TableOfContents />", "").trim();
|
||||
modified = true;
|
||||
}
|
||||
if (removeTOC(child)) {
|
||||
modified = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return modified;
|
||||
};
|
||||
|
||||
for (const doc of docs) {
|
||||
if (doc.content?.root) {
|
||||
const isModified = removeTOC(doc.content.root);
|
||||
if (isModified) {
|
||||
try {
|
||||
await payload.update({
|
||||
collection: "posts",
|
||||
id: doc.id,
|
||||
data: {
|
||||
content: doc.content,
|
||||
},
|
||||
});
|
||||
console.log(`Cleaned up TOC in "${doc.title}".`);
|
||||
updatedCount++;
|
||||
} catch (e) {
|
||||
console.error(`Failed to update ${doc.title}:`, e.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`Cleanup complete. Modified ${updatedCount} posts.`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
run();
|
||||
41
apps/web/scripts/create-user.ts
Normal file
41
apps/web/scripts/create-user.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { getPayload } from "payload";
|
||||
import configPromise from "../payload.config";
|
||||
|
||||
async function run() {
|
||||
try {
|
||||
const payload = await getPayload({ config: configPromise });
|
||||
|
||||
const existing = await payload.find({
|
||||
collection: "users",
|
||||
where: { email: { equals: "marc@mintel.me" } },
|
||||
});
|
||||
|
||||
if (existing.totalDocs > 0) {
|
||||
console.log("User already exists, updating password...");
|
||||
await payload.update({
|
||||
collection: "users",
|
||||
where: { email: { equals: "marc@mintel.me" } },
|
||||
data: {
|
||||
password: "Tim300493.",
|
||||
},
|
||||
});
|
||||
console.log("Password updated.");
|
||||
} else {
|
||||
console.log("Creating user...");
|
||||
await payload.create({
|
||||
collection: "users",
|
||||
data: {
|
||||
email: "marc@mintel.me",
|
||||
password: "Tim300493.",
|
||||
},
|
||||
});
|
||||
console.log("User marc@mintel.me created.");
|
||||
}
|
||||
process.exit(0);
|
||||
} catch (err) {
|
||||
console.error("Failed to create user:", err);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
run();
|
||||
@@ -1,91 +1,71 @@
|
||||
import { ThumbnailGenerator } from "@mintel/thumbnail-generator";
|
||||
import * as path from "node:path";
|
||||
import * as fs from "node:fs/promises";
|
||||
import { ThumbnailGenerator } from '@mintel/thumbnail-generator';
|
||||
import * as path from 'node:path';
|
||||
import * as fs from 'node:fs/promises';
|
||||
|
||||
async function run() {
|
||||
const apiKey =
|
||||
process.env.REPLICATE_API_TOKEN || process.env.REPLICATE_API_KEY;
|
||||
if (!apiKey) {
|
||||
console.error("❌ Missing REPLICATE_API_TOKEN in environment.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const targetFile = process.argv[2];
|
||||
if (!targetFile) {
|
||||
console.error(
|
||||
"❌ Usage: npx tsx scripts/generate-thumbnail.ts <file-or-topic>",
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
let topic = targetFile;
|
||||
let filename = "thumbnail.png";
|
||||
|
||||
// Try to parse the topic from the MDX frontmatter if a file is provided
|
||||
if (targetFile.endsWith(".mdx")) {
|
||||
try {
|
||||
const content = await fs.readFile(targetFile, "utf8");
|
||||
const titleMatch = content.match(/title:\s*"?([^"\n]+)"?/);
|
||||
topic = titleMatch ? titleMatch[1] : path.basename(targetFile, ".mdx");
|
||||
filename = `${path.basename(targetFile, ".mdx")}.png`;
|
||||
} catch (e) {
|
||||
console.warn(
|
||||
`⚠️ Could not read ${targetFile} as a file. Using literal argument as topic.`,
|
||||
);
|
||||
topic = targetFile;
|
||||
const apiKey = process.env.REPLICATE_API_TOKEN || process.env.REPLICATE_API_KEY;
|
||||
if (!apiKey) {
|
||||
console.error("❌ Missing REPLICATE_API_TOKEN in environment.");
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`Generating abstract thumbnail for topic: "${topic}"`);
|
||||
const targetFile = process.argv[2];
|
||||
if (!targetFile) {
|
||||
console.error("❌ Usage: npx tsx scripts/generate-thumbnail.ts <file-or-topic>");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const generator = new ThumbnailGenerator({ replicateApiKey: apiKey });
|
||||
const isRoot = process.cwd().endsWith("mintel.me");
|
||||
const baseDir = isRoot
|
||||
? path.join(process.cwd(), "apps", "web")
|
||||
: process.cwd();
|
||||
let topic = targetFile;
|
||||
let filename = "thumbnail.png";
|
||||
|
||||
const outputPath = path.join(baseDir, "public", "blog", filename);
|
||||
// Try to parse the topic from the MDX frontmatter if a file is provided
|
||||
if (targetFile.endsWith('.mdx')) {
|
||||
try {
|
||||
const content = await fs.readFile(targetFile, 'utf8');
|
||||
const titleMatch = content.match(/title:\s*"?([^"\n]+)"?/);
|
||||
topic = titleMatch ? titleMatch[1] : path.basename(targetFile, '.mdx');
|
||||
filename = `${path.basename(targetFile, '.mdx')}-thumb.png`;
|
||||
} catch (e) {
|
||||
console.warn(`⚠️ Could not read ${targetFile} as a file. Using literal argument as topic.`);
|
||||
topic = targetFile;
|
||||
}
|
||||
}
|
||||
|
||||
// Check if thumbnail already exists to avoid redundant generation
|
||||
try {
|
||||
await fs.access(outputPath);
|
||||
console.log(`⏭️ Thumbnail already exists, skipping: ${filename}`);
|
||||
return;
|
||||
} catch {
|
||||
// File does not exist, proceed with generation
|
||||
}
|
||||
console.log(`Generating abstract thumbnail for topic: "${topic}"`);
|
||||
|
||||
const inspirationPath = path.join(
|
||||
baseDir,
|
||||
"public",
|
||||
"blog",
|
||||
"inspiration.png",
|
||||
);
|
||||
let hasInspiration = false;
|
||||
try {
|
||||
await fs.access(inspirationPath);
|
||||
hasInspiration = true;
|
||||
} catch {
|
||||
hasInspiration = false;
|
||||
}
|
||||
const generator = new ThumbnailGenerator({ replicateApiKey: apiKey });
|
||||
const isRoot = process.cwd().endsWith('mintel.me');
|
||||
const baseDir = isRoot ? path.join(process.cwd(), 'apps', 'web') : process.cwd();
|
||||
|
||||
let customPrompt = `Extremely clean, flat, abstract geometric illustration. Use the provided image prompt ONLY as a STRICT color and texture reference. You MUST generate completely new, distinct geometric shapes that directly represent the topic. Characteristics: Flat vector design, 2D only (no 3D), tech/startup/agency aesthetics, highly professional, extensive use of whitespace. No text, no chaotic lines, no humans.`;
|
||||
const outputPath = path.join(baseDir, 'public', 'blog', filename);
|
||||
|
||||
if (topic.includes("Service") || topic.includes("Sorglos")) {
|
||||
customPrompt +=
|
||||
" Visually depict the concept of 'effortless automation'. Use a large, distinct, central endless loop or a floating interconnected minimal machine.";
|
||||
} else if (topic.includes("Zero") || topic.includes("Overhead")) {
|
||||
customPrompt +=
|
||||
" Visually depict the concept of 'cutting out the middleman'. Use a massive, heavy block being sliced in half or a direct, unfiltered sharp lightning/beam connecting two points.";
|
||||
}
|
||||
// Check if thumbnail already exists to avoid redundant generation
|
||||
try {
|
||||
await fs.access(outputPath);
|
||||
console.log(`⏭️ Thumbnail already exists, skipping: ${filename}`);
|
||||
return;
|
||||
} catch {
|
||||
// File does not exist, proceed with generation
|
||||
}
|
||||
|
||||
await generator.generateImage(topic, outputPath, {
|
||||
systemPrompt: customPrompt,
|
||||
imagePrompt: hasInspiration ? inspirationPath : undefined,
|
||||
});
|
||||
const inspirationPath = path.join(baseDir, 'public', 'blog', 'inspiration.png');
|
||||
let hasInspiration = false;
|
||||
try {
|
||||
await fs.access(inspirationPath);
|
||||
hasInspiration = true;
|
||||
} catch {
|
||||
hasInspiration = false;
|
||||
}
|
||||
|
||||
const customPrompt = `Extremely clean, flat, abstract geometric illustration. Use the provided image prompt ONLY as a STRICT style, color, and texture reference. Do not copy the image content, just the aesthetic. Characteristics: Flat vector design, 2D only (no 3D), tech/startup/agency aesthetics, highly professional, abstract data representations, extensive use of whitespace. No text, no chaotic lines, no humans.`;
|
||||
|
||||
await generator.generateImage(topic, outputPath, {
|
||||
systemPrompt: customPrompt,
|
||||
imagePrompt: hasInspiration ? inspirationPath : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
run().catch((e) => {
|
||||
console.error("❌ Thumbnail generation failed:", e);
|
||||
process.exit(1);
|
||||
console.error("❌ Thumbnail generation failed:", e);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
168
apps/web/scripts/import-leads.ts
Normal file
168
apps/web/scripts/import-leads.ts
Normal file
@@ -0,0 +1,168 @@
|
||||
import fs from "node:fs";
|
||||
import * as xlsxImport from "xlsx";
|
||||
const xlsx = (xlsxImport as any).default || xlsxImport;
|
||||
import { getPayload } from "payload";
|
||||
import configPromise from "../payload.config";
|
||||
|
||||
async function run() {
|
||||
try {
|
||||
console.log("Initializing Payload...");
|
||||
const payload = await getPayload({ config: configPromise });
|
||||
|
||||
const filePath = "/Users/marcmintel/Downloads/Akquise_Branchen.xlsx";
|
||||
if (!fs.existsSync(filePath)) {
|
||||
console.error("File not found:", filePath);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`Reading Excel file: ${filePath}`);
|
||||
const wb = xlsx.readFile(filePath);
|
||||
|
||||
let accountsCreated = 0;
|
||||
let contactsCreated = 0;
|
||||
|
||||
for (const sheetName of wb.SheetNames) {
|
||||
if (
|
||||
sheetName === "Weitere Kundenideen" ||
|
||||
sheetName.includes("BKF Firmen")
|
||||
)
|
||||
continue;
|
||||
|
||||
let industry = sheetName
|
||||
.replace(/^\d+_/, "")
|
||||
.replace(/^\d+\.\s*/, "")
|
||||
.replace(/_/g, " ");
|
||||
console.log(
|
||||
`\n--- Importing Sheet: ${sheetName} -> Industry: ${industry} ---`,
|
||||
);
|
||||
const rows = xlsx.utils.sheet_to_json(wb.Sheets[sheetName]);
|
||||
|
||||
for (const row of rows) {
|
||||
const companyName = row["Unternehmen"]?.trim();
|
||||
const website = row["Webseitenlink"]?.trim();
|
||||
let email = row["Emailadresse"]?.trim();
|
||||
const contactName = row["Ansprechpartner"]?.trim();
|
||||
const position = row["Position"]?.trim();
|
||||
const statusRaw = row["Webseiten-Status (alt/gut/schlecht)"]
|
||||
?.trim()
|
||||
?.toLowerCase();
|
||||
const notes = row["Notizen"]?.trim();
|
||||
|
||||
if (!companyName) continue;
|
||||
|
||||
let websiteStatus = "unknown";
|
||||
if (statusRaw === "gut") websiteStatus = "gut";
|
||||
else if (statusRaw === "ok" || statusRaw === "okay")
|
||||
websiteStatus = "ok";
|
||||
else if (
|
||||
statusRaw === "schlecht" ||
|
||||
statusRaw === "alt" ||
|
||||
statusRaw === "veraltet"
|
||||
)
|
||||
websiteStatus = "schlecht";
|
||||
|
||||
// Find or create account
|
||||
let accountId;
|
||||
const whereClause = website
|
||||
? { website: { equals: website } }
|
||||
: { name: { equals: companyName } };
|
||||
|
||||
const existingAccounts = await payload.find({
|
||||
collection: "crm-accounts",
|
||||
where: whereClause,
|
||||
});
|
||||
|
||||
if (existingAccounts.docs.length > 0) {
|
||||
accountId = existingAccounts.docs[0].id;
|
||||
console.log(`[SKIP] Account exists: ${companyName}`);
|
||||
} else {
|
||||
try {
|
||||
const newAccount = await payload.create({
|
||||
collection: "crm-accounts",
|
||||
data: {
|
||||
name: companyName,
|
||||
website: website || "",
|
||||
status: "lead",
|
||||
leadTemperature: "cold",
|
||||
industry,
|
||||
websiteStatus,
|
||||
notes,
|
||||
} as any,
|
||||
});
|
||||
accountId = newAccount.id;
|
||||
accountsCreated++;
|
||||
console.log(`[OK] Created account: ${companyName}`);
|
||||
} catch (err: any) {
|
||||
console.error(
|
||||
`[ERROR] Failed to create account ${companyName}:`,
|
||||
err.message,
|
||||
);
|
||||
continue; // Skip contact creation if account failed
|
||||
}
|
||||
}
|
||||
|
||||
// Handle contact
|
||||
if (email) {
|
||||
// Some rows have multiple emails or contacts. Let's just pick the first email if there are commas.
|
||||
if (email.includes(",")) email = email.split(",")[0].trim();
|
||||
|
||||
const existingContacts = await payload.find({
|
||||
collection: "crm-contacts",
|
||||
where: { email: { equals: email } },
|
||||
});
|
||||
|
||||
if (existingContacts.docs.length === 0) {
|
||||
let firstName = "Team";
|
||||
let lastName = companyName; // fallback
|
||||
|
||||
if (contactName) {
|
||||
// If multiple contacts are listed, just take the first one
|
||||
const firstContact = contactName.split(",")[0].trim();
|
||||
const parts = firstContact.split(" ");
|
||||
if (parts.length > 1) {
|
||||
lastName = parts.pop();
|
||||
firstName = parts.join(" ");
|
||||
} else {
|
||||
firstName = firstContact;
|
||||
lastName = "Contact";
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await payload.create({
|
||||
collection: "crm-contacts",
|
||||
data: {
|
||||
email,
|
||||
firstName,
|
||||
lastName,
|
||||
role: position,
|
||||
account: accountId as any,
|
||||
},
|
||||
});
|
||||
contactsCreated++;
|
||||
console.log(` -> [OK] Created contact: ${email}`);
|
||||
} catch (err: any) {
|
||||
console.error(
|
||||
` -> [ERROR] Failed to create contact ${email}:`,
|
||||
err.message,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
console.log(` -> [SKIP] Contact exists: ${email}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\nMigration completed successfully!`);
|
||||
console.log(
|
||||
`Created ${accountsCreated} Accounts and ${contactsCreated} Contacts.`,
|
||||
);
|
||||
process.exit(0);
|
||||
} catch (e) {
|
||||
console.error("Migration failed:", e);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
run();
|
||||
127
apps/web/scripts/index-posts.ts
Normal file
127
apps/web/scripts/index-posts.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* Index all published blog posts into Qdrant for AI search.
|
||||
*
|
||||
* Usage: pnpm --filter @mintel/web run index:posts
|
||||
*/
|
||||
import { getPayload } from 'payload';
|
||||
import configPromise from '../payload.config';
|
||||
import { upsertPostVector } from '../src/lib/qdrant';
|
||||
|
||||
function extractPlainText(node: any): string {
|
||||
if (!node) return '';
|
||||
|
||||
// Handle text nodes
|
||||
if (typeof node === 'string') return node;
|
||||
if (node.text) return node.text;
|
||||
|
||||
// Handle arrays
|
||||
if (Array.isArray(node)) {
|
||||
return node.map(extractPlainText).join('');
|
||||
}
|
||||
|
||||
// Handle node with children
|
||||
if (node.children) {
|
||||
const childText = node.children.map(extractPlainText).join('');
|
||||
|
||||
// Add line breaks for block-level elements
|
||||
if (['paragraph', 'heading', 'listitem', 'quote'].includes(node.type)) {
|
||||
return childText + '\n';
|
||||
}
|
||||
return childText;
|
||||
}
|
||||
|
||||
// Lexical root
|
||||
if (node.root) {
|
||||
return extractPlainText(node.root);
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
async function run() {
|
||||
console.log('🔍 Starting blog post indexing for AI search...');
|
||||
|
||||
let payload;
|
||||
let retries = 5;
|
||||
while (retries > 0) {
|
||||
try {
|
||||
console.log(`Connecting to database (URI: ${process.env.DATABASE_URI || 'default'})...`);
|
||||
payload = await getPayload({ config: configPromise });
|
||||
break;
|
||||
} catch (e: any) {
|
||||
if (
|
||||
e.code === 'ECONNREFUSED' ||
|
||||
e.code === 'ENOTFOUND' ||
|
||||
e.message?.includes('ECONNREFUSED') ||
|
||||
e.message?.includes('cannot connect to Postgres')
|
||||
) {
|
||||
console.log(`Database not ready, retrying in 3s... (${retries} retries left)`);
|
||||
retries--;
|
||||
await new Promise((res) => setTimeout(res, 3000));
|
||||
} else {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!payload) {
|
||||
throw new Error('Failed to connect to database after multiple retries.');
|
||||
}
|
||||
|
||||
// Fetch all published posts
|
||||
const result = await payload.find({
|
||||
collection: 'posts',
|
||||
limit: 1000,
|
||||
where: {
|
||||
_status: { equals: 'published' },
|
||||
},
|
||||
});
|
||||
|
||||
console.log(`Found ${result.docs.length} published posts to index.`);
|
||||
|
||||
let indexed = 0;
|
||||
for (const post of result.docs) {
|
||||
const plainContent = extractPlainText(post.content);
|
||||
|
||||
// Build searchable text: title + description + tags + content
|
||||
const tags = (post.tags as any[])?.map((t: any) => t.tag).filter(Boolean).join(', ') || '';
|
||||
const searchableText = [
|
||||
`Titel: ${post.title}`,
|
||||
`Beschreibung: ${post.description}`,
|
||||
tags ? `Tags: ${tags}` : '',
|
||||
`Inhalt: ${plainContent.substring(0, 2000)}`, // Limit content to avoid token overflow
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n\n');
|
||||
|
||||
// Upsert into Qdrant
|
||||
await upsertPostVector(
|
||||
post.id,
|
||||
searchableText,
|
||||
{
|
||||
content: searchableText,
|
||||
data: {
|
||||
id: post.id,
|
||||
title: post.title,
|
||||
slug: post.slug,
|
||||
description: post.description,
|
||||
tags,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
indexed++;
|
||||
console.log(` ✅ [${indexed}/${result.docs.length}] ${post.title}`);
|
||||
|
||||
// Small delay to avoid rate limiting on the embedding API
|
||||
await new Promise((res) => setTimeout(res, 200));
|
||||
}
|
||||
|
||||
console.log(`\n🎉 Successfully indexed ${indexed} posts into Qdrant.`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
run().catch((e) => {
|
||||
console.error('Indexing failed:', e);
|
||||
process.exit(1);
|
||||
});
|
||||
89
apps/web/seed-context.ts
Normal file
89
apps/web/seed-context.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import { getPayload } from "payload";
|
||||
import configPromise from "./payload.config";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
async function run() {
|
||||
try {
|
||||
let payload;
|
||||
let retries = 5;
|
||||
while (retries > 0) {
|
||||
try {
|
||||
console.log(
|
||||
`Connecting to database (URI: ${process.env.DATABASE_URI || "default"})...`,
|
||||
);
|
||||
payload = await getPayload({ config: configPromise });
|
||||
break;
|
||||
} catch (e: any) {
|
||||
if (
|
||||
e.code === "ECONNREFUSED" ||
|
||||
e.code === "ENOTFOUND" ||
|
||||
e.message?.includes("ECONNREFUSED") ||
|
||||
e.message?.includes("ENOTFOUND") ||
|
||||
e.message?.includes("cannot connect to Postgres")
|
||||
) {
|
||||
console.log(
|
||||
`Database not ready (${e.code || "UNKNOWN"}), retrying in 3 seconds... (${retries} retries left)`,
|
||||
);
|
||||
retries--;
|
||||
await new Promise((res) => setTimeout(res, 3000));
|
||||
} else {
|
||||
console.error("Fatal connection error:", e);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!payload) {
|
||||
throw new Error(
|
||||
"Failed to connect to the database after multiple retries.",
|
||||
);
|
||||
}
|
||||
|
||||
const existing = await payload.find({
|
||||
collection: "context-files",
|
||||
limit: 0,
|
||||
});
|
||||
|
||||
if (existing.totalDocs > 0) {
|
||||
console.log("Context collection already populated. Skipping seed.");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const seedDir = path.resolve(
|
||||
__dirname,
|
||||
"src/payload/collections/ContextFiles/seed",
|
||||
);
|
||||
if (!fs.existsSync(seedDir)) {
|
||||
console.log(`Seed directory not found at ${seedDir}`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const files = fs.readdirSync(seedDir).filter((f) => f.endsWith(".md"));
|
||||
let count = 0;
|
||||
|
||||
for (const file of files) {
|
||||
const content = fs.readFileSync(path.join(seedDir, file), "utf8");
|
||||
await payload.create({
|
||||
collection: "context-files",
|
||||
data: {
|
||||
filename: file,
|
||||
content: content,
|
||||
},
|
||||
});
|
||||
count++;
|
||||
}
|
||||
|
||||
console.log(`Seeded ${count} context files.`);
|
||||
process.exit(0);
|
||||
} catch (e) {
|
||||
console.error("Seeding failed:", e);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
run();
|
||||
@@ -5,21 +5,34 @@ import {
|
||||
getInquiryEmailHtml,
|
||||
getConfirmationEmailHtml,
|
||||
} from "../components/ContactForm/EmailTemplates";
|
||||
import { getPayload } from "payload";
|
||||
import configPromise from "@payload-config";
|
||||
|
||||
export async function sendContactInquiry(data: {
|
||||
name: string;
|
||||
email: string;
|
||||
phone?: string;
|
||||
role?: string;
|
||||
companyName: string;
|
||||
projectType: string;
|
||||
deadline?: string;
|
||||
message: string;
|
||||
isFreeText: boolean;
|
||||
config?: any;
|
||||
}) {
|
||||
try {
|
||||
// Payload removed, directly send emails
|
||||
// 1. Save to Payload CMS (Replaces Directus)
|
||||
const payload = await getPayload({ config: configPromise });
|
||||
await payload.create({
|
||||
collection: "inquiries",
|
||||
data: {
|
||||
name: data.name,
|
||||
email: data.email,
|
||||
companyName: data.companyName,
|
||||
projectType: data.projectType,
|
||||
message: data.message,
|
||||
isFreeText: data.isFreeText,
|
||||
config: data.config || null,
|
||||
},
|
||||
});
|
||||
|
||||
// 2. Send Inquiry to Marc
|
||||
const inquiryResult = await sendEmail({
|
||||
subject: `[PROJEKT] ${data.isFreeText ? "DIREKTANFRAGE" : "KONFIGURATION"}: ${data.companyName || data.name}`,
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useState } from "react";
|
||||
|
||||
/**
|
||||
* Automatisierte Anzeige der Projekt-Verfügbarkeit nach Quartalen.
|
||||
* Berechnet das aktuelle Quartal basierend auf dem heutigen Datum.
|
||||
*/
|
||||
export const Availability: React.FC = () => {
|
||||
const [text, setText] = useState<string>("");
|
||||
|
||||
useEffect(() => {
|
||||
const now = new Date();
|
||||
const month = now.getMonth(); // 0-11
|
||||
const year = now.getFullYear();
|
||||
const quarter = Math.floor(month / 3) + 1;
|
||||
|
||||
// Aktuelles Quartal formatiert (z.B. Q2 2026)
|
||||
setText(`Q${quarter} ${year}`);
|
||||
}, []);
|
||||
|
||||
// Während Hydrierung nichts rendern, um Mismatches zu vermeiden (bei Client-only Logic)
|
||||
if (!text) return <span className="opacity-0">Q- 202-</span>;
|
||||
|
||||
return <>{text}</>;
|
||||
};
|
||||
@@ -3,7 +3,7 @@
|
||||
import * as React from "react";
|
||||
import { useState, useMemo, useEffect, useRef } from "react";
|
||||
import { motion } from "framer-motion";
|
||||
import confetti from "canvas-confetti";
|
||||
import * as confetti from "canvas-confetti";
|
||||
import {
|
||||
Layers,
|
||||
BrainCircuit,
|
||||
@@ -62,7 +62,7 @@ export function ContactForm({
|
||||
initialStepIndex = 0,
|
||||
initialState: injectedState,
|
||||
}: ContactFormProps) {
|
||||
const [flow, setFlow] = useState<FlowState>("direct-message");
|
||||
const [flow, setFlow] = useState<FlowState>("discovery");
|
||||
const [stepIndex, setStepIndex] = useState(initialStepIndex);
|
||||
const [state, setState] = useState<FormState>({
|
||||
...initialState,
|
||||
@@ -125,11 +125,8 @@ export function ContactForm({
|
||||
const result = await sendContactInquiry({
|
||||
name: state.name,
|
||||
email: state.email,
|
||||
phone: state.phone,
|
||||
role: state.role,
|
||||
companyName: state.companyName,
|
||||
projectType: state.projectType,
|
||||
deadline: state.deadline,
|
||||
message: state.message,
|
||||
isFreeText: flow === "direct-message",
|
||||
config: flow === "configurator" ? state : undefined,
|
||||
@@ -193,7 +190,7 @@ export function ContactForm({
|
||||
<button
|
||||
onClick={() => {
|
||||
setIsSubmitted(false);
|
||||
setFlow("direct-message"); // Reset back to dm
|
||||
setFlow("discovery");
|
||||
setStepIndex(0);
|
||||
setState(initialState);
|
||||
}}
|
||||
@@ -207,7 +204,7 @@ export function ContactForm({
|
||||
);
|
||||
}
|
||||
|
||||
// Gateway Flow (Disabled but kept logically if needed, can just not render)
|
||||
// Gateway Flow
|
||||
if (flow === "discovery") {
|
||||
return (
|
||||
<ContactGateway
|
||||
@@ -228,25 +225,14 @@ export function ContactForm({
|
||||
return (
|
||||
<DirectMessageFlow
|
||||
name={state.name}
|
||||
setName={(v) => updateState({ name: v })}
|
||||
email={state.email}
|
||||
setEmail={(v) => updateState({ email: v })}
|
||||
phone={state.phone}
|
||||
setPhone={(v) => updateState({ phone: v })}
|
||||
role={state.role}
|
||||
setRole={(v) => updateState({ role: v })}
|
||||
company={state.companyName}
|
||||
setCompany={(v) => updateState({ companyName: v })}
|
||||
projectType={state.projectType}
|
||||
setProjectType={(v) => updateState({ projectType: v })}
|
||||
deadline={state.deadline}
|
||||
setDeadline={(v) => updateState({ deadline: v })}
|
||||
message={state.message}
|
||||
setMessage={(v) => updateState({ message: v })}
|
||||
onBack={() => setFlow("discovery")} // Can keep, but won't be seen if we hide button
|
||||
onSubmit={() => handleSubmit()}
|
||||
onBack={() => setFlow("discovery")}
|
||||
onSubmit={handleSubmit}
|
||||
isSubmitting={isSubmitting}
|
||||
error={error}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -88,40 +88,41 @@ export const ContactGateway = ({
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-8 w-full">
|
||||
{/* Configurator Path */}
|
||||
<Reveal width="100%" delay={0.3} direction="up">
|
||||
<div className="relative group p-[1px] rounded-3xl overflow-hidden transition-all duration-500">
|
||||
{/* Disabled Overlay Background */}
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-slate-200 to-slate-100 dark:from-slate-800 dark:to-slate-900 opacity-50" />
|
||||
<button
|
||||
onClick={onChooseConfigurator}
|
||||
disabled={!name}
|
||||
className={cn(
|
||||
"group relative flex flex-col items-start p-8 rounded-3xl border text-left transition-all duration-500 overflow-hidden",
|
||||
name
|
||||
? "bg-slate-900 border-slate-800 text-white shadow-2xl hover:-translate-y-2"
|
||||
: "bg-slate-50 border-slate-100 text-slate-400 cursor-not-allowed opacity-60",
|
||||
)}
|
||||
>
|
||||
<div className="absolute top-0 right-0 p-8 opacity-10 group-hover:opacity-20 transition-opacity">
|
||||
<Settings2 size={120} />
|
||||
</div>
|
||||
|
||||
<button
|
||||
disabled
|
||||
className={cn(
|
||||
"w-full h-full relative flex flex-col items-start p-8 rounded-[23px] border text-left bg-slate-50 border-slate-100 text-slate-400 cursor-not-allowed",
|
||||
)}
|
||||
>
|
||||
<div className="absolute top-0 right-0 p-8 opacity-5">
|
||||
<Settings2 size={120} />
|
||||
</div>
|
||||
<Settings2 size={24} className="mb-6 text-green-400" />
|
||||
<h3 className="text-2xl font-bold mb-2 tracking-tight">
|
||||
System-Konfigurator
|
||||
</h3>
|
||||
<p className="text-sm text-slate-400 font-medium mb-8 max-w-[280px]">
|
||||
Konfigurieren Sie Ihr Projekt modular für eine präzise
|
||||
Aufwandsschätzung.
|
||||
</p>
|
||||
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<Settings2 size={24} className="text-slate-300" />
|
||||
<span className="px-2 py-0.5 rounded-full bg-slate-200 text-[8px] font-bold uppercase tracking-wider text-slate-500">
|
||||
Wartungsmodus
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-auto flex items-center gap-2 text-[10px] font-mono uppercase tracking-widest font-bold">
|
||||
<span>Sitzung starten</span>
|
||||
<ArrowRight
|
||||
size={14}
|
||||
className="group-hover:translate-x-1 transition-transform"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<h3 className="text-2xl font-bold mb-2 tracking-tight opacity-50">
|
||||
System-Konfigurator
|
||||
</h3>
|
||||
<p className="text-sm text-slate-400 font-medium mb-8 max-w-[280px] opacity-70">
|
||||
Dieser Modus wird aktuell optimiert und steht in Kürze wieder
|
||||
zur Verfügung.
|
||||
</p>
|
||||
|
||||
<div className="mt-auto flex items-center gap-2 text-[10px] font-mono uppercase tracking-widest font-bold opacity-30">
|
||||
<span>Konfigurator offline</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
{!name && (
|
||||
<div className="absolute inset-0 bg-slate-50/60 backdrop-blur-[6px] z-20" />
|
||||
)}
|
||||
</button>
|
||||
</Reveal>
|
||||
|
||||
{/* Direct Mail Path */}
|
||||
|
||||
@@ -3,247 +3,99 @@
|
||||
import { motion } from "framer-motion";
|
||||
import { cn } from "../../utils/cn";
|
||||
import { Reveal } from "../Reveal";
|
||||
import { ProjectType } from "./types";
|
||||
import {
|
||||
Mail,
|
||||
MessageSquare,
|
||||
ArrowLeft,
|
||||
Send,
|
||||
Phone,
|
||||
User as UserIcon,
|
||||
Calendar,
|
||||
Layers,
|
||||
} from "lucide-react";
|
||||
import { Mail, MessageSquare, ArrowLeft, Send } from "lucide-react";
|
||||
|
||||
interface DirectMessageFlowProps {
|
||||
name: string;
|
||||
setName: (val: string) => void;
|
||||
email: string;
|
||||
setEmail: (val: string) => void;
|
||||
phone: string;
|
||||
setPhone: (val: string) => void;
|
||||
role: string;
|
||||
setRole: (val: string) => void;
|
||||
company: string;
|
||||
setCompany: (val: string) => void;
|
||||
projectType: ProjectType;
|
||||
setProjectType: (val: ProjectType) => void;
|
||||
deadline: string;
|
||||
setDeadline: (val: string) => void;
|
||||
message: string;
|
||||
setMessage: (val: string) => void;
|
||||
onBack: () => void;
|
||||
onSubmit: () => void;
|
||||
isSubmitting: boolean;
|
||||
error?: string | null;
|
||||
}
|
||||
|
||||
export const DirectMessageFlow = ({
|
||||
name,
|
||||
setName,
|
||||
email,
|
||||
setEmail,
|
||||
phone,
|
||||
setPhone,
|
||||
role,
|
||||
setRole,
|
||||
company,
|
||||
setCompany,
|
||||
projectType,
|
||||
setProjectType,
|
||||
deadline,
|
||||
setDeadline,
|
||||
message,
|
||||
setMessage,
|
||||
onBack,
|
||||
onSubmit,
|
||||
isSubmitting,
|
||||
error,
|
||||
}: DirectMessageFlowProps) => {
|
||||
return (
|
||||
<div className="w-full max-w-3xl mx-auto px-4 py-12">
|
||||
<div className="space-y-12">
|
||||
{error && (
|
||||
<Reveal width="100%" delay={0.1}>
|
||||
<div className="bg-red-50 border border-red-100 rounded-2xl p-6 flex items-start gap-4 mb-8">
|
||||
<div className="p-2 bg-red-100 rounded-lg text-red-600">
|
||||
<Mail size={20} className="animate-pulse" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<h3 className="text-red-900 font-bold text-sm uppercase tracking-wider">
|
||||
Übertragungsfehler
|
||||
</h3>
|
||||
<p className="text-red-700/80 font-medium text-base">
|
||||
{error}
|
||||
</p>
|
||||
<div className="mt-2 text-[10px] font-mono text-red-400 uppercase tracking-widest">
|
||||
Status: FEHLER_BEI_SEQUENZ_INIT
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Reveal>
|
||||
)}
|
||||
<Reveal width="100%" delay={0.1}>
|
||||
<button
|
||||
onClick={onBack}
|
||||
className="flex items-center gap-2 text-[10px] font-mono font-bold uppercase tracking-widest text-slate-400 hover:text-slate-900 transition-colors mb-12"
|
||||
>
|
||||
<ArrowLeft size={14} /> Zurück zur Auswahl
|
||||
</button>
|
||||
</Reveal>
|
||||
|
||||
<div className="space-y-12">
|
||||
<Reveal width="100%" delay={0.2}>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<span className="text-[10px] font-mono text-green-600 uppercase tracking-[0.3em] font-bold">
|
||||
DIREKTANFRAGE // SCHRITT_01
|
||||
DIREKTNACHRICHT // MODUS_AKTIVIERT
|
||||
</span>
|
||||
<h2 className="text-3xl md:text-5xl font-bold tracking-tight text-slate-900 line-clamp-2">
|
||||
Lassen Sie uns sprechen.
|
||||
<h2 className="text-3xl font-bold tracking-tight text-slate-900">
|
||||
Wie kann ich helfen, {name.split(" ")[0]}?
|
||||
</h2>
|
||||
<p className="text-slate-500 font-medium text-lg">
|
||||
Senden Sie mir eine Nachricht und ich melde mich zeitnah zurück.
|
||||
<p className="text-slate-500 font-medium">
|
||||
Sende mir eine Nachricht zu {company || "deinem Projekt"} und ich
|
||||
melde mich in Kürze.
|
||||
</p>
|
||||
</div>
|
||||
</Reveal>
|
||||
|
||||
<div className="space-y-12">
|
||||
{/* Section: Mission Focus */}
|
||||
<div className="space-y-8">
|
||||
{/* Email Input */}
|
||||
<Reveal width="100%" delay={0.3} direction="up">
|
||||
<div className="space-y-6">
|
||||
<label className="flex items-center gap-2 text-[10px] font-mono font-bold uppercase tracking-widest text-slate-400">
|
||||
<Layers size={12} /> Mission // Projekt-Typ
|
||||
</label>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
|
||||
{(["website", "web-app", "ecommerce"] as ProjectType[]).map(
|
||||
(type) => {
|
||||
const labels = {
|
||||
website: "Website",
|
||||
"web-app": "Web Application",
|
||||
ecommerce: "E-Commerce",
|
||||
};
|
||||
return (
|
||||
<button
|
||||
key={type}
|
||||
onClick={() => setProjectType(type)}
|
||||
className={cn(
|
||||
"px-6 py-4 rounded-xl border font-bold text-sm transition-all duration-200 text-left",
|
||||
projectType === type
|
||||
? "bg-slate-900 text-white border-slate-900 shadow-lg"
|
||||
: "bg-white border-slate-100 text-slate-500 hover:border-slate-300",
|
||||
)}
|
||||
>
|
||||
{labels[type]}
|
||||
</button>
|
||||
);
|
||||
},
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Reveal>
|
||||
|
||||
{/* Section: Identity Details */}
|
||||
<Reveal width="100%" delay={0.4} direction="up">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div className="space-y-4">
|
||||
<label className="flex items-center gap-2 text-[10px] font-mono font-bold uppercase tracking-widest text-slate-400">
|
||||
<UserIcon size={12} /> Ihr Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="Max Mustermann"
|
||||
className="w-full bg-slate-50 border border-slate-100 rounded-xl px-4 py-3 text-base font-medium focus:outline-none focus:ring-2 focus:ring-slate-900/5 focus:border-slate-900 transition-all"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<label className="flex items-center gap-2 text-[10px] font-mono font-bold uppercase tracking-widest text-slate-400">
|
||||
<Layers size={12} /> Unternehmen (Optional)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={company}
|
||||
onChange={(e) => setCompany(e.target.value)}
|
||||
placeholder="Beispiel GmbH"
|
||||
className="w-full bg-slate-50 border border-slate-100 rounded-xl px-4 py-3 text-base font-medium focus:outline-none focus:ring-2 focus:ring-slate-900/5 focus:border-slate-900 transition-all"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<label className="flex items-center gap-2 text-[10px] font-mono font-bold uppercase tracking-widest text-slate-400">
|
||||
<Mail size={12} /> E-Mail Adresse
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="name@firma.de"
|
||||
className="w-full bg-slate-50 border border-slate-100 rounded-xl px-4 py-3 text-base font-medium focus:outline-none focus:ring-2 focus:ring-slate-900/5 focus:border-slate-900 transition-all"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<label className="flex items-center gap-2 text-[10px] font-mono font-bold uppercase tracking-widest text-slate-400">
|
||||
<Phone size={12} /> Rückruf-Nummer (Optional)
|
||||
</label>
|
||||
<input
|
||||
type="tel"
|
||||
value={phone}
|
||||
onChange={(e) => setPhone(e.target.value)}
|
||||
placeholder="+49 123 456789"
|
||||
className="w-full bg-slate-50 border border-slate-100 rounded-xl px-4 py-3 text-base font-medium focus:outline-none focus:ring-2 focus:ring-slate-900/5 focus:border-slate-900 transition-all"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Reveal>
|
||||
|
||||
{/* Section: Additional Context */}
|
||||
<Reveal width="100%" delay={0.5} direction="up">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div className="space-y-4">
|
||||
<label className="flex items-center gap-2 text-[10px] font-mono font-bold uppercase tracking-widest text-slate-400">
|
||||
<UserIcon size={12} /> Ihre Position
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={role}
|
||||
onChange={(e) => setRole(e.target.value)}
|
||||
placeholder="z.B. Gründer, Marketing Lead..."
|
||||
className="w-full bg-slate-50 border border-slate-100 rounded-xl px-4 py-3 text-base font-medium focus:outline-none focus:ring-2 focus:ring-slate-900/5 focus:border-slate-900 transition-all"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<label className="flex items-center gap-2 text-[10px] font-mono font-bold uppercase tracking-widest text-slate-400">
|
||||
<Calendar size={12} /> Zeitfenster
|
||||
</label>
|
||||
<select
|
||||
value={deadline}
|
||||
onChange={(e) => setDeadline(e.target.value)}
|
||||
className="w-full bg-slate-50 border border-slate-100 rounded-xl px-4 py-3 text-base font-medium focus:outline-none focus:ring-2 focus:ring-slate-900/5 focus:border-slate-900 transition-all appearance-none cursor-pointer"
|
||||
>
|
||||
<option value="asap">ASAP (Sofort)</option>
|
||||
<option value="1month">< 1 Monat (Priorität)</option>
|
||||
<option value="3months">1-3 Monate (Standard)</option>
|
||||
<option value="flexible">Flexibel</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</Reveal>
|
||||
|
||||
{/* Section: Payload */}
|
||||
<Reveal width="100%" delay={0.6} direction="up">
|
||||
<div className="space-y-4">
|
||||
<label className="flex items-center gap-2 text-[10px] font-mono font-bold uppercase tracking-widest text-slate-400">
|
||||
<MessageSquare size={12} /> Nachricht // Briefing
|
||||
<Mail size={12} /> Rückantwort an
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="ihre@email.de"
|
||||
className="w-full bg-slate-50 border border-slate-100 rounded-2xl px-6 py-4 text-lg font-medium focus:outline-none focus:ring-2 focus:ring-slate-900/5 focus:border-slate-900 transition-all"
|
||||
/>
|
||||
</div>
|
||||
</Reveal>
|
||||
|
||||
{/* Message Input */}
|
||||
<Reveal width="100%" delay={0.4} direction="up">
|
||||
<div className="space-y-4">
|
||||
<label className="flex items-center gap-2 text-[10px] font-mono font-bold uppercase tracking-widest text-slate-400">
|
||||
<MessageSquare size={12} /> Ihre Nachricht
|
||||
</label>
|
||||
<textarea
|
||||
value={message}
|
||||
onChange={(e) => setMessage(e.target.value)}
|
||||
placeholder="Beschreiben Sie kurz Ihr Anliegen oder hinterlassen Sie einen Link zum Briefing..."
|
||||
rows={5}
|
||||
className="w-full bg-slate-50 border border-slate-100 rounded-2xl px-6 py-4 text-base font-medium focus:outline-none focus:ring-2 focus:ring-slate-900/5 focus:border-slate-900 transition-all resize-none"
|
||||
placeholder="Beschreiben Sie kurz Ihr Anliegen..."
|
||||
rows={6}
|
||||
className="w-full bg-slate-50 border border-slate-100 rounded-2xl px-6 py-4 text-lg font-medium focus:outline-none focus:ring-2 focus:ring-slate-900/5 focus:border-slate-900 transition-all resize-none"
|
||||
/>
|
||||
</div>
|
||||
</Reveal>
|
||||
|
||||
{/* Submit Button */}
|
||||
<Reveal width="100%" delay={0.7} direction="up">
|
||||
<Reveal width="100%" delay={0.5} direction="up">
|
||||
<button
|
||||
onClick={onSubmit}
|
||||
disabled={isSubmitting || !email || !message || !name}
|
||||
disabled={isSubmitting || !email || !message}
|
||||
className={cn(
|
||||
"group relative w-full py-5 rounded-2xl font-bold text-lg transition-all duration-300 flex items-center justify-center gap-3 overflow-hidden",
|
||||
isSubmitting || !email || !message || !name
|
||||
isSubmitting || !email || !message
|
||||
? "bg-slate-100 text-slate-400 cursor-not-allowed"
|
||||
: "bg-slate-900 text-white shadow-xl hover:shadow-2xl hover:-translate-y-1 active:scale-[0.98]",
|
||||
)}
|
||||
@@ -257,7 +109,7 @@ export const DirectMessageFlow = ({
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span>Anfrage senden</span>
|
||||
<span>Nachricht absenden</span>
|
||||
<Send
|
||||
size={20}
|
||||
className="group-hover:translate-x-1 group-hover:-translate-y-1 transition-transform"
|
||||
|
||||
@@ -4,86 +4,55 @@ export const getInquiryEmailHtml = (data: any) => `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<style>
|
||||
body { font-family: Georgia, 'Times New Roman', Times, serif; background-color: #f8fafc; color: #1e293b; margin: 0; padding: 40px 20px; line-height: 1.6; }
|
||||
.wrapper { max-width: 600px; margin: 0 auto; background-color: #ffffff; border: 1px solid #e2e8f0; border-radius: 4px; overflow: hidden; box-shadow: 0 1px 3px rgba(0,0,0,0.05); }
|
||||
.top-bar { background-color: #0f172a; color: #ffffff; padding: 12px 24px; display: flex; align-items: center; justify-content: space-between; }
|
||||
.brand { font-family: system-ui, sans-serif; font-size: 10px; font-weight: 800; letter-spacing: 0.2em; text-transform: uppercase; }
|
||||
.status { font-family: ui-monospace, monospace; font-size: 9px; color: #10b981; text-transform: uppercase; font-weight: bold; }
|
||||
.header { padding: 40px 32px 24px 32px; border-bottom: 2px solid #f1f5f9; }
|
||||
.heading { font-family: system-ui, sans-serif; font-size: 24px; font-weight: 800; color: #0f172a; margin: 0; letter-spacing: -0.02em; }
|
||||
.content { padding: 32px; }
|
||||
.data-grid { width: 100%; border-collapse: collapse; margin-bottom: 32px; }
|
||||
.data-row td { padding: 12px 0; border-bottom: 1px solid #f8fafc; vertical-align: top; }
|
||||
.label { font-family: system-ui, sans-serif; font-size: 9px; font-weight: 700; color: #94a3b8; text-transform: uppercase; letter-spacing: 0.1em; width: 140px; }
|
||||
.value { font-size: 15px; color: #334155; }
|
||||
.message-container { background-color: #f8fafc; border: 1px solid #f1f5f9; border-radius: 4px; padding: 24px; margin-top: 12px; }
|
||||
.message-text { font-family: Georgia, serif; font-size: 16px; color: #1e293b; white-space: pre-wrap; margin: 0; }
|
||||
.config-code { font-family: ui-monospace, monospace; font-size: 12px; color: #64748b; background: #f1f5f9; padding: 16px; border-radius: 4px; display: block; overflow-x: auto; }
|
||||
.footer { padding: 32px; color: #94a3b8; font-size: 11px; text-align: center; border-top: 1px solid #f1f5f9; }
|
||||
.footer-meta { font-family: ui-monospace, monospace; font-size: 9px; margin-top: 12px; text-transform: uppercase; letter-spacing: 0.05em; }
|
||||
body { font-family: 'Courier New', Courier, monospace; background-color: #0f172a; color: #f8fafc; margin: 0; padding: 20px; }
|
||||
.container { max-width: 600px; margin: 0 auto; background-color: #1e293b; border: 1px solid #334155; padding: 40px; border-radius: 8px; }
|
||||
.header { border-bottom: 2px solid #22c55e; padding-bottom: 20px; margin-bottom: 30px; }
|
||||
.title { font-size: 24px; font-weight: bold; letter-spacing: 2px; color: #f8fafc; }
|
||||
.label { color: #94a3b8; font-size: 12px; text-transform: uppercase; letter-spacing: 1px; margin-bottom: 4px; }
|
||||
.value { font-size: 16px; margin-bottom: 20px; color: #22c55e; }
|
||||
.section { margin-bottom: 30px; }
|
||||
.footer { font-size: 10px; color: #64748b; margin-top: 40px; border-top: 1px solid #334155; padding-top: 20px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrapper">
|
||||
<div class="top-bar">
|
||||
<div class="brand">MINTEL_TECHNICAL_OPERATIONS</div>
|
||||
<div class="status">● INCOMING_INQUIRY</div>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<div class="title">NEUE_ANFRAGE_INPUT</div>
|
||||
</div>
|
||||
|
||||
<div class="header">
|
||||
<h1 class="heading">${data.isFreeText ? "Direktanfrage" : "System-Konfiguration"}</h1>
|
||||
<p style="margin: 8px 0 0 0; color: #64748b; font-size: 14px;">Eingang für ${data.companyName || data.name}</p>
|
||||
<div class="section">
|
||||
<div class="label">ABSENDER</div>
|
||||
<div class="value">${data.name} (${data.email})</div>
|
||||
|
||||
<div class="label">UNTERNEHMEN</div>
|
||||
<div class="value">${data.companyName || "N/A"}</div>
|
||||
|
||||
<div class="label">PROJEKT_TYP</div>
|
||||
<div class="value">${data.projectType}</div>
|
||||
</div>
|
||||
|
||||
<div class="content">
|
||||
<table class="data-grid">
|
||||
<tr class="data-row">
|
||||
<td class="label">Absender</td>
|
||||
<td class="value"><strong>${data.name}</strong><br/><span style="color: #94a3b8; font-size: 13px;">${data.email}</span></td>
|
||||
</tr>
|
||||
${data.phone ? `
|
||||
<tr class="data-row">
|
||||
<td class="label">Telefon</td>
|
||||
<td class="value">${data.phone}</td>
|
||||
</tr>` : ""}
|
||||
${data.role ? `
|
||||
<tr class="data-row">
|
||||
<td class="label">Position</td>
|
||||
<td class="value">${data.role}</td>
|
||||
</tr>` : ""}
|
||||
<tr class="data-row">
|
||||
<td class="label">Unternehmen</td>
|
||||
<td class="value">${data.companyName || "—"}</td>
|
||||
</tr>
|
||||
<tr class="data-row">
|
||||
<td class="label">Projekt-Typ</td>
|
||||
<td class="value">${data.projectType}</td>
|
||||
</tr>
|
||||
${data.deadline ? `
|
||||
<tr class="data-row">
|
||||
<td class="label">Zeitraum</td>
|
||||
<td class="value">${data.deadline}</td>
|
||||
</tr>` : ""}
|
||||
</table>
|
||||
|
||||
${data.isFreeText ? `
|
||||
<div class="label" style="margin-bottom: 8px;">Nachricht</div>
|
||||
<div class="message-container">
|
||||
<div class="message-text">${data.message}</div>
|
||||
${
|
||||
data.isFreeText
|
||||
? `
|
||||
<div class="section">
|
||||
<div class="label">NACHRICHT (FREITEXT)</div>
|
||||
<div class="value" style="white-space: pre-wrap; color: #f8fafc;">${data.message}</div>
|
||||
</div>
|
||||
`
|
||||
: `
|
||||
<div class="section">
|
||||
<div class="label">KONFIGURATION</div>
|
||||
<div class="value" style="font-size: 12px; color: #94a3b8; background: #0f172a; padding: 15px; border-radius: 4px;">
|
||||
${JSON.stringify(data.config, null, 2)}
|
||||
</div>
|
||||
` : `
|
||||
<div class="label" style="margin-bottom: 8px;">Konfigurations-Daten</div>
|
||||
<code class="config-code">${JSON.stringify(data.config, null, 2)}</code>
|
||||
`}
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
|
||||
<div class="footer">
|
||||
Dies ist eine systemgenerierte Benachrichtigung von mintel.me.<br/>
|
||||
<div class="footer-meta">
|
||||
TIMESTAMP: ${new Date().toISOString()} | STATUS: VALIDATED
|
||||
</div>
|
||||
SISTEM_STATUS: VALIDATED<br>
|
||||
TIMESTAMP: ${new Date().toISOString()}
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
@@ -94,42 +63,32 @@ export const getConfirmationEmailHtml = (data: any) => `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<style>
|
||||
body { font-family: Georgia, 'Times New Roman', Times, serif; background-color: #ffffff; color: #1e293b; margin: 0; padding: 40px 20px; line-height: 1.8; }
|
||||
.wrapper { max-width: 600px; margin: 0 auto; }
|
||||
.logo-container { padding-bottom: 48px; border-bottom: 1px solid #f1f5f9; margin-bottom: 48px; }
|
||||
.status-badge { display: inline-block; font-family: system-ui, sans-serif; font-size: 9px; font-weight: 800; color: #3b82f6; text-transform: uppercase; letter-spacing: 0.2em; border: 1px solid #dbeafe; background: #eff6ff; padding: 4px 12px; border-radius: 99px; margin-bottom: 24px; }
|
||||
.greeting { font-family: system-ui, sans-serif; font-size: 32px; font-weight: 800; color: #0f172a; margin: 0 0 16px 0; letter-spacing: -0.03em; }
|
||||
.body-text { font-size: 18px; color: #334155; margin-bottom: 32px; }
|
||||
.cta-box { border-left: 2px solid #0f172a; padding: 8px 0 8px 24px; margin: 40px 0; }
|
||||
.footer { margin-top: 80px; padding-top: 24px; border-top: 1px solid #f1f5f9; font-family: system-ui, sans-serif; font-size: 12px; color: #94a3b8; text-align: center; }
|
||||
.footer a { color: #0f172a; text-decoration: none; font-weight: 600; }
|
||||
body { font-family: 'Courier New', Courier, monospace; background-color: #f8fafc; color: #0f172a; margin: 0; padding: 20px; }
|
||||
.container { max-width: 600px; margin: 0 auto; background-color: #ffffff; border: 1px solid #e2e8f0; padding: 40px; border-radius: 12px; box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1); }
|
||||
.header { text-align: center; margin-bottom: 40px; }
|
||||
.status-badge { display: inline-block; padding: 4px 12px; background-color: #22c55e; color: #0f172a; font-size: 10px; font-weight: bold; border-radius: 9999px; margin-bottom: 16px; }
|
||||
.title { font-size: 28px; font-weight: bold; letter-spacing: -0.02em; margin-bottom: 8px; }
|
||||
.subtitle { color: #64748b; font-size: 16px; line-height: 1.5; }
|
||||
.content { line-height: 1.6; color: #334155; margin-bottom: 40px; }
|
||||
.footer { text-align: center; font-size: 12px; color: #94a3b8; border-top: 1px solid #f1f5f9; padding-top: 30px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrapper">
|
||||
<div class="logo-container">
|
||||
<div class="status-badge">Anfrage eingegangen</div>
|
||||
<h1 class="greeting">Hallo ${data.name.split(" ")[0]},</h1>
|
||||
<p class="body-text">
|
||||
vielen Dank für dein Interesse an einer Zusammenarbeit. Deine Nachricht bezüglich <strong>${data.companyName || "deines Projekts"}</strong> ist sicher bei mir angekommen.
|
||||
</p>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<div class="status-badge">SEQUENZ_INITIIERT</div>
|
||||
<div class="title">Hallo ${data.name.split(" ")[0]},</div>
|
||||
<div class="subtitle">vielen Dank für deine Anfrage.</div>
|
||||
</div>
|
||||
|
||||
<div class="body-text">
|
||||
Ich werde mir die Details umgehend ansehen und mich in der Regel innerhalb der nächsten 24 Stunden persönlich bei dir zurückmelden, um die nächsten Schritte zu besprechen.
|
||||
</div>
|
||||
|
||||
<div class="cta-box">
|
||||
<p style="margin:0; font-style: italic; color: #64748b;">
|
||||
„Technical problems are just puzzles with more moving parts.“
|
||||
</p>
|
||||
<div class="content">
|
||||
<p>Ich habe deine Nachricht erhalten und schaue mir die Details zu <strong>${data.companyName || "deinem Projekt"}</strong> umgehend an.</p>
|
||||
<p>Normalerweise melde ich mich innerhalb von 24 Stunden bei dir zurück, um die nächsten Schritte zu besprechen.</p>
|
||||
</div>
|
||||
|
||||
<div class="footer">
|
||||
© ${new Date().getFullYear()} <a href="https://mintel.me">mintel.me</a> — Marc Mintel<br/>
|
||||
Professional Website Systems & Technical Architecture
|
||||
© ${new Date().getFullYear()} mintel.me — Technical Problem Solving
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
|
||||
@@ -35,7 +35,6 @@ export interface FormState {
|
||||
storageExpansion: number;
|
||||
name: string;
|
||||
email: string;
|
||||
phone: string;
|
||||
role: string;
|
||||
message: string;
|
||||
sitemapFile: File | null;
|
||||
|
||||
@@ -4,6 +4,7 @@ import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { useSafePathname } from "./analytics/useSafePathname";
|
||||
import * as React from "react";
|
||||
import { AISearchResults } from "./search/AISearchResults";
|
||||
|
||||
import IconWhite from "../assets/logo/Icon-White-Transparent.svg";
|
||||
|
||||
@@ -11,6 +12,19 @@ export const Header: React.FC = () => {
|
||||
const pathname = useSafePathname();
|
||||
const [isScrolled, setIsScrolled] = React.useState(false);
|
||||
const [isMobileMenuOpen, setIsMobileMenuOpen] = React.useState(false);
|
||||
const [isAISearchOpen, setIsAISearchOpen] = React.useState(false);
|
||||
|
||||
// Cmd+K to open AI search
|
||||
React.useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
|
||||
e.preventDefault();
|
||||
setIsAISearchOpen(true);
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
const handleScroll = () => {
|
||||
@@ -50,8 +64,8 @@ export const Header: React.FC = () => {
|
||||
{/* Decoupled Background Layer - Prevents backdrop-filter parent context bugs */}
|
||||
<div
|
||||
className={`absolute inset-0 transition-all duration-500 -z-10 ${isScrolled
|
||||
? "bg-white/70 backdrop-blur-xl border-b border-slate-100 shadow-sm shadow-slate-100/50"
|
||||
: "bg-white/80 backdrop-blur-md border-b border-slate-50"
|
||||
? "bg-white/70 backdrop-blur-xl border-b border-slate-100 shadow-sm shadow-slate-100/50"
|
||||
: "bg-white/80 backdrop-blur-md border-b border-slate-50"
|
||||
}`}
|
||||
/>
|
||||
|
||||
@@ -95,8 +109,8 @@ export const Header: React.FC = () => {
|
||||
key={link.href}
|
||||
href={link.href}
|
||||
className={`text-xs font-bold uppercase tracking-widest transition-colors duration-300 relative ${active
|
||||
? "text-slate-900"
|
||||
: "text-slate-400 hover:text-slate-900"
|
||||
? "text-slate-900"
|
||||
: "text-slate-400 hover:text-slate-900"
|
||||
}`}
|
||||
>
|
||||
{active && (
|
||||
@@ -108,6 +122,17 @@ export const Header: React.FC = () => {
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
<button
|
||||
onClick={() => setIsAISearchOpen(true)}
|
||||
className="text-[10px] font-bold uppercase tracking-[0.2em] text-slate-400 hover:text-slate-900 transition-all duration-300 flex items-center gap-1.5 cursor-pointer"
|
||||
title="AI Suche (⌘K)"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
|
||||
<circle cx="11" cy="11" r="8" />
|
||||
<path d="M21 21l-4.35-4.35" />
|
||||
</svg>
|
||||
AI
|
||||
</button>
|
||||
<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 hover:bg-slate-50 transition-all duration-500 hover:-translate-y-0.5 hover:shadow-lg hover:shadow-slate-100"
|
||||
@@ -246,8 +271,8 @@ export const Header: React.FC = () => {
|
||||
href={item.href}
|
||||
onClick={() => setIsMobileMenuOpen(false)}
|
||||
className={`relative flex flex-col justify-center p-6 h-[110px] rounded-2xl border transition-all duration-200 ${active
|
||||
? "bg-slate-50 border-slate-200 ring-1 ring-slate-200"
|
||||
: "bg-white border-slate-100 active:bg-slate-50"
|
||||
? "bg-slate-50 border-slate-200 ring-1 ring-slate-200"
|
||||
: "bg-white border-slate-100 active:bg-slate-50"
|
||||
}`}
|
||||
>
|
||||
<div>
|
||||
@@ -307,6 +332,12 @@ export const Header: React.FC = () => {
|
||||
</React.Fragment>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* AI Search Modal */}
|
||||
<AISearchResults
|
||||
isOpen={isAISearchOpen}
|
||||
onClose={() => setIsAISearchOpen(false)}
|
||||
/>
|
||||
</header>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -47,19 +47,19 @@ export const HeroSection: React.FC = () => {
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{ duration: 1, delay: 0.2 }}
|
||||
className="mb-4 md:mb-8 lg:mb-10 inline-flex items-center gap-3 md:gap-4 px-4 md:px-6 py-2 border border-slate-100 bg-white/40 backdrop-blur-sm rounded-full"
|
||||
className="mb-4 md:mb-10 inline-flex items-center gap-3 md:gap-4 px-4 md:px-6 py-2 border border-slate-100 bg-white/40 backdrop-blur-sm rounded-full"
|
||||
>
|
||||
<div className="flex gap-1">
|
||||
<div className="w-1 h-1 rounded-full bg-blue-500 animate-pulse" />
|
||||
<div className="w-1 h-1 rounded-full bg-blue-300 animate-pulse delay-100" />
|
||||
</div>
|
||||
<span className="text-[9px] md:text-[10px] font-mono font-bold tracking-[0.3em] md:tracking-[0.4em] text-slate-500 uppercase">
|
||||
Webentwicklung // Fixpreis
|
||||
Digital_Architect // v.2026
|
||||
</span>
|
||||
</motion.div>
|
||||
|
||||
{/* Headline */}
|
||||
<h1 className="text-4xl md:text-8xl lg:text-[10rem] xl:text-[11rem] font-black tracking-tighter leading-[0.9] md:leading-[0.85] lg:leading-[0.8] text-slate-900 mb-6 md:mb-8 lg:mb-12 uppercase">
|
||||
<h1 className="text-3xl md:text-[11rem] font-black tracking-tighter leading-[0.9] md:leading-[0.8] text-slate-900 mb-6 md:mb-12 uppercase">
|
||||
<div className="block">
|
||||
<GlitchText delay={0.5} duration={1.2}>
|
||||
Websites
|
||||
@@ -80,19 +80,19 @@ export const HeroSection: React.FC = () => {
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 1, delay: 0.8 }}
|
||||
className="flex flex-col items-center gap-6 md:gap-8 lg:gap-12"
|
||||
className="flex flex-col items-center gap-6 md:gap-12"
|
||||
>
|
||||
<p className="text-base md:text-xl lg:text-3xl text-slate-400 font-medium max-w-2xl leading-relaxed px-4">
|
||||
Ohne Agentur, ohne Baukasten, ohne Stress.{" "}
|
||||
<p className="text-base md:text-3xl text-slate-400 font-medium max-w-2xl leading-relaxed px-4">
|
||||
Ein Entwickler. Ein Ansprechpartner.{" "}
|
||||
<br className="hidden md:block" />
|
||||
<span className="text-slate-900 font-bold tracking-tight">
|
||||
Ein Entwickler. Fixpreis. Fertig.
|
||||
Systematische Architekturen für das Web.
|
||||
</span>
|
||||
</p>
|
||||
|
||||
<div className="flex flex-col md:flex-row items-center justify-center gap-4 md:gap-6 w-full px-5 md:px-0">
|
||||
<Button href="/contact" size="large" className="w-full md:w-auto">
|
||||
Kostenlos anfragen
|
||||
Projekt anfragen
|
||||
</Button>
|
||||
<Button
|
||||
href="/websites"
|
||||
@@ -100,7 +100,7 @@ export const HeroSection: React.FC = () => {
|
||||
size="large"
|
||||
className="w-full md:w-auto"
|
||||
>
|
||||
So funktioniert's
|
||||
Prozess ansehen
|
||||
</Button>
|
||||
</div>
|
||||
</motion.div>
|
||||
@@ -111,12 +111,12 @@ export const HeroSection: React.FC = () => {
|
||||
<div className="absolute inset-0 pointer-events-none border-[1px] border-slate-100 m-4 md:m-8 opacity-40 md:opacity-40" />
|
||||
<div className="absolute top-8 left-8 p-4 hidden md:block opacity-20 transform -rotate-90 origin-top-left transition-opacity hover:opacity-100 group">
|
||||
<span className="text-[10px] font-mono tracking-widest text-slate-400">
|
||||
MARC_MINTEL_DEV
|
||||
POS_TRANSMISSION_001
|
||||
</span>
|
||||
</div>
|
||||
<div className="absolute bottom-4 right-4 md:bottom-8 md:right-8 p-4 opacity-20 transition-opacity hover:opacity-100 scale-75 md:scale-100 origin-bottom-right">
|
||||
<span className="text-[10px] font-mono tracking-widest text-slate-400">
|
||||
FRANKFURT // 2026
|
||||
EST_2026 // M-ARCH
|
||||
</span>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -196,13 +196,11 @@ export const IframeSection: React.FC<IframeSectionProps> = ({
|
||||
|
||||
const updateScrollState = React.useCallback(() => {
|
||||
try {
|
||||
const win = iframeRef.current?.contentWindow;
|
||||
const doc = iframeRef.current?.contentDocument?.documentElement;
|
||||
if (doc && win) {
|
||||
const scrollTop = doc.scrollTop || win.scrollY || 0;
|
||||
const atTop = scrollTop <= 5;
|
||||
if (doc) {
|
||||
const atTop = doc.scrollTop <= 5;
|
||||
const atBottom =
|
||||
scrollTop + doc.clientHeight >= doc.scrollHeight - 5;
|
||||
doc.scrollTop + doc.clientHeight >= doc.scrollHeight - 5;
|
||||
const isScrollable = doc.scrollHeight > doc.clientHeight + 10;
|
||||
setScrollState({ atTop, atBottom, isScrollable });
|
||||
}
|
||||
@@ -495,6 +493,7 @@ export const IframeSection: React.FC<IframeSectionProps> = ({
|
||||
style.textContent = `
|
||||
*::-webkit-scrollbar { display: none !important; }
|
||||
* { -ms-overflow-style: none !important; scrollbar-width: none !important; }
|
||||
body { background: transparent !important; }
|
||||
`;
|
||||
iframe.contentDocument.head.appendChild(style);
|
||||
setTimeout(updateAmbilight, 600);
|
||||
|
||||
389
apps/web/src/components/PayloadRichText.tsx
Normal file
389
apps/web/src/components/PayloadRichText.tsx
Normal file
@@ -0,0 +1,389 @@
|
||||
import {
|
||||
RichText,
|
||||
defaultJSXConverters,
|
||||
} from "@payloadcms/richtext-lexical/react";
|
||||
import type { JSXConverters } from "@payloadcms/richtext-lexical/react";
|
||||
import { MemeCard } from "@/src/components/MemeCard";
|
||||
import { Mermaid } from "@/src/components/Mermaid";
|
||||
import { LeadMagnet } from "@/src/components/LeadMagnet";
|
||||
import { ComparisonRow } from "@/src/components/Landing/ComparisonRow";
|
||||
import { mdxComponents } from "../content-engine/components";
|
||||
import React from "react";
|
||||
|
||||
/**
|
||||
* Renders markdown-style inline links [text](/url) as <a> tags.
|
||||
* Used by mintelP blocks which store body text with links.
|
||||
*/
|
||||
function renderInlineMarkdown(text: string): React.ReactNode {
|
||||
if (!text) return null;
|
||||
const parts = text.split(/(\[[^\]]+\]\([^)]+\)|<Marker>[^<]*<\/Marker>)/);
|
||||
return parts.map((part, i) => {
|
||||
const linkMatch = part.match(/\[([^\]]+)\]\(([^)]+)\)/);
|
||||
if (linkMatch) {
|
||||
return (
|
||||
<a
|
||||
key={i}
|
||||
href={linkMatch[2]}
|
||||
className="text-slate-900 underline underline-offset-4 hover:text-slate-600 transition-colors"
|
||||
>
|
||||
{linkMatch[1]}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
const markerMatch = part.match(/<Marker>([^<]*)<\/Marker>/);
|
||||
if (markerMatch) {
|
||||
return (
|
||||
<mark key={i} className="bg-yellow-100/60 px-1 rounded">
|
||||
{markerMatch[1]}
|
||||
</mark>
|
||||
);
|
||||
}
|
||||
return <React.Fragment key={i}>{part}</React.Fragment>;
|
||||
});
|
||||
}
|
||||
|
||||
const jsxConverters: JSXConverters = {
|
||||
...defaultJSXConverters,
|
||||
// Override paragraph to filter out leftover <TableOfContents /> raw text
|
||||
paragraph: ({ node, nodesToJSX }: any) => {
|
||||
const children = node?.children;
|
||||
if (
|
||||
children?.length === 1 &&
|
||||
children[0]?.type === "text" &&
|
||||
children[0]?.text?.trim()?.startsWith("<") &&
|
||||
children[0]?.text?.trim()?.endsWith("/>")
|
||||
) {
|
||||
return null; // suppress raw JSX component text like <TableOfContents />
|
||||
}
|
||||
return <p>{nodesToJSX({ nodes: children })}</p>;
|
||||
},
|
||||
blocks: {
|
||||
memeCard: ({ node }: any) => (
|
||||
<div className="my-8">
|
||||
<MemeCard
|
||||
template={node.fields.template}
|
||||
captions={node.fields.captions}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
mermaid: ({ node }: any) => (
|
||||
<div className="my-8">
|
||||
<Mermaid
|
||||
id={node.fields.id}
|
||||
title={node.fields.title}
|
||||
showShare={node.fields.showShare}
|
||||
>
|
||||
{node.fields.chartDefinition}
|
||||
</Mermaid>
|
||||
</div>
|
||||
),
|
||||
leadMagnet: ({ node }: any) => (
|
||||
<div className="my-12">
|
||||
<LeadMagnet
|
||||
title={node.fields.title}
|
||||
description={node.fields.description}
|
||||
buttonText={node.fields.buttonText}
|
||||
href={node.fields.href}
|
||||
variant={node.fields.variant}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
comparisonRow: ({ node }: any) => (
|
||||
<ComparisonRow
|
||||
description={node.fields.description}
|
||||
negativeLabel={node.fields.negativeLabel}
|
||||
negativeText={node.fields.negativeText}
|
||||
positiveLabel={node.fields.positiveLabel}
|
||||
positiveText={node.fields.positiveText}
|
||||
reverse={node.fields.reverse}
|
||||
showShare={true}
|
||||
/>
|
||||
),
|
||||
// --- Core text blocks ---
|
||||
mintelP: ({ node }: any) => (
|
||||
<p className="text-base md:text-lg text-slate-600 leading-relaxed mb-6">
|
||||
{renderInlineMarkdown(node.fields.text)}
|
||||
</p>
|
||||
),
|
||||
mintelTldr: ({ node }: any) => (
|
||||
<mdxComponents.TLDR>{node.fields.content}</mdxComponents.TLDR>
|
||||
),
|
||||
// --- MDX Registry Injections ---
|
||||
leadParagraph: ({ node }: any) => (
|
||||
<mdxComponents.LeadParagraph>
|
||||
{node.fields.text}
|
||||
</mdxComponents.LeadParagraph>
|
||||
),
|
||||
articleBlockquote: ({ node }: any) => (
|
||||
<mdxComponents.ArticleBlockquote>
|
||||
{node.fields.quote}
|
||||
{node.fields.author && ` - ${node.fields.author}`}
|
||||
</mdxComponents.ArticleBlockquote>
|
||||
),
|
||||
mintelH2: ({ node }: any) => (
|
||||
<mdxComponents.H2>{node.fields.text}</mdxComponents.H2>
|
||||
),
|
||||
mintelH3: ({ node }: any) => (
|
||||
<mdxComponents.H3>{node.fields.text}</mdxComponents.H3>
|
||||
),
|
||||
mintelHeading: ({ node }: any) => {
|
||||
const displayLevel = node.fields.displayLevel || "h2";
|
||||
if (displayLevel === "h3")
|
||||
return <mdxComponents.H3>{node.fields.text}</mdxComponents.H3>;
|
||||
return <mdxComponents.H2>{node.fields.text}</mdxComponents.H2>;
|
||||
},
|
||||
statsDisplay: ({ node }: any) => (
|
||||
<mdxComponents.StatsDisplay
|
||||
label={node.fields.label}
|
||||
value={node.fields.value}
|
||||
subtext={node.fields.subtext}
|
||||
/>
|
||||
),
|
||||
diagramState: ({ node }: any) => (
|
||||
<div className="my-8">
|
||||
<Mermaid id={`diagram-state-${node.fields.id || Date.now()}`}>
|
||||
{node.fields.definition}
|
||||
</Mermaid>
|
||||
</div>
|
||||
),
|
||||
diagramTimeline: ({ node }: any) => (
|
||||
<div className="my-8">
|
||||
<Mermaid id={`diagram-timeline-${node.fields.id || Date.now()}`}>
|
||||
{node.fields.definition}
|
||||
</Mermaid>
|
||||
</div>
|
||||
),
|
||||
diagramGantt: ({ node }: any) => (
|
||||
<div className="my-8">
|
||||
<Mermaid id={`diagram-gantt-${node.fields.id || Date.now()}`}>
|
||||
{node.fields.definition}
|
||||
</Mermaid>
|
||||
</div>
|
||||
),
|
||||
diagramPie: ({ node }: any) => (
|
||||
<div className="my-8">
|
||||
<Mermaid id={`diagram-pie-${node.fields.id || Date.now()}`}>
|
||||
{node.fields.definition}
|
||||
</Mermaid>
|
||||
</div>
|
||||
),
|
||||
diagramSequence: ({ node }: any) => (
|
||||
<div className="my-8">
|
||||
<Mermaid id={`diagram-seq-${node.fields.id || Date.now()}`}>
|
||||
{node.fields.definition}
|
||||
</Mermaid>
|
||||
</div>
|
||||
),
|
||||
diagramFlow: ({ node }: any) => (
|
||||
<div className="my-8">
|
||||
<Mermaid id={`diagram-flow-${node.fields.id || Date.now()}`}>
|
||||
{node.fields.definition}
|
||||
</Mermaid>
|
||||
</div>
|
||||
),
|
||||
|
||||
waterfallChart: ({ node }: any) => (
|
||||
<mdxComponents.WaterfallChart
|
||||
title={node.fields.title}
|
||||
events={node.fields.metrics || []}
|
||||
/>
|
||||
),
|
||||
premiumComparisonChart: ({ node }: any) => (
|
||||
<mdxComponents.PremiumComparisonChart
|
||||
title={node.fields.title}
|
||||
items={node.fields.datasets || []}
|
||||
/>
|
||||
),
|
||||
iconList: ({ node }: any) => (
|
||||
<mdxComponents.IconList>
|
||||
{node.fields.items?.map((item: any, i: number) => {
|
||||
const isCheck = item.icon === "check" || !item.icon;
|
||||
const isCross = item.icon === "x" || item.icon === "cross";
|
||||
const isBullet = item.icon === "circle" || item.icon === "bullet";
|
||||
return (
|
||||
// @ts-ignore
|
||||
<mdxComponents.IconListItem
|
||||
key={i}
|
||||
check={isCheck}
|
||||
cross={isCross}
|
||||
bullet={isBullet}
|
||||
>
|
||||
{item.title || item.description}
|
||||
</mdxComponents.IconListItem>
|
||||
);
|
||||
})}
|
||||
</mdxComponents.IconList>
|
||||
),
|
||||
statsGrid: ({ node }: any) => {
|
||||
const rawStats = node.fields.stats || [];
|
||||
let statsStr = "";
|
||||
if (Array.isArray(rawStats)) {
|
||||
statsStr = rawStats
|
||||
.map((s: any) => `${s.value || ""}|${s.label || ""}`)
|
||||
.join("~");
|
||||
} else if (typeof rawStats === "string") {
|
||||
statsStr = rawStats;
|
||||
}
|
||||
return <mdxComponents.StatsGrid stats={statsStr} />;
|
||||
},
|
||||
metricBar: ({ node }: any) => (
|
||||
<mdxComponents.MetricBar
|
||||
label={node.fields.label}
|
||||
value={node.fields.value}
|
||||
color={node.fields.color as any}
|
||||
/>
|
||||
),
|
||||
carousel: ({ node }: any) => (
|
||||
<mdxComponents.Carousel
|
||||
items={
|
||||
node.fields.slides?.map((s: any) => ({
|
||||
title: s.title || s.caption || "Slide",
|
||||
content: s.content || s.caption || "",
|
||||
icon: undefined,
|
||||
})) || []
|
||||
}
|
||||
/>
|
||||
),
|
||||
imageText: ({ node }: any) => (
|
||||
<mdxComponents.ImageText
|
||||
image={node.fields.image?.url || ""}
|
||||
title="ImageText Component"
|
||||
>
|
||||
{node.fields.text}
|
||||
</mdxComponents.ImageText>
|
||||
),
|
||||
revenueLossCalculator: ({ node }: any) => (
|
||||
<mdxComponents.RevenueLossCalculator />
|
||||
),
|
||||
performanceChart: ({ node }: any) => <mdxComponents.PerformanceChart />,
|
||||
performanceROICalculator: ({ node }: any) => (
|
||||
<div className="not-prose my-12">
|
||||
<mdxComponents.PerformanceROICalculator />
|
||||
</div>
|
||||
),
|
||||
loadTimeSimulator: ({ node }: any) => (
|
||||
<div className="not-prose my-12">
|
||||
<mdxComponents.LoadTimeSimulator />
|
||||
</div>
|
||||
),
|
||||
architectureBuilder: ({ node }: any) => (
|
||||
<div className="not-prose my-12">
|
||||
<mdxComponents.ArchitectureBuilder />
|
||||
</div>
|
||||
),
|
||||
digitalAssetVisualizer: ({ node }: any) => (
|
||||
<div className="not-prose my-12">
|
||||
<mdxComponents.DigitalAssetVisualizer />
|
||||
</div>
|
||||
),
|
||||
|
||||
twitterEmbed: ({ node }: any) => (
|
||||
<mdxComponents.TwitterEmbed
|
||||
tweetId={node.fields.url?.split("/").pop() || ""}
|
||||
/>
|
||||
),
|
||||
youTubeEmbed: ({ node }: any) => (
|
||||
<mdxComponents.YouTubeEmbed
|
||||
videoId={node.fields.videoId}
|
||||
title={node.fields.title}
|
||||
/>
|
||||
),
|
||||
linkedInEmbed: ({ node }: any) => (
|
||||
<mdxComponents.LinkedInEmbed url={node.fields.url} />
|
||||
),
|
||||
externalLink: ({ node }: any) => (
|
||||
<mdxComponents.ExternalLink href={node.fields.href}>
|
||||
{node.fields.label}
|
||||
</mdxComponents.ExternalLink>
|
||||
),
|
||||
trackedLink: ({ node }: any) => (
|
||||
<mdxComponents.TrackedLink
|
||||
href={node.fields.href}
|
||||
eventName={node.fields.eventName}
|
||||
>
|
||||
{node.fields.label}
|
||||
</mdxComponents.TrackedLink>
|
||||
),
|
||||
articleMeme: ({ node }: any) => (
|
||||
<mdxComponents.ArticleMeme
|
||||
template="drake"
|
||||
captions={node.fields.caption || "Top|Bottom"}
|
||||
image={node.fields.image?.url || undefined}
|
||||
/>
|
||||
),
|
||||
marker: ({ node }: any) => (
|
||||
<mdxComponents.Marker color={node.fields.color} delay={node.fields.delay}>
|
||||
{node.fields.text}
|
||||
</mdxComponents.Marker>
|
||||
),
|
||||
boldNumber: ({ node }: any) => (
|
||||
<mdxComponents.BoldNumber
|
||||
value={node.fields.value}
|
||||
label={node.fields.label}
|
||||
source={node.fields.source}
|
||||
sourceUrl={node.fields.sourceUrl}
|
||||
/>
|
||||
),
|
||||
webVitalsScore: ({ node }: any) => (
|
||||
<mdxComponents.WebVitalsScore
|
||||
values={{
|
||||
lcp: node.fields.lcp,
|
||||
inp: node.fields.inp,
|
||||
cls: node.fields.cls,
|
||||
}}
|
||||
description={node.fields.description}
|
||||
/>
|
||||
),
|
||||
buttonBlock: ({ node }: any) => (
|
||||
<mdxComponents.Button
|
||||
href={node.fields.href}
|
||||
variant={node.fields.variant}
|
||||
size={node.fields.size}
|
||||
showArrow={node.fields.showArrow}
|
||||
>
|
||||
{node.fields.label}
|
||||
</mdxComponents.Button>
|
||||
),
|
||||
articleQuote: ({ node }: any) => (
|
||||
<mdxComponents.ArticleQuote
|
||||
quote={node.fields.quote}
|
||||
author={node.fields.author}
|
||||
role={node.fields.role}
|
||||
source={node.fields.source}
|
||||
sourceUrl={node.fields.sourceUrl}
|
||||
translated={node.fields.translated}
|
||||
isCompany={node.fields.isCompany}
|
||||
/>
|
||||
),
|
||||
reveal: ({ node }: any) => (
|
||||
<mdxComponents.Reveal
|
||||
direction={node.fields.direction}
|
||||
delay={node.fields.delay}
|
||||
>
|
||||
{/* Reveal component takes children, which in MDX is nested content */}
|
||||
<PayloadRichText data={node.fields.content} />
|
||||
</mdxComponents.Reveal>
|
||||
),
|
||||
section: ({ node }: any) => (
|
||||
<mdxComponents.Section title={node.fields.title}>
|
||||
<PayloadRichText data={node.fields.content} />
|
||||
</mdxComponents.Section>
|
||||
),
|
||||
tableOfContents: () => <mdxComponents.TableOfContents />,
|
||||
faqSection: ({ node }: any) => (
|
||||
<mdxComponents.FAQSection>
|
||||
<PayloadRichText data={node.fields.content} />
|
||||
</mdxComponents.FAQSection>
|
||||
),
|
||||
},
|
||||
};
|
||||
|
||||
export function PayloadRichText({ data }: { data: any }) {
|
||||
if (!data) return null;
|
||||
|
||||
return (
|
||||
<div className="article-content max-w-none">
|
||||
<RichText data={data} converters={jsxConverters} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -82,9 +82,9 @@ export const Section: React.FC<SectionProps> = ({
|
||||
|
||||
<div className={cn("relative z-10", containerClass)}>
|
||||
{hasSidebar ? (
|
||||
<div className="grid grid-cols-1 md:grid-cols-12 gap-4 md:gap-12 lg:gap-24">
|
||||
<div className="grid grid-cols-1 md:grid-cols-12 gap-4 md:gap-24">
|
||||
{/* Sidebar: Number & Title */}
|
||||
<div className="md:col-span-4 lg:col-span-3">
|
||||
<div className="md:col-span-3">
|
||||
<div className="md:sticky md:top-40 flex flex-col gap-4 md:gap-8">
|
||||
<div className="flex items-end md:flex-col md:items-start gap-6 md:gap-8">
|
||||
{number && (
|
||||
@@ -124,7 +124,7 @@ export const Section: React.FC<SectionProps> = ({
|
||||
</div>
|
||||
|
||||
{/* Main Content */}
|
||||
<div className="md:col-span-8 lg:col-span-9">{children}</div>
|
||||
<div className="md:col-span-9">{children}</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-full">{children}</div>
|
||||
|
||||
@@ -16,10 +16,6 @@ interface WebVitalsScoreProps {
|
||||
}
|
||||
|
||||
export const WebVitalsScore: React.FC<WebVitalsScoreProps> = ({ values, description }) => {
|
||||
if (!values) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const getStatus = (metric: 'lcp' | 'inp' | 'cls', value: number): 'good' | 'needs-improvement' | 'poor' => {
|
||||
if (metric === 'lcp') return value <= 2.5 ? 'good' : value <= 4.0 ? 'needs-improvement' : 'poor';
|
||||
if (metric === 'inp') return value <= 200 ? 'good' : value <= 500 ? 'needs-improvement' : 'poor';
|
||||
|
||||
554
apps/web/src/components/agent/AgentChat.tsx
Normal file
554
apps/web/src/components/agent/AgentChat.tsx
Normal file
@@ -0,0 +1,554 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useRef, useEffect, useCallback, KeyboardEvent } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { initialState, FormState } from '../../logic/pricing';
|
||||
import {
|
||||
PAGE_SAMPLES,
|
||||
FEATURE_OPTIONS,
|
||||
FUNCTION_OPTIONS,
|
||||
API_OPTIONS,
|
||||
ASSET_OPTIONS,
|
||||
} from '../../logic/pricing/constants';
|
||||
import { sendContactInquiry } from '../../actions/contact';
|
||||
|
||||
// Widgets
|
||||
import { SelectionGrid } from './widgets/SelectionGrid';
|
||||
import { DesignPicker } from './widgets/DesignPicker';
|
||||
import { FileDropzone } from './widgets/FileDropzone';
|
||||
import { ContactFields } from './widgets/ContactFields';
|
||||
import { TimelinePicker } from './widgets/TimelinePicker';
|
||||
import { EstimatePreview } from './widgets/EstimatePreview';
|
||||
|
||||
// AI Orb
|
||||
import AIOrb from '../search/AIOrb';
|
||||
|
||||
interface ToolCall {
|
||||
id: string;
|
||||
name: string;
|
||||
arguments: Record<string, any>;
|
||||
}
|
||||
|
||||
interface ChatMessage {
|
||||
id: string;
|
||||
role: 'user' | 'assistant' | 'tool';
|
||||
content: string;
|
||||
toolCalls?: ToolCall[];
|
||||
rawToolCalls?: any[];
|
||||
tool_call_id?: string;
|
||||
}
|
||||
|
||||
export function AgentChat() {
|
||||
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
||||
const [input, setInput] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [formState, setFormState] = useState<FormState>({ ...initialState } as FormState);
|
||||
const [honeypot, setHoneypot] = useState('');
|
||||
const [isSubmitted, setIsSubmitted] = useState(false);
|
||||
const [pdfUrl, setPdfUrl] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Auto-scroll on new messages
|
||||
useEffect(() => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
}, [messages, isLoading]);
|
||||
|
||||
// Auto-focus input
|
||||
useEffect(() => {
|
||||
inputRef.current?.focus();
|
||||
}, [isLoading]);
|
||||
|
||||
// Track which widgets are locked (already interacted with)
|
||||
const [lockedWidgets, setLockedWidgets] = useState<Set<string>>(new Set());
|
||||
|
||||
const lockWidget = (messageId: string) => {
|
||||
setLockedWidgets((prev) => new Set([...prev, messageId]));
|
||||
};
|
||||
|
||||
const updateFormState = useCallback((updates: Partial<FormState>) => {
|
||||
setFormState((prev) => ({ ...prev, ...updates }));
|
||||
}, []);
|
||||
|
||||
const genId = () => Math.random().toString(36).substring(2, 10);
|
||||
|
||||
// Send message to agent API
|
||||
const sendMessage = async (userMessage?: string) => {
|
||||
const msgText = userMessage || input.trim();
|
||||
if (!msgText && messages.length > 0) return;
|
||||
|
||||
setError(null);
|
||||
setIsLoading(true);
|
||||
|
||||
// Add user message
|
||||
const userMsg: ChatMessage = {
|
||||
id: genId(),
|
||||
role: 'user',
|
||||
content: msgText || 'Hallo!',
|
||||
};
|
||||
|
||||
const newMessages = [...messages, userMsg];
|
||||
setMessages(newMessages);
|
||||
setInput('');
|
||||
|
||||
try {
|
||||
// Build API messages (exclude widget rendering details)
|
||||
const apiMessages = newMessages.map((m) => ({
|
||||
role: m.role,
|
||||
content: m.content,
|
||||
...(m.rawToolCalls ? { tool_calls: m.rawToolCalls } : {}),
|
||||
...(m.tool_call_id ? { tool_call_id: m.tool_call_id } : {}),
|
||||
}));
|
||||
|
||||
const res = await fetch('/api/agent-chat', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
messages: apiMessages,
|
||||
formState,
|
||||
honeypot,
|
||||
}),
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(data.error || 'API request failed');
|
||||
}
|
||||
|
||||
// Process tool calls to update FormState
|
||||
const toolCalls: ToolCall[] = data.toolCalls || [];
|
||||
for (const tc of toolCalls) {
|
||||
processToolCall(tc);
|
||||
}
|
||||
|
||||
// Add assistant message
|
||||
const assistantMsg: ChatMessage = {
|
||||
id: genId(),
|
||||
role: 'assistant',
|
||||
content: data.message || '',
|
||||
toolCalls,
|
||||
rawToolCalls: data.rawToolCalls,
|
||||
};
|
||||
|
||||
setMessages((prev) => [...prev, assistantMsg]);
|
||||
|
||||
// If there are tool calls, we need to send tool results back
|
||||
if (toolCalls.length > 0 && data.rawToolCalls?.length > 0) {
|
||||
// Auto-acknowledge tool calls
|
||||
const toolResultMessages = toolCalls.map((tc) => ({
|
||||
id: genId(),
|
||||
role: 'tool' as const,
|
||||
content: JSON.stringify({ status: 'ok', tool: tc.name }),
|
||||
tool_call_id: tc.id,
|
||||
}));
|
||||
|
||||
setMessages((prev) => [...prev, ...toolResultMessages]);
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error('Agent chat error:', err);
|
||||
setError(err.message || 'Ein Fehler ist aufgetreten.');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Process tool calls to update form state
|
||||
const processToolCall = (tc: ToolCall) => {
|
||||
switch (tc.name) {
|
||||
case 'update_company_info':
|
||||
updateFormState({
|
||||
...(tc.arguments.companyName && { companyName: tc.arguments.companyName }),
|
||||
...(tc.arguments.name && { name: tc.arguments.name }),
|
||||
...(tc.arguments.employeeCount && { employeeCount: tc.arguments.employeeCount }),
|
||||
...(tc.arguments.existingWebsite && { existingWebsite: tc.arguments.existingWebsite }),
|
||||
});
|
||||
break;
|
||||
case 'update_project_type':
|
||||
updateFormState({ projectType: tc.arguments.projectType });
|
||||
break;
|
||||
case 'show_page_selector':
|
||||
if (tc.arguments.preselected?.length) {
|
||||
updateFormState({ selectedPages: tc.arguments.preselected });
|
||||
}
|
||||
break;
|
||||
case 'show_feature_selector':
|
||||
if (tc.arguments.preselected?.length) {
|
||||
updateFormState({ features: tc.arguments.preselected });
|
||||
}
|
||||
break;
|
||||
case 'show_function_selector':
|
||||
if (tc.arguments.preselected?.length) {
|
||||
updateFormState({ functions: tc.arguments.preselected });
|
||||
}
|
||||
break;
|
||||
case 'show_api_selector':
|
||||
if (tc.arguments.preselected?.length) {
|
||||
updateFormState({ apiSystems: tc.arguments.preselected });
|
||||
}
|
||||
break;
|
||||
case 'show_asset_selector':
|
||||
if (tc.arguments.preselected?.length) {
|
||||
updateFormState({ assets: tc.arguments.preselected });
|
||||
}
|
||||
break;
|
||||
case 'show_design_picker':
|
||||
if (tc.arguments.preselected) {
|
||||
updateFormState({ designVibe: tc.arguments.preselected });
|
||||
}
|
||||
break;
|
||||
case 'show_timeline_picker':
|
||||
if (tc.arguments.preselected) {
|
||||
updateFormState({ deadline: tc.arguments.preselected });
|
||||
}
|
||||
break;
|
||||
case 'submit_inquiry':
|
||||
handleSubmitInquiry();
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
// Submit inquiry
|
||||
const handleSubmitInquiry = async () => {
|
||||
try {
|
||||
const result = await sendContactInquiry({
|
||||
name: formState.name,
|
||||
email: formState.email,
|
||||
companyName: formState.companyName,
|
||||
projectType: formState.projectType,
|
||||
message: formState.message || 'Agent-gestützte Anfrage',
|
||||
isFreeText: false,
|
||||
config: formState,
|
||||
});
|
||||
if (result.success) {
|
||||
setIsSubmitted(true);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Submit error:', e);
|
||||
}
|
||||
};
|
||||
|
||||
// Render tool call as widget
|
||||
const renderToolCallWidget = (tc: ToolCall, messageId: string) => {
|
||||
const isLocked = lockedWidgets.has(`${messageId}-${tc.name}`);
|
||||
const widgetKey = `${messageId}-${tc.name}`;
|
||||
|
||||
switch (tc.name) {
|
||||
case 'show_page_selector':
|
||||
return (
|
||||
<SelectionGrid
|
||||
key={widgetKey}
|
||||
title="Seiten"
|
||||
options={PAGE_SAMPLES.map((p) => ({ id: p.id, label: p.label, desc: p.desc }))}
|
||||
selected={formState.selectedPages}
|
||||
onSelectionChange={(selected) => {
|
||||
updateFormState({ selectedPages: selected });
|
||||
}}
|
||||
locked={isLocked}
|
||||
/>
|
||||
);
|
||||
case 'show_feature_selector':
|
||||
return (
|
||||
<SelectionGrid
|
||||
key={widgetKey}
|
||||
title="Features"
|
||||
options={FEATURE_OPTIONS.map((f) => ({ id: f.id, label: f.label, desc: f.desc }))}
|
||||
selected={formState.features}
|
||||
onSelectionChange={(selected) => {
|
||||
updateFormState({ features: selected });
|
||||
}}
|
||||
locked={isLocked}
|
||||
/>
|
||||
);
|
||||
case 'show_function_selector':
|
||||
return (
|
||||
<SelectionGrid
|
||||
key={widgetKey}
|
||||
title="Funktionen"
|
||||
options={FUNCTION_OPTIONS.map((f) => ({ id: f.id, label: f.label, desc: f.desc }))}
|
||||
selected={formState.functions}
|
||||
onSelectionChange={(selected) => {
|
||||
updateFormState({ functions: selected });
|
||||
}}
|
||||
locked={isLocked}
|
||||
/>
|
||||
);
|
||||
case 'show_api_selector':
|
||||
return (
|
||||
<SelectionGrid
|
||||
key={widgetKey}
|
||||
title="Integrationen"
|
||||
options={API_OPTIONS.map((a) => ({ id: a.id, label: a.label, desc: a.desc }))}
|
||||
selected={formState.apiSystems}
|
||||
onSelectionChange={(selected) => {
|
||||
updateFormState({ apiSystems: selected });
|
||||
}}
|
||||
locked={isLocked}
|
||||
/>
|
||||
);
|
||||
case 'show_asset_selector':
|
||||
return (
|
||||
<SelectionGrid
|
||||
key={widgetKey}
|
||||
title="Vorhandene Assets"
|
||||
options={ASSET_OPTIONS.map((a) => ({ id: a.id, label: a.label, desc: a.desc }))}
|
||||
selected={formState.assets}
|
||||
onSelectionChange={(selected) => {
|
||||
updateFormState({ assets: selected });
|
||||
}}
|
||||
locked={isLocked}
|
||||
/>
|
||||
);
|
||||
case 'show_design_picker':
|
||||
return (
|
||||
<DesignPicker
|
||||
key={widgetKey}
|
||||
selected={formState.designVibe}
|
||||
onSelect={(id) => updateFormState({ designVibe: id })}
|
||||
locked={isLocked}
|
||||
/>
|
||||
);
|
||||
case 'show_timeline_picker':
|
||||
return (
|
||||
<TimelinePicker
|
||||
key={widgetKey}
|
||||
selected={formState.deadline}
|
||||
onSelect={(id) => updateFormState({ deadline: id })}
|
||||
locked={isLocked}
|
||||
/>
|
||||
);
|
||||
case 'show_contact_fields':
|
||||
return (
|
||||
<ContactFields
|
||||
key={widgetKey}
|
||||
email={formState.email}
|
||||
setEmail={(v) => updateFormState({ email: v })}
|
||||
message={formState.message}
|
||||
setMessage={(v) => updateFormState({ message: v })}
|
||||
locked={isLocked}
|
||||
/>
|
||||
);
|
||||
case 'request_file_upload':
|
||||
return (
|
||||
<FileDropzone
|
||||
key={widgetKey}
|
||||
label={tc.arguments.label || 'Dateien hochladen'}
|
||||
files={formState.contactFiles || []}
|
||||
onFilesAdded={(files) => {
|
||||
updateFormState({
|
||||
contactFiles: [...(formState.contactFiles || []), ...files],
|
||||
});
|
||||
}}
|
||||
locked={isLocked}
|
||||
/>
|
||||
);
|
||||
case 'show_estimate_preview':
|
||||
return <EstimatePreview key={widgetKey} formState={formState} />;
|
||||
case 'generate_estimate_pdf':
|
||||
return (
|
||||
<div key={widgetKey} className="w-full">
|
||||
<div className="flex items-center gap-3 p-4 rounded-xl bg-slate-50 border border-slate-200">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="text-slate-500 shrink-0">
|
||||
<path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z" />
|
||||
<polyline points="14 2 14 8 20 8" />
|
||||
<polyline points="16 13 12 17 8 13" />
|
||||
<line x1="12" y1="12" x2="12" y2="17" />
|
||||
</svg>
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-bold text-slate-900">PDF-Angebot</p>
|
||||
<p className="text-[10px] text-slate-500">
|
||||
Das Angebot wird nach Absenden der Anfrage erstellt und zugesendet.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const onKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
sendMessage();
|
||||
}
|
||||
};
|
||||
|
||||
// Start conversation automatically
|
||||
useEffect(() => {
|
||||
if (messages.length === 0) {
|
||||
sendMessage('Hallo!');
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Success view
|
||||
if (isSubmitted) {
|
||||
return (
|
||||
<div className="w-full min-h-[60vh] flex flex-col items-center justify-center text-center space-y-8 p-8">
|
||||
<motion.div
|
||||
initial={{ scale: 0 }}
|
||||
animate={{ scale: 1 }}
|
||||
transition={{ type: 'spring', stiffness: 300, damping: 20 }}
|
||||
className="w-20 h-20 bg-green-500 rounded-full flex items-center justify-center"
|
||||
>
|
||||
<svg width="36" height="36" viewBox="0 0 24 24" fill="none" stroke="white" strokeWidth="3">
|
||||
<polyline points="20 6 9 17 4 12" />
|
||||
</svg>
|
||||
</motion.div>
|
||||
<div className="space-y-2">
|
||||
<h2 className="text-3xl font-black text-slate-900 tracking-tight">Anfrage gesendet!</h2>
|
||||
<p className="text-sm text-slate-500 max-w-md">
|
||||
Marc wird sich in Kürze bei dir unter <strong>{formState.email}</strong> melden.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
setIsSubmitted(false);
|
||||
setMessages([]);
|
||||
setFormState({ ...initialState } as FormState);
|
||||
setLockedWidgets(new Set());
|
||||
}}
|
||||
className="text-sm font-bold text-slate-400 underline hover:text-slate-900 transition-colors"
|
||||
>
|
||||
Neue Anfrage starten
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full max-w-4xl mx-auto flex flex-col" style={{ minHeight: '70vh' }}>
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-3 pb-6 mb-6 border-b border-slate-100">
|
||||
<AIOrb isThinking={isLoading} size="sm" />
|
||||
<div>
|
||||
<h2 className="text-sm font-black tracking-tight text-slate-900 uppercase">
|
||||
Projekt-Assistent
|
||||
</h2>
|
||||
<p className="text-[10px] font-mono text-slate-400 tracking-wider">
|
||||
AI-GESTÜTZTE BERATUNG
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Chat Messages */}
|
||||
<div className="flex-1 space-y-6 overflow-y-auto pb-6">
|
||||
<AnimatePresence mode="popLayout">
|
||||
{messages
|
||||
.filter((m) => m.role !== 'tool')
|
||||
.map((msg) => (
|
||||
<motion.div
|
||||
key={msg.id}
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
className={`flex ${msg.role === 'user' ? 'justify-end' : 'justify-start'}`}
|
||||
>
|
||||
<div
|
||||
className={`max-w-[85%] space-y-3 ${msg.role === 'user'
|
||||
? 'bg-slate-900 text-white rounded-2xl rounded-tr-sm p-4'
|
||||
: ''
|
||||
}`}
|
||||
>
|
||||
{/* Text content */}
|
||||
{msg.content && (
|
||||
<div
|
||||
className={
|
||||
msg.role === 'assistant'
|
||||
? 'text-sm text-slate-700 leading-relaxed whitespace-pre-wrap'
|
||||
: 'text-sm leading-relaxed'
|
||||
}
|
||||
>
|
||||
{msg.role === 'user' && msg.content === 'Hallo!'
|
||||
? null
|
||||
: msg.content}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tool call widgets */}
|
||||
{msg.role === 'assistant' && msg.toolCalls && msg.toolCalls.length > 0 && (
|
||||
<div className="space-y-3 mt-2">
|
||||
{msg.toolCalls
|
||||
.filter((tc) => !['update_company_info', 'update_project_type', 'submit_inquiry'].includes(tc.name))
|
||||
.map((tc) => renderToolCallWidget(tc, msg.id))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Loading indicator */}
|
||||
{isLoading && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
className="flex justify-start"
|
||||
>
|
||||
<div className="flex items-center gap-3 px-2">
|
||||
<AIOrb isThinking={true} size="sm" />
|
||||
<span className="text-xs text-slate-400 font-mono animate-pulse">
|
||||
denkt nach...
|
||||
</span>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<div className="flex items-center gap-2 p-3 bg-red-50 text-red-600 rounded-xl border border-red-100 text-xs font-bold">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<line x1="15" y1="9" x2="9" y2="15" />
|
||||
<line x1="9" y1="9" x2="15" y2="15" />
|
||||
</svg>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
|
||||
{/* Input */}
|
||||
<div className="pt-4 border-t border-slate-100 mt-auto">
|
||||
<div className="relative flex items-center rounded-xl border border-slate-200 bg-white transition-all focus-within:border-slate-900 focus-within:ring-1 focus-within:ring-slate-900">
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={onKeyDown}
|
||||
placeholder="Beschreibe dein Projekt..."
|
||||
disabled={isLoading}
|
||||
className="flex-1 bg-transparent border-none text-sm p-4 focus:outline-none text-slate-900 placeholder:text-slate-300"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
className="hidden"
|
||||
value={honeypot}
|
||||
onChange={(e) => setHoneypot(e.target.value)}
|
||||
tabIndex={-1}
|
||||
autoComplete="off"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<button
|
||||
onClick={() => sendMessage()}
|
||||
disabled={!input.trim() || isLoading}
|
||||
className="p-4 transition-all shrink-0 cursor-pointer disabled:opacity-30 text-slate-400 hover:text-slate-900"
|
||||
aria-label="Nachricht senden"
|
||||
>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M22 2L11 13M22 2l-7 20-4-9-9-4 20-7z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-center text-[10px] text-slate-300 mt-2 font-mono tracking-wider">
|
||||
Enter zum Senden
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
55
apps/web/src/components/agent/widgets/ContactFields.tsx
Normal file
55
apps/web/src/components/agent/widgets/ContactFields.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
'use client';
|
||||
|
||||
import { cn } from '../../../utils/cn';
|
||||
|
||||
interface ContactFieldsProps {
|
||||
email: string;
|
||||
setEmail: (val: string) => void;
|
||||
message: string;
|
||||
setMessage: (val: string) => void;
|
||||
locked?: boolean;
|
||||
}
|
||||
|
||||
export function ContactFields({
|
||||
email,
|
||||
setEmail,
|
||||
message,
|
||||
setMessage,
|
||||
locked = false,
|
||||
}: ContactFieldsProps) {
|
||||
return (
|
||||
<div className="space-y-3 w-full">
|
||||
<h4 className="text-[10px] font-mono font-bold uppercase tracking-[0.2em] text-slate-400">
|
||||
Kontaktdaten
|
||||
</h4>
|
||||
<div className="space-y-2">
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="ihre@email.de"
|
||||
disabled={locked}
|
||||
className={cn(
|
||||
'w-full px-4 py-3 rounded-xl border text-sm font-medium transition-all focus:outline-none',
|
||||
'bg-white border-slate-200 text-slate-900 placeholder:text-slate-300',
|
||||
'focus:border-slate-900 focus:ring-1 focus:ring-slate-900',
|
||||
locked && 'opacity-60',
|
||||
)}
|
||||
/>
|
||||
<textarea
|
||||
value={message}
|
||||
onChange={(e) => setMessage(e.target.value)}
|
||||
placeholder="Optionale Nachricht oder weitere Details..."
|
||||
rows={3}
|
||||
disabled={locked}
|
||||
className={cn(
|
||||
'w-full px-4 py-3 rounded-xl border text-sm font-medium transition-all focus:outline-none resize-none',
|
||||
'bg-white border-slate-200 text-slate-900 placeholder:text-slate-300',
|
||||
'focus:border-slate-900 focus:ring-1 focus:ring-slate-900',
|
||||
locked && 'opacity-60',
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
95
apps/web/src/components/agent/widgets/DesignPicker.tsx
Normal file
95
apps/web/src/components/agent/widgets/DesignPicker.tsx
Normal file
@@ -0,0 +1,95 @@
|
||||
'use client';
|
||||
|
||||
import { cn } from '../../../utils/cn';
|
||||
|
||||
interface DesignOption {
|
||||
id: string;
|
||||
label: string;
|
||||
desc: string;
|
||||
}
|
||||
|
||||
const DESIGN_STYLES: DesignOption[] = [
|
||||
{ id: 'minimal', label: 'Minimalistisch', desc: 'Viel Weißraum, klare Typografie.' },
|
||||
{ id: 'bold', label: 'Mutig & Laut', desc: 'Starke Kontraste, große Schriften.' },
|
||||
{ id: 'nature', label: 'Natürlich', desc: 'Sanfte Erdtöne, organische Formen.' },
|
||||
{ id: 'tech', label: 'Technisch', desc: 'Präzise Linien, dunkle Akzente.' },
|
||||
];
|
||||
|
||||
interface DesignPickerProps {
|
||||
selected: string;
|
||||
onSelect: (id: string) => void;
|
||||
locked?: boolean;
|
||||
}
|
||||
|
||||
export function DesignPicker({ selected, onSelect, locked = false }: DesignPickerProps) {
|
||||
return (
|
||||
<div className="space-y-3 w-full">
|
||||
<h4 className="text-[10px] font-mono font-bold uppercase tracking-[0.2em] text-slate-400">
|
||||
Design-Stil
|
||||
</h4>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{DESIGN_STYLES.map((style) => {
|
||||
const isSelected = selected === style.id;
|
||||
return (
|
||||
<button
|
||||
key={style.id}
|
||||
onClick={() => !locked && onSelect(style.id)}
|
||||
disabled={locked}
|
||||
className={cn(
|
||||
'relative flex flex-col p-4 rounded-xl border text-left transition-all duration-200 cursor-pointer',
|
||||
isSelected
|
||||
? 'bg-slate-900 border-slate-800 text-white shadow-lg ring-2 ring-slate-700'
|
||||
: 'bg-white border-slate-200 text-slate-700 hover:border-slate-400',
|
||||
locked && 'opacity-60 cursor-default',
|
||||
)}
|
||||
>
|
||||
{/* Mini illustration */}
|
||||
<div
|
||||
className={cn(
|
||||
'w-full h-12 rounded-lg mb-3 overflow-hidden',
|
||||
isSelected ? 'bg-slate-800' : 'bg-slate-50',
|
||||
)}
|
||||
>
|
||||
{style.id === 'minimal' && (
|
||||
<div className="p-2 space-y-1">
|
||||
<div className={cn('h-1 w-3/4 rounded', isSelected ? 'bg-slate-600' : 'bg-slate-200')} />
|
||||
<div className={cn('h-1 w-1/2 rounded', isSelected ? 'bg-slate-700' : 'bg-slate-100')} />
|
||||
</div>
|
||||
)}
|
||||
{style.id === 'bold' && (
|
||||
<div className="p-2 space-y-1">
|
||||
<div className={cn('h-3 w-full rounded', isSelected ? 'bg-slate-600' : 'bg-slate-300')} />
|
||||
<div className={cn('h-3 w-full rounded', isSelected ? 'bg-slate-600' : 'bg-slate-300')} />
|
||||
</div>
|
||||
)}
|
||||
{style.id === 'nature' && (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className={cn('w-8 h-8 rounded-full', isSelected ? 'bg-emerald-800' : 'bg-emerald-100')} />
|
||||
<div className={cn('w-6 h-6 rounded-full -ml-2', isSelected ? 'bg-emerald-700' : 'bg-emerald-50')} />
|
||||
</div>
|
||||
)}
|
||||
{style.id === 'tech' && (
|
||||
<div className="p-2 grid grid-cols-2 gap-1 h-full">
|
||||
<div className={cn('rounded border', isSelected ? 'border-slate-600' : 'border-slate-200')} />
|
||||
<div className={cn('rounded border', isSelected ? 'border-slate-600' : 'border-slate-200')} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-sm font-bold">{style.label}</span>
|
||||
<span className={cn('text-[10px] mt-0.5', isSelected ? 'text-slate-400' : 'text-slate-400')}>
|
||||
{style.desc}
|
||||
</span>
|
||||
{isSelected && (
|
||||
<div className="absolute top-2 right-2">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3" className="text-green-400">
|
||||
<polyline points="20 6 9 17 4 12" />
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
53
apps/web/src/components/agent/widgets/EstimatePreview.tsx
Normal file
53
apps/web/src/components/agent/widgets/EstimatePreview.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
'use client';
|
||||
|
||||
import { PRICING } from '../../../logic/pricing/constants';
|
||||
import { calculateTotals } from '@mintel/pdf';
|
||||
|
||||
interface EstimatePreviewProps {
|
||||
formState: any;
|
||||
}
|
||||
|
||||
export function EstimatePreview({ formState }: EstimatePreviewProps) {
|
||||
const totals = calculateTotals(formState, PRICING);
|
||||
|
||||
const items = [
|
||||
{ label: 'Seiten', value: totals.totalPagesCount },
|
||||
{ label: 'Features', value: totals.totalFeatures },
|
||||
{ label: 'Funktionen', value: totals.totalFunctions },
|
||||
{ label: 'Integrationen', value: totals.totalApis },
|
||||
{ label: 'Sprachen', value: totals.languagesCount },
|
||||
].filter((i) => i.value > 0);
|
||||
|
||||
return (
|
||||
<div className="w-full rounded-xl border border-slate-200 overflow-hidden bg-white">
|
||||
<div className="p-4 bg-slate-900 text-white">
|
||||
<h4 className="text-[10px] font-mono font-bold uppercase tracking-[0.2em] text-slate-400 mb-1">
|
||||
Kostenübersicht
|
||||
</h4>
|
||||
<div className="flex items-baseline gap-1">
|
||||
<span className="text-3xl font-black tracking-tight">
|
||||
{totals.totalPrice.toLocaleString('de-DE')}
|
||||
</span>
|
||||
<span className="text-sm font-bold text-slate-400">€ netto</span>
|
||||
</div>
|
||||
{totals.monthlyPrice > 0 && (
|
||||
<p className="text-xs text-slate-400 mt-1">
|
||||
+ {totals.monthlyPrice.toLocaleString('de-DE')} € / Monat (Hosting & Betrieb)
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{items.length > 0 && (
|
||||
<div className="p-4 grid grid-cols-3 gap-3">
|
||||
{items.map((item) => (
|
||||
<div key={item.label} className="text-center">
|
||||
<p className="text-xl font-black text-slate-900">{item.value}</p>
|
||||
<p className="text-[10px] font-mono font-bold text-slate-400 uppercase tracking-wider">
|
||||
{item.label}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
115
apps/web/src/components/agent/widgets/FileDropzone.tsx
Normal file
115
apps/web/src/components/agent/widgets/FileDropzone.tsx
Normal file
@@ -0,0 +1,115 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useState } from 'react';
|
||||
import { cn } from '../../../utils/cn';
|
||||
|
||||
interface FileDropzoneProps {
|
||||
label?: string;
|
||||
onFilesAdded: (files: File[]) => void;
|
||||
files: File[];
|
||||
locked?: boolean;
|
||||
}
|
||||
|
||||
export function FileDropzone({
|
||||
label = 'Dateien hier ablegen',
|
||||
onFilesAdded,
|
||||
files,
|
||||
locked = false,
|
||||
}: FileDropzoneProps) {
|
||||
const [isDragOver, setIsDragOver] = useState(false);
|
||||
|
||||
const handleDrop = useCallback(
|
||||
(e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setIsDragOver(false);
|
||||
if (locked) return;
|
||||
const droppedFiles = Array.from(e.dataTransfer.files);
|
||||
onFilesAdded(droppedFiles);
|
||||
},
|
||||
[onFilesAdded, locked],
|
||||
);
|
||||
|
||||
const handleFileInput = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (locked) return;
|
||||
const selectedFiles = Array.from(e.target.files || []);
|
||||
onFilesAdded(selectedFiles);
|
||||
},
|
||||
[onFilesAdded, locked],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-2 w-full">
|
||||
<div
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
setIsDragOver(true);
|
||||
}}
|
||||
onDragLeave={() => setIsDragOver(false)}
|
||||
onDrop={handleDrop}
|
||||
className={cn(
|
||||
'relative border-2 border-dashed rounded-xl p-6 text-center transition-all duration-200 cursor-pointer',
|
||||
isDragOver
|
||||
? 'border-slate-900 bg-slate-50'
|
||||
: 'border-slate-200 bg-white hover:border-slate-400',
|
||||
locked && 'opacity-60 cursor-default',
|
||||
)}
|
||||
>
|
||||
<input
|
||||
type="file"
|
||||
multiple
|
||||
onChange={handleFileInput}
|
||||
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer"
|
||||
disabled={locked}
|
||||
/>
|
||||
<div className="space-y-2">
|
||||
<svg
|
||||
className="mx-auto text-slate-300"
|
||||
width="32"
|
||||
height="32"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
>
|
||||
<path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4" />
|
||||
<polyline points="17 8 12 3 7 8" />
|
||||
<line x1="12" y1="3" x2="12" y2="15" />
|
||||
</svg>
|
||||
<p className="text-xs font-bold text-slate-500">{label}</p>
|
||||
<p className="text-[10px] text-slate-400">
|
||||
Oder klicken zum Auswählen
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{files.length > 0 && (
|
||||
<div className="space-y-1">
|
||||
{files.map((file, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex items-center gap-2 px-3 py-1.5 bg-slate-50 rounded-lg"
|
||||
>
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
className="text-slate-400 shrink-0"
|
||||
>
|
||||
<path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z" />
|
||||
<polyline points="14 2 14 8 20 8" />
|
||||
</svg>
|
||||
<span className="text-xs text-slate-600 font-medium truncate">{file.name}</span>
|
||||
<span className="text-[10px] text-slate-400 shrink-0">
|
||||
{(file.size / 1024).toFixed(0)} KB
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
92
apps/web/src/components/agent/widgets/SelectionGrid.tsx
Normal file
92
apps/web/src/components/agent/widgets/SelectionGrid.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
'use client';
|
||||
|
||||
import { cn } from '../../../utils/cn';
|
||||
|
||||
interface SelectionOption {
|
||||
id: string;
|
||||
label: string;
|
||||
desc?: string;
|
||||
}
|
||||
|
||||
interface SelectionGridProps {
|
||||
title: string;
|
||||
options: SelectionOption[];
|
||||
selected: string[];
|
||||
onSelectionChange: (selected: string[]) => void;
|
||||
locked?: boolean;
|
||||
}
|
||||
|
||||
export function SelectionGrid({
|
||||
title,
|
||||
options,
|
||||
selected,
|
||||
onSelectionChange,
|
||||
locked = false,
|
||||
}: SelectionGridProps) {
|
||||
const toggle = (id: string) => {
|
||||
if (locked) return;
|
||||
const next = selected.includes(id)
|
||||
? selected.filter((s) => s !== id)
|
||||
: [...selected, id];
|
||||
onSelectionChange(next);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-3 w-full">
|
||||
<h4 className="text-[10px] font-mono font-bold uppercase tracking-[0.2em] text-slate-400">
|
||||
{title}
|
||||
</h4>
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-2">
|
||||
{options.map((opt) => {
|
||||
const isSelected = selected.includes(opt.id);
|
||||
return (
|
||||
<button
|
||||
key={opt.id}
|
||||
onClick={() => toggle(opt.id)}
|
||||
disabled={locked}
|
||||
className={cn(
|
||||
'group relative flex flex-col items-start p-3 rounded-xl border text-left transition-all duration-200 cursor-pointer',
|
||||
isSelected
|
||||
? 'bg-slate-900 border-slate-800 text-white shadow-md'
|
||||
: 'bg-white border-slate-200 text-slate-700 hover:border-slate-400 hover:shadow-sm',
|
||||
locked && 'opacity-60 cursor-default',
|
||||
)}
|
||||
>
|
||||
<span className="text-xs font-bold tracking-tight">{opt.label}</span>
|
||||
{opt.desc && (
|
||||
<span
|
||||
className={cn(
|
||||
'text-[10px] mt-0.5 line-clamp-2',
|
||||
isSelected ? 'text-slate-400' : 'text-slate-400',
|
||||
)}
|
||||
>
|
||||
{opt.desc}
|
||||
</span>
|
||||
)}
|
||||
{isSelected && (
|
||||
<div className="absolute top-2 right-2">
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="3"
|
||||
className="text-green-400"
|
||||
>
|
||||
<polyline points="20 6 9 17 4 12" />
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{selected.length > 0 && (
|
||||
<p className="text-[10px] font-mono text-slate-400 mt-1">
|
||||
{selected.length} ausgewählt
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
48
apps/web/src/components/agent/widgets/TimelinePicker.tsx
Normal file
48
apps/web/src/components/agent/widgets/TimelinePicker.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
'use client';
|
||||
|
||||
import { cn } from '../../../utils/cn';
|
||||
|
||||
const TIMELINE_OPTIONS = [
|
||||
{ id: 'asap', label: 'So schnell wie möglich', icon: '⚡' },
|
||||
{ id: '2-3-months', label: 'In 2–3 Monaten', icon: '📅' },
|
||||
{ id: '3-6-months', label: 'In 3–6 Monaten', icon: '🗓️' },
|
||||
{ id: 'flexible', label: 'Flexibel', icon: '🔄' },
|
||||
];
|
||||
|
||||
interface TimelinePickerProps {
|
||||
selected: string;
|
||||
onSelect: (id: string) => void;
|
||||
locked?: boolean;
|
||||
}
|
||||
|
||||
export function TimelinePicker({ selected, onSelect, locked = false }: TimelinePickerProps) {
|
||||
return (
|
||||
<div className="space-y-3 w-full">
|
||||
<h4 className="text-[10px] font-mono font-bold uppercase tracking-[0.2em] text-slate-400">
|
||||
Zeitrahmen
|
||||
</h4>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{TIMELINE_OPTIONS.map((opt) => {
|
||||
const isSelected = selected === opt.id;
|
||||
return (
|
||||
<button
|
||||
key={opt.id}
|
||||
onClick={() => !locked && onSelect(opt.id)}
|
||||
disabled={locked}
|
||||
className={cn(
|
||||
'flex items-center gap-2 p-3 rounded-xl border text-left transition-all duration-200 cursor-pointer',
|
||||
isSelected
|
||||
? 'bg-slate-900 border-slate-800 text-white shadow-md'
|
||||
: 'bg-white border-slate-200 text-slate-700 hover:border-slate-400',
|
||||
locked && 'opacity-60 cursor-default',
|
||||
)}
|
||||
>
|
||||
<span className="text-lg">{opt.icon}</span>
|
||||
<span className="text-xs font-bold">{opt.label}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
66
apps/web/src/components/search/AIOrb.tsx
Normal file
66
apps/web/src/components/search/AIOrb.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
|
||||
interface AIOrbProps {
|
||||
isThinking: boolean;
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
}
|
||||
|
||||
export default function AIOrb({ isThinking = false, size = 'md' }: AIOrbProps) {
|
||||
const sizeMap = {
|
||||
sm: { container: 'w-8 h-8', orb: 'w-5 h-5' },
|
||||
md: { container: 'w-16 h-16', orb: 'w-10 h-10' },
|
||||
lg: { container: 'w-24 h-24', orb: 'w-16 h-16' },
|
||||
};
|
||||
|
||||
const s = sizeMap[size];
|
||||
|
||||
return (
|
||||
<div className={`${s.container} relative flex items-center justify-center`}>
|
||||
{/* Ambient glow */}
|
||||
<div
|
||||
className={`absolute inset-0 rounded-full blur-xl transition-all duration-1000 ${isThinking
|
||||
? 'bg-gradient-to-br from-emerald-400/60 to-cyan-400/40 animate-pulse'
|
||||
: 'bg-gradient-to-br from-slate-400/30 to-slate-300/20'
|
||||
}`}
|
||||
/>
|
||||
|
||||
{/* Orb */}
|
||||
<div
|
||||
className={`${s.orb} rounded-full relative z-10 transition-all duration-700 ${isThinking
|
||||
? 'bg-gradient-to-br from-emerald-400 via-cyan-400 to-blue-500 shadow-lg shadow-emerald-400/40'
|
||||
: 'bg-gradient-to-br from-slate-500 via-slate-400 to-slate-300 shadow-md shadow-slate-400/20'
|
||||
}`}
|
||||
style={{
|
||||
animation: isThinking
|
||||
? 'ai-orb-pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite, ai-orb-rotate 3s linear infinite'
|
||||
: 'ai-orb-float 4s ease-in-out infinite',
|
||||
}}
|
||||
>
|
||||
{/* Inner highlight */}
|
||||
<div
|
||||
className={`absolute inset-[15%] rounded-full transition-all duration-700 ${isThinking
|
||||
? 'bg-gradient-to-br from-white/40 to-transparent'
|
||||
: 'bg-gradient-to-br from-white/25 to-transparent'
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<style jsx>{`
|
||||
@keyframes ai-orb-pulse {
|
||||
0%, 100% { transform: scale(1); }
|
||||
50% { transform: scale(1.15); }
|
||||
}
|
||||
@keyframes ai-orb-rotate {
|
||||
from { filter: hue-rotate(0deg); }
|
||||
to { filter: hue-rotate(360deg); }
|
||||
}
|
||||
@keyframes ai-orb-float {
|
||||
0%, 100% { transform: translateY(0); }
|
||||
50% { transform: translateY(-3px); }
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
419
apps/web/src/components/search/AISearchResults.tsx
Normal file
419
apps/web/src/components/search/AISearchResults.tsx
Normal file
@@ -0,0 +1,419 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useRef, useEffect, KeyboardEvent } from 'react';
|
||||
import Link from 'next/link';
|
||||
import AIOrb from './AIOrb';
|
||||
|
||||
interface PostMatch {
|
||||
id: string;
|
||||
title: string;
|
||||
slug: string;
|
||||
description: string;
|
||||
tags?: string;
|
||||
}
|
||||
|
||||
interface Message {
|
||||
role: 'user' | 'assistant';
|
||||
content: string;
|
||||
posts?: PostMatch[];
|
||||
}
|
||||
|
||||
interface ComponentProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
initialQuery?: string;
|
||||
triggerSearch?: boolean;
|
||||
}
|
||||
|
||||
export function AISearchResults({
|
||||
isOpen,
|
||||
onClose,
|
||||
initialQuery = '',
|
||||
triggerSearch = false,
|
||||
}: ComponentProps) {
|
||||
const [query, setQuery] = useState('');
|
||||
const [messages, setMessages] = useState<Message[]>([]);
|
||||
const [honeypot, setHoneypot] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const modalRef = useRef<HTMLDivElement>(null);
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
document.body.style.overflow = 'hidden';
|
||||
setTimeout(() => inputRef.current?.focus(), 100);
|
||||
|
||||
if (triggerSearch && initialQuery && messages.length === 0) {
|
||||
setQuery(initialQuery);
|
||||
handleSearch(initialQuery);
|
||||
} else if (!triggerSearch) {
|
||||
setQuery('');
|
||||
}
|
||||
} else {
|
||||
document.body.style.overflow = 'unset';
|
||||
setQuery('');
|
||||
setMessages([]);
|
||||
setError(null);
|
||||
setIsLoading(false);
|
||||
}
|
||||
return () => {
|
||||
document.body.style.overflow = 'unset';
|
||||
};
|
||||
}, [isOpen, triggerSearch]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen && initialQuery && messages.length === 0) {
|
||||
setQuery(initialQuery);
|
||||
}
|
||||
}, [initialQuery, isOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
}, [messages, isLoading]);
|
||||
|
||||
// Keyboard shortcut: Cmd+K to open
|
||||
useEffect(() => {
|
||||
const handleGlobalKeyDown = (e: globalThis.KeyboardEvent) => {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
|
||||
e.preventDefault();
|
||||
if (!isOpen) {
|
||||
// Parent handles opening
|
||||
} else {
|
||||
inputRef.current?.focus();
|
||||
}
|
||||
}
|
||||
if (e.key === 'Escape' && isOpen) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleGlobalKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleGlobalKeyDown);
|
||||
}, [isOpen, onClose]);
|
||||
|
||||
const handleSearch = async (searchQuery: string = query) => {
|
||||
if (!searchQuery.trim() || isLoading) return;
|
||||
|
||||
const newUserMessage: Message = { role: 'user', content: searchQuery };
|
||||
const newMessagesContext = [...messages, newUserMessage];
|
||||
|
||||
setMessages(newMessagesContext);
|
||||
setQuery('');
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/ai-search', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
messages: newMessagesContext,
|
||||
honeypot,
|
||||
}),
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(data.error || 'Failed to fetch search results');
|
||||
}
|
||||
|
||||
setMessages((prev) => [
|
||||
...prev,
|
||||
{
|
||||
role: 'assistant',
|
||||
content: data.answerText,
|
||||
posts: data.posts,
|
||||
},
|
||||
]);
|
||||
|
||||
setTimeout(() => inputRef.current?.focus(), 100);
|
||||
} catch (err: any) {
|
||||
console.error(err);
|
||||
setError(err.message || 'Ein Fehler ist aufgetreten. Bitte versuche es erneut.');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const onKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
handleSearch();
|
||||
}
|
||||
if (e.key === 'Escape') {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const handleBackdropClick = (e: React.MouseEvent) => {
|
||||
if (e.target === e.currentTarget) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-[100] flex items-start justify-center pt-16 md:pt-24 px-4 transition-all duration-300"
|
||||
onClick={handleBackdropClick}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
style={{
|
||||
backgroundColor: 'rgba(15, 15, 15, 0.95)',
|
||||
backdropFilter: 'blur(20px)',
|
||||
animation: 'ai-modal-fade-in 0.3s ease-out',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
ref={modalRef}
|
||||
className="relative w-full max-w-4xl overflow-hidden flex flex-col"
|
||||
style={{
|
||||
height: '75vh',
|
||||
background: 'linear-gradient(180deg, rgba(30, 30, 30, 0.95) 0%, rgba(20, 20, 20, 0.98) 100%)',
|
||||
border: '1px solid rgba(255, 255, 255, 0.08)',
|
||||
borderRadius: '1.5rem',
|
||||
boxShadow: '0 25px 50px -12px rgba(0, 0, 0, 0.8)',
|
||||
animation: 'ai-modal-slide-up 0.4s cubic-bezier(0.16, 1, 0.3, 1)',
|
||||
}}
|
||||
>
|
||||
{/* Header */}
|
||||
<div
|
||||
className="p-4 md:p-6 flex items-center justify-between relative z-10"
|
||||
style={{
|
||||
borderBottom: '1px solid rgba(255, 255, 255, 0.06)',
|
||||
background: 'rgba(15, 15, 15, 0.8)',
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<AIOrb isThinking={isLoading} size="sm" />
|
||||
<h2
|
||||
className="font-bold tracking-widest uppercase text-sm"
|
||||
style={{ color: 'rgba(255, 255, 255, 0.9)' }}
|
||||
>
|
||||
AI Assistent
|
||||
</h2>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="transition-colors p-2 rounded-lg hover:bg-white/5"
|
||||
style={{ color: 'rgba(255, 255, 255, 0.4)' }}
|
||||
aria-label="Schließen"
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M18 6L6 18M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Chat History */}
|
||||
<div className="flex-1 overflow-y-auto p-4 md:p-8 relative space-y-6 scroll-smooth">
|
||||
{messages.length === 0 && !isLoading && !error && (
|
||||
<div
|
||||
className="flex flex-col items-center justify-center h-full text-center space-y-4"
|
||||
style={{ opacity: 0.5, animation: 'ai-modal-fade-in 0.5s ease-out 0.2s both' }}
|
||||
>
|
||||
<AIOrb isThinking={false} size="lg" />
|
||||
<p className="text-xl md:text-2xl font-bold mt-6" style={{ color: 'rgba(255, 255, 255, 0.9)' }}>
|
||||
Wie kann ich helfen?
|
||||
</p>
|
||||
<p className="text-sm" style={{ color: 'rgba(255, 255, 255, 0.5)' }}>
|
||||
Frag mich zu Blog-Themen, Technologien oder unseren Services.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{messages.map((msg, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={`flex ${msg.role === 'user' ? 'justify-end' : 'justify-start'}`}
|
||||
>
|
||||
<div
|
||||
className="max-w-[85%] rounded-2xl p-5"
|
||||
style={{
|
||||
...(msg.role === 'user'
|
||||
? {
|
||||
background: 'linear-gradient(135deg, #333 0%, #222 100%)',
|
||||
color: '#fff',
|
||||
borderTopRightRadius: '0.25rem',
|
||||
}
|
||||
: {
|
||||
background: 'rgba(255, 255, 255, 0.04)',
|
||||
border: '1px solid rgba(255, 255, 255, 0.06)',
|
||||
color: 'rgba(255, 255, 255, 0.9)',
|
||||
borderTopLeftRadius: '0.25rem',
|
||||
}),
|
||||
}}
|
||||
>
|
||||
{msg.role === 'assistant' && (
|
||||
<h3
|
||||
className="text-xs font-bold tracking-widest uppercase mb-2 flex items-center gap-1"
|
||||
style={{ color: 'rgba(255, 255, 255, 0.4)' }}
|
||||
>
|
||||
<AIOrb isThinking={false} size="sm" />
|
||||
AI Assistent
|
||||
</h3>
|
||||
)}
|
||||
<div className="text-base leading-relaxed whitespace-pre-wrap">
|
||||
{msg.content}
|
||||
</div>
|
||||
|
||||
{/* Post matches */}
|
||||
{msg.role === 'assistant' && msg.posts && msg.posts.length > 0 && (
|
||||
<div
|
||||
className="mt-6 space-y-3 pt-4"
|
||||
style={{ borderTop: '1px solid rgba(255, 255, 255, 0.08)' }}
|
||||
>
|
||||
<h4
|
||||
className="text-xs font-bold tracking-widest uppercase"
|
||||
style={{ color: 'rgba(255, 255, 255, 0.35)' }}
|
||||
>
|
||||
Relevante Artikel
|
||||
</h4>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
{msg.posts.map((post, idx) => (
|
||||
<Link
|
||||
key={idx}
|
||||
href={`/blog/${post.slug}`}
|
||||
onClick={onClose}
|
||||
className="group flex flex-col justify-between rounded-lg p-4 transition-all duration-300 hover:-translate-y-0.5"
|
||||
style={{
|
||||
background: 'rgba(255, 255, 255, 0.06)',
|
||||
border: '1px solid rgba(255, 255, 255, 0.08)',
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
{post.tags && (
|
||||
<p
|
||||
className="text-[10px] font-bold tracking-wider mb-1"
|
||||
style={{ color: 'rgba(255, 255, 255, 0.35)' }}
|
||||
>
|
||||
{post.tags}
|
||||
</p>
|
||||
)}
|
||||
<h5 className="text-sm font-extrabold mb-1 line-clamp-2 transition-colors" style={{ color: '#fff' }}>
|
||||
{post.title}
|
||||
</h5>
|
||||
<p className="text-xs line-clamp-2" style={{ color: 'rgba(255, 255, 255, 0.5)' }}>
|
||||
{post.description}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center justify-end mt-2">
|
||||
<span
|
||||
className="text-[10px] font-bold tracking-widest uppercase transition-colors"
|
||||
style={{ color: 'rgba(255, 255, 255, 0.5)' }}
|
||||
>
|
||||
Lesen →
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{isLoading && (
|
||||
<div className="flex justify-start">
|
||||
<div className="rounded-2xl p-2 w-20 flex justify-center">
|
||||
<AIOrb isThinking={true} size="md" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div
|
||||
className="flex items-start space-x-4 p-4 rounded-xl mt-4"
|
||||
style={{
|
||||
background: 'rgba(239, 68, 68, 0.1)',
|
||||
border: '1px solid rgba(239, 68, 68, 0.2)',
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<h3 className="text-sm font-bold" style={{ color: 'rgba(239, 68, 68, 0.8)' }}>Fehler</h3>
|
||||
<p className="text-xs mt-1" style={{ color: 'rgba(239, 68, 68, 0.6)' }}>{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
|
||||
{/* Input */}
|
||||
<div
|
||||
className="p-4 md:p-6"
|
||||
style={{
|
||||
borderTop: '1px solid rgba(255, 255, 255, 0.06)',
|
||||
background: 'rgba(15, 15, 15, 0.8)',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="relative flex items-center rounded-xl transition-all"
|
||||
style={{
|
||||
background: 'rgba(255, 255, 255, 0.04)',
|
||||
border: '1px solid rgba(255, 255, 255, 0.08)',
|
||||
}}
|
||||
>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
onKeyDown={onKeyDown}
|
||||
placeholder="Stelle eine Frage..."
|
||||
className="flex-1 bg-transparent border-none text-base md:text-lg p-4 focus:outline-none"
|
||||
style={{
|
||||
color: 'rgba(255, 255, 255, 0.9)',
|
||||
}}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
className="hidden"
|
||||
value={honeypot}
|
||||
onChange={(e) => setHoneypot(e.target.value)}
|
||||
tabIndex={-1}
|
||||
autoComplete="off"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<button
|
||||
onClick={() => handleSearch()}
|
||||
disabled={!query.trim() || isLoading}
|
||||
className="p-4 transition-colors shrink-0 cursor-pointer disabled:opacity-30"
|
||||
style={{ color: 'rgba(255, 255, 255, 0.5)' }}
|
||||
aria-label="Nachricht senden"
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M22 2L11 13M22 2l-7 20-4-9-9-4 20-7z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div className="text-center mt-3">
|
||||
<span
|
||||
className="text-[10px] uppercase tracking-widest font-bold"
|
||||
style={{ color: 'rgba(255, 255, 255, 0.2)' }}
|
||||
>
|
||||
Enter zum Senden • Esc zum Schließen
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style jsx>{`
|
||||
@keyframes ai-modal-fade-in {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
@keyframes ai-modal-slide-up {
|
||||
from { opacity: 0; transform: translateY(20px) scale(0.98); }
|
||||
to { opacity: 1; transform: translateY(0) scale(1); }
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -85,7 +85,6 @@ export const mdxComponents = {
|
||||
Section,
|
||||
Reveal,
|
||||
TableOfContents,
|
||||
TOC: TableOfContents,
|
||||
RevenueLossCalculator,
|
||||
PerformanceChart,
|
||||
PerformanceROICalculator,
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
import { ComponentDefinition } from "@mintel/content-engine";
|
||||
import { allComponentDefinitions } from "../payload/blocks/allBlocks";
|
||||
|
||||
export const componentDefinitions: ComponentDefinition[] = [];
|
||||
/**
|
||||
* Single Source of Truth for all MDX component definitions.
|
||||
* Now dynamically generated from individual Payload block definitions.
|
||||
*/
|
||||
export const componentDefinitions: ComponentDefinition[] =
|
||||
allComponentDefinitions;
|
||||
|
||||
@@ -8,8 +8,8 @@ const envExtension = {
|
||||
// Mail Configuration
|
||||
MAIL_HOST: z.string().optional(),
|
||||
MAIL_PORT: z.coerce.number().optional().default(587),
|
||||
MAIL_USERNAME: z.string().optional(),
|
||||
MAIL_PASSWORD: z.string().optional(),
|
||||
MAIL_USER: z.string().optional(),
|
||||
MAIL_PASS: z.string().optional(),
|
||||
MAIL_FROM: z.string().optional().default("marc@mintel.me"),
|
||||
MAIL_RECIPIENTS: z.string().optional().default("marc@mintel.me"),
|
||||
|
||||
@@ -21,16 +21,8 @@ const envExtension = {
|
||||
.optional()
|
||||
.default("https://analytics.infra.mintel.me"),
|
||||
|
||||
// S3 Storage (Required for importMap at build-time)
|
||||
S3_ENDPOINT: z.string().optional(),
|
||||
S3_ACCESS_KEY: z.string().optional(),
|
||||
S3_SECRET_KEY: z.string().optional(),
|
||||
S3_BUCKET: z.string().optional(),
|
||||
S3_REGION: z.string().optional(),
|
||||
S3_PREFIX: z.string().optional(),
|
||||
|
||||
// Error Tracking
|
||||
SENTRY_DSN: z.string().optional(),
|
||||
SENTRY_DSN: z.string().url().optional(),
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -24,8 +24,8 @@ function getTransporter() {
|
||||
port: env.MAIL_PORT,
|
||||
secure: env.MAIL_PORT === 465,
|
||||
auth: {
|
||||
user: env.MAIL_USERNAME,
|
||||
pass: env.MAIL_PASSWORD,
|
||||
user: env.MAIL_USER,
|
||||
pass: env.MAIL_PASS,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -45,23 +45,11 @@ export async function sendEmail({
|
||||
subject,
|
||||
html,
|
||||
}: SendEmailOptions) {
|
||||
let recipients = to || env.MAIL_RECIPIENTS;
|
||||
let from = env.MAIL_FROM;
|
||||
|
||||
if (!from) {
|
||||
from = "info@mintel.me";
|
||||
console.warn("MAIL_FROM is empty. Using fallback: info@mintel.me");
|
||||
}
|
||||
|
||||
if (!recipients) {
|
||||
recipients = "marc@mintel.me";
|
||||
console.warn("MAIL_RECIPIENTS is empty. Using fallback: marc@mintel.me");
|
||||
}
|
||||
|
||||
const recipients = to || env.MAIL_RECIPIENTS;
|
||||
const transporter = getTransporter();
|
||||
|
||||
const mailOptions = {
|
||||
from,
|
||||
from: env.MAIL_FROM,
|
||||
to: recipients,
|
||||
replyTo,
|
||||
subject,
|
||||
|
||||
@@ -1,37 +1,58 @@
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import matter from "gray-matter";
|
||||
|
||||
const POSTS_DIR = path.join(process.cwd(), "content/blog");
|
||||
import { getPayload } from "payload";
|
||||
import configPromise from "@payload-config";
|
||||
|
||||
export async function getAllPosts() {
|
||||
if (!process.env.DATABASE_URI && !process.env.POSTGRES_URI) {
|
||||
console.warn(
|
||||
"⚠️ Bypassing Payload fetch during Next.js build: DATABASE_URI is missing.",
|
||||
);
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
if (!fs.existsSync(POSTS_DIR)) return [];
|
||||
|
||||
const files = fs.readdirSync(POSTS_DIR);
|
||||
const posts = files
|
||||
.filter((filename) => filename.endsWith(".mdx"))
|
||||
.map((filename) => {
|
||||
const filePath = path.join(POSTS_DIR, filename);
|
||||
const fileContent = fs.readFileSync(filePath, "utf-8");
|
||||
const { data, content } = matter(fileContent);
|
||||
const payload = await getPayload({ config: configPromise });
|
||||
const { docs } = await payload.find({
|
||||
collection: "posts",
|
||||
limit: 1000,
|
||||
sort: "-date",
|
||||
where: {
|
||||
and: [
|
||||
{
|
||||
_status: {
|
||||
equals: "published",
|
||||
},
|
||||
},
|
||||
{
|
||||
date: {
|
||||
less_than_equal: new Date(),
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
title: data.title || "",
|
||||
description: data.description || "",
|
||||
date: data.date || new Date().toISOString(),
|
||||
tags: data.tags || [],
|
||||
slug: filename.replace(/\.mdx$/, ""),
|
||||
thumbnail: data.thumbnail || "",
|
||||
body: { code: "" },
|
||||
lexicalContent: null,
|
||||
mdxContent: content,
|
||||
};
|
||||
});
|
||||
|
||||
return posts.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime());
|
||||
return docs.map((doc) => ({
|
||||
title: doc.title as string,
|
||||
description: doc.description as string,
|
||||
date: doc.date as string,
|
||||
tags: (doc.tags || []).map((t) =>
|
||||
typeof t === "object" && t !== null ? t.tag : t,
|
||||
) as string[],
|
||||
slug: doc.slug as string,
|
||||
thumbnail:
|
||||
(doc.featuredImage &&
|
||||
typeof doc.featuredImage === "object" &&
|
||||
doc.featuredImage.url
|
||||
? doc.featuredImage.url
|
||||
: "") || "",
|
||||
body: { code: "" as string },
|
||||
lexicalContent: doc.content || null,
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error("Error reading MDX posts:", error);
|
||||
console.warn(
|
||||
"⚠️ Bypassing Payload fetch during build: Database connection refused.",
|
||||
error,
|
||||
);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
138
apps/web/src/lib/qdrant.ts
Normal file
138
apps/web/src/lib/qdrant.ts
Normal file
@@ -0,0 +1,138 @@
|
||||
import { QdrantClient } from '@qdrant/js-client-rest';
|
||||
import * as os from 'os';
|
||||
|
||||
const isDockerContainer =
|
||||
process.env.IS_DOCKER === 'true' || os.hostname().includes('mintel-me') || process.env.HOSTNAME?.includes('mintel-me');
|
||||
|
||||
let qdrantUrl = process.env.QDRANT_URL || 'http://localhost:6333';
|
||||
if (isDockerContainer && qdrantUrl.includes('localhost')) {
|
||||
qdrantUrl = qdrantUrl.replace('localhost', 'mintel-qdrant');
|
||||
}
|
||||
|
||||
const qdrantApiKey = process.env.QDRANT_API_KEY || '';
|
||||
|
||||
export const qdrant = new QdrantClient({
|
||||
url: qdrantUrl,
|
||||
apiKey: qdrantApiKey || undefined,
|
||||
});
|
||||
|
||||
export const COLLECTION_NAME = 'mintel_posts';
|
||||
export const VECTOR_SIZE = 1536; // OpenAI text-embedding-3-small
|
||||
|
||||
/**
|
||||
* Ensure the collection exists in Qdrant.
|
||||
*/
|
||||
export async function ensureCollection() {
|
||||
try {
|
||||
const collections = await qdrant.getCollections();
|
||||
const exists = collections.collections.some((c) => c.name === COLLECTION_NAME);
|
||||
if (!exists) {
|
||||
await qdrant.createCollection(COLLECTION_NAME, {
|
||||
vectors: {
|
||||
size: VECTOR_SIZE,
|
||||
distance: 'Cosine',
|
||||
},
|
||||
});
|
||||
console.log(`Successfully created Qdrant collection: ${COLLECTION_NAME}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error ensuring Qdrant collection:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate an embedding for a given text using OpenRouter (OpenAI embedding proxy)
|
||||
*/
|
||||
export async function generateEmbedding(text: string): Promise<number[]> {
|
||||
const openRouterKey = process.env.OPENROUTER_API_KEY;
|
||||
if (!openRouterKey) {
|
||||
throw new Error('OPENROUTER_API_KEY is not set');
|
||||
}
|
||||
|
||||
const response = await fetch('https://openrouter.ai/api/v1/embeddings', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${openRouterKey}`,
|
||||
'Content-Type': 'application/json',
|
||||
'HTTP-Referer': process.env.NEXT_PUBLIC_BASE_URL || 'https://mintel.me',
|
||||
'X-Title': 'Mintel.me AI Search',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: 'openai/text-embedding-3-small',
|
||||
input: text,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorBody = await response.text();
|
||||
throw new Error(
|
||||
`Failed to generate embedding: ${response.status} ${response.statusText} ${errorBody}`,
|
||||
);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return data.data[0].embedding;
|
||||
}
|
||||
|
||||
/**
|
||||
* Upsert a post into Qdrant
|
||||
*/
|
||||
export async function upsertPostVector(
|
||||
id: string | number,
|
||||
text: string,
|
||||
payload: Record<string, any>,
|
||||
) {
|
||||
try {
|
||||
await ensureCollection();
|
||||
const vector = await generateEmbedding(text);
|
||||
|
||||
await qdrant.upsert(COLLECTION_NAME, {
|
||||
wait: true,
|
||||
points: [
|
||||
{
|
||||
id,
|
||||
vector,
|
||||
payload,
|
||||
},
|
||||
],
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error writing to Qdrant:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a post from Qdrant
|
||||
*/
|
||||
export async function deletePostVector(id: string | number) {
|
||||
try {
|
||||
await ensureCollection();
|
||||
await qdrant.delete(COLLECTION_NAME, {
|
||||
wait: true,
|
||||
points: [id] as [string | number],
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error deleting from Qdrant:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Search posts in Qdrant
|
||||
*/
|
||||
export async function searchPosts(query: string, limit = 5) {
|
||||
try {
|
||||
await ensureCollection();
|
||||
const vector = await generateEmbedding(query);
|
||||
|
||||
const results = await qdrant.search(COLLECTION_NAME, {
|
||||
vector,
|
||||
limit,
|
||||
with_payload: true,
|
||||
});
|
||||
|
||||
return results;
|
||||
} catch (error) {
|
||||
console.error('Error searching in Qdrant:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
25
apps/web/src/lib/redis.ts
Normal file
25
apps/web/src/lib/redis.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import Redis from 'ioredis';
|
||||
import * as os from 'os';
|
||||
|
||||
const isDockerContainer =
|
||||
process.env.IS_DOCKER === 'true' || os.hostname().includes('mintel-me') || process.env.HOSTNAME?.includes('mintel-me');
|
||||
|
||||
let redisUrl = process.env.REDIS_URL || 'redis://localhost:6379';
|
||||
if (isDockerContainer && redisUrl.includes('localhost')) {
|
||||
redisUrl = redisUrl.replace('localhost', 'mintel-redis');
|
||||
}
|
||||
|
||||
// Only create a single instance in Node.js
|
||||
const globalForRedis = global as unknown as { redis: Redis };
|
||||
|
||||
export const redis =
|
||||
globalForRedis.redis ||
|
||||
new Redis(redisUrl, {
|
||||
maxRetriesPerRequest: 3,
|
||||
});
|
||||
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
globalForRedis.redis = redis;
|
||||
}
|
||||
|
||||
export default redis;
|
||||
@@ -47,7 +47,6 @@ export const initialState: FormState = {
|
||||
storageExpansion: 0,
|
||||
name: "",
|
||||
email: "",
|
||||
phone: "",
|
||||
role: "",
|
||||
message: "",
|
||||
sitemapFile: null,
|
||||
|
||||
@@ -1,90 +1,89 @@
|
||||
export type ProjectType = "website" | "web-app";
|
||||
export type ProjectType = 'website' | 'web-app';
|
||||
|
||||
export interface FormState {
|
||||
projectType: ProjectType;
|
||||
// Company
|
||||
companyName: string;
|
||||
employeeCount: string;
|
||||
// Existing Presence
|
||||
existingWebsite: string;
|
||||
socialMedia: string[];
|
||||
socialMediaUrls: Record<string, string>;
|
||||
existingDomain: string;
|
||||
wishedDomain: string;
|
||||
// Project
|
||||
websiteTopic: string;
|
||||
selectedPages: string[];
|
||||
otherPages: string[];
|
||||
otherPagesCount: number;
|
||||
features: string[];
|
||||
otherFeatures: string[];
|
||||
otherFeaturesCount: number;
|
||||
functions: string[];
|
||||
otherFunctions: string[];
|
||||
otherFunctionsCount: number;
|
||||
apiSystems: string[];
|
||||
otherTech: string[];
|
||||
otherTechCount: number;
|
||||
assets: string[];
|
||||
otherAssets: string[];
|
||||
otherAssetsCount: number;
|
||||
newDatasets: number;
|
||||
cmsSetup: boolean;
|
||||
storageExpansion: number;
|
||||
name: string;
|
||||
email: string;
|
||||
phone: string;
|
||||
role: string;
|
||||
message: string;
|
||||
sitemapFile: any; // Using any for File/null to be CLI-compatible
|
||||
contactFiles: any[]; // Using any[] for File[]
|
||||
// Design
|
||||
designVibe: string;
|
||||
colorScheme: string[];
|
||||
references: string[];
|
||||
designWishes: string;
|
||||
// Maintenance
|
||||
expectedAdjustments: string;
|
||||
languagesList: string[];
|
||||
// Timeline
|
||||
deadline: string;
|
||||
// Web App specific
|
||||
targetAudience: string;
|
||||
userRoles: string[];
|
||||
dataSensitivity: string;
|
||||
platformType: string;
|
||||
// Meta
|
||||
dontKnows: string[];
|
||||
visualStaging: string;
|
||||
complexInteractions: string;
|
||||
gridDontKnows?: Record<string, string>;
|
||||
briefingSummary?: string;
|
||||
companyAddress?: string;
|
||||
companyPhone?: string;
|
||||
personName?: string;
|
||||
taxId?: string;
|
||||
designVision?: string;
|
||||
positionDescriptions?: Record<string, string>;
|
||||
sitemap?: {
|
||||
category: string;
|
||||
pages: { title: string; desc: string }[];
|
||||
}[];
|
||||
projectType: ProjectType;
|
||||
// Company
|
||||
companyName: string;
|
||||
employeeCount: string;
|
||||
// Existing Presence
|
||||
existingWebsite: string;
|
||||
socialMedia: string[];
|
||||
socialMediaUrls: Record<string, string>;
|
||||
existingDomain: string;
|
||||
wishedDomain: string;
|
||||
// Project
|
||||
websiteTopic: string;
|
||||
selectedPages: string[];
|
||||
otherPages: string[];
|
||||
otherPagesCount: number;
|
||||
features: string[];
|
||||
otherFeatures: string[];
|
||||
otherFeaturesCount: number;
|
||||
functions: string[];
|
||||
otherFunctions: string[];
|
||||
otherFunctionsCount: number;
|
||||
apiSystems: string[];
|
||||
otherTech: string[];
|
||||
otherTechCount: number;
|
||||
assets: string[];
|
||||
otherAssets: string[];
|
||||
otherAssetsCount: number;
|
||||
newDatasets: number;
|
||||
cmsSetup: boolean;
|
||||
storageExpansion: number;
|
||||
name: string;
|
||||
email: string;
|
||||
role: string;
|
||||
message: string;
|
||||
sitemapFile: any; // Using any for File/null to be CLI-compatible
|
||||
contactFiles: any[]; // Using any[] for File[]
|
||||
// Design
|
||||
designVibe: string;
|
||||
colorScheme: string[];
|
||||
references: string[];
|
||||
designWishes: string;
|
||||
// Maintenance
|
||||
expectedAdjustments: string;
|
||||
languagesList: string[];
|
||||
// Timeline
|
||||
deadline: string;
|
||||
// Web App specific
|
||||
targetAudience: string;
|
||||
userRoles: string[];
|
||||
dataSensitivity: string;
|
||||
platformType: string;
|
||||
// Meta
|
||||
dontKnows: string[];
|
||||
visualStaging: string;
|
||||
complexInteractions: string;
|
||||
gridDontKnows?: Record<string, string>;
|
||||
briefingSummary?: string;
|
||||
companyAddress?: string;
|
||||
companyPhone?: string;
|
||||
personName?: string;
|
||||
taxId?: string;
|
||||
designVision?: string;
|
||||
positionDescriptions?: Record<string, string>;
|
||||
sitemap?: {
|
||||
category: string;
|
||||
pages: { title: string; desc: string }[];
|
||||
}[];
|
||||
}
|
||||
|
||||
export interface Position {
|
||||
pos: number;
|
||||
title: string;
|
||||
desc: string;
|
||||
qty: number;
|
||||
price: number;
|
||||
isRecurring?: boolean;
|
||||
pos: number;
|
||||
title: string;
|
||||
desc: string;
|
||||
qty: number;
|
||||
price: number;
|
||||
isRecurring?: boolean;
|
||||
}
|
||||
export interface Totals {
|
||||
totalPrice: number;
|
||||
monthlyPrice: number;
|
||||
totalPagesCount: number;
|
||||
totalFeatures: number;
|
||||
totalFunctions: number;
|
||||
totalApis: number;
|
||||
languagesCount: number;
|
||||
totalPrice: number;
|
||||
monthlyPrice: number;
|
||||
totalPagesCount: number;
|
||||
totalFeatures: number;
|
||||
totalFunctions: number;
|
||||
totalApis: number;
|
||||
languagesCount: number;
|
||||
}
|
||||
|
||||
@@ -1,69 +0,0 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import type { NextRequest } from 'next/server';
|
||||
|
||||
/**
|
||||
* PRODUCTION STABILIZATION MIDDLEWARE
|
||||
* This middleware handles legacy asset routing for the KLZ showcase,
|
||||
* ensuring assets are correctly mapped regardless of Next.js config wrapper behavior.
|
||||
*/
|
||||
export function middleware(request: NextRequest) {
|
||||
const { pathname } = request.nextUrl;
|
||||
|
||||
|
||||
// 2. Legacy Showcase Asset Mapping
|
||||
// Handles:
|
||||
// - Global assets (/wp-content/*, /wp-includes/*, /assets/*)
|
||||
// - Showcase-specific resources (/case-studies/klz-cables/index.html, etc.)
|
||||
|
||||
// Normalize pathname to ensure we're matching correctly
|
||||
const path = pathname;
|
||||
|
||||
// Map Pattern 2: Showcase-prefixed assets (used for relative links within the showcase)
|
||||
const showcaseMatch = path.match(/^\/(?:case-studies|work|blog)\/([^\/]+)\/(.*)/);
|
||||
if (showcaseMatch) {
|
||||
let [, slug, remainingPath] = showcaseMatch;
|
||||
|
||||
// Safety: Only rewrite if it looks like a static asset (has extension)
|
||||
// or belongs to known legacy folders. This prevents catching Next.js routes.
|
||||
if (remainingPath.match(/\.(?:html|php|js|css|png|jpg|jpeg|svg|gif|webp|woff2?|ttf|pdf|json|xml)$/) ||
|
||||
remainingPath.includes('wp-content/') ||
|
||||
remainingPath.includes('wp-includes/') ||
|
||||
remainingPath.includes('assets/')) {
|
||||
|
||||
// If the URL resolved to /case-studies/assets/..., 'assets' is captured as the slug
|
||||
if (slug === 'assets') {
|
||||
const nextSlash = remainingPath.indexOf('/');
|
||||
if (nextSlash !== -1) {
|
||||
const actualDir = remainingPath.substring(0, nextSlash);
|
||||
slug = actualDir === 'klz-cables.com' ? 'klz-cables' : actualDir; // Normalize back to expected slug
|
||||
remainingPath = remainingPath.substring(nextSlash + 1);
|
||||
}
|
||||
}
|
||||
|
||||
// Normalize slug: klz-cables -> klz-cables.com (literal folder name)
|
||||
const directory = slug === 'klz-cables' ? 'klz-cables.com' : slug;
|
||||
|
||||
// If the remaining path already starts with assets/directory, don't double it
|
||||
const targetPath = remainingPath.startsWith(`assets/${directory}/`)
|
||||
? `/${remainingPath}`
|
||||
: `/assets/${directory}/${remainingPath}`;
|
||||
|
||||
console.log(`[Middleware] Rewriting to: /showcase/${directory}${targetPath}`);
|
||||
return NextResponse.rewrite(new URL(`/showcase/${directory}${targetPath}`, request.url));
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.next();
|
||||
}
|
||||
|
||||
// Optimization: Only run middleware for legacy asset paths to minimize overhead
|
||||
export const config = {
|
||||
matcher: [
|
||||
'/wp-content/:path*',
|
||||
'/wp-includes/:path*',
|
||||
'/assets/:path*',
|
||||
'/case-studies/:path*',
|
||||
'/work/:path*',
|
||||
'/blog/:path*',
|
||||
],
|
||||
};
|
||||
2868
apps/web/src/migrations/20260227_171023_crm_collections.json
Normal file
2868
apps/web/src/migrations/20260227_171023_crm_collections.json
Normal file
File diff suppressed because it is too large
Load Diff
392
apps/web/src/migrations/20260227_171023_crm_collections.ts
Normal file
392
apps/web/src/migrations/20260227_171023_crm_collections.ts
Normal file
@@ -0,0 +1,392 @@
|
||||
import { MigrateUpArgs, MigrateDownArgs, sql } from "@payloadcms/db-postgres";
|
||||
|
||||
export async function up({ db, payload, req }: MigrateUpArgs): Promise<void> {
|
||||
await db.execute(sql`
|
||||
CREATE TYPE "public"."enum_posts_status" AS ENUM('draft', 'published');
|
||||
CREATE TYPE "public"."enum__posts_v_version_status" AS ENUM('draft', 'published');
|
||||
CREATE TYPE "public"."enum_crm_accounts_status" AS ENUM('lead', 'client', 'lost');
|
||||
CREATE TYPE "public"."enum_crm_accounts_lead_temperature" AS ENUM('cold', 'warm', 'hot');
|
||||
CREATE TYPE "public"."enum_crm_interactions_type" AS ENUM('email', 'call', 'meeting', 'note');
|
||||
CREATE TYPE "public"."enum_crm_interactions_direction" AS ENUM('inbound', 'outbound');
|
||||
CREATE TABLE "users_sessions" (
|
||||
"_order" integer NOT NULL,
|
||||
"_parent_id" integer NOT NULL,
|
||||
"id" varchar PRIMARY KEY NOT NULL,
|
||||
"created_at" timestamp(3) with time zone,
|
||||
"expires_at" timestamp(3) with time zone NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE "users" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
|
||||
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
|
||||
"email" varchar NOT NULL,
|
||||
"reset_password_token" varchar,
|
||||
"reset_password_expiration" timestamp(3) with time zone,
|
||||
"salt" varchar,
|
||||
"hash" varchar,
|
||||
"login_attempts" numeric DEFAULT 0,
|
||||
"lock_until" timestamp(3) with time zone
|
||||
);
|
||||
|
||||
CREATE TABLE "media" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"alt" varchar NOT NULL,
|
||||
"prefix" varchar DEFAULT 'mintel-me/media',
|
||||
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
|
||||
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
|
||||
"url" varchar,
|
||||
"thumbnail_u_r_l" varchar,
|
||||
"filename" varchar,
|
||||
"mime_type" varchar,
|
||||
"filesize" numeric,
|
||||
"width" numeric,
|
||||
"height" numeric,
|
||||
"focal_x" numeric,
|
||||
"focal_y" numeric,
|
||||
"sizes_thumbnail_url" varchar,
|
||||
"sizes_thumbnail_width" numeric,
|
||||
"sizes_thumbnail_height" numeric,
|
||||
"sizes_thumbnail_mime_type" varchar,
|
||||
"sizes_thumbnail_filesize" numeric,
|
||||
"sizes_thumbnail_filename" varchar,
|
||||
"sizes_card_url" varchar,
|
||||
"sizes_card_width" numeric,
|
||||
"sizes_card_height" numeric,
|
||||
"sizes_card_mime_type" varchar,
|
||||
"sizes_card_filesize" numeric,
|
||||
"sizes_card_filename" varchar,
|
||||
"sizes_tablet_url" varchar,
|
||||
"sizes_tablet_width" numeric,
|
||||
"sizes_tablet_height" numeric,
|
||||
"sizes_tablet_mime_type" varchar,
|
||||
"sizes_tablet_filesize" numeric,
|
||||
"sizes_tablet_filename" varchar
|
||||
);
|
||||
|
||||
CREATE TABLE "posts_tags" (
|
||||
"_order" integer NOT NULL,
|
||||
"_parent_id" integer NOT NULL,
|
||||
"id" varchar PRIMARY KEY NOT NULL,
|
||||
"tag" varchar
|
||||
);
|
||||
|
||||
CREATE TABLE "posts" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"title" varchar,
|
||||
"slug" varchar,
|
||||
"description" varchar,
|
||||
"date" timestamp(3) with time zone,
|
||||
"featured_image_id" integer,
|
||||
"content" jsonb,
|
||||
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
|
||||
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
|
||||
"_status" "enum_posts_status" DEFAULT 'draft'
|
||||
);
|
||||
|
||||
CREATE TABLE "_posts_v_version_tags" (
|
||||
"_order" integer NOT NULL,
|
||||
"_parent_id" integer NOT NULL,
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"tag" varchar,
|
||||
"_uuid" varchar
|
||||
);
|
||||
|
||||
CREATE TABLE "_posts_v" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"parent_id" integer,
|
||||
"version_title" varchar,
|
||||
"version_slug" varchar,
|
||||
"version_description" varchar,
|
||||
"version_date" timestamp(3) with time zone,
|
||||
"version_featured_image_id" integer,
|
||||
"version_content" jsonb,
|
||||
"version_updated_at" timestamp(3) with time zone,
|
||||
"version_created_at" timestamp(3) with time zone,
|
||||
"version__status" "enum__posts_v_version_status" DEFAULT 'draft',
|
||||
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
|
||||
"latest" boolean
|
||||
);
|
||||
|
||||
CREATE TABLE "inquiries" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"name" varchar NOT NULL,
|
||||
"email" varchar NOT NULL,
|
||||
"company_name" varchar,
|
||||
"project_type" varchar,
|
||||
"message" varchar,
|
||||
"is_free_text" boolean DEFAULT false,
|
||||
"config" jsonb,
|
||||
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
|
||||
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE "redirects" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"from" varchar NOT NULL,
|
||||
"to" varchar NOT NULL,
|
||||
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
|
||||
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE "context_files" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"filename" varchar NOT NULL,
|
||||
"content" varchar NOT NULL,
|
||||
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
|
||||
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE "crm_accounts" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"name" varchar NOT NULL,
|
||||
"website" varchar,
|
||||
"status" "enum_crm_accounts_status" DEFAULT 'lead',
|
||||
"lead_temperature" "enum_crm_accounts_lead_temperature",
|
||||
"assigned_to_id" integer,
|
||||
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
|
||||
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE "crm_accounts_rels" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"order" integer,
|
||||
"parent_id" integer NOT NULL,
|
||||
"path" varchar NOT NULL,
|
||||
"media_id" integer
|
||||
);
|
||||
|
||||
CREATE TABLE "crm_contacts" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"first_name" varchar NOT NULL,
|
||||
"last_name" varchar NOT NULL,
|
||||
"email" varchar NOT NULL,
|
||||
"phone" varchar,
|
||||
"linked_in" varchar,
|
||||
"role" varchar,
|
||||
"account_id" integer,
|
||||
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
|
||||
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE "crm_interactions" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"type" "enum_crm_interactions_type" DEFAULT 'email' NOT NULL,
|
||||
"direction" "enum_crm_interactions_direction",
|
||||
"date" timestamp(3) with time zone NOT NULL,
|
||||
"contact_id" integer,
|
||||
"account_id" integer,
|
||||
"subject" varchar NOT NULL,
|
||||
"content" jsonb,
|
||||
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
|
||||
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE "payload_kv" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"key" varchar NOT NULL,
|
||||
"data" jsonb NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE "payload_locked_documents" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"global_slug" varchar,
|
||||
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
|
||||
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE "payload_locked_documents_rels" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"order" integer,
|
||||
"parent_id" integer NOT NULL,
|
||||
"path" varchar NOT NULL,
|
||||
"users_id" integer,
|
||||
"media_id" integer,
|
||||
"posts_id" integer,
|
||||
"inquiries_id" integer,
|
||||
"redirects_id" integer,
|
||||
"context_files_id" integer,
|
||||
"crm_accounts_id" integer,
|
||||
"crm_contacts_id" integer,
|
||||
"crm_interactions_id" integer
|
||||
);
|
||||
|
||||
CREATE TABLE "payload_preferences" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"key" varchar,
|
||||
"value" jsonb,
|
||||
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
|
||||
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE "payload_preferences_rels" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"order" integer,
|
||||
"parent_id" integer NOT NULL,
|
||||
"path" varchar NOT NULL,
|
||||
"users_id" integer
|
||||
);
|
||||
|
||||
CREATE TABLE "payload_migrations" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"name" varchar,
|
||||
"batch" numeric,
|
||||
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
|
||||
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE "ai_settings_custom_sources" (
|
||||
"_order" integer NOT NULL,
|
||||
"_parent_id" integer NOT NULL,
|
||||
"id" varchar PRIMARY KEY NOT NULL,
|
||||
"source_name" varchar NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE "ai_settings" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"updated_at" timestamp(3) with time zone,
|
||||
"created_at" timestamp(3) with time zone
|
||||
);
|
||||
|
||||
ALTER TABLE "users_sessions" ADD CONSTRAINT "users_sessions_parent_id_fk" FOREIGN KEY ("_parent_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;
|
||||
ALTER TABLE "posts_tags" ADD CONSTRAINT "posts_tags_parent_id_fk" FOREIGN KEY ("_parent_id") REFERENCES "public"."posts"("id") ON DELETE cascade ON UPDATE no action;
|
||||
ALTER TABLE "posts" ADD CONSTRAINT "posts_featured_image_id_media_id_fk" FOREIGN KEY ("featured_image_id") REFERENCES "public"."media"("id") ON DELETE set null ON UPDATE no action;
|
||||
ALTER TABLE "_posts_v_version_tags" ADD CONSTRAINT "_posts_v_version_tags_parent_id_fk" FOREIGN KEY ("_parent_id") REFERENCES "public"."_posts_v"("id") ON DELETE cascade ON UPDATE no action;
|
||||
ALTER TABLE "_posts_v" ADD CONSTRAINT "_posts_v_parent_id_posts_id_fk" FOREIGN KEY ("parent_id") REFERENCES "public"."posts"("id") ON DELETE set null ON UPDATE no action;
|
||||
ALTER TABLE "_posts_v" ADD CONSTRAINT "_posts_v_version_featured_image_id_media_id_fk" FOREIGN KEY ("version_featured_image_id") REFERENCES "public"."media"("id") ON DELETE set null ON UPDATE no action;
|
||||
ALTER TABLE "crm_accounts" ADD CONSTRAINT "crm_accounts_assigned_to_id_users_id_fk" FOREIGN KEY ("assigned_to_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;
|
||||
ALTER TABLE "crm_accounts_rels" ADD CONSTRAINT "crm_accounts_rels_parent_fk" FOREIGN KEY ("parent_id") REFERENCES "public"."crm_accounts"("id") ON DELETE cascade ON UPDATE no action;
|
||||
ALTER TABLE "crm_accounts_rels" ADD CONSTRAINT "crm_accounts_rels_media_fk" FOREIGN KEY ("media_id") REFERENCES "public"."media"("id") ON DELETE cascade ON UPDATE no action;
|
||||
ALTER TABLE "crm_contacts" ADD CONSTRAINT "crm_contacts_account_id_crm_accounts_id_fk" FOREIGN KEY ("account_id") REFERENCES "public"."crm_accounts"("id") ON DELETE set null ON UPDATE no action;
|
||||
ALTER TABLE "crm_interactions" ADD CONSTRAINT "crm_interactions_contact_id_crm_contacts_id_fk" FOREIGN KEY ("contact_id") REFERENCES "public"."crm_contacts"("id") ON DELETE set null ON UPDATE no action;
|
||||
ALTER TABLE "crm_interactions" ADD CONSTRAINT "crm_interactions_account_id_crm_accounts_id_fk" FOREIGN KEY ("account_id") REFERENCES "public"."crm_accounts"("id") ON DELETE set null ON UPDATE no action;
|
||||
ALTER TABLE "payload_locked_documents_rels" ADD CONSTRAINT "payload_locked_documents_rels_parent_fk" FOREIGN KEY ("parent_id") REFERENCES "public"."payload_locked_documents"("id") ON DELETE cascade ON UPDATE no action;
|
||||
ALTER TABLE "payload_locked_documents_rels" ADD CONSTRAINT "payload_locked_documents_rels_users_fk" FOREIGN KEY ("users_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;
|
||||
ALTER TABLE "payload_locked_documents_rels" ADD CONSTRAINT "payload_locked_documents_rels_media_fk" FOREIGN KEY ("media_id") REFERENCES "public"."media"("id") ON DELETE cascade ON UPDATE no action;
|
||||
ALTER TABLE "payload_locked_documents_rels" ADD CONSTRAINT "payload_locked_documents_rels_posts_fk" FOREIGN KEY ("posts_id") REFERENCES "public"."posts"("id") ON DELETE cascade ON UPDATE no action;
|
||||
ALTER TABLE "payload_locked_documents_rels" ADD CONSTRAINT "payload_locked_documents_rels_inquiries_fk" FOREIGN KEY ("inquiries_id") REFERENCES "public"."inquiries"("id") ON DELETE cascade ON UPDATE no action;
|
||||
ALTER TABLE "payload_locked_documents_rels" ADD CONSTRAINT "payload_locked_documents_rels_redirects_fk" FOREIGN KEY ("redirects_id") REFERENCES "public"."redirects"("id") ON DELETE cascade ON UPDATE no action;
|
||||
ALTER TABLE "payload_locked_documents_rels" ADD CONSTRAINT "payload_locked_documents_rels_context_files_fk" FOREIGN KEY ("context_files_id") REFERENCES "public"."context_files"("id") ON DELETE cascade ON UPDATE no action;
|
||||
ALTER TABLE "payload_locked_documents_rels" ADD CONSTRAINT "payload_locked_documents_rels_crm_accounts_fk" FOREIGN KEY ("crm_accounts_id") REFERENCES "public"."crm_accounts"("id") ON DELETE cascade ON UPDATE no action;
|
||||
ALTER TABLE "payload_locked_documents_rels" ADD CONSTRAINT "payload_locked_documents_rels_crm_contacts_fk" FOREIGN KEY ("crm_contacts_id") REFERENCES "public"."crm_contacts"("id") ON DELETE cascade ON UPDATE no action;
|
||||
ALTER TABLE "payload_locked_documents_rels" ADD CONSTRAINT "payload_locked_documents_rels_crm_interactions_fk" FOREIGN KEY ("crm_interactions_id") REFERENCES "public"."crm_interactions"("id") ON DELETE cascade ON UPDATE no action;
|
||||
ALTER TABLE "payload_preferences_rels" ADD CONSTRAINT "payload_preferences_rels_parent_fk" FOREIGN KEY ("parent_id") REFERENCES "public"."payload_preferences"("id") ON DELETE cascade ON UPDATE no action;
|
||||
ALTER TABLE "payload_preferences_rels" ADD CONSTRAINT "payload_preferences_rels_users_fk" FOREIGN KEY ("users_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;
|
||||
ALTER TABLE "ai_settings_custom_sources" ADD CONSTRAINT "ai_settings_custom_sources_parent_id_fk" FOREIGN KEY ("_parent_id") REFERENCES "public"."ai_settings"("id") ON DELETE cascade ON UPDATE no action;
|
||||
CREATE INDEX "users_sessions_order_idx" ON "users_sessions" USING btree ("_order");
|
||||
CREATE INDEX "users_sessions_parent_id_idx" ON "users_sessions" USING btree ("_parent_id");
|
||||
CREATE INDEX "users_updated_at_idx" ON "users" USING btree ("updated_at");
|
||||
CREATE INDEX "users_created_at_idx" ON "users" USING btree ("created_at");
|
||||
CREATE UNIQUE INDEX "users_email_idx" ON "users" USING btree ("email");
|
||||
CREATE INDEX "media_updated_at_idx" ON "media" USING btree ("updated_at");
|
||||
CREATE INDEX "media_created_at_idx" ON "media" USING btree ("created_at");
|
||||
CREATE UNIQUE INDEX "media_filename_idx" ON "media" USING btree ("filename");
|
||||
CREATE INDEX "media_sizes_thumbnail_sizes_thumbnail_filename_idx" ON "media" USING btree ("sizes_thumbnail_filename");
|
||||
CREATE INDEX "media_sizes_card_sizes_card_filename_idx" ON "media" USING btree ("sizes_card_filename");
|
||||
CREATE INDEX "media_sizes_tablet_sizes_tablet_filename_idx" ON "media" USING btree ("sizes_tablet_filename");
|
||||
CREATE INDEX "posts_tags_order_idx" ON "posts_tags" USING btree ("_order");
|
||||
CREATE INDEX "posts_tags_parent_id_idx" ON "posts_tags" USING btree ("_parent_id");
|
||||
CREATE UNIQUE INDEX "posts_slug_idx" ON "posts" USING btree ("slug");
|
||||
CREATE INDEX "posts_featured_image_idx" ON "posts" USING btree ("featured_image_id");
|
||||
CREATE INDEX "posts_updated_at_idx" ON "posts" USING btree ("updated_at");
|
||||
CREATE INDEX "posts_created_at_idx" ON "posts" USING btree ("created_at");
|
||||
CREATE INDEX "posts__status_idx" ON "posts" USING btree ("_status");
|
||||
CREATE INDEX "_posts_v_version_tags_order_idx" ON "_posts_v_version_tags" USING btree ("_order");
|
||||
CREATE INDEX "_posts_v_version_tags_parent_id_idx" ON "_posts_v_version_tags" USING btree ("_parent_id");
|
||||
CREATE INDEX "_posts_v_parent_idx" ON "_posts_v" USING btree ("parent_id");
|
||||
CREATE INDEX "_posts_v_version_version_slug_idx" ON "_posts_v" USING btree ("version_slug");
|
||||
CREATE INDEX "_posts_v_version_version_featured_image_idx" ON "_posts_v" USING btree ("version_featured_image_id");
|
||||
CREATE INDEX "_posts_v_version_version_updated_at_idx" ON "_posts_v" USING btree ("version_updated_at");
|
||||
CREATE INDEX "_posts_v_version_version_created_at_idx" ON "_posts_v" USING btree ("version_created_at");
|
||||
CREATE INDEX "_posts_v_version_version__status_idx" ON "_posts_v" USING btree ("version__status");
|
||||
CREATE INDEX "_posts_v_created_at_idx" ON "_posts_v" USING btree ("created_at");
|
||||
CREATE INDEX "_posts_v_updated_at_idx" ON "_posts_v" USING btree ("updated_at");
|
||||
CREATE INDEX "_posts_v_latest_idx" ON "_posts_v" USING btree ("latest");
|
||||
CREATE INDEX "inquiries_updated_at_idx" ON "inquiries" USING btree ("updated_at");
|
||||
CREATE INDEX "inquiries_created_at_idx" ON "inquiries" USING btree ("created_at");
|
||||
CREATE UNIQUE INDEX "redirects_from_idx" ON "redirects" USING btree ("from");
|
||||
CREATE INDEX "redirects_updated_at_idx" ON "redirects" USING btree ("updated_at");
|
||||
CREATE INDEX "redirects_created_at_idx" ON "redirects" USING btree ("created_at");
|
||||
CREATE UNIQUE INDEX "context_files_filename_idx" ON "context_files" USING btree ("filename");
|
||||
CREATE INDEX "context_files_updated_at_idx" ON "context_files" USING btree ("updated_at");
|
||||
CREATE INDEX "context_files_created_at_idx" ON "context_files" USING btree ("created_at");
|
||||
CREATE INDEX "crm_accounts_assigned_to_idx" ON "crm_accounts" USING btree ("assigned_to_id");
|
||||
CREATE INDEX "crm_accounts_updated_at_idx" ON "crm_accounts" USING btree ("updated_at");
|
||||
CREATE INDEX "crm_accounts_created_at_idx" ON "crm_accounts" USING btree ("created_at");
|
||||
CREATE INDEX "crm_accounts_rels_order_idx" ON "crm_accounts_rels" USING btree ("order");
|
||||
CREATE INDEX "crm_accounts_rels_parent_idx" ON "crm_accounts_rels" USING btree ("parent_id");
|
||||
CREATE INDEX "crm_accounts_rels_path_idx" ON "crm_accounts_rels" USING btree ("path");
|
||||
CREATE INDEX "crm_accounts_rels_media_id_idx" ON "crm_accounts_rels" USING btree ("media_id");
|
||||
CREATE UNIQUE INDEX "crm_contacts_email_idx" ON "crm_contacts" USING btree ("email");
|
||||
CREATE INDEX "crm_contacts_account_idx" ON "crm_contacts" USING btree ("account_id");
|
||||
CREATE INDEX "crm_contacts_updated_at_idx" ON "crm_contacts" USING btree ("updated_at");
|
||||
CREATE INDEX "crm_contacts_created_at_idx" ON "crm_contacts" USING btree ("created_at");
|
||||
CREATE INDEX "crm_interactions_contact_idx" ON "crm_interactions" USING btree ("contact_id");
|
||||
CREATE INDEX "crm_interactions_account_idx" ON "crm_interactions" USING btree ("account_id");
|
||||
CREATE INDEX "crm_interactions_updated_at_idx" ON "crm_interactions" USING btree ("updated_at");
|
||||
CREATE INDEX "crm_interactions_created_at_idx" ON "crm_interactions" USING btree ("created_at");
|
||||
CREATE UNIQUE INDEX "payload_kv_key_idx" ON "payload_kv" USING btree ("key");
|
||||
CREATE INDEX "payload_locked_documents_global_slug_idx" ON "payload_locked_documents" USING btree ("global_slug");
|
||||
CREATE INDEX "payload_locked_documents_updated_at_idx" ON "payload_locked_documents" USING btree ("updated_at");
|
||||
CREATE INDEX "payload_locked_documents_created_at_idx" ON "payload_locked_documents" USING btree ("created_at");
|
||||
CREATE INDEX "payload_locked_documents_rels_order_idx" ON "payload_locked_documents_rels" USING btree ("order");
|
||||
CREATE INDEX "payload_locked_documents_rels_parent_idx" ON "payload_locked_documents_rels" USING btree ("parent_id");
|
||||
CREATE INDEX "payload_locked_documents_rels_path_idx" ON "payload_locked_documents_rels" USING btree ("path");
|
||||
CREATE INDEX "payload_locked_documents_rels_users_id_idx" ON "payload_locked_documents_rels" USING btree ("users_id");
|
||||
CREATE INDEX "payload_locked_documents_rels_media_id_idx" ON "payload_locked_documents_rels" USING btree ("media_id");
|
||||
CREATE INDEX "payload_locked_documents_rels_posts_id_idx" ON "payload_locked_documents_rels" USING btree ("posts_id");
|
||||
CREATE INDEX "payload_locked_documents_rels_inquiries_id_idx" ON "payload_locked_documents_rels" USING btree ("inquiries_id");
|
||||
CREATE INDEX "payload_locked_documents_rels_redirects_id_idx" ON "payload_locked_documents_rels" USING btree ("redirects_id");
|
||||
CREATE INDEX "payload_locked_documents_rels_context_files_id_idx" ON "payload_locked_documents_rels" USING btree ("context_files_id");
|
||||
CREATE INDEX "payload_locked_documents_rels_crm_accounts_id_idx" ON "payload_locked_documents_rels" USING btree ("crm_accounts_id");
|
||||
CREATE INDEX "payload_locked_documents_rels_crm_contacts_id_idx" ON "payload_locked_documents_rels" USING btree ("crm_contacts_id");
|
||||
CREATE INDEX "payload_locked_documents_rels_crm_interactions_id_idx" ON "payload_locked_documents_rels" USING btree ("crm_interactions_id");
|
||||
CREATE INDEX "payload_preferences_key_idx" ON "payload_preferences" USING btree ("key");
|
||||
CREATE INDEX "payload_preferences_updated_at_idx" ON "payload_preferences" USING btree ("updated_at");
|
||||
CREATE INDEX "payload_preferences_created_at_idx" ON "payload_preferences" USING btree ("created_at");
|
||||
CREATE INDEX "payload_preferences_rels_order_idx" ON "payload_preferences_rels" USING btree ("order");
|
||||
CREATE INDEX "payload_preferences_rels_parent_idx" ON "payload_preferences_rels" USING btree ("parent_id");
|
||||
CREATE INDEX "payload_preferences_rels_path_idx" ON "payload_preferences_rels" USING btree ("path");
|
||||
CREATE INDEX "payload_preferences_rels_users_id_idx" ON "payload_preferences_rels" USING btree ("users_id");
|
||||
CREATE INDEX "payload_migrations_updated_at_idx" ON "payload_migrations" USING btree ("updated_at");
|
||||
CREATE INDEX "payload_migrations_created_at_idx" ON "payload_migrations" USING btree ("created_at");
|
||||
CREATE INDEX "ai_settings_custom_sources_order_idx" ON "ai_settings_custom_sources" USING btree ("_order");
|
||||
CREATE INDEX "ai_settings_custom_sources_parent_id_idx" ON "ai_settings_custom_sources" USING btree ("_parent_id");`);
|
||||
}
|
||||
|
||||
export async function down({
|
||||
db,
|
||||
payload,
|
||||
req,
|
||||
}: MigrateDownArgs): Promise<void> {
|
||||
await db.execute(sql`
|
||||
DROP TABLE "users_sessions" CASCADE;
|
||||
DROP TABLE "users" CASCADE;
|
||||
DROP TABLE "media" CASCADE;
|
||||
DROP TABLE "posts_tags" CASCADE;
|
||||
DROP TABLE "posts" CASCADE;
|
||||
DROP TABLE "_posts_v_version_tags" CASCADE;
|
||||
DROP TABLE "_posts_v" CASCADE;
|
||||
DROP TABLE "inquiries" CASCADE;
|
||||
DROP TABLE "redirects" CASCADE;
|
||||
DROP TABLE "context_files" CASCADE;
|
||||
DROP TABLE "crm_accounts" CASCADE;
|
||||
DROP TABLE "crm_accounts_rels" CASCADE;
|
||||
DROP TABLE "crm_contacts" CASCADE;
|
||||
DROP TABLE "crm_interactions" CASCADE;
|
||||
DROP TABLE "payload_kv" CASCADE;
|
||||
DROP TABLE "payload_locked_documents" CASCADE;
|
||||
DROP TABLE "payload_locked_documents_rels" CASCADE;
|
||||
DROP TABLE "payload_preferences" CASCADE;
|
||||
DROP TABLE "payload_preferences_rels" CASCADE;
|
||||
DROP TABLE "payload_migrations" CASCADE;
|
||||
DROP TABLE "ai_settings_custom_sources" CASCADE;
|
||||
DROP TABLE "ai_settings" CASCADE;
|
||||
DROP TYPE "public"."enum_posts_status";
|
||||
DROP TYPE "public"."enum__posts_v_version_status";
|
||||
DROP TYPE "public"."enum_crm_accounts_status";
|
||||
DROP TYPE "public"."enum_crm_accounts_lead_temperature";
|
||||
DROP TYPE "public"."enum_crm_interactions_type";
|
||||
DROP TYPE "public"."enum_crm_interactions_direction";`);
|
||||
}
|
||||
3671
apps/web/src/migrations/20260301_151838.json
Normal file
3671
apps/web/src/migrations/20260301_151838.json
Normal file
File diff suppressed because it is too large
Load Diff
155
apps/web/src/migrations/20260301_151838.ts
Normal file
155
apps/web/src/migrations/20260301_151838.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
import { MigrateUpArgs, MigrateDownArgs, sql } from "@payloadcms/db-postgres";
|
||||
|
||||
export async function up({ db, payload, req }: MigrateUpArgs): Promise<void> {
|
||||
await db.execute(sql`
|
||||
CREATE TYPE "public"."enum_crm_topics_status" AS ENUM('active', 'paused', 'won', 'lost');
|
||||
CREATE TYPE "public"."enum_crm_topics_stage" AS ENUM('discovery', 'proposal', 'negotiation', 'implementation');
|
||||
CREATE TYPE "public"."enum_projects_milestones_status" AS ENUM('todo', 'in_progress', 'done');
|
||||
CREATE TYPE "public"."enum_projects_milestones_priority" AS ENUM('low', 'medium', 'high');
|
||||
CREATE TYPE "public"."enum_projects_status" AS ENUM('draft', 'in_progress', 'review', 'completed');
|
||||
ALTER TYPE "public"."enum_crm_accounts_status" ADD VALUE 'partner' BEFORE 'lost';
|
||||
ALTER TYPE "public"."enum_crm_interactions_type" ADD VALUE 'whatsapp' BEFORE 'note';
|
||||
ALTER TYPE "public"."enum_crm_interactions_type" ADD VALUE 'social' BEFORE 'note';
|
||||
ALTER TYPE "public"."enum_crm_interactions_type" ADD VALUE 'document' BEFORE 'note';
|
||||
CREATE TABLE "crm_topics" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"title" varchar NOT NULL,
|
||||
"account_id" integer NOT NULL,
|
||||
"status" "enum_crm_topics_status" DEFAULT 'active' NOT NULL,
|
||||
"stage" "enum_crm_topics_stage",
|
||||
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
|
||||
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE "crm_interactions_rels" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"order" integer,
|
||||
"parent_id" integer NOT NULL,
|
||||
"path" varchar NOT NULL,
|
||||
"media_id" integer
|
||||
);
|
||||
|
||||
CREATE TABLE "projects_milestones" (
|
||||
"_order" integer NOT NULL,
|
||||
"_parent_id" integer NOT NULL,
|
||||
"id" varchar PRIMARY KEY NOT NULL,
|
||||
"name" varchar NOT NULL,
|
||||
"status" "enum_projects_milestones_status" DEFAULT 'todo' NOT NULL,
|
||||
"priority" "enum_projects_milestones_priority" DEFAULT 'medium',
|
||||
"start_date" timestamp(3) with time zone,
|
||||
"target_date" timestamp(3) with time zone,
|
||||
"assignee_id" integer
|
||||
);
|
||||
|
||||
CREATE TABLE "projects" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"title" varchar NOT NULL,
|
||||
"account_id" integer NOT NULL,
|
||||
"status" "enum_projects_status" DEFAULT 'draft' NOT NULL,
|
||||
"start_date" timestamp(3) with time zone,
|
||||
"target_date" timestamp(3) with time zone,
|
||||
"value_min" numeric,
|
||||
"value_max" numeric,
|
||||
"briefing" jsonb,
|
||||
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
|
||||
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE "projects_rels" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"order" integer,
|
||||
"parent_id" integer NOT NULL,
|
||||
"path" varchar NOT NULL,
|
||||
"crm_contacts_id" integer,
|
||||
"media_id" integer
|
||||
);
|
||||
|
||||
ALTER TABLE "crm_interactions" ALTER COLUMN "type" SET DEFAULT 'note';
|
||||
ALTER TABLE "inquiries" ADD COLUMN "processed" boolean DEFAULT false;
|
||||
ALTER TABLE "crm_contacts" ADD COLUMN "full_name" varchar;
|
||||
ALTER TABLE "crm_interactions" ADD COLUMN "topic_id" integer;
|
||||
ALTER TABLE "payload_locked_documents_rels" ADD COLUMN "crm_topics_id" integer;
|
||||
ALTER TABLE "payload_locked_documents_rels" ADD COLUMN "projects_id" integer;
|
||||
ALTER TABLE "crm_topics" ADD CONSTRAINT "crm_topics_account_id_crm_accounts_id_fk" FOREIGN KEY ("account_id") REFERENCES "public"."crm_accounts"("id") ON DELETE set null ON UPDATE no action;
|
||||
ALTER TABLE "crm_interactions_rels" ADD CONSTRAINT "crm_interactions_rels_parent_fk" FOREIGN KEY ("parent_id") REFERENCES "public"."crm_interactions"("id") ON DELETE cascade ON UPDATE no action;
|
||||
ALTER TABLE "crm_interactions_rels" ADD CONSTRAINT "crm_interactions_rels_media_fk" FOREIGN KEY ("media_id") REFERENCES "public"."media"("id") ON DELETE cascade ON UPDATE no action;
|
||||
ALTER TABLE "projects_milestones" ADD CONSTRAINT "projects_milestones_assignee_id_users_id_fk" FOREIGN KEY ("assignee_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;
|
||||
ALTER TABLE "projects_milestones" ADD CONSTRAINT "projects_milestones_parent_id_fk" FOREIGN KEY ("_parent_id") REFERENCES "public"."projects"("id") ON DELETE cascade ON UPDATE no action;
|
||||
ALTER TABLE "projects" ADD CONSTRAINT "projects_account_id_crm_accounts_id_fk" FOREIGN KEY ("account_id") REFERENCES "public"."crm_accounts"("id") ON DELETE set null ON UPDATE no action;
|
||||
ALTER TABLE "projects_rels" ADD CONSTRAINT "projects_rels_parent_fk" FOREIGN KEY ("parent_id") REFERENCES "public"."projects"("id") ON DELETE cascade ON UPDATE no action;
|
||||
ALTER TABLE "projects_rels" ADD CONSTRAINT "projects_rels_crm_contacts_fk" FOREIGN KEY ("crm_contacts_id") REFERENCES "public"."crm_contacts"("id") ON DELETE cascade ON UPDATE no action;
|
||||
ALTER TABLE "projects_rels" ADD CONSTRAINT "projects_rels_media_fk" FOREIGN KEY ("media_id") REFERENCES "public"."media"("id") ON DELETE cascade ON UPDATE no action;
|
||||
CREATE INDEX "crm_topics_account_idx" ON "crm_topics" USING btree ("account_id");
|
||||
CREATE INDEX "crm_topics_updated_at_idx" ON "crm_topics" USING btree ("updated_at");
|
||||
CREATE INDEX "crm_topics_created_at_idx" ON "crm_topics" USING btree ("created_at");
|
||||
CREATE INDEX "crm_interactions_rels_order_idx" ON "crm_interactions_rels" USING btree ("order");
|
||||
CREATE INDEX "crm_interactions_rels_parent_idx" ON "crm_interactions_rels" USING btree ("parent_id");
|
||||
CREATE INDEX "crm_interactions_rels_path_idx" ON "crm_interactions_rels" USING btree ("path");
|
||||
CREATE INDEX "crm_interactions_rels_media_id_idx" ON "crm_interactions_rels" USING btree ("media_id");
|
||||
CREATE INDEX "projects_milestones_order_idx" ON "projects_milestones" USING btree ("_order");
|
||||
CREATE INDEX "projects_milestones_parent_id_idx" ON "projects_milestones" USING btree ("_parent_id");
|
||||
CREATE INDEX "projects_milestones_assignee_idx" ON "projects_milestones" USING btree ("assignee_id");
|
||||
CREATE INDEX "projects_account_idx" ON "projects" USING btree ("account_id");
|
||||
CREATE INDEX "projects_updated_at_idx" ON "projects" USING btree ("updated_at");
|
||||
CREATE INDEX "projects_created_at_idx" ON "projects" USING btree ("created_at");
|
||||
CREATE INDEX "projects_rels_order_idx" ON "projects_rels" USING btree ("order");
|
||||
CREATE INDEX "projects_rels_parent_idx" ON "projects_rels" USING btree ("parent_id");
|
||||
CREATE INDEX "projects_rels_path_idx" ON "projects_rels" USING btree ("path");
|
||||
CREATE INDEX "projects_rels_crm_contacts_id_idx" ON "projects_rels" USING btree ("crm_contacts_id");
|
||||
CREATE INDEX "projects_rels_media_id_idx" ON "projects_rels" USING btree ("media_id");
|
||||
ALTER TABLE "crm_interactions" ADD CONSTRAINT "crm_interactions_topic_id_crm_topics_id_fk" FOREIGN KEY ("topic_id") REFERENCES "public"."crm_topics"("id") ON DELETE set null ON UPDATE no action;
|
||||
ALTER TABLE "payload_locked_documents_rels" ADD CONSTRAINT "payload_locked_documents_rels_crm_topics_fk" FOREIGN KEY ("crm_topics_id") REFERENCES "public"."crm_topics"("id") ON DELETE cascade ON UPDATE no action;
|
||||
ALTER TABLE "payload_locked_documents_rels" ADD CONSTRAINT "payload_locked_documents_rels_projects_fk" FOREIGN KEY ("projects_id") REFERENCES "public"."projects"("id") ON DELETE cascade ON UPDATE no action;
|
||||
CREATE INDEX "crm_interactions_topic_idx" ON "crm_interactions" USING btree ("topic_id");
|
||||
CREATE INDEX "payload_locked_documents_rels_crm_topics_id_idx" ON "payload_locked_documents_rels" USING btree ("crm_topics_id");
|
||||
CREATE INDEX "payload_locked_documents_rels_projects_id_idx" ON "payload_locked_documents_rels" USING btree ("projects_id");`);
|
||||
}
|
||||
|
||||
export async function down({
|
||||
db,
|
||||
payload,
|
||||
req,
|
||||
}: MigrateDownArgs): Promise<void> {
|
||||
await db.execute(sql`
|
||||
ALTER TABLE "crm_topics" DISABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE "crm_interactions_rels" DISABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE "projects_milestones" DISABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE "projects" DISABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE "projects_rels" DISABLE ROW LEVEL SECURITY;
|
||||
DROP TABLE "crm_topics" CASCADE;
|
||||
DROP TABLE "crm_interactions_rels" CASCADE;
|
||||
DROP TABLE "projects_milestones" CASCADE;
|
||||
DROP TABLE "projects" CASCADE;
|
||||
DROP TABLE "projects_rels" CASCADE;
|
||||
ALTER TABLE "crm_interactions" DROP CONSTRAINT "crm_interactions_topic_id_crm_topics_id_fk";
|
||||
|
||||
ALTER TABLE "payload_locked_documents_rels" DROP CONSTRAINT "payload_locked_documents_rels_crm_topics_fk";
|
||||
|
||||
ALTER TABLE "payload_locked_documents_rels" DROP CONSTRAINT "payload_locked_documents_rels_projects_fk";
|
||||
|
||||
ALTER TABLE "crm_accounts" ALTER COLUMN "status" SET DATA TYPE text;
|
||||
ALTER TABLE "crm_accounts" ALTER COLUMN "status" SET DEFAULT 'lead'::text;
|
||||
DROP TYPE "public"."enum_crm_accounts_status";
|
||||
CREATE TYPE "public"."enum_crm_accounts_status" AS ENUM('lead', 'client', 'lost');
|
||||
ALTER TABLE "crm_accounts" ALTER COLUMN "status" SET DEFAULT 'lead'::"public"."enum_crm_accounts_status";
|
||||
ALTER TABLE "crm_accounts" ALTER COLUMN "status" SET DATA TYPE "public"."enum_crm_accounts_status" USING "status"::"public"."enum_crm_accounts_status";
|
||||
ALTER TABLE "crm_interactions" ALTER COLUMN "type" SET DATA TYPE text;
|
||||
ALTER TABLE "crm_interactions" ALTER COLUMN "type" SET DEFAULT 'email'::text;
|
||||
DROP TYPE "public"."enum_crm_interactions_type";
|
||||
CREATE TYPE "public"."enum_crm_interactions_type" AS ENUM('email', 'call', 'meeting', 'note');
|
||||
ALTER TABLE "crm_interactions" ALTER COLUMN "type" SET DEFAULT 'email'::"public"."enum_crm_interactions_type";
|
||||
ALTER TABLE "crm_interactions" ALTER COLUMN "type" SET DATA TYPE "public"."enum_crm_interactions_type" USING "type"::"public"."enum_crm_interactions_type";
|
||||
DROP INDEX "crm_interactions_topic_idx";
|
||||
DROP INDEX "payload_locked_documents_rels_crm_topics_id_idx";
|
||||
DROP INDEX "payload_locked_documents_rels_projects_id_idx";
|
||||
ALTER TABLE "inquiries" DROP COLUMN "processed";
|
||||
ALTER TABLE "crm_contacts" DROP COLUMN "full_name";
|
||||
ALTER TABLE "crm_interactions" DROP COLUMN "topic_id";
|
||||
ALTER TABLE "payload_locked_documents_rels" DROP COLUMN "crm_topics_id";
|
||||
ALTER TABLE "payload_locked_documents_rels" DROP COLUMN "projects_id";
|
||||
DROP TYPE "public"."enum_crm_topics_status";
|
||||
DROP TYPE "public"."enum_crm_topics_stage";
|
||||
DROP TYPE "public"."enum_projects_milestones_status";
|
||||
DROP TYPE "public"."enum_projects_milestones_priority";
|
||||
DROP TYPE "public"."enum_projects_status";`);
|
||||
}
|
||||
15
apps/web/src/migrations/index.ts
Normal file
15
apps/web/src/migrations/index.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import * as migration_20260227_171023_crm_collections from "./20260227_171023_crm_collections";
|
||||
import * as migration_20260301_151838 from "./20260301_151838";
|
||||
|
||||
export const migrations = [
|
||||
{
|
||||
up: migration_20260227_171023_crm_collections.up,
|
||||
down: migration_20260227_171023_crm_collections.down,
|
||||
name: "20260227_171023_crm_collections",
|
||||
},
|
||||
{
|
||||
up: migration_20260301_151838.up,
|
||||
down: migration_20260301_151838.down,
|
||||
name: "20260301_151838",
|
||||
},
|
||||
];
|
||||
22
apps/web/src/payload/blocks/ArchitectureBuilderBlock.ts
Normal file
22
apps/web/src/payload/blocks/ArchitectureBuilderBlock.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { MintelBlock } from "./types";
|
||||
|
||||
import type { Block } from "payload";
|
||||
|
||||
export const ArchitectureBuilderBlock: MintelBlock = {
|
||||
slug: "architectureBuilder",
|
||||
labels: {
|
||||
singular: "Architecture Builder",
|
||||
plural: "Architecture Builders",
|
||||
},
|
||||
admin: {
|
||||
group: "MDX Components",
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: "preset",
|
||||
type: "text",
|
||||
defaultValue: "standard",
|
||||
admin: { description: "Geben Sie den Text für preset ein." },
|
||||
},
|
||||
],
|
||||
};
|
||||
53
apps/web/src/payload/blocks/ArticleBlockquoteBlock.ts
Normal file
53
apps/web/src/payload/blocks/ArticleBlockquoteBlock.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { MintelBlock } from "./types";
|
||||
|
||||
import type { Block } from "payload";
|
||||
|
||||
export const ArticleBlockquoteBlock: MintelBlock = {
|
||||
slug: "articleBlockquote",
|
||||
labels: {
|
||||
singular: "Article Blockquote",
|
||||
plural: "Article Blockquotes",
|
||||
},
|
||||
admin: {
|
||||
group: "MDX Components",
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: "quote",
|
||||
type: "textarea",
|
||||
required: true,
|
||||
admin: {
|
||||
components: {
|
||||
afterInput: [
|
||||
"@mintel/payload-ai/components/AiFieldButton#AiFieldButton",
|
||||
],
|
||||
},
|
||||
description: "Geben Sie den mehrzeiligen Text für quote ein.",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "author",
|
||||
type: "text",
|
||||
admin: {
|
||||
components: {
|
||||
afterInput: [
|
||||
"@mintel/payload-ai/components/AiFieldButton#AiFieldButton",
|
||||
],
|
||||
},
|
||||
description: "Geben Sie den Text für author ein.",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "role",
|
||||
type: "text",
|
||||
admin: {
|
||||
components: {
|
||||
afterInput: [
|
||||
"@mintel/payload-ai/components/AiFieldButton#AiFieldButton",
|
||||
],
|
||||
},
|
||||
description: "Geben Sie den Text für role ein.",
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
47
apps/web/src/payload/blocks/ArticleMemeBlock.ts
Normal file
47
apps/web/src/payload/blocks/ArticleMemeBlock.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { MintelBlock } from "./types";
|
||||
import type { Block } from "payload";
|
||||
|
||||
export const ArticleMemeBlock: MintelBlock = {
|
||||
slug: "articleMeme",
|
||||
labels: {
|
||||
singular: "Article Meme",
|
||||
plural: "Article Memes",
|
||||
},
|
||||
admin: {
|
||||
group: "MDX Components",
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: "image",
|
||||
type: "upload",
|
||||
relationTo: "media",
|
||||
required: true,
|
||||
admin: { description: "Laden Sie die Datei für image hoch." },
|
||||
},
|
||||
{
|
||||
name: "alt",
|
||||
type: "text",
|
||||
required: true,
|
||||
admin: {
|
||||
components: {
|
||||
afterInput: [
|
||||
"@mintel/payload-ai/components/AiFieldButton#AiFieldButton",
|
||||
],
|
||||
},
|
||||
description: "Geben Sie den Text für alt ein.",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "caption",
|
||||
type: "text",
|
||||
admin: {
|
||||
components: {
|
||||
afterInput: [
|
||||
"@mintel/payload-ai/components/AiFieldButton#AiFieldButton",
|
||||
],
|
||||
},
|
||||
description: "Geben Sie den Text für caption ein.",
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
97
apps/web/src/payload/blocks/ArticleQuoteBlock.ts
Normal file
97
apps/web/src/payload/blocks/ArticleQuoteBlock.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
import { MintelBlock } from "./types";
|
||||
|
||||
import type { Block } from "payload";
|
||||
|
||||
export const ArticleQuoteBlock: MintelBlock = {
|
||||
slug: "articleQuote",
|
||||
labels: {
|
||||
singular: "Article Quote",
|
||||
plural: "Article Quotes",
|
||||
},
|
||||
admin: {
|
||||
group: "MDX Components",
|
||||
},
|
||||
ai: {
|
||||
name: "ArticleQuote",
|
||||
description:
|
||||
"Dark-themed quote card. Use for expert quotes or statements. Use isCompany={true} for brands/orgs to show an entity icon instead of personal initials. MANDATORY: always include source and sourceUrl for verifiability. Props: quote, author, role (optional), source (REQUIRED), sourceUrl (REQUIRED), isCompany (optional), translated (optional boolean).",
|
||||
usageExample:
|
||||
'\'<ArticleQuote quote="Optimizing for speed." author="Google" isCompany={true',
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: "quote",
|
||||
type: "textarea",
|
||||
required: true,
|
||||
admin: {
|
||||
components: {
|
||||
afterInput: [
|
||||
"@mintel/payload-ai/components/AiFieldButton#AiFieldButton",
|
||||
],
|
||||
},
|
||||
description: "Geben Sie den mehrzeiligen Text für quote ein.",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "author",
|
||||
type: "text",
|
||||
required: true,
|
||||
admin: {
|
||||
components: {
|
||||
afterInput: [
|
||||
"@mintel/payload-ai/components/AiFieldButton#AiFieldButton",
|
||||
],
|
||||
},
|
||||
description: "Geben Sie den Text für author ein.",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "role",
|
||||
type: "text",
|
||||
admin: {
|
||||
components: {
|
||||
afterInput: [
|
||||
"@mintel/payload-ai/components/AiFieldButton#AiFieldButton",
|
||||
],
|
||||
},
|
||||
description: "Geben Sie den Text für role ein.",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "source",
|
||||
type: "text",
|
||||
admin: {
|
||||
components: {
|
||||
afterInput: [
|
||||
"@mintel/payload-ai/components/AiFieldButton#AiFieldButton",
|
||||
],
|
||||
},
|
||||
description: "Geben Sie den Text für source ein.",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "sourceUrl",
|
||||
type: "text",
|
||||
admin: {
|
||||
components: {
|
||||
afterInput: [
|
||||
"@mintel/payload-ai/components/AiFieldButton#AiFieldButton",
|
||||
],
|
||||
},
|
||||
description: "Geben Sie den Text für sourceUrl ein.",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "translated",
|
||||
type: "checkbox",
|
||||
defaultValue: false,
|
||||
admin: { description: "Wert für translated eingeben." },
|
||||
},
|
||||
{
|
||||
name: "isCompany",
|
||||
type: "checkbox",
|
||||
defaultValue: false,
|
||||
admin: { description: "Wert für isCompany eingeben." },
|
||||
},
|
||||
],
|
||||
};
|
||||
72
apps/web/src/payload/blocks/BoldNumberBlock.ts
Normal file
72
apps/web/src/payload/blocks/BoldNumberBlock.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import { MintelBlock } from "./types";
|
||||
|
||||
import type { Block } from "payload";
|
||||
|
||||
export const BoldNumberBlock: MintelBlock = {
|
||||
slug: "boldNumber",
|
||||
labels: {
|
||||
singular: "Bold Number",
|
||||
plural: "Bold Numbers",
|
||||
},
|
||||
admin: {
|
||||
group: "MDX Components",
|
||||
},
|
||||
ai: {
|
||||
name: "BoldNumber",
|
||||
description: "Large centerpiece number with label for primary statistics.",
|
||||
usageExample:
|
||||
'\'<BoldNumber value="5x" label="höhere Conversion-Rate" source="Portent" />\'',
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: "value",
|
||||
type: "text",
|
||||
required: true,
|
||||
admin: {
|
||||
description: "e.g. 53% or 2.5M€",
|
||||
components: {
|
||||
afterInput: [
|
||||
"@mintel/payload-ai/components/AiFieldButton#AiFieldButton",
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "label",
|
||||
type: "text",
|
||||
required: true,
|
||||
admin: {
|
||||
components: {
|
||||
afterInput: [
|
||||
"@mintel/payload-ai/components/AiFieldButton#AiFieldButton",
|
||||
],
|
||||
},
|
||||
description: "Geben Sie den Text für label ein.",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "source",
|
||||
type: "text",
|
||||
admin: {
|
||||
components: {
|
||||
afterInput: [
|
||||
"@mintel/payload-ai/components/AiFieldButton#AiFieldButton",
|
||||
],
|
||||
},
|
||||
description: "Geben Sie den Text für source ein.",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "sourceUrl",
|
||||
type: "text",
|
||||
admin: {
|
||||
components: {
|
||||
afterInput: [
|
||||
"@mintel/payload-ai/components/AiFieldButton#AiFieldButton",
|
||||
],
|
||||
},
|
||||
description: "Geben Sie den Text für sourceUrl ein.",
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
62
apps/web/src/payload/blocks/ButtonBlock.ts
Normal file
62
apps/web/src/payload/blocks/ButtonBlock.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { MintelBlock } from "./types";
|
||||
|
||||
import type { Block } from "payload";
|
||||
|
||||
export const ButtonBlock: MintelBlock = {
|
||||
slug: "buttonBlock",
|
||||
labels: {
|
||||
singular: "Button Block",
|
||||
plural: "Button Blocks",
|
||||
},
|
||||
admin: {
|
||||
group: "MDX Components",
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: "label",
|
||||
type: "text",
|
||||
required: true,
|
||||
admin: {
|
||||
components: {
|
||||
afterInput: [
|
||||
"@mintel/payload-ai/components/AiFieldButton#AiFieldButton",
|
||||
],
|
||||
},
|
||||
description: "Geben Sie den Text für label ein.",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "href",
|
||||
type: "text",
|
||||
required: true,
|
||||
admin: { description: "Geben Sie den Text für href ein." },
|
||||
},
|
||||
{
|
||||
name: "variant",
|
||||
type: "select",
|
||||
options: [
|
||||
{ label: "Primary", value: "primary" },
|
||||
{ label: "Outline", value: "outline" },
|
||||
{ label: "Ghost", value: "ghost" },
|
||||
],
|
||||
defaultValue: "primary",
|
||||
admin: { description: "Wählen Sie eine Option für variant aus." },
|
||||
},
|
||||
{
|
||||
name: "size",
|
||||
type: "select",
|
||||
options: [
|
||||
{ label: "Normal", value: "normal" },
|
||||
{ label: "Large", value: "large" },
|
||||
],
|
||||
defaultValue: "normal",
|
||||
admin: { description: "Wählen Sie eine Option für size aus." },
|
||||
},
|
||||
{
|
||||
name: "showArrow",
|
||||
type: "checkbox",
|
||||
defaultValue: true,
|
||||
admin: { description: "Wert für showArrow eingeben." },
|
||||
},
|
||||
],
|
||||
};
|
||||
51
apps/web/src/payload/blocks/CarouselBlock.ts
Normal file
51
apps/web/src/payload/blocks/CarouselBlock.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { MintelBlock } from "./types";
|
||||
|
||||
import type { Block } from "payload";
|
||||
|
||||
export const CarouselBlock: MintelBlock = {
|
||||
slug: "carousel",
|
||||
labels: {
|
||||
singular: "Carousel",
|
||||
plural: "Carousels",
|
||||
},
|
||||
admin: {
|
||||
group: "MDX Components",
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: "slides",
|
||||
type: "array",
|
||||
fields: [
|
||||
{
|
||||
name: "title",
|
||||
type: "text",
|
||||
admin: { description: "Titel der Slide-Karte." },
|
||||
},
|
||||
{
|
||||
name: "content",
|
||||
type: "textarea",
|
||||
admin: { description: "Beschreibungstext der Slide-Karte." },
|
||||
},
|
||||
{
|
||||
name: "image",
|
||||
type: "upload",
|
||||
relationTo: "media",
|
||||
admin: { description: "Laden Sie die Datei für image hoch." },
|
||||
},
|
||||
{
|
||||
name: "caption",
|
||||
type: "text",
|
||||
admin: {
|
||||
components: {
|
||||
afterInput: [
|
||||
"@mintel/payload-ai/components/AiFieldButton#AiFieldButton",
|
||||
],
|
||||
},
|
||||
description: "Geben Sie den Text für caption ein.",
|
||||
},
|
||||
},
|
||||
],
|
||||
admin: { description: "Fügen Sie Elemente zur Liste slides hinzu." },
|
||||
},
|
||||
],
|
||||
};
|
||||
102
apps/web/src/payload/blocks/ComparisonRowBlock.ts
Normal file
102
apps/web/src/payload/blocks/ComparisonRowBlock.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import { MintelBlock } from "./types";
|
||||
|
||||
import type { Block } from "payload";
|
||||
|
||||
export const ComparisonRowBlock: MintelBlock = {
|
||||
slug: "comparisonRow",
|
||||
labels: {
|
||||
singular: "Comparison Row",
|
||||
plural: "Comparison Rows",
|
||||
},
|
||||
admin: {
|
||||
group: "MDX Components",
|
||||
},
|
||||
ai: {
|
||||
name: "ComparisonRow",
|
||||
description:
|
||||
'Side-by-side comparison: negative "Standard" approach vs positive "Mintel" approach. Props include showShare boolean.',
|
||||
usageExample: `<ComparisonRow
|
||||
description="Architektur-Vergleich"
|
||||
negativeLabel="Legacy CMS"
|
||||
negativeText="Langsame Datenbankabfragen, verwundbare Plugins."
|
||||
positiveLabel="Mintel Stack"
|
||||
positiveText="Statische Generierung, perfekte Sicherheit."
|
||||
showShare={true`,
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: "description",
|
||||
type: "text",
|
||||
admin: {
|
||||
description: "Optional overarching description for the comparison.",
|
||||
components: {
|
||||
afterInput: [
|
||||
"@mintel/payload-ai/components/AiFieldButton#AiFieldButton",
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "negativeLabel",
|
||||
type: "text",
|
||||
required: true,
|
||||
defaultValue: "Legacy",
|
||||
admin: {
|
||||
components: {
|
||||
afterInput: [
|
||||
"@mintel/payload-ai/components/AiFieldButton#AiFieldButton",
|
||||
],
|
||||
},
|
||||
description: "Geben Sie den Text für negativeLabel ein.",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "negativeText",
|
||||
type: "text",
|
||||
required: true,
|
||||
admin: {
|
||||
components: {
|
||||
afterInput: [
|
||||
"@mintel/payload-ai/components/AiFieldButton#AiFieldButton",
|
||||
],
|
||||
},
|
||||
description: "Geben Sie den Text für negativeText ein.",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "positiveLabel",
|
||||
type: "text",
|
||||
required: true,
|
||||
defaultValue: "Mintel Stack",
|
||||
admin: {
|
||||
components: {
|
||||
afterInput: [
|
||||
"@mintel/payload-ai/components/AiFieldButton#AiFieldButton",
|
||||
],
|
||||
},
|
||||
description: "Geben Sie den Text für positiveLabel ein.",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "positiveText",
|
||||
type: "text",
|
||||
required: true,
|
||||
admin: {
|
||||
components: {
|
||||
afterInput: [
|
||||
"@mintel/payload-ai/components/AiFieldButton#AiFieldButton",
|
||||
],
|
||||
},
|
||||
description: "Geben Sie den Text für positiveText ein.",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "reverse",
|
||||
type: "checkbox",
|
||||
defaultValue: false,
|
||||
admin: {
|
||||
description: "Swap the visual order of the positive/negative cards?",
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
29
apps/web/src/payload/blocks/DiagramFlowBlock.ts
Normal file
29
apps/web/src/payload/blocks/DiagramFlowBlock.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { MintelBlock } from "./types";
|
||||
|
||||
import type { Block } from "payload";
|
||||
|
||||
export const DiagramFlowBlock: MintelBlock = {
|
||||
slug: "diagramFlow",
|
||||
labels: {
|
||||
singular: "Diagram Flow",
|
||||
plural: "Diagram Flows",
|
||||
},
|
||||
admin: {
|
||||
group: "MDX Components",
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: "definition",
|
||||
type: "textarea",
|
||||
required: true,
|
||||
admin: {
|
||||
components: {
|
||||
afterInput: [
|
||||
"@mintel/payload-ai/components/AiFieldButton#AiFieldButton",
|
||||
],
|
||||
},
|
||||
description: "Geben Sie den mehrzeiligen Text für definition ein.",
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
29
apps/web/src/payload/blocks/DiagramGanttBlock.ts
Normal file
29
apps/web/src/payload/blocks/DiagramGanttBlock.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { MintelBlock } from "./types";
|
||||
|
||||
import type { Block } from "payload";
|
||||
|
||||
export const DiagramGanttBlock: MintelBlock = {
|
||||
slug: "diagramGantt",
|
||||
labels: {
|
||||
singular: "Diagram Gantt",
|
||||
plural: "Diagram Gantts",
|
||||
},
|
||||
admin: {
|
||||
group: "MDX Components",
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: "definition",
|
||||
type: "textarea",
|
||||
required: true,
|
||||
admin: {
|
||||
components: {
|
||||
afterInput: [
|
||||
"@mintel/payload-ai/components/AiFieldButton#AiFieldButton",
|
||||
],
|
||||
},
|
||||
description: "Geben Sie den mehrzeiligen Text für definition ein.",
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
29
apps/web/src/payload/blocks/DiagramPieBlock.ts
Normal file
29
apps/web/src/payload/blocks/DiagramPieBlock.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { MintelBlock } from "./types";
|
||||
|
||||
import type { Block } from "payload";
|
||||
|
||||
export const DiagramPieBlock: MintelBlock = {
|
||||
slug: "diagramPie",
|
||||
labels: {
|
||||
singular: "Diagram Pie",
|
||||
plural: "Diagram Pies",
|
||||
},
|
||||
admin: {
|
||||
group: "MDX Components",
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: "definition",
|
||||
type: "textarea",
|
||||
required: true,
|
||||
admin: {
|
||||
components: {
|
||||
afterInput: [
|
||||
"@mintel/payload-ai/components/AiFieldButton#AiFieldButton",
|
||||
],
|
||||
},
|
||||
description: "Geben Sie den mehrzeiligen Text für definition ein.",
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
29
apps/web/src/payload/blocks/DiagramSequenceBlock.ts
Normal file
29
apps/web/src/payload/blocks/DiagramSequenceBlock.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { MintelBlock } from "./types";
|
||||
|
||||
import type { Block } from "payload";
|
||||
|
||||
export const DiagramSequenceBlock: MintelBlock = {
|
||||
slug: "diagramSequence",
|
||||
labels: {
|
||||
singular: "Diagram Sequence",
|
||||
plural: "Diagram Sequences",
|
||||
},
|
||||
admin: {
|
||||
group: "MDX Components",
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: "definition",
|
||||
type: "textarea",
|
||||
required: true,
|
||||
admin: {
|
||||
components: {
|
||||
afterInput: [
|
||||
"@mintel/payload-ai/components/AiFieldButton#AiFieldButton",
|
||||
],
|
||||
},
|
||||
description: "Geben Sie den mehrzeiligen Text für definition ein.",
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
29
apps/web/src/payload/blocks/DiagramStateBlock.ts
Normal file
29
apps/web/src/payload/blocks/DiagramStateBlock.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { MintelBlock } from "./types";
|
||||
|
||||
import type { Block } from "payload";
|
||||
|
||||
export const DiagramStateBlock: MintelBlock = {
|
||||
slug: "diagramState",
|
||||
labels: {
|
||||
singular: "Diagram State",
|
||||
plural: "Diagram States",
|
||||
},
|
||||
admin: {
|
||||
group: "MDX Components",
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: "definition",
|
||||
type: "textarea",
|
||||
required: true,
|
||||
admin: {
|
||||
components: {
|
||||
afterInput: [
|
||||
"@mintel/payload-ai/components/AiFieldButton#AiFieldButton",
|
||||
],
|
||||
},
|
||||
description: "Geben Sie den mehrzeiligen Text für definition ein.",
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
29
apps/web/src/payload/blocks/DiagramTimelineBlock.ts
Normal file
29
apps/web/src/payload/blocks/DiagramTimelineBlock.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { MintelBlock } from "./types";
|
||||
|
||||
import type { Block } from "payload";
|
||||
|
||||
export const DiagramTimelineBlock: MintelBlock = {
|
||||
slug: "diagramTimeline",
|
||||
labels: {
|
||||
singular: "Diagram Timeline",
|
||||
plural: "Diagram Timelines",
|
||||
},
|
||||
admin: {
|
||||
group: "MDX Components",
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: "definition",
|
||||
type: "textarea",
|
||||
required: true,
|
||||
admin: {
|
||||
components: {
|
||||
afterInput: [
|
||||
"@mintel/payload-ai/components/AiFieldButton#AiFieldButton",
|
||||
],
|
||||
},
|
||||
description: "Geben Sie den mehrzeiligen Text für definition ein.",
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
21
apps/web/src/payload/blocks/DigitalAssetVisualizerBlock.ts
Normal file
21
apps/web/src/payload/blocks/DigitalAssetVisualizerBlock.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { MintelBlock } from "./types";
|
||||
|
||||
import type { Block } from "payload";
|
||||
|
||||
export const DigitalAssetVisualizerBlock: MintelBlock = {
|
||||
slug: "digitalAssetVisualizer",
|
||||
labels: {
|
||||
singular: "Digital Asset Visualizer",
|
||||
plural: "Digital Asset Visualizers",
|
||||
},
|
||||
admin: {
|
||||
group: "MDX Components",
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: "assetId",
|
||||
type: "text",
|
||||
admin: { description: "Geben Sie den Text für assetId ein." },
|
||||
},
|
||||
],
|
||||
};
|
||||
42
apps/web/src/payload/blocks/ExternalLinkBlock.ts
Normal file
42
apps/web/src/payload/blocks/ExternalLinkBlock.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { MintelBlock } from "./types";
|
||||
|
||||
import type { Block } from "payload";
|
||||
|
||||
export const ExternalLinkBlock: MintelBlock = {
|
||||
slug: "externalLink",
|
||||
labels: {
|
||||
singular: "External Link",
|
||||
plural: "External Links",
|
||||
},
|
||||
admin: {
|
||||
group: "MDX Components",
|
||||
},
|
||||
ai: {
|
||||
name: "ExternalLink",
|
||||
description:
|
||||
"Inline external link with ↗ icon and outbound analytics tracking. Use for all source citations and external references within Paragraph text.",
|
||||
usageExample:
|
||||
"'<ExternalLink href=\"https://web.dev/articles/vitals\">Google Core Web Vitals</ExternalLink>'",
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: "href",
|
||||
type: "text",
|
||||
required: true,
|
||||
admin: { description: "Geben Sie den Text für href ein." },
|
||||
},
|
||||
{
|
||||
name: "label",
|
||||
type: "text",
|
||||
required: true,
|
||||
admin: {
|
||||
components: {
|
||||
afterInput: [
|
||||
"@mintel/payload-ai/components/AiFieldButton#AiFieldButton",
|
||||
],
|
||||
},
|
||||
description: "Geben Sie den Text für label ein.",
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
40
apps/web/src/payload/blocks/FAQSectionBlock.ts
Normal file
40
apps/web/src/payload/blocks/FAQSectionBlock.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { MintelBlock } from "./types";
|
||||
|
||||
import type { Block } from "payload";
|
||||
import { lexicalEditor, BlocksFeature } from "@payloadcms/richtext-lexical";
|
||||
import { HeadingBlock } from "./HeadingBlock";
|
||||
import { ParagraphBlock } from "./ParagraphBlock";
|
||||
import { ExternalLinkBlock } from "./ExternalLinkBlock";
|
||||
import { TrackedLinkBlock } from "./TrackedLinkBlock";
|
||||
|
||||
export const FAQSectionBlock: MintelBlock = {
|
||||
slug: "faqSection",
|
||||
labels: {
|
||||
singular: "Faq Section",
|
||||
plural: "Faq Sections",
|
||||
},
|
||||
admin: {
|
||||
group: "MDX Components",
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: "content",
|
||||
type: "richText",
|
||||
editor: lexicalEditor({
|
||||
features: ({ defaultFeatures }) => [
|
||||
...defaultFeatures,
|
||||
BlocksFeature({
|
||||
blocks: [
|
||||
HeadingBlock,
|
||||
ParagraphBlock,
|
||||
ExternalLinkBlock,
|
||||
TrackedLinkBlock,
|
||||
].map(({ ai, render, ...b }) => b),
|
||||
}),
|
||||
],
|
||||
}),
|
||||
required: true,
|
||||
admin: { description: "Formatierter Textbereich für content." },
|
||||
},
|
||||
],
|
||||
};
|
||||
24
apps/web/src/payload/blocks/H2Block.ts
Normal file
24
apps/web/src/payload/blocks/H2Block.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import type { MintelBlock } from "./types";
|
||||
|
||||
export const H2Block: MintelBlock = {
|
||||
slug: "mintelH2",
|
||||
labels: {
|
||||
singular: "Heading 2",
|
||||
plural: "Headings 2",
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: "text",
|
||||
type: "text",
|
||||
required: true,
|
||||
admin: {
|
||||
description: "Geben Sie den Text für die H2-Überschrift ein.",
|
||||
components: {
|
||||
afterInput: [
|
||||
"@mintel/payload-ai/components/AiFieldButton#AiFieldButton",
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
24
apps/web/src/payload/blocks/H3Block.ts
Normal file
24
apps/web/src/payload/blocks/H3Block.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import type { MintelBlock } from "./types";
|
||||
|
||||
export const H3Block: MintelBlock = {
|
||||
slug: "mintelH3",
|
||||
labels: {
|
||||
singular: "Heading 3",
|
||||
plural: "Headings 3",
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: "text",
|
||||
type: "text",
|
||||
required: true,
|
||||
admin: {
|
||||
description: "Geben Sie den Text für die H3-Überschrift ein.",
|
||||
components: {
|
||||
afterInput: [
|
||||
"@mintel/payload-ai/components/AiFieldButton#AiFieldButton",
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user