Compare commits

..

1 Commits

Author SHA1 Message Date
85d2d2c069 feat(ai): Implement AI agent contact form and fix local Qdrant network configs
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 7s
Build & Deploy / 🏗️ Build (push) Failing after 18m2s
Build & Deploy / 🚀 Deploy (push) Has been skipped
Build & Deploy / 🧪 QA (push) Has been skipped
Build & Deploy / 🧪 Post-Deploy Verification (push) Has been skipped
Build & Deploy / 🔔 Notify (push) Successful in 3s
2026-03-06 11:56:12 +01:00
169 changed files with 22514 additions and 1110 deletions

View File

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

View File

@@ -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,30 +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 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:
@@ -190,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 }}
@@ -214,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
# ──────────────────────────────────────────────────────────────────────────────
@@ -241,7 +353,12 @@ 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 }}
@@ -305,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
@@ -325,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
@@ -404,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"
@@ -438,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 }}"
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
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
git clone https://git.infra.mintel.me/mmintel/at-mintel.git _at-mintel
- name: 🌐 Check Case Study Assets and Rewrites
shell: sh
# 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: |
BASE_URL="${{ needs.prepare.outputs.next_public_url }}"
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"
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"
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"
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
done
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: |
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
# 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
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
[ -f /usr/bin/chromium ] && sudo ln -sf /usr/bin/chromium /usr/bin/google-chrome
pnpm --filter @mintel/web check:forms
# ──────────────────────────────────────────────────────────────────────────────
# JOB 6: Notifications
@@ -520,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
@@ -530,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
View 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
View File

@@ -56,8 +56,4 @@ apps/web/out/estimations/
# Backups
backups/
.turbo
# Manual build artifacts
_at-mintel/
local_build_*.log
*.tar
.turbo

View File

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

View File

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

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

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

View File

@@ -0,0 +1 @@
export const importMap = {};

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

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -26,7 +26,6 @@ import { HeroSection } from "@/src/components/HeroSection";
import { GlitchText } from "@/src/components/GlitchText";
import { Marker } from "@/src/components/Marker";
import { PenCircle } from "@/src/components/PenCircle";
import { Availability } from "@/src/components/Availability";
export default function LandingPage() {
return (
@@ -51,7 +50,7 @@ export default function LandingPage() {
</H3>
</Reveal>
<div className="grid grid-cols-1 md:grid-cols-1 lg:grid-cols-2 gap-8 lg:gap-12 relative z-10">
<div className="grid grid-cols-1 md:grid-cols-2 gap-8 relative z-10">
{[
{
icon: <ConceptCommunication className="w-8 h-8" />,
@@ -137,7 +136,7 @@ export default function LandingPage() {
{/* Section 04: Target Group */}
<Section number="04" title="Für wen" borderTop>
<div className="grid grid-cols-1 md:grid-cols-1 lg:grid-cols-2 gap-4 md:gap-6 lg:gap-10 relative z-10">
<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">
@@ -216,8 +215,8 @@ export default function LandingPage() {
},
].map((service, i) => (
<Reveal key={i} delay={0.1 + i * 0.15}>
<div className="group py-8 md:py-12 lg: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-10 lg:gap-16">
<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">
@@ -281,13 +280,13 @@ export default function LandingPage() {
<Section number="06" title="Kontakt" borderTop>
<div className="relative py-4 md:py-12" id="contact">
<Reveal>
<div className="space-y-8 md:space-y-12 lg:space-y-16">
<H1 className="text-4xl md:text-6xl lg:text-8xl">
<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-6 md:gap-10 lg:gap-16 items-start relative z-10">
<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{" "}
@@ -314,7 +313,7 @@ export default function LandingPage() {
</div>
<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>

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

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

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

View File

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

View File

@@ -18,7 +18,7 @@ tags: ["management", "business"]
<Section>
<H2>Inhaltsverzeichnis</H2>
<TableOfContents />
<TOC />
</Section>
<H2>Die Falle der unendlichen Stunden</H2>

View File

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

View File

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

54
apps/web/migrate-docs.ts Normal file
View 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();

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

View File

@@ -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,42 +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, '../../'),
};
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)));

View File

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

File diff suppressed because it is too large Load Diff

119
apps/web/payload.config.ts Normal file
View 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,
},
],
});

87
apps/web/remove-toc.ts Normal file
View 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();

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

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

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

View File

@@ -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}`,

View File

@@ -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}</>;
};

View File

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

View File

@@ -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 */}

View File

@@ -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">&lt; 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"

View File

@@ -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">
&copy; ${new Date().getFullYear()} <a href="https://mintel.me">mintel.me</a> — Marc Mintel<br/>
Professional Website Systems & Technical Architecture
&copy; ${new Date().getFullYear()} mintel.me — Technical Problem Solving
</div>
</div>
</body>

View File

@@ -35,7 +35,6 @@ export interface FormState {
storageExpansion: number;
name: string;
email: string;
phone: string;
role: string;
message: string;
sitemapFile: File | null;

View File

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

View File

@@ -47,7 +47,7 @@ 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" />
@@ -59,7 +59,7 @@ export const HeroSection: React.FC = () => {
</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,9 +80,9 @@ 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">
<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">

View File

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

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

View File

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

View File

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

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

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

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

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

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

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

View 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 23 Monaten', icon: '📅' },
{ id: '3-6-months', label: 'In 36 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>
);
}

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

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

View File

@@ -85,7 +85,6 @@ export const mdxComponents = {
Section,
Reveal,
TableOfContents,
TOC: TableOfContents,
RevenueLossCalculator,
PerformanceChart,
PerformanceROICalculator,

View File

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

View File

@@ -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(),
};
/**

View File

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

View File

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

View File

@@ -47,7 +47,6 @@ export const initialState: FormState = {
storageExpansion: 0,
name: "",
email: "",
phone: "",
role: "",
message: "",
sitemapFile: null,

View File

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

View File

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

File diff suppressed because it is too large Load Diff

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

File diff suppressed because it is too large Load Diff

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

View 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",
},
];

View 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." },
},
],
};

View 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.",
},
},
],
};

View 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.",
},
},
],
};

View 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." },
},
],
};

View 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.",
},
},
],
};

View 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." },
},
],
};

View 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." },
},
],
};

View 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?",
},
},
],
};

View 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.",
},
},
],
};

View 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.",
},
},
],
};

View 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.",
},
},
],
};

View 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.",
},
},
],
};

View 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.",
},
},
],
};

View 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.",
},
},
],
};

View 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." },
},
],
};

View 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.",
},
},
],
};

View 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." },
},
],
};

View 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",
],
},
},
},
],
};

View 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",
],
},
},
},
],
};

View File

@@ -0,0 +1,50 @@
import { MintelBlock } from "./types";
export const HeadingBlock: MintelBlock = {
slug: "mintelHeading",
labels: {
singular: "Heading",
plural: "Headings",
},
admin: {
group: "MDX Components",
},
ai: {
name: "Heading",
description:
"Flexible heading component with separated SEO and visual display levels.",
usageExample:
'\'<Heading seoLevel="h2" displayLevel="h3">Titel</Heading>\'',
},
fields: [
{
name: "text",
type: "text",
required: true,
admin: {
description: "Der Text der Überschrift.",
components: {
afterInput: [
"@mintel/payload-ai/components/AiFieldButton#AiFieldButton",
],
},
},
},
{
name: "seoLevel",
type: "select",
options: ["h1", "h2", "h3", "h4", "h5", "h6"],
defaultValue: "h2",
admin: { description: "Das semantische HTML-Tag für SEO." },
},
{
name: "displayLevel",
type: "select",
options: ["h1", "h2", "h3", "h4", "h5", "h6"],
defaultValue: "h2",
admin: {
description: "Die visuelle Größe der Überschrift (unabhängig von SEO).",
},
},
],
};

View File

@@ -0,0 +1,69 @@
import { MintelBlock } from "./types";
import type { Block } from "payload";
export const IconListBlock: MintelBlock = {
slug: "iconList",
labels: {
singular: "Icon List",
plural: "Icon Lists",
},
admin: {
group: "MDX Components",
},
ai: {
name: "IconList",
description:
"Checklist with check/cross icons. Wrap IconListItem children inside.",
usageExample: `<IconList>
<IconListItem check>
<strong>Zero-Computation:</strong> Statische Seiten, kein Serverwarten.
</IconListItem>
<IconListItem cross>
<strong>Legacy CMS:</strong> Datenbankabfragen bei jedem Request.
</IconListItem>
</IconList>`,
},
fields: [
{
name: "items",
type: "array",
fields: [
{
name: "icon",
type: "text",
admin: {
description: "Lucide icon",
components: { Field: "@/src/payload/components/IconSelector" },
},
},
{
name: "title",
type: "text",
required: true,
admin: {
components: {
afterInput: [
"@mintel/payload-ai/components/AiFieldButton#AiFieldButton",
],
},
description: "Geben Sie den Text für title ein.",
},
},
{
name: "description",
type: "textarea",
admin: {
components: {
afterInput: [
"@mintel/payload-ai/components/AiFieldButton#AiFieldButton",
],
},
description: "Geben Sie den mehrzeiligen Text für description ein.",
},
},
],
admin: { description: "Fügen Sie Elemente zur Liste items hinzu." },
},
],
};

View File

@@ -0,0 +1,46 @@
import { MintelBlock } from "./types";
import type { Block } from "payload";
export const ImageTextBlock: MintelBlock = {
slug: "imageText",
labels: {
singular: "Image Text",
plural: "Image Texts",
},
admin: {
group: "MDX Components",
},
fields: [
{
name: "image",
type: "upload",
relationTo: "media",
required: true,
admin: { description: "Laden Sie die Datei für image hoch." },
},
{
name: "text",
type: "textarea",
required: true,
admin: {
components: {
afterInput: [
"@mintel/payload-ai/components/AiFieldButton#AiFieldButton",
],
},
description: "Geben Sie den mehrzeiligen Text für text ein.",
},
},
{
name: "alignment",
type: "select",
options: [
{ label: "Left", value: "left" },
{ label: "Right", value: "right" },
],
defaultValue: "left",
admin: { description: "Wählen Sie eine Option für alignment aus." },
},
],
};

View File

@@ -0,0 +1,81 @@
import { MintelBlock } from "./types";
import type { Block } from "payload";
export const LeadMagnetBlock: MintelBlock = {
slug: "leadMagnet",
labels: {
singular: "Lead Magnet CTA",
plural: "Lead Magnet CTAs",
},
admin: {
group: "MDX Components",
},
ai: {
name: "LeadMagnet",
description:
"Premium B2B conversion card. Use 1-2 per article as main high-impact CTAs. Props: title (strong headline), description (value prop), buttonText (action), href (link), variant (performance|security|standard).",
usageExample:
'\'<LeadMagnet title="Performance-Check anfragen" description="Wir analysieren Ihre Core Web Vitals und decken Umsatzpotenziale auf." buttonText="Jetzt analysieren lassen" href="/contact" variant="performance" />\'',
},
fields: [
{
name: "title",
type: "text",
required: true,
admin: {
description: "The strong headline for the Call-to-Action",
components: {
afterInput: [
"@mintel/payload-ai/components/AiFieldButton#AiFieldButton",
],
},
},
},
{
name: "description",
type: "text",
required: true,
admin: {
description: "The value proposition text.",
components: {
afterInput: [
"@mintel/payload-ai/components/AiFieldButton#AiFieldButton",
],
},
},
},
{
name: "buttonText",
type: "text",
required: true,
defaultValue: "Jetzt anfragen",
admin: {
components: {
afterInput: [
"@mintel/payload-ai/components/AiFieldButton#AiFieldButton",
],
},
description: "Geben Sie den Text für buttonText ein.",
},
},
{
name: "href",
type: "text",
required: true,
defaultValue: "/contact",
admin: { description: "Geben Sie den Text für href ein." },
},
{
name: "variant",
type: "select",
options: [
{ label: "Performance", value: "performance" },
{ label: "Security", value: "security" },
{ label: "Standard", value: "standard" },
],
defaultValue: "standard",
admin: { description: "Wählen Sie eine Option für variant aus." },
},
],
};

View File

@@ -0,0 +1,36 @@
import { MintelBlock } from "./types";
import type { Block } from "payload";
export const LeadParagraphBlock: MintelBlock = {
slug: "leadParagraph",
labels: {
singular: "Lead Paragraph",
plural: "Lead Paragraphs",
},
admin: {
group: "MDX Components",
},
ai: {
name: "LeadParagraph",
description:
"Larger, emphasized paragraph for the article introduction. Use 1-3 at the start.",
usageExample:
"'<LeadParagraph>\n Unternehmen investieren oft Unsummen in glänzende Oberflächen, während das technische Fundament bröckelt.\n</LeadParagraph>'",
},
fields: [
{
name: "text",
type: "textarea",
required: true,
admin: {
components: {
afterInput: [
"@mintel/payload-ai/components/AiFieldButton#AiFieldButton",
],
},
description: "Geben Sie den mehrzeiligen Text für text ein.",
},
},
],
};

View File

@@ -0,0 +1,22 @@
import { MintelBlock } from "./types";
import type { Block } from "payload";
export const LinkedInEmbedBlock: MintelBlock = {
slug: "linkedInEmbed",
labels: {
singular: "Linked In Embed",
plural: "Linked In Embeds",
},
admin: {
group: "MDX Components",
},
fields: [
{
name: "url",
type: "text",
required: true,
admin: { description: "Geben Sie den Text für url ein." },
},
],
};

View File

@@ -0,0 +1,25 @@
import { MintelBlock } from "./types";
import type { Block } from "payload";
export const LoadTimeSimulatorBlock: MintelBlock = {
slug: "loadTimeSimulator",
labels: {
singular: "Load Time Simulator",
plural: "Load Time Simulators",
},
admin: {
group: "MDX Components",
},
fields: [
{
name: "initialLoadTime",
type: "number",
defaultValue: 3.5,
admin: {
description:
"Tragen Sie einen numerischen Wert für initialLoadTime ein.",
},
},
],
};

View File

@@ -0,0 +1,45 @@
import { MintelBlock } from "./types";
import type { Block } from "payload";
export const MarkerBlock: MintelBlock = {
slug: "marker",
labels: {
singular: "Marker",
plural: "Markers",
},
admin: {
group: "MDX Components",
},
fields: [
{
name: "text",
type: "text",
required: true,
admin: {
components: {
afterInput: [
"@mintel/payload-ai/components/AiFieldButton#AiFieldButton",
],
},
description: "Geben Sie den Text für text ein.",
},
},
{
name: "color",
type: "text",
admin: {
description: "Hex or rgba color",
components: { Field: "@/src/payload/components/ColorPicker" },
},
},
{
name: "delay",
type: "number",
defaultValue: 0,
admin: {
description: "Tragen Sie einen numerischen Wert für delay ein.",
},
},
],
};

View File

@@ -0,0 +1,51 @@
import { MintelBlock } from "./types";
import type { Block } from "payload";
export const MemeCardBlock: MintelBlock = {
slug: "memeCard",
labels: {
singular: "Meme Card",
plural: "Meme Cards",
},
admin: {
group: "MDX Components",
},
ai: {
name: "MemeCard",
description:
"Real meme from memegen.link. ONLY use these templates: drake, distracted-bf, change-my-mind, uno-draw-25, always-has-been. Captions MUST be in German, extremely sarcastic, max 6 words per line. Use pipe | to separate caption lines. Use sparingly: MAX 1 meme per article.",
usageExample: `<div className="my-8">
<MemeCard template="drake" captions="47 WordPress Plugins installieren|Eine saubere Serverless Architektur" />
</div>`,
},
fields: [
{
name: "template",
type: "text",
required: true,
admin: {
description:
"The template ID from memegen.link (e.g. 'drake', 'disastergirl')",
components: {
afterInput: [
"@mintel/payload-ai/components/AiFieldButton#AiFieldButton",
],
},
},
},
{
name: "captions",
type: "textarea",
required: true,
admin: {
description:
"Pipe-separated captions for the meme (e.g. 'Legacy Code|Mintel Stack'). Maximum 6 words per line.",
components: {
afterInput: [
"@mintel/payload-ai/components/AiFieldButton#AiFieldButton",
],
},
},
},
],
};

View File

@@ -0,0 +1,68 @@
import { MintelBlock } from "./types";
import type { Block } from "payload";
export const MermaidBlock: MintelBlock = {
slug: "mermaid",
labels: {
singular: "Mermaid",
plural: "Mermaids",
},
admin: {
group: "MDX Components",
},
ai: {
name: "Mermaid",
description:
'Renders a Mermaid.js diagram (flowchart, sequence, pie, etc.). Diagram code goes as children. Keep it tiny (max 3-4 nodes). Wrap in div with className="my-8".',
usageExample: `<div className="my-8">
<Mermaid id="my-diagram" title="System Architecture" showShare={true`,
},
fields: [
{
name: "id",
type: "text",
required: true,
admin: {
description:
"A unique ASCII ID for the diagram (e.g. 'architecture-1').",
},
},
{
name: "title",
type: "text",
required: false,
admin: {
description: "Optional title displayed above the diagram.",
components: {
afterInput: [
"@mintel/payload-ai/components/AiFieldButton#AiFieldButton",
],
},
},
},
{
name: "showShare",
type: "checkbox",
defaultValue: false,
admin: {
description: "Show the share button for this diagram?",
},
},
{
name: "chartDefinition",
type: "code",
required: true,
admin: {
language: "markdown",
description:
"The raw Mermaid.js syntax (e.g. graph TD... shadowing, loops, etc.).",
components: {
afterInput: [
"@mintel/payload-ai/components/AiFieldButton#AiFieldButton",
],
},
},
},
],
};

Some files were not shown because too many files have changed in this diff Show More