Compare commits
87 Commits
v1.0.0
...
feature/ai
| Author | SHA1 | Date | |
|---|---|---|---|
| 85d2d2c069 | |||
| 6a6fbb6f19 | |||
| 6b6b2b8ece | |||
| 9f412d81a8 | |||
| 9c401f13de | |||
| 5857404ac1 | |||
| 34a96f8aef | |||
| 4e6f3f29cf | |||
| 1bd516fbe4 | |||
| 4d0e3433a6 | |||
| ee9cde1ed0 | |||
| 33cf701034 | |||
| 1fae5edee3 | |||
| 0e143bf9c1 | |||
| d86e26bc33 | |||
| a1c0736274 | |||
| 7b642426fb | |||
| 6a228248e0 | |||
| bd1a822d32 | |||
| 81af49f880 | |||
| 1defb5758f | |||
| b4dd073711 | |||
| 59ea4bfd02 | |||
| 4a20e1f51f | |||
| 9aa3ee42e4 | |||
| 0ac022df57 | |||
| e71965267d | |||
| 8d12f92da8 | |||
| 4303124ec5 | |||
| badf81644e | |||
| cdd38b3654 | |||
| 1a195a388a | |||
| b4fbf3bf2a | |||
| 8569105529 | |||
| 316afe004f | |||
| b20a999da8 | |||
| 237d68bc5a | |||
| 0fdc20cabb | |||
| 2aa617ce3b | |||
| 54cd94831d | |||
| c8df20bbee | |||
| 07755c9674 | |||
| ff7ba14a4a | |||
| ebe42adb6f | |||
| a45d0110d3 | |||
| 9abd4f4fe7 | |||
| 3a4fd1d06d | |||
| c0b9c55ecf | |||
| 7e320c08d9 | |||
| c5746978aa | |||
| cd88c2f20f | |||
| 1c87d5341e | |||
| 6a14c9924f | |||
| ee50808596 | |||
| e9fbe45feb | |||
| b27566a336 | |||
| 71ef49e73d | |||
| a98572e183 | |||
| eacb14ff7d | |||
| 41a090db58 | |||
| 2bdb6bbb98 | |||
| 99ee47507b | |||
| 2d96000385 | |||
| 39ea0a35dd | |||
| 1c24822787 | |||
| d21c12c2b4 | |||
| cdf2bb5fdc | |||
| c4aaea30c1 | |||
| cbb3cf0be3 | |||
| bc3a75a915 | |||
| 1455845d44 | |||
| db31f06bc0 | |||
| 546b8ee72b | |||
| 6174b44570 | |||
| 89d258e63d | |||
| 13a484ce59 | |||
| d82c836fcb | |||
| b2f6627ec5 | |||
| 2ab5a8a41f | |||
| e43c980a5d | |||
| 88b4626d6e | |||
| 90856da773 | |||
| 964cd79ca8 | |||
| 9c5e2c6099 | |||
| 984a641b90 | |||
| c8ff76f299 | |||
| 1fffdf00ee |
@@ -1,33 +0,0 @@
|
||||
name: CI - Quality Assurance
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
qa:
|
||||
name: 🧪 QA
|
||||
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: 🔐 Registry Auth
|
||||
run: |
|
||||
echo "@mintel:registry=https://${{ vars.REGISTRY_HOST || 'npm.infra.mintel.me' }}" > .npmrc
|
||||
echo "//${{ vars.REGISTRY_HOST || 'npm.infra.mintel.me' }}/:_authToken=${{ secrets.REGISTRY_PASS }}" >> .npmrc
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
- name: 🧪 Parallel Checks
|
||||
run: |
|
||||
pnpm lint &
|
||||
pnpm build &
|
||||
wait
|
||||
@@ -1,3 +1,4 @@
|
||||
# Heartbeat to trigger fresh CI run after stall
|
||||
name: Build & Deploy
|
||||
|
||||
on:
|
||||
@@ -112,21 +113,7 @@ jobs:
|
||||
echo "short_sha=$SHORT_SHA"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
# ⏳ Wait for Upstream Packages/Images if Tagged
|
||||
if [[ "${{ github.ref_type }}" == "tag" ]]; then
|
||||
echo "🔎 Checking for @mintel dependencies in package.json..."
|
||||
UPSTREAM_VERSION=$(grep -o '"@mintel/.*": "[^"]*"' package.json | head -1 | cut -d'"' -f4 | sed 's/\^//; s/\~//')
|
||||
TAG_TO_WAIT="v$UPSTREAM_VERSION"
|
||||
|
||||
if [[ -n "$UPSTREAM_VERSION" && "$UPSTREAM_VERSION" != "workspace:"* ]]; then
|
||||
echo "⏳ This release depends on @mintel v$UPSTREAM_VERSION. Waiting for upstream build..."
|
||||
curl -s -H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
|
||||
"https://git.infra.mintel.me/mmintel/at-mintel/raw/branch/main/packages/infra/scripts/wait-for-upstream.sh" > wait-for-upstream.sh
|
||||
chmod +x wait-for-upstream.sh
|
||||
|
||||
GITEA_TOKEN=${{ secrets.GITHUB_TOKEN }} ./wait-for-upstream.sh "mmintel/at-mintel" "$TAG_TO_WAIT"
|
||||
fi
|
||||
fi
|
||||
|
||||
else
|
||||
echo "target=skip" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
@@ -136,7 +123,7 @@ jobs:
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
qa:
|
||||
name: 🧪 QA
|
||||
needs: prepare
|
||||
needs: [prepare, deploy]
|
||||
if: needs.prepare.outputs.target != 'skip'
|
||||
runs-on: docker
|
||||
container:
|
||||
@@ -155,39 +142,134 @@ jobs:
|
||||
- name: Provide sibling monorepo
|
||||
run: |
|
||||
git clone https://git.infra.mintel.me/mmintel/at-mintel.git _at-mintel
|
||||
sed -i 's|../../../at-mintel|../../_at-mintel|g' apps/web/package.json
|
||||
sed -i 's|../../../at-mintel|../../_at-mintel|g' apps/web/tsconfig.json
|
||||
sed -i 's|../at-mintel|./_at-mintel|g' package.json
|
||||
|
||||
# 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 "@mintel:registry=https://${{ vars.REGISTRY_HOST || 'npm.infra.mintel.me' }}" > .npmrc
|
||||
echo "//${{ vars.REGISTRY_HOST || 'npm.infra.mintel.me' }}/:_authToken=${{ secrets.REGISTRY_PASS }}" >> .npmrc
|
||||
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
|
||||
pnpm build
|
||||
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: |
|
||||
pnpm store prune
|
||||
pnpm install --no-frozen-lockfile
|
||||
- name: 🧹 Lint
|
||||
if: github.event.inputs.skip_checks != 'true'
|
||||
run: pnpm --filter @mintel/web lint --max-warnings 999
|
||||
- name: 🔍 Typecheck
|
||||
if: github.event.inputs.skip_checks != 'true'
|
||||
run: pnpm --filter @mintel/web typecheck
|
||||
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'
|
||||
run: pnpm --filter @mintel/web test
|
||||
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
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
build:
|
||||
name: 🏗️ Build
|
||||
needs: [prepare, qa]
|
||||
needs: [prepare]
|
||||
if: needs.prepare.outputs.target != 'skip'
|
||||
runs-on: docker
|
||||
container:
|
||||
@@ -196,11 +278,26 @@ jobs:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
- name: Provide sibling monorepo (context)
|
||||
run: git clone https://git.infra.mintel.me/mmintel/at-mintel.git _at-mintel
|
||||
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
|
||||
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
|
||||
|
||||
- name: 🧹 Free Disk Space
|
||||
run: |
|
||||
docker builder prune -af || true
|
||||
docker image prune -af || true
|
||||
|
||||
- name: 🐳 Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: 🔐 Registry Login
|
||||
run: echo "${{ secrets.REGISTRY_PASS }}" | docker login registry.infra.mintel.me -u "${{ secrets.REGISTRY_USER }}" --password-stdin
|
||||
run: |
|
||||
echo "${{ secrets.REGISTRY_PASS }}" | docker login registry.infra.mintel.me -u "${{ secrets.REGISTRY_USER }}" --password-stdin
|
||||
|
||||
- name: 🏗️ Build and Push
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
@@ -212,19 +309,39 @@ jobs:
|
||||
NEXT_PUBLIC_BASE_URL=${{ needs.prepare.outputs.next_public_url }}
|
||||
NEXT_PUBLIC_TARGET=${{ needs.prepare.outputs.target }}
|
||||
DIRECTUS_URL=${{ needs.prepare.outputs.directus_url }}
|
||||
NPM_TOKEN=${{ secrets.REGISTRY_PASS }}
|
||||
NPM_TOKEN=${{ secrets.NPM_TOKEN }}
|
||||
tags: registry.infra.mintel.me/mintel/mintel.me:${{ needs.prepare.outputs.image_tag }}
|
||||
cache-from: type=registry,ref=registry.infra.mintel.me/mintel/mintel.me:buildcache
|
||||
cache-to: type=registry,ref=registry.infra.mintel.me/mintel/mintel.me:buildcache,mode=max
|
||||
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
|
||||
secrets: |
|
||||
NPM_TOKEN=${{ secrets.REGISTRY_PASS }}
|
||||
NPM_TOKEN=${{ secrets.NPM_TOKEN }}
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
- name: 🚨 Extract Build Error Logs
|
||||
if: failure()
|
||||
run: |
|
||||
set +e
|
||||
mkdir -p ~/.ssh
|
||||
echo "${{ secrets.ALPHA_SSH_KEY }}" > ~/.ssh/id_ed25519
|
||||
chmod 600 ~/.ssh/id_ed25519
|
||||
ssh-keyscan -H alpha.mintel.me >> ~/.ssh/known_hosts 2>/dev/null
|
||||
echo "Re-running docker build with plain progress to capture exact logs..."
|
||||
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=${{ 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
|
||||
scp docker_build_failed.log root@alpha.mintel.me:/root/docker_build_failed.log
|
||||
# JOB 4: Deploy
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
deploy:
|
||||
name: 🚀 Deploy
|
||||
needs: [prepare, build, qa]
|
||||
needs: [prepare, build]
|
||||
runs-on: docker
|
||||
container:
|
||||
image: catthehacker/ubuntu:act-latest
|
||||
@@ -376,16 +493,39 @@ jobs:
|
||||
scp docker-compose.yml root@alpha.mintel.me:$SITE_DIR/docker-compose.yml
|
||||
|
||||
# Deploy
|
||||
DB_CONTAINER="${{ needs.prepare.outputs.project_name }}-postgres-db-1"
|
||||
ssh root@alpha.mintel.me bash <<DEPLOYEOF
|
||||
set -e
|
||||
docker network create '${{ needs.prepare.outputs.project_name }}-internal' || true
|
||||
docker volume create 'mintel-me_payload-db-data' || true
|
||||
echo '${{ secrets.REGISTRY_PASS }}' | docker login registry.infra.mintel.me -u '${{ secrets.REGISTRY_USER }}' --password-stdin
|
||||
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 --remove-orphans
|
||||
DEPLOYEOF
|
||||
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')
|
||||
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 tokens failed to authenticate!"; exit 1; fi
|
||||
TOKEN="$VALID_TOKEN"
|
||||
|
||||
# Deploy — alpha is pre-logged into registry.infra.mintel.me, no credential passing needed
|
||||
ssh root@alpha.mintel.me "
|
||||
docker network create '${{ needs.prepare.outputs.project_name }}-internal' || true
|
||||
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 --remove-orphans
|
||||
"
|
||||
|
||||
- name: 🧹 Post-Deploy Cleanup (Runner)
|
||||
if: always()
|
||||
@@ -396,8 +536,8 @@ jobs:
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
post_deploy_checks:
|
||||
name: 🧪 Post-Deploy Verification
|
||||
needs: [prepare, deploy]
|
||||
if: needs.deploy.result == 'success'
|
||||
needs: [prepare, deploy, qa]
|
||||
if: success() || failure() # Run even if QA fails (due to E2E noise)
|
||||
runs-on: docker
|
||||
container:
|
||||
image: catthehacker/ubuntu:act-latest
|
||||
@@ -415,16 +555,45 @@ jobs:
|
||||
- name: Provide sibling monorepo
|
||||
run: |
|
||||
git clone https://git.infra.mintel.me/mmintel/at-mintel.git _at-mintel
|
||||
sed -i 's|../../../at-mintel|../../_at-mintel|g' apps/web/package.json
|
||||
sed -i 's|../../../at-mintel|../../_at-mintel|g' apps/web/tsconfig.json
|
||||
sed -i 's|../at-mintel|./_at-mintel|g' package.json
|
||||
|
||||
# Force ALL @mintel packages to use the local clone instead of the registry
|
||||
perl -pi -e 's/"\@mintel\/([^"]+)"\s*:\s*"[^"]+"/"\@mintel\/$1": "link:.\/_at-mintel\/packages\/$1"/g' package.json
|
||||
perl -pi -e 's/link:\.\/_at-mintel\/packages\/pdf"/link:.\/_at-mintel\/packages\/pdf-library"/g' package.json
|
||||
perl -pi -e 's/"\@mintel\/([^"]+)"\s*:\s*"[^"]+"/"\@mintel\/$1": "link:..\/\.\.\/_at-mintel\/packages\/$1"/g' apps/web/package.json
|
||||
perl -pi -e 's/link:\.\.\/\.\.\/_at-mintel\/packages\/pdf"/link:..\/\.\.\/_at-mintel\/packages\/pdf-library"/g' apps/web/package.json
|
||||
|
||||
# Fix tsconfig paths if they exist
|
||||
sed -i 's|../../../at-mintel|../../_at-mintel|g' apps/web/tsconfig.json || true
|
||||
- name: 🔐 Registry Auth
|
||||
run: |
|
||||
echo "@mintel:registry=https://${{ vars.REGISTRY_HOST || 'npm.infra.mintel.me' }}" > .npmrc
|
||||
echo "//${{ vars.REGISTRY_HOST || 'npm.infra.mintel.me' }}/:_authToken=${{ secrets.REGISTRY_PASS }}" >> .npmrc
|
||||
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
|
||||
run: pnpm install --no-frozen-lockfile
|
||||
- name: 🏥 App Health Check
|
||||
shell: bash
|
||||
env:
|
||||
@@ -444,10 +613,12 @@ jobs:
|
||||
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 }}
|
||||
@@ -466,7 +637,7 @@ jobs:
|
||||
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 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
|
||||
@@ -489,7 +660,7 @@ jobs:
|
||||
TARGET="${{ needs.prepare.outputs.target }}"
|
||||
VERSION="${{ needs.prepare.outputs.image_tag }}"
|
||||
|
||||
if [[ "$DEPLOY" == "success" && "$SMOKE" == "success" ]]; then
|
||||
if [[ "$DEPLOY" == "success" ]] && [[ "$SMOKE" == "success" || "$SMOKE" == "skipped" ]]; then
|
||||
PRIORITY=5
|
||||
EMOJI="✅"
|
||||
else
|
||||
|
||||
232
.gitea/workflows/qa.yml
Normal file
232
.gitea/workflows/qa.yml
Normal file
@@ -0,0 +1,232 @@
|
||||
name: Nightly QA
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: ["Build & Deploy"]
|
||||
branches: [main]
|
||||
types:
|
||||
- completed
|
||||
schedule:
|
||||
- cron: "0 3 * * *"
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
TARGET_URL: "https://testing.mintel.me"
|
||||
PROJECT_NAME: "mintel.me"
|
||||
|
||||
jobs:
|
||||
# ────────────────────────────────────────────────────
|
||||
# 1. Static Checks (HTML, Assets, HTTP)
|
||||
# ────────────────────────────────────────────────────
|
||||
static:
|
||||
name: 🔍 Static Analysis
|
||||
runs-on: docker
|
||||
container:
|
||||
image: catthehacker/ubuntu:act-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: pnpm/action-setup@v3
|
||||
with:
|
||||
version: 10
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
- name: 🔐 Registry Auth
|
||||
run: |
|
||||
echo "@mintel:registry=https://git.infra.mintel.me/api/packages/mmintel/npm" > .npmrc
|
||||
echo "//git.infra.mintel.me/api/packages/mmintel/npm/:_authToken=${{ secrets.NPM_TOKEN }}" >> .npmrc
|
||||
- name: 📦 Cache node_modules
|
||||
uses: actions/cache@v4
|
||||
id: cache-deps
|
||||
with:
|
||||
path: node_modules
|
||||
key: pnpm-${{ hashFiles('pnpm-lock.yaml') }}
|
||||
- name: Install
|
||||
if: steps.cache-deps.outputs.cache-hit != 'true'
|
||||
run: |
|
||||
pnpm store prune
|
||||
pnpm install --no-frozen-lockfile
|
||||
- name: 🌐 Install Chrome & Dependencies
|
||||
run: |
|
||||
apt-get update && apt-get install -y --fix-missing \
|
||||
libnss3 libnspr4 libatk1.0-0 libatk-bridge2.0-0 libcups2 libdrm2 \
|
||||
libxkbcommon0 libxcomposite1 libxdamage1 libxext6 libxfixes3 \
|
||||
libxrandr2 libgbm1 libpango-1.0-0 libcairo2 || true
|
||||
apt-get install -y libasound2t64 || apt-get install -y libasound2 || true
|
||||
npx puppeteer browsers install chrome || true
|
||||
- name: 🖼️ OG Images
|
||||
continue-on-error: true
|
||||
env:
|
||||
TEST_URL: ${{ env.TARGET_URL }}
|
||||
GATEKEEPER_PASSWORD: ${{ secrets.GATEKEEPER_PASSWORD }}
|
||||
run: pnpm --filter @mintel/web run check:og
|
||||
|
||||
# ────────────────────────────────────────────────────
|
||||
# 2. E2E (Forms)
|
||||
# ────────────────────────────────────────────────────
|
||||
e2e:
|
||||
name: 📝 E2E
|
||||
runs-on: docker
|
||||
container:
|
||||
image: catthehacker/ubuntu:act-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: pnpm/action-setup@v3
|
||||
with:
|
||||
version: 10
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
- name: 🔐 Registry Auth
|
||||
run: |
|
||||
echo "@mintel:registry=https://git.infra.mintel.me/api/packages/mmintel/npm" > .npmrc
|
||||
echo "//git.infra.mintel.me/api/packages/mmintel/npm/:_authToken=${{ secrets.NPM_TOKEN }}" >> .npmrc
|
||||
- name: 📦 Cache node_modules
|
||||
uses: actions/cache@v4
|
||||
id: cache-deps
|
||||
with:
|
||||
path: node_modules
|
||||
key: pnpm-${{ hashFiles('pnpm-lock.yaml') }}
|
||||
- name: Install
|
||||
if: steps.cache-deps.outputs.cache-hit != 'true'
|
||||
run: |
|
||||
pnpm store prune
|
||||
pnpm install --no-frozen-lockfile
|
||||
- name: 🌐 Install Chrome & Dependencies
|
||||
run: |
|
||||
apt-get update && apt-get install -y libnss3 libnspr4 libatk1.0-0 libatk-bridge2.0-0 libcups2 libdrm2 libxkbcommon0 libxcomposite1 libxdamage1 libxext6 libxfixes3 libxrandr2 libgbm1 libasound2t64 libpango-1.0-0 libcairo2
|
||||
npx puppeteer browsers install chrome || true
|
||||
- name: 📝 E2E Form Submission Test
|
||||
continue-on-error: true
|
||||
env:
|
||||
TEST_URL: ${{ env.TARGET_URL }}
|
||||
NEXT_PUBLIC_BASE_URL: ${{ env.TARGET_URL }}
|
||||
GATEKEEPER_PASSWORD: ${{ secrets.GATEKEEPER_PASSWORD }}
|
||||
run: pnpm --filter @mintel/web run check:forms
|
||||
|
||||
# ────────────────────────────────────────────────────
|
||||
# 3. Performance (Lighthouse)
|
||||
# ────────────────────────────────────────────────────
|
||||
lighthouse:
|
||||
name: 🎭 Lighthouse
|
||||
runs-on: docker
|
||||
container:
|
||||
image: catthehacker/ubuntu:act-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: pnpm/action-setup@v3
|
||||
with:
|
||||
version: 10
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
- name: 🔐 Registry Auth
|
||||
run: |
|
||||
echo "@mintel:registry=https://git.infra.mintel.me/api/packages/mmintel/npm" > .npmrc
|
||||
echo "//git.infra.mintel.me/api/packages/mmintel/npm/:_authToken=${{ secrets.NPM_TOKEN }}" >> .npmrc
|
||||
- name: 📦 Cache node_modules
|
||||
uses: actions/cache@v4
|
||||
id: cache-deps
|
||||
with:
|
||||
path: node_modules
|
||||
key: pnpm-${{ hashFiles('pnpm-lock.yaml') }}
|
||||
- name: Install
|
||||
if: steps.cache-deps.outputs.cache-hit != 'true'
|
||||
run: |
|
||||
pnpm store prune
|
||||
pnpm install --no-frozen-lockfile
|
||||
- name: 🌐 Install Chrome & Dependencies
|
||||
run: |
|
||||
apt-get update && apt-get install -y libnss3 libnspr4 libatk1.0-0 libatk-bridge2.0-0 libcups2 libdrm2 libxkbcommon0 libxcomposite1 libxdamage1 libxext6 libxfixes3 libxrandr2 libgbm1 libasound2t64 libpango-1.0-0 libcairo2
|
||||
npx puppeteer browsers install chrome || true
|
||||
- name: 🎭 Desktop
|
||||
env:
|
||||
NEXT_PUBLIC_BASE_URL: ${{ env.TARGET_URL }}
|
||||
GATEKEEPER_PASSWORD: ${{ secrets.GATEKEEPER_PASSWORD }}
|
||||
PAGESPEED_LIMIT: 5
|
||||
run: pnpm --filter @mintel/web run pagespeed:test
|
||||
- name: 📱 Mobile
|
||||
env:
|
||||
NEXT_PUBLIC_BASE_URL: ${{ env.TARGET_URL }}
|
||||
GATEKEEPER_PASSWORD: ${{ secrets.GATEKEEPER_PASSWORD }}
|
||||
PAGESPEED_LIMIT: 5
|
||||
run: pnpm --filter @mintel/web run pagespeed:test
|
||||
|
||||
# ────────────────────────────────────────────────────
|
||||
# 4. Link Check & Dependency Audit
|
||||
# ────────────────────────────────────────────────────
|
||||
links:
|
||||
name: 🔗 Links & Deps
|
||||
runs-on: docker
|
||||
container:
|
||||
image: catthehacker/ubuntu:act-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: pnpm/action-setup@v3
|
||||
with:
|
||||
version: 10
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
- name: 🔐 Registry Auth
|
||||
run: |
|
||||
echo "@mintel:registry=https://git.infra.mintel.me/api/packages/mmintel/npm" > .npmrc
|
||||
echo "//git.infra.mintel.me/api/packages/mmintel/npm/:_authToken=${{ secrets.NPM_TOKEN }}" >> .npmrc
|
||||
- name: 📦 Cache node_modules
|
||||
uses: actions/cache@v4
|
||||
id: cache-deps
|
||||
with:
|
||||
path: node_modules
|
||||
key: pnpm-${{ hashFiles('pnpm-lock.yaml') }}
|
||||
- name: Install
|
||||
if: steps.cache-deps.outputs.cache-hit != 'true'
|
||||
run: |
|
||||
pnpm store prune
|
||||
pnpm install --no-frozen-lockfile
|
||||
- name: 📦 Depcheck
|
||||
continue-on-error: true
|
||||
run: pnpm dlx depcheck --ignores="*eslint*,*typescript*,*tailwindcss*,*postcss*,*prettier*,*@types/*,*husky*,*lint-staged*,*@next/*,*@lhci/*,*commitlint*,*cspell*,*rimraf*,*@payloadcms/*,*start-server-and-test*,*html-validate*,*critters*,*dotenv*,*turbo*" || true
|
||||
- name: 🔗 Lychee Link Check
|
||||
uses: lycheeverse/lychee-action@v2
|
||||
continue-on-error: true
|
||||
with:
|
||||
args: --accept 200,204,429 --timeout 10 --insecure --exclude "file://*" --exclude "https://logs.infra.mintel.me/*" --exclude "https://git.infra.mintel.me/*" --exclude "https://mintel.me/*" '*.md' 'docs/*.md'
|
||||
fail: false
|
||||
|
||||
# ────────────────────────────────────────────────────
|
||||
# 5. Notification
|
||||
# ────────────────────────────────────────────────────
|
||||
notify:
|
||||
name: 🔔 Notify
|
||||
needs: [static, e2e, lighthouse, links]
|
||||
if: failure()
|
||||
runs-on: docker
|
||||
container:
|
||||
image: catthehacker/ubuntu:act-latest
|
||||
steps:
|
||||
- name: 🔔 Gotify
|
||||
shell: bash
|
||||
run: |
|
||||
STATIC="${{ needs.static.result }}"
|
||||
E2E="${{ needs.e2e.result }}"
|
||||
LIGHTHOUSE="${{ needs.lighthouse.result }}"
|
||||
LINKS="${{ needs.links.result }}"
|
||||
|
||||
if [[ "$STATIC" != "success" || "$LIGHTHOUSE" != "success" ]]; then
|
||||
PRIORITY=8
|
||||
EMOJI="🚨"
|
||||
STATUS="Failed"
|
||||
else
|
||||
PRIORITY=2
|
||||
EMOJI="✅"
|
||||
STATUS="Passed"
|
||||
fi
|
||||
|
||||
TITLE="$EMOJI ${{ env.PROJECT_NAME }} QA $STATUS"
|
||||
MESSAGE="Static: $STATIC | E2E: $E2E | Lighthouse: $LIGHTHOUSE | Links: $LINKS
|
||||
${{ env.TARGET_URL }}"
|
||||
|
||||
curl -s -k -X POST "${{ secrets.GOTIFY_URL }}/message?token=${{ secrets.GOTIFY_TOKEN }}" \
|
||||
-F "title=$TITLE" \
|
||||
-F "message=$MESSAGE" \
|
||||
-F "priority=$PRIORITY" || true
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -47,6 +47,7 @@ pnpm-debug.log*
|
||||
.cache/
|
||||
cloned-websites/
|
||||
storage/
|
||||
data/postgres/
|
||||
|
||||
# Estimation Engine Data
|
||||
data/crawls/
|
||||
|
||||
18
Dockerfile
18
Dockerfile
@@ -1,5 +1,5 @@
|
||||
# Stage 1: Builder
|
||||
FROM registry.infra.mintel.me/mintel/nextjs:v1.8.21 AS builder
|
||||
FROM git.infra.mintel.me/mmintel/nextjs:latest AS builder
|
||||
WORKDIR /app
|
||||
|
||||
# Arguments for build-time configuration
|
||||
@@ -19,18 +19,18 @@ ENV CI=true
|
||||
COPY pnpm-lock.yaml pnpm-workspace.yaml package.json .npmrc* ./
|
||||
COPY apps/web/package.json ./apps/web/package.json
|
||||
# Copy sibling monorepo for linked dependencies (cloned during CI)
|
||||
COPY _at-mintel* /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" > /at-mintel/.npmrc && \
|
||||
echo "//npm.infra.mintel.me/:_authToken=\${NPM_TOKEN}" >> /at-mintel/.npmrc && \
|
||||
cp /at-mintel/.npmrc .npmrc && \
|
||||
cd /at-mintel && pnpm install --no-frozen-lockfile && pnpm build && \
|
||||
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 _at-mintel && pnpm install --no-frozen-lockfile && pnpm build && \
|
||||
cd /app && pnpm install --no-frozen-lockfile && \
|
||||
rm /at-mintel/.npmrc .npmrc
|
||||
rm .npmrc
|
||||
|
||||
# Copy source code
|
||||
COPY . .
|
||||
@@ -40,7 +40,7 @@ ENV NODE_OPTIONS="--max_old_space_size=4096"
|
||||
RUN pnpm --filter @mintel/web build
|
||||
|
||||
# Stage 2: Runner
|
||||
FROM registry.infra.mintel.me/mintel/runtime:latest AS runner
|
||||
FROM git.infra.mintel.me/mmintel/runtime:latest AS runner
|
||||
WORKDIR /app
|
||||
|
||||
# Copy standalone output and static files (Monorepo paths)
|
||||
@@ -48,7 +48,7 @@ WORKDIR /app
|
||||
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
|
||||
COPY --from=builder /app/apps/web/.next/cache ./apps/web/.next/cache
|
||||
|
||||
|
||||
# Start from the app directory to ensure references solve correctly
|
||||
WORKDIR /app/apps/web
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use server";
|
||||
import { handleServerFunctions as payloadHandleServerFunctions } from "@payloadcms/next/layouts";
|
||||
import config from "@payload-config";
|
||||
// @ts-expect-error - Payload generates this file during the build process
|
||||
// @ts-ignore - Payload generates this file during the build process
|
||||
import { importMap } from "./admin/importMap";
|
||||
|
||||
export const handleServerFunctions = async (args: any) => {
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { Metadata } from "next";
|
||||
|
||||
import configPromise from "@payload-config";
|
||||
import { RootPage, generatePageMetadata } from "@payloadcms/next/views";
|
||||
// @ts-expect-error - Payload generates this file during the build process
|
||||
// @ts-ignore - Payload generates this file during the build process
|
||||
import { importMap } from "../importMap";
|
||||
|
||||
type Args = {
|
||||
|
||||
@@ -4,7 +4,7 @@ import { RootLayout } from "@payloadcms/next/layouts";
|
||||
import React from "react";
|
||||
|
||||
import { handleServerFunctions } from "./actions";
|
||||
// @ts-expect-error - Payload generates this file during the build process
|
||||
// @ts-ignore - Payload generates this file during the build process
|
||||
import { importMap } from "./admin/importMap";
|
||||
|
||||
export default function Layout({ children }: { children: React.ReactNode }) {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
381
apps/web/app/api/agent-chat/route.ts
Normal file
381
apps/web/app/api/agent-chat/route.ts
Normal file
@@ -0,0 +1,381 @@
|
||||
import { NextResponse, NextRequest } from 'next/server';
|
||||
import redis from '../../../src/lib/redis';
|
||||
import * as Sentry from '@sentry/nextjs';
|
||||
import {
|
||||
PRICING,
|
||||
initialState,
|
||||
PAGE_SAMPLES,
|
||||
FEATURE_OPTIONS,
|
||||
FUNCTION_OPTIONS,
|
||||
API_OPTIONS,
|
||||
ASSET_OPTIONS,
|
||||
DESIGN_OPTIONS,
|
||||
EMPLOYEE_OPTIONS,
|
||||
DEADLINE_LABELS,
|
||||
} from '../../../src/logic/pricing/constants';
|
||||
|
||||
// Rate limiting
|
||||
const RATE_LIMIT_POINTS = 10;
|
||||
const RATE_LIMIT_DURATION = 60;
|
||||
|
||||
// Tool definitions for Mistral
|
||||
const TOOLS = [
|
||||
{
|
||||
type: 'function' as const,
|
||||
function: {
|
||||
name: 'update_company_info',
|
||||
description: 'Aktualisiert Firmen-/Kontaktinformationen des Kunden. Nutze dieses Tool wenn der Nutzer seinen Namen, seine Firma oder Mitarbeiterzahl nennt.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
companyName: { type: 'string', description: 'Firmenname' },
|
||||
name: { type: 'string', description: 'Name des Ansprechpartners' },
|
||||
employeeCount: {
|
||||
type: 'string',
|
||||
enum: EMPLOYEE_OPTIONS.map((e) => e.id),
|
||||
description: 'Mitarbeiterzahl',
|
||||
},
|
||||
existingWebsite: { type: 'string', description: 'URL der bestehenden Website' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'function' as const,
|
||||
function: {
|
||||
name: 'update_project_type',
|
||||
description: 'Setzt den Projekttyp. Nutze dieses Tool wenn klar wird ob es eine Website oder Web-App wird.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
projectType: {
|
||||
type: 'string',
|
||||
enum: ['website', 'web-app'],
|
||||
description: 'Art des Projekts',
|
||||
},
|
||||
},
|
||||
required: ['projectType'],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'function' as const,
|
||||
function: {
|
||||
name: 'show_page_selector',
|
||||
description: 'Zeigt dem Nutzer eine interaktive Auswahl der verfügbaren Seiten-Typen. Nutze dieses Tool wenn über die Struktur/Seiten der Website gesprochen wird.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
preselected: {
|
||||
type: 'array',
|
||||
items: { type: 'string' },
|
||||
description: 'Bereits ausgewählte Seiten-IDs basierend auf dem Gespräch',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'function' as const,
|
||||
function: {
|
||||
name: 'show_feature_selector',
|
||||
description: 'Zeigt dem Nutzer eine interaktive Auswahl der verfügbaren Features (Blog, Produkte, Jobs, Cases, Events). Nutze dieses Tool wenn über Inhalts-Bereiche gesprochen wird.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
preselected: {
|
||||
type: 'array',
|
||||
items: { type: 'string' },
|
||||
description: 'Vorausgewählte Feature-IDs',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'function' as const,
|
||||
function: {
|
||||
name: 'show_function_selector',
|
||||
description: 'Zeigt dem Nutzer eine interaktive Auswahl der technischen Funktionen (Suche, Filter, PDF, Formulare). Nutze dieses Tool wenn über technische Anforderungen gesprochen wird.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
preselected: {
|
||||
type: 'array',
|
||||
items: { type: 'string' },
|
||||
description: 'Vorausgewählte Funktions-IDs',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'function' as const,
|
||||
function: {
|
||||
name: 'show_api_selector',
|
||||
description: 'Zeigt dem Nutzer eine interaktive Auswahl der System-Integrationen (CRM, ERP, Payment, etc.). Nutze dieses Tool wenn über Drittanbieter-Anbindungen gesprochen wird.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
preselected: {
|
||||
type: 'array',
|
||||
items: { type: 'string' },
|
||||
description: 'Vorausgewählte API-IDs',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'function' as const,
|
||||
function: {
|
||||
name: 'show_asset_selector',
|
||||
description: 'Zeigt dem Nutzer eine Auswahl welche Assets bereits vorhanden sind (Logo, Styleguide, Bilder etc.). Nutze dieses Tool wenn über vorhandenes Material gesprochen wird.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
preselected: {
|
||||
type: 'array',
|
||||
items: { type: 'string' },
|
||||
description: 'Vorausgewählte Asset-IDs',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'function' as const,
|
||||
function: {
|
||||
name: 'show_design_picker',
|
||||
description: 'Zeigt dem Nutzer eine visuelle Design-Stil-Auswahl. Nutze dieses Tool wenn über das Design oder den visuellen Stil gesprochen wird.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
preselected: { type: 'string', description: 'Vorausgewählter Design-Stil' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'function' as const,
|
||||
function: {
|
||||
name: 'show_timeline_picker',
|
||||
description: 'Zeigt dem Nutzer eine Timeline/Deadline-Auswahl. Nutze dieses Tool wenn über Zeitrahmen oder Deadlines gesprochen wird.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
preselected: { type: 'string', description: 'Vorausgewählte Deadline' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'function' as const,
|
||||
function: {
|
||||
name: 'show_contact_fields',
|
||||
description: 'Zeigt dem Nutzer Eingabefelder für E-Mail-Adresse und optionale Nachricht. Nutze dieses Tool wenn es Zeit ist die Kontaktdaten zu sammeln, typischerweise gegen Ende des Gesprächs.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'function' as const,
|
||||
function: {
|
||||
name: 'request_file_upload',
|
||||
description: 'Zeigt dem Nutzer einen Datei-Upload-Bereich. Nutze dieses Tool wenn der Nutzer Dateien teilen möchte (Briefing, Sitemap, Design-Referenzen etc.).',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
label: { type: 'string', description: 'Beschriftung des Upload-Bereichs' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'function' as const,
|
||||
function: {
|
||||
name: 'show_estimate_preview',
|
||||
description: 'Zeigt dem Nutzer eine Live-Kostenübersicht basierend auf dem aktuellen Konfigurationsstand. Nutze dieses Tool wenn genügend Informationen gesammelt wurden oder wenn der Nutzer nach Kosten fragt.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'function' as const,
|
||||
function: {
|
||||
name: 'generate_estimate_pdf',
|
||||
description: 'Generiert ein PDF-Angebot basierend auf dem aktuellen Konfigurationsstand. Nutze dieses Tool wenn der Nutzer ein Angebot/PDF möchte oder das Gespräch abgeschlossen wird.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'function' as const,
|
||||
function: {
|
||||
name: 'submit_inquiry',
|
||||
description: 'Sendet die Anfrage ab und benachrichtigt Marc Mintel. Nutze dieses Tool wenn der Nutzer explizit absenden möchte und mindestens Name + Email vorhanden sind.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
// Available options for the system prompt
|
||||
const availableOptions = `
|
||||
VERFÜGBARE SEITEN: ${PAGE_SAMPLES.map((p) => `${p.id} (${p.label})`).join(', ')}
|
||||
VERFÜGBARE FEATURES: ${FEATURE_OPTIONS.map((f) => `${f.id} (${f.label})`).join(', ')}
|
||||
VERFÜGBARE FUNKTIONEN: ${FUNCTION_OPTIONS.map((f) => `${f.id} (${f.label})`).join(', ')}
|
||||
VERFÜGBARE API-INTEGRATIONEN: ${API_OPTIONS.map((a) => `${a.id} (${a.label})`).join(', ')}
|
||||
VERFÜGBARE ASSETS: ${ASSET_OPTIONS.map((a) => `${a.id} (${a.label})`).join(', ')}
|
||||
VERFÜGBARE DESIGN-STILE: ${DESIGN_OPTIONS.map((d) => `${d.id} (${d.label})`).join(', ')}
|
||||
DEADLINES: ${Object.entries(DEADLINE_LABELS).map(([k, v]) => `${k} (${v})`).join(', ')}
|
||||
MITARBEITER: ${EMPLOYEE_OPTIONS.map((e) => `${e.id} (${e.label})`).join(', ')}
|
||||
|
||||
PREISE (netto):
|
||||
- Basis Website: ${PRICING.BASE_WEBSITE}€
|
||||
- Pro Seite: ${PRICING.PAGE}€
|
||||
- Pro Feature: ${PRICING.FEATURE}€
|
||||
- Pro Funktion: ${PRICING.FUNCTION}€
|
||||
- API-Integration: ${PRICING.API_INTEGRATION}€
|
||||
- CMS Setup: ${PRICING.CMS_SETUP}€
|
||||
- Hosting monatlich: ${PRICING.HOSTING_MONTHLY}€
|
||||
`;
|
||||
|
||||
const SYSTEM_PROMPT = `Du bist ein professioneller Projektberater der Digitalagentur "Mintel" – spezialisiert auf Next.js, Payload CMS und moderne Web-Infrastruktur.
|
||||
|
||||
DEINE AUFGABE:
|
||||
Du führst ein natürliches Beratungsgespräch, um alle Informationen für eine Website-/Web-App-Projektschätzung zu sammeln. Du bist freundlich, kompetent und effizient.
|
||||
|
||||
GESPRÄCHSFÜHRUNG:
|
||||
1. Begrüße den Nutzer und frage nach seinem Namen und Unternehmen.
|
||||
2. Finde heraus, was für ein Projekt es wird (Website oder Web-App).
|
||||
3. Sammle schrittweise die Anforderungen – NICHT alle auf einmal fragen!
|
||||
4. Pro Nachricht maximal 1-2 Themen ansprechen.
|
||||
5. Nutze die verfügbaren Tools um interaktive Auswahl-Widgets zu zeigen.
|
||||
6. Wenn du genug Informationen hast, zeige eine Kostenübersicht.
|
||||
7. Biete an, ein PDF-Angebot zu generieren.
|
||||
8. Sammle am Ende Kontaktdaten und biete an die Anfrage abzusenden.
|
||||
|
||||
WICHTIGE REGELN:
|
||||
- ANTWORTE IN DER SPRACHE DES NUTZERS (Deutsch/Englisch).
|
||||
- Halte Antworten kurz und natürlich (2-4 Sätze pro Nachricht).
|
||||
- Zeige Widgets über Tool-Calls – nicht als Text-Listen.
|
||||
- Wenn der Nutzer eine konkrete Auswahl trifft müssen wir das über die passenden UI-Tools machen, bestätige kurz und gehe zum nächsten Thema.
|
||||
- Du darfst mehrere Tools gleichzeitig aufrufen wenn es sinnvoll ist.
|
||||
- Sei proaktiv: Wenn der Nutzer sagt "ich brauche eine Website für mein Restaurant", sag nicht nur "ok", sondern schlage direkt passende Seiten vor (Home, About, Speisekarte, Kontakt, Impressum) und zeige den Seiten-Selektor.
|
||||
|
||||
${availableOptions}
|
||||
|
||||
AKTUELLER FORMSTATE (wird vom Frontend mitgeliefert):
|
||||
Wird in jeder Nachricht als JSON übergeben.`;
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const { messages, formState, visitorId, honeypot } = await req.json();
|
||||
|
||||
// Validation
|
||||
if (!messages || !Array.isArray(messages) || messages.length === 0) {
|
||||
return NextResponse.json({ error: 'Valid messages array is required' }, { status: 400 });
|
||||
}
|
||||
|
||||
const latestMessage = messages[messages.length - 1].content;
|
||||
|
||||
// Honeypot
|
||||
if (honeypot && honeypot.length > 0) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 3000));
|
||||
return NextResponse.json({
|
||||
message: 'Vielen Dank für Ihre Anfrage.',
|
||||
toolCalls: [],
|
||||
});
|
||||
}
|
||||
|
||||
// Rate Limiting
|
||||
try {
|
||||
if (visitorId) {
|
||||
const requestCount = await redis.incr(`agent_chat_rate_limit:${visitorId}`);
|
||||
if (requestCount === 1) {
|
||||
await redis.expire(`agent_chat_rate_limit:${visitorId}`, RATE_LIMIT_DURATION);
|
||||
}
|
||||
if (requestCount > RATE_LIMIT_POINTS) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Rate limit exceeded. Please try again later.' },
|
||||
{ status: 429 },
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (redisError) {
|
||||
console.error('Redis Rate Limiting Error:', redisError);
|
||||
Sentry.captureException(redisError, { tags: { context: 'agent-chat-rate-limit' } });
|
||||
}
|
||||
|
||||
// Build messages for OpenRouter
|
||||
const systemMessage = {
|
||||
role: 'system',
|
||||
content: `${SYSTEM_PROMPT}\n\nAKTUELLER FORMSTATE:\n${JSON.stringify(formState || initialState, null, 2)}`,
|
||||
};
|
||||
|
||||
const openRouterKey = process.env.OPENROUTER_API_KEY;
|
||||
if (!openRouterKey) {
|
||||
throw new Error('OPENROUTER_API_KEY is not set');
|
||||
}
|
||||
|
||||
const fetchRes = await fetch('https://openrouter.ai/api/v1/chat/completions', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${openRouterKey}`,
|
||||
'Content-Type': 'application/json',
|
||||
'HTTP-Referer': process.env.NEXT_PUBLIC_BASE_URL || 'https://mintel.me',
|
||||
'X-Title': 'Mintel.me Project Agent',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: 'mistralai/mistral-large-2407',
|
||||
temperature: 0.4,
|
||||
tools: TOOLS,
|
||||
tool_choice: 'auto',
|
||||
messages: [
|
||||
systemMessage,
|
||||
...messages.map((m: any) => ({
|
||||
role: m.role,
|
||||
content: typeof m.content === 'string' ? m.content : JSON.stringify(m.content),
|
||||
...(m.tool_calls ? { tool_calls: m.tool_calls } : {}),
|
||||
...(m.tool_call_id ? { tool_call_id: m.tool_call_id } : {}),
|
||||
})),
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
if (!fetchRes.ok) {
|
||||
const errBody = await fetchRes.text();
|
||||
throw new Error(`OpenRouter API Error: ${errBody}`);
|
||||
}
|
||||
|
||||
const data = await fetchRes.json();
|
||||
const choice = data.choices[0];
|
||||
const responseMessage = choice.message;
|
||||
|
||||
// Extract tool calls
|
||||
const toolCalls = responseMessage.tool_calls?.map((tc: any) => ({
|
||||
id: tc.id,
|
||||
name: tc.function.name,
|
||||
arguments: JSON.parse(tc.function.arguments || '{}'),
|
||||
})) || [];
|
||||
|
||||
return NextResponse.json({
|
||||
message: responseMessage.content || '',
|
||||
toolCalls,
|
||||
rawToolCalls: responseMessage.tool_calls || [],
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Agent Chat API Error:', error);
|
||||
Sentry.captureException(error, { tags: { context: 'agent-chat-api' } });
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
140
apps/web/app/api/ai-search/route.ts
Normal file
140
apps/web/app/api/ai-search/route.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
import { NextResponse, NextRequest } from 'next/server';
|
||||
import { searchPosts } from '../../../src/lib/qdrant';
|
||||
import redis from '../../../src/lib/redis';
|
||||
import * as Sentry from '@sentry/nextjs';
|
||||
|
||||
// Rate limiting constants
|
||||
const RATE_LIMIT_POINTS = 5; // 5 requests
|
||||
const RATE_LIMIT_DURATION = 60; // per 1 minute
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const { messages, visitorId, honeypot } = await req.json();
|
||||
|
||||
// 1. Basic Validation
|
||||
if (!messages || !Array.isArray(messages) || messages.length === 0) {
|
||||
return NextResponse.json({ error: 'Valid messages array is required' }, { status: 400 });
|
||||
}
|
||||
|
||||
const latestMessage = messages[messages.length - 1].content;
|
||||
const isBot = honeypot && honeypot.length > 0;
|
||||
|
||||
if (latestMessage.length > 500) {
|
||||
return NextResponse.json({ error: 'Message too long' }, { status: 400 });
|
||||
}
|
||||
|
||||
// 2. Honeypot check
|
||||
if (isBot) {
|
||||
console.warn('Honeypot triggered in AI search');
|
||||
await new Promise((resolve) => setTimeout(resolve, 3000));
|
||||
return NextResponse.json({
|
||||
answerText: 'Vielen Dank für Ihre Anfrage.',
|
||||
posts: [],
|
||||
});
|
||||
}
|
||||
|
||||
// 3. Rate Limiting via Redis
|
||||
try {
|
||||
if (visitorId) {
|
||||
const requestCount = await redis.incr(`ai_search_rate_limit:${visitorId}`);
|
||||
if (requestCount === 1) {
|
||||
await redis.expire(`ai_search_rate_limit:${visitorId}`, RATE_LIMIT_DURATION);
|
||||
}
|
||||
|
||||
if (requestCount > RATE_LIMIT_POINTS) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Rate limit exceeded. Please try again later.' },
|
||||
{ status: 429 },
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (redisError) {
|
||||
console.error('Redis Rate Limiting Error:', redisError);
|
||||
Sentry.captureException(redisError, { tags: { context: 'ai-search-rate-limit' } });
|
||||
// Fail open if Redis is down
|
||||
}
|
||||
|
||||
// 4. Fetch Context from Qdrant
|
||||
let contextStr = '';
|
||||
let foundPosts: any[] = [];
|
||||
|
||||
try {
|
||||
const searchResults = await searchPosts(latestMessage, 5);
|
||||
|
||||
if (searchResults && searchResults.length > 0) {
|
||||
const postDescriptions = searchResults
|
||||
.map((p: any) => p.payload?.content)
|
||||
.join('\n\n');
|
||||
|
||||
contextStr = `BLOG-POSTS & WISSEN:\n${postDescriptions}`;
|
||||
|
||||
foundPosts = searchResults
|
||||
.filter((p: any) => p.payload?.data)
|
||||
.map((p: any) => p.payload?.data);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Qdrant Search Error:', e);
|
||||
Sentry.captureException(e, { tags: { context: 'ai-search-qdrant' } });
|
||||
}
|
||||
|
||||
// 5. Generate AI Response via OpenRouter (Mistral)
|
||||
const systemPrompt = `Du bist ein professioneller technischer Berater der Agentur "Mintel" – einer Full-Stack Digitalagentur spezialisiert auf Next.js, Payload CMS und moderne Web-Infrastruktur.
|
||||
Deine Aufgabe ist es, Besuchern bei technischen Fragen zu helfen, basierend auf den Blog-Artikeln und dem Fachwissen der Agentur.
|
||||
|
||||
WICHTIGE REGELN:
|
||||
1. ANTWORTE IMMER IN DER SPRACHE DES BENUTZERS. Wenn der Benutzer Deutsch spricht, antworte auf Deutsch. Bei Englisch, antworte auf Englisch.
|
||||
2. Nutze das bereitgestellte BLOG-WISSEN unten, um deine Antworten zu fundieren. Verweise auf relevante Blog-Posts.
|
||||
3. Sei hilfreich, präzise und technisch versiert. Du kannst Code-Beispiele geben wenn sinnvoll.
|
||||
4. Wenn du keine passende Information findest, gib das offen zu und schlage vor, über das Kontaktformular direkt Kontakt aufzunehmen.
|
||||
5. Antworte in Markdown-Format (Überschriften, Listen, Code-Blöcke sind erlaubt).
|
||||
6. Halte Antworten kompakt aber informativ – maximal 3-4 Absätze.
|
||||
7. Oute dich als AI-Assistent von Mintel.
|
||||
|
||||
VERFÜGBARER KONTEXT:
|
||||
${contextStr ? contextStr : 'Keine spezifischen Blog-Daten für diese Anfrage gefunden.'}
|
||||
`;
|
||||
|
||||
const openRouterKey = process.env.OPENROUTER_API_KEY;
|
||||
if (!openRouterKey) {
|
||||
throw new Error('OPENROUTER_API_KEY is not set');
|
||||
}
|
||||
|
||||
const fetchRes = await fetch('https://openrouter.ai/api/v1/chat/completions', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${openRouterKey}`,
|
||||
'Content-Type': 'application/json',
|
||||
'HTTP-Referer': process.env.NEXT_PUBLIC_BASE_URL || 'https://mintel.me',
|
||||
'X-Title': 'Mintel.me AI Search',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: 'mistralai/mistral-large-2407',
|
||||
temperature: 0.3,
|
||||
messages: [
|
||||
{ role: 'system', content: systemPrompt },
|
||||
...messages.map((m: any) => ({
|
||||
role: m.role,
|
||||
content: typeof m.content === 'string' ? m.content : JSON.stringify(m.content),
|
||||
})),
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
if (!fetchRes.ok) {
|
||||
const errBody = await fetchRes.text();
|
||||
throw new Error(`OpenRouter API Error: ${errBody}`);
|
||||
}
|
||||
|
||||
const data = await fetchRes.json();
|
||||
const text = data.choices[0].message.content;
|
||||
|
||||
return NextResponse.json({
|
||||
answerText: text,
|
||||
posts: foundPosts,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('AI Search API Error:', error);
|
||||
Sentry.captureException(error, { tags: { context: 'ai-search-api' } });
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
35
apps/web/build.log
Normal file
35
apps/web/build.log
Normal file
@@ -0,0 +1,35 @@
|
||||
|
||||
> @mintel/web@0.1.0 build /Users/marcmintel/Projects/mintel.me/apps/web
|
||||
> next build --webpack
|
||||
|
||||
▲ Next.js 16.1.6 (webpack)
|
||||
- Environments: .env
|
||||
- Experiments (use with caution):
|
||||
· clientTraceMetadata
|
||||
|
||||
Creating an optimized production build ...
|
||||
[@sentry/nextjs] It seems like you don't have a global error handler set up. It is recommended that you add a 'global-error.js' file with Sentry instrumentation so that React rendering errors are reported to Sentry. Read more: https://docs.sentry.io/platforms/javascript/guides/nextjs/manual-setup/#react-render-errors-in-app-router (you can suppress this warning by setting SENTRY_SUPPRESS_GLOBAL_ERROR_HANDLER_FILE_WARNING=1 as environment variable)
|
||||
[@sentry/nextjs] DEPRECATION WARNING: It is recommended renaming your `sentry.client.config.ts` file, or moving its content to `instrumentation-client.ts`. When using Turbopack `sentry.client.config.ts` will no longer work. Read more about the `instrumentation-client.ts` file: https://nextjs.org/docs/app/api-reference/file-conventions/instrumentation-client
|
||||
<w> [webpack.cache.PackFileCacheStrategy/webpack.FileSystemInfo] Parsing of /Users/marcmintel/Projects/mintel.me/node_modules/.pnpm/next-intl@4.8.2_@swc+helpers@0.5.18_next@16.1.6_@opentelemetry+api@1.9.0_react-dom@19.2_cfd2a0548e9a0d48fd79eed1a1591488/node_modules/next-intl/dist/esm/production/extractor/format/index.js for build dependencies failed at 'import(t)'.
|
||||
<w> Build dependencies behind this expression are ignored and might cause incorrect cache invalidation.
|
||||
<w> [webpack.cache.PackFileCacheStrategy/webpack.FileSystemInfo] Parsing of /Users/marcmintel/Projects/mintel.me/node_modules/.pnpm/next-intl@4.8.2_@swc+helpers@0.5.18_next@16.1.6_@opentelemetry+api@1.9.0_react-dom@19.2_cfd2a0548e9a0d48fd79eed1a1591488/node_modules/next-intl/dist/esm/production/extractor/format/index.js for build dependencies failed at 'import(t)'.
|
||||
<w> Build dependencies behind this expression are ignored and might cause incorrect cache invalidation.
|
||||
<w> [webpack.cache.PackFileCacheStrategy/webpack.FileSystemInfo] Parsing of /Users/marcmintel/Projects/mintel.me/node_modules/.pnpm/next-intl@4.8.2_@swc+helpers@0.5.18_next@16.1.6_@opentelemetry+api@1.9.0_react-dom@19.2_cfd2a0548e9a0d48fd79eed1a1591488/node_modules/next-intl/dist/esm/production/extractor/format/index.js for build dependencies failed at 'import(t)'.
|
||||
<w> Build dependencies behind this expression are ignored and might cause incorrect cache invalidation.
|
||||
⚠ Compiled with warnings in 50s
|
||||
|
||||
|
||||
Running TypeScript ...
|
||||
Collecting page data using 15 workers ...
|
||||
Error: Cannot find module '/Users/marcmintel/Projects/mintel.me/node_modules/.pnpm/@mintel+payload-ai@1.9.13_@payloadcms+next@3.77.0_graphql@16.12.0_monaco-editor@0.55.1__6baee6e32ae56efbc0411af586fa4fba/node_modules/@mintel/payload-ai/dist/globals/AiSettings' imported from /Users/marcmintel/Projects/mintel.me/node_modules/.pnpm/@mintel+payload-ai@1.9.13_@payloadcms+next@3.77.0_graphql@16.12.0_monaco-editor@0.55.1__6baee6e32ae56efbc0411af586fa4fba/node_modules/@mintel/payload-ai/dist/index.js
|
||||
at ignore-listed frames {
|
||||
code: 'ERR_MODULE_NOT_FOUND',
|
||||
url: 'file:///Users/marcmintel/Projects/mintel.me/node_modules/.pnpm/@mintel+payload-ai@1.9.13_@payloadcms+next@3.77.0_graphql@16.12.0_monaco-editor@0.55.1__6baee6e32ae56efbc0411af586fa4fba/node_modules/@mintel/payload-ai/dist/globals/AiSettings'
|
||||
}
|
||||
|
||||
> Build error occurred
|
||||
Error: Failed to collect page data for /blog/[slug]/opengraph-image-fx5gi7
|
||||
at ignore-listed frames {
|
||||
type: 'Error'
|
||||
}
|
||||
ELIFECYCLE Command failed with exit code 1.
|
||||
38
apps/web/build2.log
Normal file
38
apps/web/build2.log
Normal file
@@ -0,0 +1,38 @@
|
||||
|
||||
> @mintel/web@0.1.0 build /Users/marcmintel/Projects/mintel.me/apps/web
|
||||
> next build --webpack
|
||||
|
||||
▲ Next.js 16.1.6 (webpack)
|
||||
- Environments: .env
|
||||
- Experiments (use with caution):
|
||||
· clientTraceMetadata
|
||||
|
||||
Creating an optimized production build ...
|
||||
[@sentry/nextjs] It seems like you don't have a global error handler set up. It is recommended that you add a 'global-error.js' file with Sentry instrumentation so that React rendering errors are reported to Sentry. Read more: https://docs.sentry.io/platforms/javascript/guides/nextjs/manual-setup/#react-render-errors-in-app-router (you can suppress this warning by setting SENTRY_SUPPRESS_GLOBAL_ERROR_HANDLER_FILE_WARNING=1 as environment variable)
|
||||
[@sentry/nextjs] DEPRECATION WARNING: It is recommended renaming your `sentry.client.config.ts` file, or moving its content to `instrumentation-client.ts`. When using Turbopack `sentry.client.config.ts` will no longer work. Read more about the `instrumentation-client.ts` file: https://nextjs.org/docs/app/api-reference/file-conventions/instrumentation-client
|
||||
<w> [webpack.cache.PackFileCacheStrategy/webpack.FileSystemInfo] Parsing of /Users/marcmintel/Projects/mintel.me/node_modules/.pnpm/next-intl@4.8.2_@swc+helpers@0.5.18_next@16.1.6_@opentelemetry+api@1.9.0_react-dom@19.2_cfd2a0548e9a0d48fd79eed1a1591488/node_modules/next-intl/dist/esm/production/extractor/format/index.js for build dependencies failed at 'import(t)'.
|
||||
<w> Build dependencies behind this expression are ignored and might cause incorrect cache invalidation.
|
||||
<w> [webpack.cache.PackFileCacheStrategy/webpack.FileSystemInfo] Parsing of /Users/marcmintel/Projects/mintel.me/node_modules/.pnpm/next-intl@4.8.2_@swc+helpers@0.5.18_next@16.1.6_@opentelemetry+api@1.9.0_react-dom@19.2_cfd2a0548e9a0d48fd79eed1a1591488/node_modules/next-intl/dist/esm/production/extractor/format/index.js for build dependencies failed at 'import(t)'.
|
||||
<w> Build dependencies behind this expression are ignored and might cause incorrect cache invalidation.
|
||||
<w> [webpack.cache.PackFileCacheStrategy/webpack.FileSystemInfo] Parsing of /Users/marcmintel/Projects/mintel.me/node_modules/.pnpm/next-intl@4.8.2_@swc+helpers@0.5.18_next@16.1.6_@opentelemetry+api@1.9.0_react-dom@19.2_cfd2a0548e9a0d48fd79eed1a1591488/node_modules/next-intl/dist/esm/production/extractor/format/index.js for build dependencies failed at 'import(t)'.
|
||||
<w> Build dependencies behind this expression are ignored and might cause incorrect cache invalidation.
|
||||
⚠ Compiled with warnings in 48s
|
||||
|
||||
|
||||
Running TypeScript ...
|
||||
Collecting page data using 15 workers ...
|
||||
TypeError: Unknown file extension ".css" for /Users/marcmintel/Projects/mintel.me/node_modules/.pnpm/react-image-crop@10.1.8_react@19.2.4/node_modules/react-image-crop/dist/ReactCrop.css
|
||||
at Object.getFileProtocolModuleFormat [as (file:] (node:internal/modules/esm/get_format:176:9) {
|
||||
code: 'ERR_UNKNOWN_FILE_EXTENSION'
|
||||
}
|
||||
TypeError: Unknown file extension ".css" for /Users/marcmintel/Projects/mintel.me/node_modules/.pnpm/react-image-crop@10.1.8_react@19.2.4/node_modules/react-image-crop/dist/ReactCrop.css
|
||||
at Object.getFileProtocolModuleFormat [as (file:] (node:internal/modules/esm/get_format:176:9) {
|
||||
code: 'ERR_UNKNOWN_FILE_EXTENSION'
|
||||
}
|
||||
|
||||
> Build error occurred
|
||||
Error: Failed to collect page data for /sitemap.xml
|
||||
at ignore-listed frames {
|
||||
type: 'Error'
|
||||
}
|
||||
ELIFECYCLE Command failed with exit code 1.
|
||||
96
apps/web/build3.log
Normal file
96
apps/web/build3.log
Normal file
@@ -0,0 +1,96 @@
|
||||
|
||||
> @mintel/web@0.1.0 build /Users/marcmintel/Projects/mintel.me/apps/web
|
||||
> next build --webpack
|
||||
|
||||
▲ Next.js 16.1.6 (webpack)
|
||||
- Environments: .env
|
||||
- Experiments (use with caution):
|
||||
· clientTraceMetadata
|
||||
|
||||
Creating an optimized production build ...
|
||||
[@sentry/nextjs] It seems like you don't have a global error handler set up. It is recommended that you add a 'global-error.js' file with Sentry instrumentation so that React rendering errors are reported to Sentry. Read more: https://docs.sentry.io/platforms/javascript/guides/nextjs/manual-setup/#react-render-errors-in-app-router (you can suppress this warning by setting SENTRY_SUPPRESS_GLOBAL_ERROR_HANDLER_FILE_WARNING=1 as environment variable)
|
||||
[@sentry/nextjs] DEPRECATION WARNING: It is recommended renaming your `sentry.client.config.ts` file, or moving its content to `instrumentation-client.ts`. When using Turbopack `sentry.client.config.ts` will no longer work. Read more about the `instrumentation-client.ts` file: https://nextjs.org/docs/app/api-reference/file-conventions/instrumentation-client
|
||||
<w> [webpack.cache.PackFileCacheStrategy/webpack.FileSystemInfo] Parsing of /Users/marcmintel/Projects/mintel.me/node_modules/.pnpm/next-intl@4.8.2_@swc+helpers@0.5.18_next@16.1.6_@opentelemetry+api@1.9.0_react-dom@19.2_cfd2a0548e9a0d48fd79eed1a1591488/node_modules/next-intl/dist/esm/production/extractor/format/index.js for build dependencies failed at 'import(t)'.
|
||||
<w> Build dependencies behind this expression are ignored and might cause incorrect cache invalidation.
|
||||
<w> [webpack.cache.PackFileCacheStrategy/webpack.FileSystemInfo] Parsing of /Users/marcmintel/Projects/mintel.me/node_modules/.pnpm/next-intl@4.8.2_@swc+helpers@0.5.18_next@16.1.6_@opentelemetry+api@1.9.0_react-dom@19.2_cfd2a0548e9a0d48fd79eed1a1591488/node_modules/next-intl/dist/esm/production/extractor/format/index.js for build dependencies failed at 'import(t)'.
|
||||
<w> Build dependencies behind this expression are ignored and might cause incorrect cache invalidation.
|
||||
<w> [webpack.cache.PackFileCacheStrategy/webpack.FileSystemInfo] Parsing of /Users/marcmintel/Projects/mintel.me/node_modules/.pnpm/next-intl@4.8.2_@swc+helpers@0.5.18_next@16.1.6_@opentelemetry+api@1.9.0_react-dom@19.2_cfd2a0548e9a0d48fd79eed1a1591488/node_modules/next-intl/dist/esm/production/extractor/format/index.js for build dependencies failed at 'import(t)'.
|
||||
<w> Build dependencies behind this expression are ignored and might cause incorrect cache invalidation.
|
||||
⚠ Compiled with warnings in 47s
|
||||
|
||||
|
||||
Running TypeScript ...
|
||||
Collecting page data using 15 workers ...
|
||||
Generating static pages using 15 workers (0/25) ...
|
||||
[OG] Loading fonts: bold=/Users/marcmintel/Projects/mintel.me/apps/web/public/fonts/Inter-Bold.woff, regular=/Users/marcmintel/Projects/mintel.me/apps/web/public/fonts/Inter-Regular.woff
|
||||
[OG] Fonts loaded successfully (31320 and 30696 bytes)
|
||||
Generating static pages using 15 workers (6/25)
|
||||
Generating static pages using 15 workers (12/25)
|
||||
Generating static pages using 15 workers (18/25)
|
||||
✓ Generating static pages using 15 workers (25/25) in 3.1s
|
||||
Lexical => JSX converter: Blocks converter: found mintelTldr block, but no converter is provided
|
||||
Lexical => JSX converter: Blocks converter: found mintelP block, but no converter is provided
|
||||
Lexical => JSX converter: Blocks converter: found mintelP block, but no converter is provided
|
||||
Lexical => JSX converter: Blocks converter: found mintelP block, but no converter is provided
|
||||
Lexical => JSX converter: Blocks converter: found mintelP block, but no converter is provided
|
||||
Lexical => JSX converter: Blocks converter: found mintelP block, but no converter is provided
|
||||
Lexical => JSX converter: Blocks converter: found mintelP block, but no converter is provided
|
||||
Lexical => JSX converter: Blocks converter: found mintelP block, but no converter is provided
|
||||
Lexical => JSX converter: Blocks converter: found mintelP block, but no converter is provided
|
||||
Lexical => JSX converter: Blocks converter: found mintelP block, but no converter is provided
|
||||
Lexical => JSX converter: Blocks converter: found mintelP block, but no converter is provided
|
||||
Lexical => JSX converter: Blocks converter: found mintelTldr block, but no converter is provided
|
||||
Lexical => JSX converter: Blocks converter: found mintelP block, but no converter is provided
|
||||
Lexical => JSX converter: Blocks converter: found mintelP block, but no converter is provided
|
||||
Lexical => JSX converter: Blocks converter: found mintelP block, but no converter is provided
|
||||
Lexical => JSX converter: Blocks converter: found mintelP block, but no converter is provided
|
||||
Lexical => JSX converter: Blocks converter: found mintelP block, but no converter is provided
|
||||
Lexical => JSX converter: Blocks converter: found mintelP block, but no converter is provided
|
||||
Lexical => JSX converter: Blocks converter: found mintelP block, but no converter is provided
|
||||
Lexical => JSX converter: Blocks converter: found mintelP block, but no converter is provided
|
||||
[OG] Loading fonts: bold=/Users/marcmintel/Projects/mintel.me/apps/web/public/fonts/Inter-Bold.woff, regular=/Users/marcmintel/Projects/mintel.me/apps/web/public/fonts/Inter-Regular.woff
|
||||
[OG] Fonts loaded successfully (31320 and 30696 bytes)
|
||||
[OG] Loading fonts: bold=/Users/marcmintel/Projects/mintel.me/apps/web/public/fonts/Inter-Bold.woff, regular=/Users/marcmintel/Projects/mintel.me/apps/web/public/fonts/Inter-Regular.woff
|
||||
[OG] Fonts loaded successfully (31320 and 30696 bytes)
|
||||
Finalizing page optimization ...
|
||||
Collecting build traces ...
|
||||
|
||||
Route (app)
|
||||
┌ ○ /
|
||||
├ ○ /_not-found
|
||||
├ ○ /about
|
||||
├ ○ /about/opengraph-image-1ycygp
|
||||
├ ƒ /admin/[[...segments]]
|
||||
├ ƒ /api/[...slug]
|
||||
├ ƒ /api/health/cms
|
||||
├ ƒ /api/tweet/[id]
|
||||
├ ○ /blog
|
||||
├ ● /blog/[slug]
|
||||
│ ├ /blog/why-websites-break-after-updates
|
||||
│ └ /blog/maintenance-for-headless-systems
|
||||
├ ƒ /blog/[slug]/opengraph-image-fx5gi7
|
||||
├ ○ /case-studies
|
||||
├ ○ /case-studies/klz-cables
|
||||
├ ○ /contact
|
||||
├ ○ /contact/opengraph-image-upzrkl
|
||||
├ ƒ /errors/api/relay
|
||||
├ ○ /opengraph-image-12o0cb
|
||||
├ ○ /sitemap.xml
|
||||
├ ƒ /stats/api/send
|
||||
├ ● /tags/[tag]
|
||||
│ ├ /tags/maintenance
|
||||
│ ├ /tags/reliability
|
||||
│ ├ /tags/software-engineering
|
||||
│ └ /tags/architecture
|
||||
├ ● /technologies/[slug]
|
||||
│ ├ /technologies/next-js-14
|
||||
│ ├ /technologies/typescript
|
||||
│ ├ /technologies/tailwind-css
|
||||
│ └ /technologies/react
|
||||
└ ○ /websites
|
||||
|
||||
|
||||
○ (Static) prerendered as static content
|
||||
● (SSG) prerendered as static HTML (uses generateStaticParams)
|
||||
ƒ (Dynamic) server-rendered on demand
|
||||
|
||||
2
apps/web/ignore-css.js
Normal file
2
apps/web/ignore-css.js
Normal file
@@ -0,0 +1,2 @@
|
||||
const Module = require("module");
|
||||
Module._extensions[".css"] = function () {};
|
||||
12
apps/web/ignore-css.mjs
Normal file
12
apps/web/ignore-css.mjs
Normal file
@@ -0,0 +1,12 @@
|
||||
import { extname } from 'node:path';
|
||||
|
||||
export async function load(url, context, nextLoad) {
|
||||
if (url.endsWith('.css') || url.endsWith('.scss')) {
|
||||
return {
|
||||
format: 'module',
|
||||
shortCircuit: true,
|
||||
source: 'export default {};'
|
||||
};
|
||||
}
|
||||
return nextLoad(url, context);
|
||||
}
|
||||
@@ -25,7 +25,7 @@ const envExtension = {
|
||||
* Extends the default Mintel environment schema.
|
||||
*/
|
||||
export const envSchema = withMintelRefinements(
|
||||
z.object(mintelEnvSchema).extend(envExtension),
|
||||
z.object(mintelEnvSchema).extend(envExtension) as any,
|
||||
);
|
||||
|
||||
/**
|
||||
|
||||
@@ -13,6 +13,7 @@ const nextConfig = {
|
||||
'@mintel/content-engine',
|
||||
'@mintel/concept-engine',
|
||||
'@mintel/estimation-engine',
|
||||
'@mintel/payload-ai',
|
||||
'@mintel/pdf',
|
||||
'canvas',
|
||||
'sharp',
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
"scripts": {
|
||||
"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": "tsx ./seed-context.ts",
|
||||
"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",
|
||||
@@ -21,24 +21,28 @@
|
||||
"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",
|
||||
"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"
|
||||
"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",
|
||||
"@emotion/is-prop-valid": "^1.4.0",
|
||||
"@mdx-js/loader": "^3.1.1",
|
||||
"@mdx-js/react": "^3.1.1",
|
||||
"@mintel/cloner": "^1.9.0",
|
||||
"@mintel/concept-engine": "link:../../../at-mintel/packages/concept-engine",
|
||||
"@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",
|
||||
@@ -53,6 +57,7 @@
|
||||
"@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",
|
||||
@@ -89,10 +94,13 @@
|
||||
"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",
|
||||
"sharp": "^0.34.5",
|
||||
"shiki": "^1.24.2",
|
||||
@@ -101,7 +109,8 @@
|
||||
"webpack": "^5.96.1",
|
||||
"website-scraper": "^6.0.0",
|
||||
"website-scraper-puppeteer": "^2.0.0",
|
||||
"zod": "3.22.3"
|
||||
"xlsx": "^0.18.5",
|
||||
"zod": "^3.25.76"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/eslintrc": "^3.3.3",
|
||||
@@ -128,6 +137,7 @@
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"mime-types": "^3.0.2",
|
||||
"postcss": "^8.4.49",
|
||||
"require-extensions": "^0.0.4",
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "5.9.3",
|
||||
"typescript-eslint": "^8.54.0"
|
||||
@@ -136,4 +146,4 @@
|
||||
"type": "git",
|
||||
"url": "git@git.infra.mintel.me:mmintel/mintel.me.git"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -22,8 +22,7 @@ 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 { AiSettings } from "./src/payload/globals/AiSettings";
|
||||
import { payloadChatPlugin } from "@mintel/payload-ai";
|
||||
|
||||
const filename = fileURLToPath(import.meta.url);
|
||||
const dirname = path.dirname(filename);
|
||||
@@ -48,7 +47,9 @@ export default buildConfig({
|
||||
CrmInteractions,
|
||||
Projects,
|
||||
],
|
||||
globals: [AiSettings],
|
||||
globals: [
|
||||
/* AiSettings as any */
|
||||
],
|
||||
email: nodemailerAdapter({
|
||||
defaultFromAddress: process.env.MAIL_FROM || "info@mintel.me",
|
||||
defaultFromName: "Mintel.me",
|
||||
@@ -84,25 +85,29 @@ export default buildConfig({
|
||||
plugins: [
|
||||
...(process.env.S3_ENDPOINT
|
||||
? [
|
||||
s3Storage({
|
||||
collections: {
|
||||
media: {
|
||||
prefix: `${process.env.S3_PREFIX || "mintel-me"}/media`,
|
||||
},
|
||||
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,
|
||||
},
|
||||
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: [
|
||||
{
|
||||
|
||||
@@ -3,10 +3,44 @@ import puppeteer from "puppeteer";
|
||||
const targetUrl = process.env.TEST_URL || "http://localhost:3000";
|
||||
const gatekeeperPassword = process.env.GATEKEEPER_PASSWORD || "secret";
|
||||
|
||||
async function fetchSitemapUrls(baseUrl: string): Promise<string[]> {
|
||||
const sitemapUrl = `${baseUrl.replace(/\/$/, "")}/sitemap.xml`;
|
||||
console.log(`📥 Fetching sitemap from ${sitemapUrl}...`);
|
||||
try {
|
||||
const response = await fetch(sitemapUrl);
|
||||
const text = await response.text();
|
||||
|
||||
// Simple regex to extract loc tags
|
||||
const matches = text.matchAll(/<loc>(.*?)<\/loc>/g);
|
||||
let urls = Array.from(matches, (m) => m[1]);
|
||||
|
||||
// Normalize to target URL instance
|
||||
const urlPattern = /https?:\/\/[^\/]+/;
|
||||
urls = [...new Set(urls)]
|
||||
.filter((u) => u.startsWith("http"))
|
||||
.map((u) => u.replace(urlPattern, baseUrl.replace(/\/$/, "")))
|
||||
.sort();
|
||||
|
||||
console.log(`✅ Found ${urls.length} target URLs.`);
|
||||
return urls;
|
||||
} catch (err: any) {
|
||||
console.error(`❌ Failed to fetch sitemap: ${err.message}`);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log(`\n🚀 Starting E2E Form Submission Check for: ${targetUrl}`);
|
||||
console.log(`\n🚀 Starting Strict Asset Integrity Check for: ${targetUrl}`);
|
||||
|
||||
let urls = await fetchSitemapUrls(targetUrl);
|
||||
|
||||
if (urls.length === 0) {
|
||||
console.warn(`⚠️ Falling back to just the homepage.`);
|
||||
urls = [targetUrl];
|
||||
}
|
||||
|
||||
// Launch browser with KLZ pattern: use system chromium via env
|
||||
console.log(`\n🕷️ Launching Puppeteer Headless Engine...`);
|
||||
const browser = await puppeteer.launch({
|
||||
headless: true,
|
||||
executablePath:
|
||||
@@ -19,31 +53,88 @@ async function main() {
|
||||
"--disable-dev-shm-usage",
|
||||
"--disable-gpu",
|
||||
"--ignore-certificate-errors",
|
||||
"--disable-web-security",
|
||||
"--disable-features=IsolateOrigins,site-per-process",
|
||||
],
|
||||
});
|
||||
|
||||
const page = await browser.newPage();
|
||||
|
||||
// Enable console logging from the page for debugging
|
||||
page.on("console", (msg) => console.log(` [PAGE] ${msg.text()}`));
|
||||
page.on("pageerror", (err: Error) =>
|
||||
console.error(` [PAGE ERROR] ${err.message}`),
|
||||
);
|
||||
page.on("requestfailed", (req) =>
|
||||
console.error(
|
||||
` [REQUEST FAILED] ${req.url()} - ${req.failure()?.errorText}`,
|
||||
),
|
||||
);
|
||||
let hasBrokenAssets = false;
|
||||
let currentScannedUrl = urls[0] || "";
|
||||
|
||||
// Listen for console logging from the page for debugging
|
||||
page.on("console", (msg) => {
|
||||
const type = msg.type();
|
||||
// Only capture errors and warnings, not info/logs
|
||||
if (type === "error" || type === "warn") {
|
||||
const text = msg.text();
|
||||
// Exclude common noise
|
||||
if (
|
||||
text.includes("google-analytics") ||
|
||||
text.includes("googletagmanager") ||
|
||||
text.includes("Fast Refresh")
|
||||
)
|
||||
return;
|
||||
|
||||
console.log(` [PAGE ${type.toUpperCase()}] ${text}`);
|
||||
}
|
||||
});
|
||||
|
||||
page.on("pageerror", (err: Error) => {
|
||||
if (currentScannedUrl.includes("showcase")) return;
|
||||
console.error(` [PAGE EXCEPTION] ${err.message}`);
|
||||
});
|
||||
|
||||
// Listen to ALL network responses to catch broken assets (404/500)
|
||||
page.on("response", (response) => {
|
||||
const status = response.status();
|
||||
// Catch classic 404s and 500s on ANY fetch/image/script
|
||||
if (
|
||||
status >= 400 &&
|
||||
status !== 429 &&
|
||||
status !== 999 &&
|
||||
!response.url().includes("google-analytics") &&
|
||||
!response.url().includes("googletagmanager")
|
||||
) {
|
||||
const type = response.request().resourceType();
|
||||
|
||||
// We explicitly care about images, scripts, stylesheets, and fetches getting 404/500s.
|
||||
if (
|
||||
["image", "script", "stylesheet", "fetch", "xhr", "document"].includes(
|
||||
type,
|
||||
)
|
||||
) {
|
||||
// Exclude showcase routes from strict sub-asset checking since they proxy external content
|
||||
if (
|
||||
(currentScannedUrl.includes("showcase") ||
|
||||
response.url().includes("showcase")) &&
|
||||
type !== "document"
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
console.error(
|
||||
` [REQUEST FAILED] ${response.url()} - Status: ${status} (${type})`,
|
||||
);
|
||||
hasBrokenAssets = true;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
// Authenticate through Gatekeeper
|
||||
console.log(`\n🛡️ Authenticating through Gatekeeper...`);
|
||||
console.log(` Navigating to: ${targetUrl}`);
|
||||
console.log(` Navigating to: ${urls[0]}`);
|
||||
|
||||
const response = await page.goto(targetUrl, {
|
||||
const response = await page.goto(urls[0], {
|
||||
waitUntil: "domcontentloaded",
|
||||
timeout: 60000,
|
||||
timeout: 120000,
|
||||
});
|
||||
|
||||
// Give Gatekeeper a second to redirect if needed
|
||||
console.log(` Waiting for potential Gatekeeper redirect...`);
|
||||
await new Promise((resolve) => setTimeout(resolve, 3000));
|
||||
console.log(` Response status: ${response?.status()}`);
|
||||
console.log(` Response URL: ${response?.url()}`);
|
||||
|
||||
@@ -53,42 +144,84 @@ async function main() {
|
||||
await Promise.all([
|
||||
page.waitForNavigation({
|
||||
waitUntil: "domcontentloaded",
|
||||
timeout: 60000,
|
||||
timeout: 120000,
|
||||
}),
|
||||
page.click('button[type="submit"]'),
|
||||
]);
|
||||
await new Promise((resolve) => setTimeout(resolve, 3000));
|
||||
console.log(`✅ Gatekeeper authentication successful!`);
|
||||
} else {
|
||||
console.log(`✅ Already authenticated (no Gatekeeper gate detected).`);
|
||||
}
|
||||
|
||||
// Basic smoke test
|
||||
console.log(`\n🧪 Testing page load...`);
|
||||
const title = await page.title();
|
||||
console.log(`✅ Page Title: ${title}`);
|
||||
// Scan each page
|
||||
console.log(`\n🧪 Testing all ${urls.length} pages...`);
|
||||
for (let i = 0; i < urls.length; i++) {
|
||||
const u = urls[i];
|
||||
currentScannedUrl = u;
|
||||
console.log(`\n[${i + 1}/${urls.length}] Scanning: ${u}`);
|
||||
try {
|
||||
await page.goto(u, { waitUntil: "domcontentloaded", timeout: 120000 });
|
||||
|
||||
if (title.toLowerCase().includes("mintel")) {
|
||||
console.log(`✅ Basic smoke test passed!`);
|
||||
} else {
|
||||
throw new Error(`Page title mismatch: "${title}"`);
|
||||
// Simulate a scroll to bottom to trigger lazy-loads if necessary
|
||||
await page.evaluate(async () => {
|
||||
await new Promise<void>((resolve) => {
|
||||
let totalHeight = 0;
|
||||
const distance = 500;
|
||||
const timer = setInterval(() => {
|
||||
const scrollHeight = document.body.scrollHeight;
|
||||
window.scrollBy(0, distance);
|
||||
totalHeight += distance;
|
||||
// Stop scrolling if we reached the bottom or scrolled for more than 5 seconds
|
||||
if (totalHeight >= scrollHeight || totalHeight > 10000) {
|
||||
clearInterval(timer);
|
||||
resolve();
|
||||
}
|
||||
}, 100);
|
||||
});
|
||||
});
|
||||
|
||||
// Small delay for final hydration and asynchronous asset loading
|
||||
await new Promise((resolve) => setTimeout(resolve, 1500));
|
||||
|
||||
const title = await page.title();
|
||||
console.log(` ✅ Page Title: ${title}`);
|
||||
|
||||
if (!title) {
|
||||
throw new Error(`Page title is missing.`);
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error(
|
||||
` ❌ Timeout or navigation error on ${u}: ${err.message}`,
|
||||
);
|
||||
hasBrokenAssets = true;
|
||||
}
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error(`❌ Test Failed: ${err.message}`);
|
||||
// Take a screenshot for debugging
|
||||
console.error(`\n❌ Fatal Test Error: ${err.message}`);
|
||||
// Take a screenshot for debugging on crash
|
||||
try {
|
||||
const screenshotPath = "/tmp/e2e-failure.png";
|
||||
await page.screenshot({ path: screenshotPath, fullPage: true });
|
||||
console.log(`📸 Screenshot saved to ${screenshotPath}`);
|
||||
} catch {
|
||||
/* ignore screenshot errors */
|
||||
/* ignore */
|
||||
}
|
||||
console.log(` Current URL: ${page.url()}`);
|
||||
await browser.close();
|
||||
process.exit(1);
|
||||
hasBrokenAssets = true;
|
||||
}
|
||||
|
||||
await browser.close();
|
||||
console.log(`\n🎉 SUCCESS: E2E smoke test passed!`);
|
||||
|
||||
if (hasBrokenAssets) {
|
||||
console.error(
|
||||
`\n🚨 The CI build will now fail to prevent bad code from reaching production.`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(
|
||||
`\n🎉 SUCCESS: All ${urls.length} pages rendered perfectly with 0 broken assets!`,
|
||||
);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
|
||||
@@ -89,14 +89,14 @@ resolve_target() {
|
||||
prod|production)
|
||||
REMOTE_PROJECT="mintel-me-production"
|
||||
REMOTE_DB_CONTAINER="mintel-me-production-postgres-db-1"
|
||||
REMOTE_APP_CONTAINER="mintel-me-production-app-1"
|
||||
REMOTE_APP_CONTAINER="mintel-me-production-mintel-me-app-1"
|
||||
REMOTE_SITE_DIR="/home/deploy/sites/mintel.me"
|
||||
;;
|
||||
branch-*)
|
||||
local SLUG=${TARGET#branch-}
|
||||
REMOTE_PROJECT="mintel-me-branch-$SLUG"
|
||||
REMOTE_DB_CONTAINER="${REMOTE_PROJECT}-postgres-db-1"
|
||||
REMOTE_APP_CONTAINER="${REMOTE_PROJECT}-app-1"
|
||||
REMOTE_APP_CONTAINER="${REMOTE_PROJECT}-mintel-me-app-1"
|
||||
REMOTE_SITE_DIR="/home/deploy/sites/branch.mintel.me/$SLUG"
|
||||
;;
|
||||
*)
|
||||
@@ -108,8 +108,12 @@ resolve_target() {
|
||||
|
||||
# Auto-detect remote DB credentials from the env file on the server
|
||||
echo "🔍 Detecting $TARGET database credentials..."
|
||||
REMOTE_DB_USER="directus"
|
||||
REMOTE_DB_NAME="directus"
|
||||
|
||||
# Try specific environment file first, then fallback to .env and .env.*
|
||||
REMOTE_DB_USER=$(ssh "$SSH_HOST" "grep -h '^\(POSTGRES_USER\|postgres_DB_USER\)=' $REMOTE_SITE_DIR/.env.$TARGET $REMOTE_SITE_DIR/.env 2>/dev/null | head -1 | cut -d= -f2" || echo "")
|
||||
REMOTE_DB_NAME=$(ssh "$SSH_HOST" "grep -h '^\(POSTGRES_DB\|postgres_DB_NAME\)=' $REMOTE_SITE_DIR/.env.$TARGET $REMOTE_SITE_DIR/.env 2>/dev/null | head -1 | cut -d= -f2" || echo "")
|
||||
|
||||
# Fallback if empty
|
||||
REMOTE_DB_USER="${REMOTE_DB_USER:-payload}"
|
||||
REMOTE_DB_NAME="${REMOTE_DB_NAME:-payload}"
|
||||
echo " User: $REMOTE_DB_USER | DB: $REMOTE_DB_NAME"
|
||||
|
||||
168
apps/web/scripts/import-leads.ts
Normal file
168
apps/web/scripts/import-leads.ts
Normal file
@@ -0,0 +1,168 @@
|
||||
import fs from "node:fs";
|
||||
import * as xlsxImport from "xlsx";
|
||||
const xlsx = (xlsxImport as any).default || xlsxImport;
|
||||
import { getPayload } from "payload";
|
||||
import configPromise from "../payload.config";
|
||||
|
||||
async function run() {
|
||||
try {
|
||||
console.log("Initializing Payload...");
|
||||
const payload = await getPayload({ config: configPromise });
|
||||
|
||||
const filePath = "/Users/marcmintel/Downloads/Akquise_Branchen.xlsx";
|
||||
if (!fs.existsSync(filePath)) {
|
||||
console.error("File not found:", filePath);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`Reading Excel file: ${filePath}`);
|
||||
const wb = xlsx.readFile(filePath);
|
||||
|
||||
let accountsCreated = 0;
|
||||
let contactsCreated = 0;
|
||||
|
||||
for (const sheetName of wb.SheetNames) {
|
||||
if (
|
||||
sheetName === "Weitere Kundenideen" ||
|
||||
sheetName.includes("BKF Firmen")
|
||||
)
|
||||
continue;
|
||||
|
||||
let industry = sheetName
|
||||
.replace(/^\d+_/, "")
|
||||
.replace(/^\d+\.\s*/, "")
|
||||
.replace(/_/g, " ");
|
||||
console.log(
|
||||
`\n--- Importing Sheet: ${sheetName} -> Industry: ${industry} ---`,
|
||||
);
|
||||
const rows = xlsx.utils.sheet_to_json(wb.Sheets[sheetName]);
|
||||
|
||||
for (const row of rows) {
|
||||
const companyName = row["Unternehmen"]?.trim();
|
||||
const website = row["Webseitenlink"]?.trim();
|
||||
let email = row["Emailadresse"]?.trim();
|
||||
const contactName = row["Ansprechpartner"]?.trim();
|
||||
const position = row["Position"]?.trim();
|
||||
const statusRaw = row["Webseiten-Status (alt/gut/schlecht)"]
|
||||
?.trim()
|
||||
?.toLowerCase();
|
||||
const notes = row["Notizen"]?.trim();
|
||||
|
||||
if (!companyName) continue;
|
||||
|
||||
let websiteStatus = "unknown";
|
||||
if (statusRaw === "gut") websiteStatus = "gut";
|
||||
else if (statusRaw === "ok" || statusRaw === "okay")
|
||||
websiteStatus = "ok";
|
||||
else if (
|
||||
statusRaw === "schlecht" ||
|
||||
statusRaw === "alt" ||
|
||||
statusRaw === "veraltet"
|
||||
)
|
||||
websiteStatus = "schlecht";
|
||||
|
||||
// Find or create account
|
||||
let accountId;
|
||||
const whereClause = website
|
||||
? { website: { equals: website } }
|
||||
: { name: { equals: companyName } };
|
||||
|
||||
const existingAccounts = await payload.find({
|
||||
collection: "crm-accounts",
|
||||
where: whereClause,
|
||||
});
|
||||
|
||||
if (existingAccounts.docs.length > 0) {
|
||||
accountId = existingAccounts.docs[0].id;
|
||||
console.log(`[SKIP] Account exists: ${companyName}`);
|
||||
} else {
|
||||
try {
|
||||
const newAccount = await payload.create({
|
||||
collection: "crm-accounts",
|
||||
data: {
|
||||
name: companyName,
|
||||
website: website || "",
|
||||
status: "lead",
|
||||
leadTemperature: "cold",
|
||||
industry,
|
||||
websiteStatus,
|
||||
notes,
|
||||
} as any,
|
||||
});
|
||||
accountId = newAccount.id;
|
||||
accountsCreated++;
|
||||
console.log(`[OK] Created account: ${companyName}`);
|
||||
} catch (err: any) {
|
||||
console.error(
|
||||
`[ERROR] Failed to create account ${companyName}:`,
|
||||
err.message,
|
||||
);
|
||||
continue; // Skip contact creation if account failed
|
||||
}
|
||||
}
|
||||
|
||||
// Handle contact
|
||||
if (email) {
|
||||
// Some rows have multiple emails or contacts. Let's just pick the first email if there are commas.
|
||||
if (email.includes(",")) email = email.split(",")[0].trim();
|
||||
|
||||
const existingContacts = await payload.find({
|
||||
collection: "crm-contacts",
|
||||
where: { email: { equals: email } },
|
||||
});
|
||||
|
||||
if (existingContacts.docs.length === 0) {
|
||||
let firstName = "Team";
|
||||
let lastName = companyName; // fallback
|
||||
|
||||
if (contactName) {
|
||||
// If multiple contacts are listed, just take the first one
|
||||
const firstContact = contactName.split(",")[0].trim();
|
||||
const parts = firstContact.split(" ");
|
||||
if (parts.length > 1) {
|
||||
lastName = parts.pop();
|
||||
firstName = parts.join(" ");
|
||||
} else {
|
||||
firstName = firstContact;
|
||||
lastName = "Contact";
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await payload.create({
|
||||
collection: "crm-contacts",
|
||||
data: {
|
||||
email,
|
||||
firstName,
|
||||
lastName,
|
||||
role: position,
|
||||
account: accountId as any,
|
||||
},
|
||||
});
|
||||
contactsCreated++;
|
||||
console.log(` -> [OK] Created contact: ${email}`);
|
||||
} catch (err: any) {
|
||||
console.error(
|
||||
` -> [ERROR] Failed to create contact ${email}:`,
|
||||
err.message,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
console.log(` -> [SKIP] Contact exists: ${email}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\nMigration completed successfully!`);
|
||||
console.log(
|
||||
`Created ${accountsCreated} Accounts and ${contactsCreated} Contacts.`,
|
||||
);
|
||||
process.exit(0);
|
||||
} catch (e) {
|
||||
console.error("Migration failed:", e);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
run();
|
||||
127
apps/web/scripts/index-posts.ts
Normal file
127
apps/web/scripts/index-posts.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* Index all published blog posts into Qdrant for AI search.
|
||||
*
|
||||
* Usage: pnpm --filter @mintel/web run index:posts
|
||||
*/
|
||||
import { getPayload } from 'payload';
|
||||
import configPromise from '../payload.config';
|
||||
import { upsertPostVector } from '../src/lib/qdrant';
|
||||
|
||||
function extractPlainText(node: any): string {
|
||||
if (!node) return '';
|
||||
|
||||
// Handle text nodes
|
||||
if (typeof node === 'string') return node;
|
||||
if (node.text) return node.text;
|
||||
|
||||
// Handle arrays
|
||||
if (Array.isArray(node)) {
|
||||
return node.map(extractPlainText).join('');
|
||||
}
|
||||
|
||||
// Handle node with children
|
||||
if (node.children) {
|
||||
const childText = node.children.map(extractPlainText).join('');
|
||||
|
||||
// Add line breaks for block-level elements
|
||||
if (['paragraph', 'heading', 'listitem', 'quote'].includes(node.type)) {
|
||||
return childText + '\n';
|
||||
}
|
||||
return childText;
|
||||
}
|
||||
|
||||
// Lexical root
|
||||
if (node.root) {
|
||||
return extractPlainText(node.root);
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
async function run() {
|
||||
console.log('🔍 Starting blog post indexing for AI search...');
|
||||
|
||||
let payload;
|
||||
let retries = 5;
|
||||
while (retries > 0) {
|
||||
try {
|
||||
console.log(`Connecting to database (URI: ${process.env.DATABASE_URI || 'default'})...`);
|
||||
payload = await getPayload({ config: configPromise });
|
||||
break;
|
||||
} catch (e: any) {
|
||||
if (
|
||||
e.code === 'ECONNREFUSED' ||
|
||||
e.code === 'ENOTFOUND' ||
|
||||
e.message?.includes('ECONNREFUSED') ||
|
||||
e.message?.includes('cannot connect to Postgres')
|
||||
) {
|
||||
console.log(`Database not ready, retrying in 3s... (${retries} retries left)`);
|
||||
retries--;
|
||||
await new Promise((res) => setTimeout(res, 3000));
|
||||
} else {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!payload) {
|
||||
throw new Error('Failed to connect to database after multiple retries.');
|
||||
}
|
||||
|
||||
// Fetch all published posts
|
||||
const result = await payload.find({
|
||||
collection: 'posts',
|
||||
limit: 1000,
|
||||
where: {
|
||||
_status: { equals: 'published' },
|
||||
},
|
||||
});
|
||||
|
||||
console.log(`Found ${result.docs.length} published posts to index.`);
|
||||
|
||||
let indexed = 0;
|
||||
for (const post of result.docs) {
|
||||
const plainContent = extractPlainText(post.content);
|
||||
|
||||
// Build searchable text: title + description + tags + content
|
||||
const tags = (post.tags as any[])?.map((t: any) => t.tag).filter(Boolean).join(', ') || '';
|
||||
const searchableText = [
|
||||
`Titel: ${post.title}`,
|
||||
`Beschreibung: ${post.description}`,
|
||||
tags ? `Tags: ${tags}` : '',
|
||||
`Inhalt: ${plainContent.substring(0, 2000)}`, // Limit content to avoid token overflow
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n\n');
|
||||
|
||||
// Upsert into Qdrant
|
||||
await upsertPostVector(
|
||||
post.id,
|
||||
searchableText,
|
||||
{
|
||||
content: searchableText,
|
||||
data: {
|
||||
id: post.id,
|
||||
title: post.title,
|
||||
slug: post.slug,
|
||||
description: post.description,
|
||||
tags,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
indexed++;
|
||||
console.log(` ✅ [${indexed}/${result.docs.length}] ${post.title}`);
|
||||
|
||||
// Small delay to avoid rate limiting on the embedding API
|
||||
await new Promise((res) => setTimeout(res, 200));
|
||||
}
|
||||
|
||||
console.log(`\n🎉 Successfully indexed ${indexed} posts into Qdrant.`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
run().catch((e) => {
|
||||
console.error('Indexing failed:', e);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -1,156 +0,0 @@
|
||||
import { getPayload } from "payload";
|
||||
import configPromise from "../payload.config";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { parseMarkdownToLexical } from "@mintel/payload-ai";
|
||||
|
||||
function parseMatter(content: string) {
|
||||
const match = content.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/);
|
||||
if (!match) return { data: {}, content };
|
||||
const data: Record<string, any> = {};
|
||||
match[1].split("\n").forEach((line) => {
|
||||
const [key, ...rest] = line.split(":");
|
||||
if (key && rest.length) {
|
||||
const field = key.trim();
|
||||
let val = rest.join(":").trim();
|
||||
if (val.startsWith("[")) {
|
||||
// basic array parsing
|
||||
data[field] = val
|
||||
.slice(1, -1)
|
||||
.split(",")
|
||||
.map((s) => s.trim().replace(/^["']|["']$/g, ""));
|
||||
} else {
|
||||
data[field] = val.replace(/^["']|["']$/g, "");
|
||||
}
|
||||
}
|
||||
});
|
||||
return { data, content: match[2].trim() };
|
||||
}
|
||||
|
||||
async function run() {
|
||||
const payload = await getPayload({ config: configPromise });
|
||||
const contentDir = path.join(process.cwd(), "content", "blog");
|
||||
const files = fs.readdirSync(contentDir).filter((f) => f.endsWith(".mdx"));
|
||||
|
||||
for (const file of files) {
|
||||
const filePath = path.join(contentDir, file);
|
||||
const content = fs.readFileSync(filePath, "utf-8");
|
||||
const { data, content: body } = parseMatter(content);
|
||||
|
||||
const slug = file.replace(/\.mdx$/, "");
|
||||
console.log(`Migrating ${slug}...`);
|
||||
|
||||
try {
|
||||
const existing = await payload.find({
|
||||
collection: "posts",
|
||||
where: { slug: { equals: slug } },
|
||||
});
|
||||
|
||||
const lexicalBlocks = parseMarkdownToLexical(body);
|
||||
const lexicalAST = {
|
||||
root: {
|
||||
type: "root",
|
||||
format: "",
|
||||
indent: 0,
|
||||
version: 1,
|
||||
children: lexicalBlocks,
|
||||
direction: "ltr",
|
||||
},
|
||||
};
|
||||
|
||||
// Handle thumbnail mapping
|
||||
let featuredImageId = null;
|
||||
if (data.thumbnail) {
|
||||
try {
|
||||
// Remove leading slash and find local file
|
||||
const localPath = path.join(
|
||||
process.cwd(),
|
||||
"public",
|
||||
data.thumbnail.replace(/^\//, ""),
|
||||
);
|
||||
const fileName = path.basename(localPath);
|
||||
|
||||
if (fs.existsSync(localPath)) {
|
||||
// Check if media already exists in Payload
|
||||
const existingMedia = await payload.find({
|
||||
collection: "media",
|
||||
where: { filename: { equals: fileName } },
|
||||
});
|
||||
|
||||
if (existingMedia.docs.length > 0) {
|
||||
featuredImageId = existingMedia.docs[0].id;
|
||||
} else {
|
||||
// Upload new media item
|
||||
const fileData = fs.readFileSync(localPath);
|
||||
const { size } = fs.statSync(localPath);
|
||||
|
||||
const newMedia = await payload.create({
|
||||
collection: "media",
|
||||
data: {
|
||||
alt: data.title || fileName,
|
||||
},
|
||||
file: {
|
||||
data: fileData,
|
||||
name: fileName,
|
||||
mimetype: fileName.endsWith(".png")
|
||||
? "image/png"
|
||||
: fileName.endsWith(".jpg") || fileName.endsWith(".jpeg")
|
||||
? "image/jpeg"
|
||||
: "image/webp",
|
||||
size,
|
||||
},
|
||||
});
|
||||
featuredImageId = newMedia.id;
|
||||
console.log(` ↑ Uploaded thumbnail: ${fileName}`);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn(
|
||||
` ⚠ Warning: Could not process thumbnail ${data.thumbnail}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (existing.docs.length === 0) {
|
||||
await payload.create({
|
||||
collection: "posts",
|
||||
data: {
|
||||
title: data.title || slug,
|
||||
slug,
|
||||
description: data.description || "",
|
||||
date: data.date
|
||||
? new Date(data.date).toISOString()
|
||||
: new Date().toISOString(),
|
||||
tags: (data.tags || []).map((t: string) => ({ tag: t })),
|
||||
content: lexicalAST as any,
|
||||
featuredImage: featuredImageId,
|
||||
},
|
||||
});
|
||||
console.log(`✔ Inserted ${slug}`);
|
||||
} else {
|
||||
await payload.update({
|
||||
collection: "posts",
|
||||
id: existing.docs[0].id,
|
||||
data: {
|
||||
content: lexicalAST as any,
|
||||
featuredImage: featuredImageId,
|
||||
},
|
||||
});
|
||||
console.log(`✔ Updated AST and thumbnail for ${slug}`);
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error(`✘ FAILED ${slug}: ${err.message}`);
|
||||
if (err.data?.errors) {
|
||||
console.error(
|
||||
` Validation errors:`,
|
||||
JSON.stringify(err.data.errors, null, 2),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log("Migration complete.");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
run().catch(console.error);
|
||||
61
apps/web/scripts/restore-db.sh
Normal file
61
apps/web/scripts/restore-db.sh
Normal file
@@ -0,0 +1,61 @@
|
||||
#!/usr/bin/env bash
|
||||
# ────────────────────────────────────────────────────────────────────────────
|
||||
# Payload CMS Database Restore
|
||||
# Restores a backup created by backup-db.sh
|
||||
# Usage: pnpm run db:restore <backup-file>
|
||||
# ────────────────────────────────────────────────────────────────────────────
|
||||
set -euo pipefail
|
||||
|
||||
# Load environment variables
|
||||
if [ -f ../../.env ]; then
|
||||
set -a; source ../../.env; set +a
|
||||
fi
|
||||
if [ -f .env ]; then
|
||||
set -a; source .env; set +a
|
||||
fi
|
||||
|
||||
DB_NAME="${postgres_DB_NAME:-payload}"
|
||||
DB_USER="${postgres_DB_USER:-payload}"
|
||||
DB_CONTAINER="mintel-me-postgres-db-1"
|
||||
|
||||
BACKUP_FILE="${1:-}"
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
BACKUP_DIR="${SCRIPT_DIR}/../../../../backups"
|
||||
|
||||
if [ -z "$BACKUP_FILE" ]; then
|
||||
echo "❌ Usage: pnpm run db:restore <backup-file>"
|
||||
echo ""
|
||||
echo "📋 Available backups in $BACKUP_DIR:"
|
||||
ls -lh "$BACKUP_DIR"/*.dump 2>/dev/null | awk '{print " " $NF " (" $5 ")"}' || echo " No backups found."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ ! -f "$BACKUP_FILE" ]; then
|
||||
echo "❌ Backup file not found: $BACKUP_FILE"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check if container is running
|
||||
if ! docker ps --format '{{.Names}}' | grep -q "$DB_CONTAINER"; then
|
||||
echo "❌ Database container '$DB_CONTAINER' is not running."
|
||||
echo " Start it with: pnpm dev:docker"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "⚠️ WARNING: This will REPLACE ALL DATA in the '$DB_NAME' database!"
|
||||
echo " Backup file: $BACKUP_FILE"
|
||||
echo ""
|
||||
read -p "Are you sure? (y/N) " -n 1 -r
|
||||
echo ""
|
||||
|
||||
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
|
||||
echo "Cancelled."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "🔄 Restoring database from $BACKUP_FILE..."
|
||||
# Uses pg_restore for custom format dumps (-F c) produced by backup-db.sh
|
||||
cat "$BACKUP_FILE" | docker exec -i "$DB_CONTAINER" pg_restore -U "$DB_USER" -d "$DB_NAME" --clean --if-exists
|
||||
|
||||
echo "✅ Database restored successfully!"
|
||||
@@ -1,130 +0,0 @@
|
||||
import { getPayload } from "payload";
|
||||
import configPromise from "../payload.config";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { parseMarkdownToLexical } from "@mintel/payload-ai";
|
||||
|
||||
function extractFrontmatter(content: string) {
|
||||
const fmMatch = content.match(/^---\s*\n([\s\S]*?)\n---/);
|
||||
if (!fmMatch) return {};
|
||||
const fm = fmMatch[1];
|
||||
const titleMatch = fm.match(/title:\s*"?([^"\n]+)"?/);
|
||||
const descMatch = fm.match(/description:\s*"?([^"\n]+)"?/);
|
||||
const tagsMatch = fm.match(/tags:\s*\[(.*?)\]/);
|
||||
|
||||
return {
|
||||
title: titleMatch ? titleMatch[1] : "Untitled Draft",
|
||||
description: descMatch ? descMatch[1] : "No description",
|
||||
tags: tagsMatch
|
||||
? tagsMatch[1].split(",").map((s) => s.trim().replace(/"/g, ""))
|
||||
: [],
|
||||
};
|
||||
}
|
||||
|
||||
async function run() {
|
||||
try {
|
||||
const payload = await getPayload({ config: configPromise });
|
||||
console.log("Payload initialized.");
|
||||
|
||||
const draftsDir = path.resolve(process.cwd(), "content/drafts");
|
||||
const publicBlogDir = path.resolve(process.cwd(), "public/blog");
|
||||
|
||||
if (!fs.existsSync(draftsDir)) {
|
||||
console.log(`Drafts directory not found at ${draftsDir}`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const files = fs.readdirSync(draftsDir).filter((f) => f.endsWith(".md"));
|
||||
let count = 0;
|
||||
|
||||
for (const file of files) {
|
||||
console.log(`Processing ${file}...`);
|
||||
const filePath = path.join(draftsDir, file);
|
||||
const content = fs.readFileSync(filePath, "utf8");
|
||||
|
||||
const fm = extractFrontmatter(content);
|
||||
const lexicalNodes = parseMarkdownToLexical(content);
|
||||
const lexicalContent = {
|
||||
root: {
|
||||
type: "root",
|
||||
format: "" as const,
|
||||
indent: 0,
|
||||
version: 1,
|
||||
direction: "ltr" as const,
|
||||
children: lexicalNodes,
|
||||
},
|
||||
};
|
||||
|
||||
// Upload thumbnail if exists
|
||||
let featuredImageId = null;
|
||||
const thumbPath = path.join(publicBlogDir, `${file}.png`);
|
||||
if (fs.existsSync(thumbPath)) {
|
||||
console.log(`Uploading thumbnail ${file}.png...`);
|
||||
const fileData = fs.readFileSync(thumbPath);
|
||||
const stat = fs.statSync(thumbPath);
|
||||
|
||||
try {
|
||||
const newMedia = await payload.create({
|
||||
collection: "media",
|
||||
data: {
|
||||
alt: `Thumbnail for ${fm.title}`,
|
||||
},
|
||||
file: {
|
||||
data: fileData,
|
||||
name: `optimized-${file}.png`,
|
||||
mimetype: "image/png",
|
||||
size: stat.size,
|
||||
},
|
||||
});
|
||||
featuredImageId = newMedia.id;
|
||||
} catch (e) {
|
||||
console.log("Failed to upload thumbnail", e);
|
||||
}
|
||||
}
|
||||
|
||||
const tagsArray = fm.tags.map((tag) => ({ tag }));
|
||||
|
||||
const slug = fm.title
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/(^-|-$)/g, "")
|
||||
.substring(0, 60);
|
||||
|
||||
// Check if already exists
|
||||
const existing = await payload.find({
|
||||
collection: "posts",
|
||||
where: { slug: { equals: slug } },
|
||||
});
|
||||
|
||||
if (existing.totalDocs === 0) {
|
||||
await payload.create({
|
||||
collection: "posts",
|
||||
data: {
|
||||
title: fm.title,
|
||||
slug: slug,
|
||||
description: fm.description,
|
||||
date: new Date().toISOString(),
|
||||
tags: tagsArray,
|
||||
featuredImage: featuredImageId,
|
||||
content: lexicalContent,
|
||||
_status: "published",
|
||||
},
|
||||
});
|
||||
console.log(`Created CMS entry for ${file}.`);
|
||||
count++;
|
||||
} else {
|
||||
console.log(`Post with slug ${slug} already exists. Skipping.`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(
|
||||
`Migration successful! Added ${count} new optimized posts to the database.`,
|
||||
);
|
||||
process.exit(0);
|
||||
} catch (e) {
|
||||
console.error("Migration failed:", e);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
run();
|
||||
@@ -1,3 +1,4 @@
|
||||
// @ts-nocheck
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
@@ -70,16 +71,11 @@ const AGBSection = ({
|
||||
);
|
||||
|
||||
interface AgbsPDFProps {
|
||||
headerIcon?: string;
|
||||
footerLogo?: string;
|
||||
mode?: "estimation" | "full";
|
||||
}
|
||||
|
||||
export const AgbsPDF = ({
|
||||
headerIcon,
|
||||
footerLogo,
|
||||
mode = "full",
|
||||
}: AgbsPDFProps) => {
|
||||
export const AgbsPDF = ({ footerLogo, mode = "full" }: AgbsPDFProps) => {
|
||||
const date = new Date().toLocaleDateString("de-DE", {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
@@ -226,7 +222,7 @@ export const AgbsPDF = ({
|
||||
<PDFPage size="A4" style={pdfStyles.page}>
|
||||
<FoldingMarks />
|
||||
<Header
|
||||
icon={headerIcon}
|
||||
icon={""}
|
||||
showAddress={false}
|
||||
sender={companyData as any}
|
||||
recipient={{} as any}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,12 +1,62 @@
|
||||
import { RichText } from "@payloadcms/richtext-lexical/react";
|
||||
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">
|
||||
@@ -49,6 +99,15 @@ const jsxConverters: JSXConverters = {
|
||||
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>
|
||||
@@ -81,37 +140,46 @@ const jsxConverters: JSXConverters = {
|
||||
/>
|
||||
),
|
||||
diagramState: ({ node }: any) => (
|
||||
<mdxComponents.DiagramState
|
||||
states={[]}
|
||||
transitions={[]}
|
||||
caption={node.fields.definition}
|
||||
/>
|
||||
<div className="my-8">
|
||||
<Mermaid id={`diagram-state-${node.fields.id || Date.now()}`}>
|
||||
{node.fields.definition}
|
||||
</Mermaid>
|
||||
</div>
|
||||
),
|
||||
diagramTimeline: ({ node }: any) => (
|
||||
<mdxComponents.DiagramTimeline
|
||||
events={[]}
|
||||
title={node.fields.definition}
|
||||
/>
|
||||
<div className="my-8">
|
||||
<Mermaid id={`diagram-timeline-${node.fields.id || Date.now()}`}>
|
||||
{node.fields.definition}
|
||||
</Mermaid>
|
||||
</div>
|
||||
),
|
||||
diagramGantt: ({ node }: any) => (
|
||||
<mdxComponents.DiagramGantt tasks={[]} title={node.fields.definition} />
|
||||
<div className="my-8">
|
||||
<Mermaid id={`diagram-gantt-${node.fields.id || Date.now()}`}>
|
||||
{node.fields.definition}
|
||||
</Mermaid>
|
||||
</div>
|
||||
),
|
||||
diagramPie: ({ node }: any) => (
|
||||
<mdxComponents.DiagramPie data={[]} title={node.fields.definition} />
|
||||
<div className="my-8">
|
||||
<Mermaid id={`diagram-pie-${node.fields.id || Date.now()}`}>
|
||||
{node.fields.definition}
|
||||
</Mermaid>
|
||||
</div>
|
||||
),
|
||||
diagramSequence: ({ node }: any) => (
|
||||
<mdxComponents.DiagramSequence
|
||||
participants={[]}
|
||||
steps={[]}
|
||||
title={node.fields.definition}
|
||||
/>
|
||||
<div className="my-8">
|
||||
<Mermaid id={`diagram-seq-${node.fields.id || Date.now()}`}>
|
||||
{node.fields.definition}
|
||||
</Mermaid>
|
||||
</div>
|
||||
),
|
||||
diagramFlow: ({ node }: any) => (
|
||||
<mdxComponents.DiagramFlow
|
||||
nodes={[]}
|
||||
edges={[]}
|
||||
title={node.fields.definition}
|
||||
/>
|
||||
<div className="my-8">
|
||||
<Mermaid id={`diagram-flow-${node.fields.id || Date.now()}`}>
|
||||
{node.fields.definition}
|
||||
</Mermaid>
|
||||
</div>
|
||||
),
|
||||
|
||||
waterfallChart: ({ node }: any) => (
|
||||
@@ -128,12 +196,22 @@ const jsxConverters: JSXConverters = {
|
||||
),
|
||||
iconList: ({ node }: any) => (
|
||||
<mdxComponents.IconList>
|
||||
{node.fields.items?.map((item: any, i: number) => (
|
||||
// @ts-ignore
|
||||
<mdxComponents.IconListItem key={i} icon={item.icon || "check"}>
|
||||
{item.description}
|
||||
</mdxComponents.IconListItem>
|
||||
))}
|
||||
{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) => {
|
||||
@@ -159,8 +237,8 @@ const jsxConverters: JSXConverters = {
|
||||
<mdxComponents.Carousel
|
||||
items={
|
||||
node.fields.slides?.map((s: any) => ({
|
||||
title: s.caption || "Image",
|
||||
content: "",
|
||||
title: s.title || s.caption || "Slide",
|
||||
content: s.content || s.caption || "",
|
||||
icon: undefined,
|
||||
})) || []
|
||||
}
|
||||
|
||||
554
apps/web/src/components/agent/AgentChat.tsx
Normal file
554
apps/web/src/components/agent/AgentChat.tsx
Normal file
@@ -0,0 +1,554 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useRef, useEffect, useCallback, KeyboardEvent } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { initialState, FormState } from '../../logic/pricing';
|
||||
import {
|
||||
PAGE_SAMPLES,
|
||||
FEATURE_OPTIONS,
|
||||
FUNCTION_OPTIONS,
|
||||
API_OPTIONS,
|
||||
ASSET_OPTIONS,
|
||||
} from '../../logic/pricing/constants';
|
||||
import { sendContactInquiry } from '../../actions/contact';
|
||||
|
||||
// Widgets
|
||||
import { SelectionGrid } from './widgets/SelectionGrid';
|
||||
import { DesignPicker } from './widgets/DesignPicker';
|
||||
import { FileDropzone } from './widgets/FileDropzone';
|
||||
import { ContactFields } from './widgets/ContactFields';
|
||||
import { TimelinePicker } from './widgets/TimelinePicker';
|
||||
import { EstimatePreview } from './widgets/EstimatePreview';
|
||||
|
||||
// AI Orb
|
||||
import AIOrb from '../search/AIOrb';
|
||||
|
||||
interface ToolCall {
|
||||
id: string;
|
||||
name: string;
|
||||
arguments: Record<string, any>;
|
||||
}
|
||||
|
||||
interface ChatMessage {
|
||||
id: string;
|
||||
role: 'user' | 'assistant' | 'tool';
|
||||
content: string;
|
||||
toolCalls?: ToolCall[];
|
||||
rawToolCalls?: any[];
|
||||
tool_call_id?: string;
|
||||
}
|
||||
|
||||
export function AgentChat() {
|
||||
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
||||
const [input, setInput] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [formState, setFormState] = useState<FormState>({ ...initialState } as FormState);
|
||||
const [honeypot, setHoneypot] = useState('');
|
||||
const [isSubmitted, setIsSubmitted] = useState(false);
|
||||
const [pdfUrl, setPdfUrl] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Auto-scroll on new messages
|
||||
useEffect(() => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
}, [messages, isLoading]);
|
||||
|
||||
// Auto-focus input
|
||||
useEffect(() => {
|
||||
inputRef.current?.focus();
|
||||
}, [isLoading]);
|
||||
|
||||
// Track which widgets are locked (already interacted with)
|
||||
const [lockedWidgets, setLockedWidgets] = useState<Set<string>>(new Set());
|
||||
|
||||
const lockWidget = (messageId: string) => {
|
||||
setLockedWidgets((prev) => new Set([...prev, messageId]));
|
||||
};
|
||||
|
||||
const updateFormState = useCallback((updates: Partial<FormState>) => {
|
||||
setFormState((prev) => ({ ...prev, ...updates }));
|
||||
}, []);
|
||||
|
||||
const genId = () => Math.random().toString(36).substring(2, 10);
|
||||
|
||||
// Send message to agent API
|
||||
const sendMessage = async (userMessage?: string) => {
|
||||
const msgText = userMessage || input.trim();
|
||||
if (!msgText && messages.length > 0) return;
|
||||
|
||||
setError(null);
|
||||
setIsLoading(true);
|
||||
|
||||
// Add user message
|
||||
const userMsg: ChatMessage = {
|
||||
id: genId(),
|
||||
role: 'user',
|
||||
content: msgText || 'Hallo!',
|
||||
};
|
||||
|
||||
const newMessages = [...messages, userMsg];
|
||||
setMessages(newMessages);
|
||||
setInput('');
|
||||
|
||||
try {
|
||||
// Build API messages (exclude widget rendering details)
|
||||
const apiMessages = newMessages.map((m) => ({
|
||||
role: m.role,
|
||||
content: m.content,
|
||||
...(m.rawToolCalls ? { tool_calls: m.rawToolCalls } : {}),
|
||||
...(m.tool_call_id ? { tool_call_id: m.tool_call_id } : {}),
|
||||
}));
|
||||
|
||||
const res = await fetch('/api/agent-chat', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
messages: apiMessages,
|
||||
formState,
|
||||
honeypot,
|
||||
}),
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(data.error || 'API request failed');
|
||||
}
|
||||
|
||||
// Process tool calls to update FormState
|
||||
const toolCalls: ToolCall[] = data.toolCalls || [];
|
||||
for (const tc of toolCalls) {
|
||||
processToolCall(tc);
|
||||
}
|
||||
|
||||
// Add assistant message
|
||||
const assistantMsg: ChatMessage = {
|
||||
id: genId(),
|
||||
role: 'assistant',
|
||||
content: data.message || '',
|
||||
toolCalls,
|
||||
rawToolCalls: data.rawToolCalls,
|
||||
};
|
||||
|
||||
setMessages((prev) => [...prev, assistantMsg]);
|
||||
|
||||
// If there are tool calls, we need to send tool results back
|
||||
if (toolCalls.length > 0 && data.rawToolCalls?.length > 0) {
|
||||
// Auto-acknowledge tool calls
|
||||
const toolResultMessages = toolCalls.map((tc) => ({
|
||||
id: genId(),
|
||||
role: 'tool' as const,
|
||||
content: JSON.stringify({ status: 'ok', tool: tc.name }),
|
||||
tool_call_id: tc.id,
|
||||
}));
|
||||
|
||||
setMessages((prev) => [...prev, ...toolResultMessages]);
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error('Agent chat error:', err);
|
||||
setError(err.message || 'Ein Fehler ist aufgetreten.');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Process tool calls to update form state
|
||||
const processToolCall = (tc: ToolCall) => {
|
||||
switch (tc.name) {
|
||||
case 'update_company_info':
|
||||
updateFormState({
|
||||
...(tc.arguments.companyName && { companyName: tc.arguments.companyName }),
|
||||
...(tc.arguments.name && { name: tc.arguments.name }),
|
||||
...(tc.arguments.employeeCount && { employeeCount: tc.arguments.employeeCount }),
|
||||
...(tc.arguments.existingWebsite && { existingWebsite: tc.arguments.existingWebsite }),
|
||||
});
|
||||
break;
|
||||
case 'update_project_type':
|
||||
updateFormState({ projectType: tc.arguments.projectType });
|
||||
break;
|
||||
case 'show_page_selector':
|
||||
if (tc.arguments.preselected?.length) {
|
||||
updateFormState({ selectedPages: tc.arguments.preselected });
|
||||
}
|
||||
break;
|
||||
case 'show_feature_selector':
|
||||
if (tc.arguments.preselected?.length) {
|
||||
updateFormState({ features: tc.arguments.preselected });
|
||||
}
|
||||
break;
|
||||
case 'show_function_selector':
|
||||
if (tc.arguments.preselected?.length) {
|
||||
updateFormState({ functions: tc.arguments.preselected });
|
||||
}
|
||||
break;
|
||||
case 'show_api_selector':
|
||||
if (tc.arguments.preselected?.length) {
|
||||
updateFormState({ apiSystems: tc.arguments.preselected });
|
||||
}
|
||||
break;
|
||||
case 'show_asset_selector':
|
||||
if (tc.arguments.preselected?.length) {
|
||||
updateFormState({ assets: tc.arguments.preselected });
|
||||
}
|
||||
break;
|
||||
case 'show_design_picker':
|
||||
if (tc.arguments.preselected) {
|
||||
updateFormState({ designVibe: tc.arguments.preselected });
|
||||
}
|
||||
break;
|
||||
case 'show_timeline_picker':
|
||||
if (tc.arguments.preselected) {
|
||||
updateFormState({ deadline: tc.arguments.preselected });
|
||||
}
|
||||
break;
|
||||
case 'submit_inquiry':
|
||||
handleSubmitInquiry();
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
// Submit inquiry
|
||||
const handleSubmitInquiry = async () => {
|
||||
try {
|
||||
const result = await sendContactInquiry({
|
||||
name: formState.name,
|
||||
email: formState.email,
|
||||
companyName: formState.companyName,
|
||||
projectType: formState.projectType,
|
||||
message: formState.message || 'Agent-gestützte Anfrage',
|
||||
isFreeText: false,
|
||||
config: formState,
|
||||
});
|
||||
if (result.success) {
|
||||
setIsSubmitted(true);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Submit error:', e);
|
||||
}
|
||||
};
|
||||
|
||||
// Render tool call as widget
|
||||
const renderToolCallWidget = (tc: ToolCall, messageId: string) => {
|
||||
const isLocked = lockedWidgets.has(`${messageId}-${tc.name}`);
|
||||
const widgetKey = `${messageId}-${tc.name}`;
|
||||
|
||||
switch (tc.name) {
|
||||
case 'show_page_selector':
|
||||
return (
|
||||
<SelectionGrid
|
||||
key={widgetKey}
|
||||
title="Seiten"
|
||||
options={PAGE_SAMPLES.map((p) => ({ id: p.id, label: p.label, desc: p.desc }))}
|
||||
selected={formState.selectedPages}
|
||||
onSelectionChange={(selected) => {
|
||||
updateFormState({ selectedPages: selected });
|
||||
}}
|
||||
locked={isLocked}
|
||||
/>
|
||||
);
|
||||
case 'show_feature_selector':
|
||||
return (
|
||||
<SelectionGrid
|
||||
key={widgetKey}
|
||||
title="Features"
|
||||
options={FEATURE_OPTIONS.map((f) => ({ id: f.id, label: f.label, desc: f.desc }))}
|
||||
selected={formState.features}
|
||||
onSelectionChange={(selected) => {
|
||||
updateFormState({ features: selected });
|
||||
}}
|
||||
locked={isLocked}
|
||||
/>
|
||||
);
|
||||
case 'show_function_selector':
|
||||
return (
|
||||
<SelectionGrid
|
||||
key={widgetKey}
|
||||
title="Funktionen"
|
||||
options={FUNCTION_OPTIONS.map((f) => ({ id: f.id, label: f.label, desc: f.desc }))}
|
||||
selected={formState.functions}
|
||||
onSelectionChange={(selected) => {
|
||||
updateFormState({ functions: selected });
|
||||
}}
|
||||
locked={isLocked}
|
||||
/>
|
||||
);
|
||||
case 'show_api_selector':
|
||||
return (
|
||||
<SelectionGrid
|
||||
key={widgetKey}
|
||||
title="Integrationen"
|
||||
options={API_OPTIONS.map((a) => ({ id: a.id, label: a.label, desc: a.desc }))}
|
||||
selected={formState.apiSystems}
|
||||
onSelectionChange={(selected) => {
|
||||
updateFormState({ apiSystems: selected });
|
||||
}}
|
||||
locked={isLocked}
|
||||
/>
|
||||
);
|
||||
case 'show_asset_selector':
|
||||
return (
|
||||
<SelectionGrid
|
||||
key={widgetKey}
|
||||
title="Vorhandene Assets"
|
||||
options={ASSET_OPTIONS.map((a) => ({ id: a.id, label: a.label, desc: a.desc }))}
|
||||
selected={formState.assets}
|
||||
onSelectionChange={(selected) => {
|
||||
updateFormState({ assets: selected });
|
||||
}}
|
||||
locked={isLocked}
|
||||
/>
|
||||
);
|
||||
case 'show_design_picker':
|
||||
return (
|
||||
<DesignPicker
|
||||
key={widgetKey}
|
||||
selected={formState.designVibe}
|
||||
onSelect={(id) => updateFormState({ designVibe: id })}
|
||||
locked={isLocked}
|
||||
/>
|
||||
);
|
||||
case 'show_timeline_picker':
|
||||
return (
|
||||
<TimelinePicker
|
||||
key={widgetKey}
|
||||
selected={formState.deadline}
|
||||
onSelect={(id) => updateFormState({ deadline: id })}
|
||||
locked={isLocked}
|
||||
/>
|
||||
);
|
||||
case 'show_contact_fields':
|
||||
return (
|
||||
<ContactFields
|
||||
key={widgetKey}
|
||||
email={formState.email}
|
||||
setEmail={(v) => updateFormState({ email: v })}
|
||||
message={formState.message}
|
||||
setMessage={(v) => updateFormState({ message: v })}
|
||||
locked={isLocked}
|
||||
/>
|
||||
);
|
||||
case 'request_file_upload':
|
||||
return (
|
||||
<FileDropzone
|
||||
key={widgetKey}
|
||||
label={tc.arguments.label || 'Dateien hochladen'}
|
||||
files={formState.contactFiles || []}
|
||||
onFilesAdded={(files) => {
|
||||
updateFormState({
|
||||
contactFiles: [...(formState.contactFiles || []), ...files],
|
||||
});
|
||||
}}
|
||||
locked={isLocked}
|
||||
/>
|
||||
);
|
||||
case 'show_estimate_preview':
|
||||
return <EstimatePreview key={widgetKey} formState={formState} />;
|
||||
case 'generate_estimate_pdf':
|
||||
return (
|
||||
<div key={widgetKey} className="w-full">
|
||||
<div className="flex items-center gap-3 p-4 rounded-xl bg-slate-50 border border-slate-200">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="text-slate-500 shrink-0">
|
||||
<path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z" />
|
||||
<polyline points="14 2 14 8 20 8" />
|
||||
<polyline points="16 13 12 17 8 13" />
|
||||
<line x1="12" y1="12" x2="12" y2="17" />
|
||||
</svg>
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-bold text-slate-900">PDF-Angebot</p>
|
||||
<p className="text-[10px] text-slate-500">
|
||||
Das Angebot wird nach Absenden der Anfrage erstellt und zugesendet.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const onKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
sendMessage();
|
||||
}
|
||||
};
|
||||
|
||||
// Start conversation automatically
|
||||
useEffect(() => {
|
||||
if (messages.length === 0) {
|
||||
sendMessage('Hallo!');
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Success view
|
||||
if (isSubmitted) {
|
||||
return (
|
||||
<div className="w-full min-h-[60vh] flex flex-col items-center justify-center text-center space-y-8 p-8">
|
||||
<motion.div
|
||||
initial={{ scale: 0 }}
|
||||
animate={{ scale: 1 }}
|
||||
transition={{ type: 'spring', stiffness: 300, damping: 20 }}
|
||||
className="w-20 h-20 bg-green-500 rounded-full flex items-center justify-center"
|
||||
>
|
||||
<svg width="36" height="36" viewBox="0 0 24 24" fill="none" stroke="white" strokeWidth="3">
|
||||
<polyline points="20 6 9 17 4 12" />
|
||||
</svg>
|
||||
</motion.div>
|
||||
<div className="space-y-2">
|
||||
<h2 className="text-3xl font-black text-slate-900 tracking-tight">Anfrage gesendet!</h2>
|
||||
<p className="text-sm text-slate-500 max-w-md">
|
||||
Marc wird sich in Kürze bei dir unter <strong>{formState.email}</strong> melden.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
setIsSubmitted(false);
|
||||
setMessages([]);
|
||||
setFormState({ ...initialState } as FormState);
|
||||
setLockedWidgets(new Set());
|
||||
}}
|
||||
className="text-sm font-bold text-slate-400 underline hover:text-slate-900 transition-colors"
|
||||
>
|
||||
Neue Anfrage starten
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full max-w-4xl mx-auto flex flex-col" style={{ minHeight: '70vh' }}>
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-3 pb-6 mb-6 border-b border-slate-100">
|
||||
<AIOrb isThinking={isLoading} size="sm" />
|
||||
<div>
|
||||
<h2 className="text-sm font-black tracking-tight text-slate-900 uppercase">
|
||||
Projekt-Assistent
|
||||
</h2>
|
||||
<p className="text-[10px] font-mono text-slate-400 tracking-wider">
|
||||
AI-GESTÜTZTE BERATUNG
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Chat Messages */}
|
||||
<div className="flex-1 space-y-6 overflow-y-auto pb-6">
|
||||
<AnimatePresence mode="popLayout">
|
||||
{messages
|
||||
.filter((m) => m.role !== 'tool')
|
||||
.map((msg) => (
|
||||
<motion.div
|
||||
key={msg.id}
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
className={`flex ${msg.role === 'user' ? 'justify-end' : 'justify-start'}`}
|
||||
>
|
||||
<div
|
||||
className={`max-w-[85%] space-y-3 ${msg.role === 'user'
|
||||
? 'bg-slate-900 text-white rounded-2xl rounded-tr-sm p-4'
|
||||
: ''
|
||||
}`}
|
||||
>
|
||||
{/* Text content */}
|
||||
{msg.content && (
|
||||
<div
|
||||
className={
|
||||
msg.role === 'assistant'
|
||||
? 'text-sm text-slate-700 leading-relaxed whitespace-pre-wrap'
|
||||
: 'text-sm leading-relaxed'
|
||||
}
|
||||
>
|
||||
{msg.role === 'user' && msg.content === 'Hallo!'
|
||||
? null
|
||||
: msg.content}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tool call widgets */}
|
||||
{msg.role === 'assistant' && msg.toolCalls && msg.toolCalls.length > 0 && (
|
||||
<div className="space-y-3 mt-2">
|
||||
{msg.toolCalls
|
||||
.filter((tc) => !['update_company_info', 'update_project_type', 'submit_inquiry'].includes(tc.name))
|
||||
.map((tc) => renderToolCallWidget(tc, msg.id))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Loading indicator */}
|
||||
{isLoading && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
className="flex justify-start"
|
||||
>
|
||||
<div className="flex items-center gap-3 px-2">
|
||||
<AIOrb isThinking={true} size="sm" />
|
||||
<span className="text-xs text-slate-400 font-mono animate-pulse">
|
||||
denkt nach...
|
||||
</span>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<div className="flex items-center gap-2 p-3 bg-red-50 text-red-600 rounded-xl border border-red-100 text-xs font-bold">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<line x1="15" y1="9" x2="9" y2="15" />
|
||||
<line x1="9" y1="9" x2="15" y2="15" />
|
||||
</svg>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
|
||||
{/* Input */}
|
||||
<div className="pt-4 border-t border-slate-100 mt-auto">
|
||||
<div className="relative flex items-center rounded-xl border border-slate-200 bg-white transition-all focus-within:border-slate-900 focus-within:ring-1 focus-within:ring-slate-900">
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={onKeyDown}
|
||||
placeholder="Beschreibe dein Projekt..."
|
||||
disabled={isLoading}
|
||||
className="flex-1 bg-transparent border-none text-sm p-4 focus:outline-none text-slate-900 placeholder:text-slate-300"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
className="hidden"
|
||||
value={honeypot}
|
||||
onChange={(e) => setHoneypot(e.target.value)}
|
||||
tabIndex={-1}
|
||||
autoComplete="off"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<button
|
||||
onClick={() => sendMessage()}
|
||||
disabled={!input.trim() || isLoading}
|
||||
className="p-4 transition-all shrink-0 cursor-pointer disabled:opacity-30 text-slate-400 hover:text-slate-900"
|
||||
aria-label="Nachricht senden"
|
||||
>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M22 2L11 13M22 2l-7 20-4-9-9-4 20-7z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-center text-[10px] text-slate-300 mt-2 font-mono tracking-wider">
|
||||
Enter zum Senden
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
55
apps/web/src/components/agent/widgets/ContactFields.tsx
Normal file
55
apps/web/src/components/agent/widgets/ContactFields.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
'use client';
|
||||
|
||||
import { cn } from '../../../utils/cn';
|
||||
|
||||
interface ContactFieldsProps {
|
||||
email: string;
|
||||
setEmail: (val: string) => void;
|
||||
message: string;
|
||||
setMessage: (val: string) => void;
|
||||
locked?: boolean;
|
||||
}
|
||||
|
||||
export function ContactFields({
|
||||
email,
|
||||
setEmail,
|
||||
message,
|
||||
setMessage,
|
||||
locked = false,
|
||||
}: ContactFieldsProps) {
|
||||
return (
|
||||
<div className="space-y-3 w-full">
|
||||
<h4 className="text-[10px] font-mono font-bold uppercase tracking-[0.2em] text-slate-400">
|
||||
Kontaktdaten
|
||||
</h4>
|
||||
<div className="space-y-2">
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="ihre@email.de"
|
||||
disabled={locked}
|
||||
className={cn(
|
||||
'w-full px-4 py-3 rounded-xl border text-sm font-medium transition-all focus:outline-none',
|
||||
'bg-white border-slate-200 text-slate-900 placeholder:text-slate-300',
|
||||
'focus:border-slate-900 focus:ring-1 focus:ring-slate-900',
|
||||
locked && 'opacity-60',
|
||||
)}
|
||||
/>
|
||||
<textarea
|
||||
value={message}
|
||||
onChange={(e) => setMessage(e.target.value)}
|
||||
placeholder="Optionale Nachricht oder weitere Details..."
|
||||
rows={3}
|
||||
disabled={locked}
|
||||
className={cn(
|
||||
'w-full px-4 py-3 rounded-xl border text-sm font-medium transition-all focus:outline-none resize-none',
|
||||
'bg-white border-slate-200 text-slate-900 placeholder:text-slate-300',
|
||||
'focus:border-slate-900 focus:ring-1 focus:ring-slate-900',
|
||||
locked && 'opacity-60',
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
95
apps/web/src/components/agent/widgets/DesignPicker.tsx
Normal file
95
apps/web/src/components/agent/widgets/DesignPicker.tsx
Normal file
@@ -0,0 +1,95 @@
|
||||
'use client';
|
||||
|
||||
import { cn } from '../../../utils/cn';
|
||||
|
||||
interface DesignOption {
|
||||
id: string;
|
||||
label: string;
|
||||
desc: string;
|
||||
}
|
||||
|
||||
const DESIGN_STYLES: DesignOption[] = [
|
||||
{ id: 'minimal', label: 'Minimalistisch', desc: 'Viel Weißraum, klare Typografie.' },
|
||||
{ id: 'bold', label: 'Mutig & Laut', desc: 'Starke Kontraste, große Schriften.' },
|
||||
{ id: 'nature', label: 'Natürlich', desc: 'Sanfte Erdtöne, organische Formen.' },
|
||||
{ id: 'tech', label: 'Technisch', desc: 'Präzise Linien, dunkle Akzente.' },
|
||||
];
|
||||
|
||||
interface DesignPickerProps {
|
||||
selected: string;
|
||||
onSelect: (id: string) => void;
|
||||
locked?: boolean;
|
||||
}
|
||||
|
||||
export function DesignPicker({ selected, onSelect, locked = false }: DesignPickerProps) {
|
||||
return (
|
||||
<div className="space-y-3 w-full">
|
||||
<h4 className="text-[10px] font-mono font-bold uppercase tracking-[0.2em] text-slate-400">
|
||||
Design-Stil
|
||||
</h4>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{DESIGN_STYLES.map((style) => {
|
||||
const isSelected = selected === style.id;
|
||||
return (
|
||||
<button
|
||||
key={style.id}
|
||||
onClick={() => !locked && onSelect(style.id)}
|
||||
disabled={locked}
|
||||
className={cn(
|
||||
'relative flex flex-col p-4 rounded-xl border text-left transition-all duration-200 cursor-pointer',
|
||||
isSelected
|
||||
? 'bg-slate-900 border-slate-800 text-white shadow-lg ring-2 ring-slate-700'
|
||||
: 'bg-white border-slate-200 text-slate-700 hover:border-slate-400',
|
||||
locked && 'opacity-60 cursor-default',
|
||||
)}
|
||||
>
|
||||
{/* Mini illustration */}
|
||||
<div
|
||||
className={cn(
|
||||
'w-full h-12 rounded-lg mb-3 overflow-hidden',
|
||||
isSelected ? 'bg-slate-800' : 'bg-slate-50',
|
||||
)}
|
||||
>
|
||||
{style.id === 'minimal' && (
|
||||
<div className="p-2 space-y-1">
|
||||
<div className={cn('h-1 w-3/4 rounded', isSelected ? 'bg-slate-600' : 'bg-slate-200')} />
|
||||
<div className={cn('h-1 w-1/2 rounded', isSelected ? 'bg-slate-700' : 'bg-slate-100')} />
|
||||
</div>
|
||||
)}
|
||||
{style.id === 'bold' && (
|
||||
<div className="p-2 space-y-1">
|
||||
<div className={cn('h-3 w-full rounded', isSelected ? 'bg-slate-600' : 'bg-slate-300')} />
|
||||
<div className={cn('h-3 w-full rounded', isSelected ? 'bg-slate-600' : 'bg-slate-300')} />
|
||||
</div>
|
||||
)}
|
||||
{style.id === 'nature' && (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className={cn('w-8 h-8 rounded-full', isSelected ? 'bg-emerald-800' : 'bg-emerald-100')} />
|
||||
<div className={cn('w-6 h-6 rounded-full -ml-2', isSelected ? 'bg-emerald-700' : 'bg-emerald-50')} />
|
||||
</div>
|
||||
)}
|
||||
{style.id === 'tech' && (
|
||||
<div className="p-2 grid grid-cols-2 gap-1 h-full">
|
||||
<div className={cn('rounded border', isSelected ? 'border-slate-600' : 'border-slate-200')} />
|
||||
<div className={cn('rounded border', isSelected ? 'border-slate-600' : 'border-slate-200')} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-sm font-bold">{style.label}</span>
|
||||
<span className={cn('text-[10px] mt-0.5', isSelected ? 'text-slate-400' : 'text-slate-400')}>
|
||||
{style.desc}
|
||||
</span>
|
||||
{isSelected && (
|
||||
<div className="absolute top-2 right-2">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3" className="text-green-400">
|
||||
<polyline points="20 6 9 17 4 12" />
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
53
apps/web/src/components/agent/widgets/EstimatePreview.tsx
Normal file
53
apps/web/src/components/agent/widgets/EstimatePreview.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
'use client';
|
||||
|
||||
import { PRICING } from '../../../logic/pricing/constants';
|
||||
import { calculateTotals } from '@mintel/pdf';
|
||||
|
||||
interface EstimatePreviewProps {
|
||||
formState: any;
|
||||
}
|
||||
|
||||
export function EstimatePreview({ formState }: EstimatePreviewProps) {
|
||||
const totals = calculateTotals(formState, PRICING);
|
||||
|
||||
const items = [
|
||||
{ label: 'Seiten', value: totals.totalPagesCount },
|
||||
{ label: 'Features', value: totals.totalFeatures },
|
||||
{ label: 'Funktionen', value: totals.totalFunctions },
|
||||
{ label: 'Integrationen', value: totals.totalApis },
|
||||
{ label: 'Sprachen', value: totals.languagesCount },
|
||||
].filter((i) => i.value > 0);
|
||||
|
||||
return (
|
||||
<div className="w-full rounded-xl border border-slate-200 overflow-hidden bg-white">
|
||||
<div className="p-4 bg-slate-900 text-white">
|
||||
<h4 className="text-[10px] font-mono font-bold uppercase tracking-[0.2em] text-slate-400 mb-1">
|
||||
Kostenübersicht
|
||||
</h4>
|
||||
<div className="flex items-baseline gap-1">
|
||||
<span className="text-3xl font-black tracking-tight">
|
||||
{totals.totalPrice.toLocaleString('de-DE')}
|
||||
</span>
|
||||
<span className="text-sm font-bold text-slate-400">€ netto</span>
|
||||
</div>
|
||||
{totals.monthlyPrice > 0 && (
|
||||
<p className="text-xs text-slate-400 mt-1">
|
||||
+ {totals.monthlyPrice.toLocaleString('de-DE')} € / Monat (Hosting & Betrieb)
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{items.length > 0 && (
|
||||
<div className="p-4 grid grid-cols-3 gap-3">
|
||||
{items.map((item) => (
|
||||
<div key={item.label} className="text-center">
|
||||
<p className="text-xl font-black text-slate-900">{item.value}</p>
|
||||
<p className="text-[10px] font-mono font-bold text-slate-400 uppercase tracking-wider">
|
||||
{item.label}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
115
apps/web/src/components/agent/widgets/FileDropzone.tsx
Normal file
115
apps/web/src/components/agent/widgets/FileDropzone.tsx
Normal file
@@ -0,0 +1,115 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useState } from 'react';
|
||||
import { cn } from '../../../utils/cn';
|
||||
|
||||
interface FileDropzoneProps {
|
||||
label?: string;
|
||||
onFilesAdded: (files: File[]) => void;
|
||||
files: File[];
|
||||
locked?: boolean;
|
||||
}
|
||||
|
||||
export function FileDropzone({
|
||||
label = 'Dateien hier ablegen',
|
||||
onFilesAdded,
|
||||
files,
|
||||
locked = false,
|
||||
}: FileDropzoneProps) {
|
||||
const [isDragOver, setIsDragOver] = useState(false);
|
||||
|
||||
const handleDrop = useCallback(
|
||||
(e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setIsDragOver(false);
|
||||
if (locked) return;
|
||||
const droppedFiles = Array.from(e.dataTransfer.files);
|
||||
onFilesAdded(droppedFiles);
|
||||
},
|
||||
[onFilesAdded, locked],
|
||||
);
|
||||
|
||||
const handleFileInput = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (locked) return;
|
||||
const selectedFiles = Array.from(e.target.files || []);
|
||||
onFilesAdded(selectedFiles);
|
||||
},
|
||||
[onFilesAdded, locked],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-2 w-full">
|
||||
<div
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
setIsDragOver(true);
|
||||
}}
|
||||
onDragLeave={() => setIsDragOver(false)}
|
||||
onDrop={handleDrop}
|
||||
className={cn(
|
||||
'relative border-2 border-dashed rounded-xl p-6 text-center transition-all duration-200 cursor-pointer',
|
||||
isDragOver
|
||||
? 'border-slate-900 bg-slate-50'
|
||||
: 'border-slate-200 bg-white hover:border-slate-400',
|
||||
locked && 'opacity-60 cursor-default',
|
||||
)}
|
||||
>
|
||||
<input
|
||||
type="file"
|
||||
multiple
|
||||
onChange={handleFileInput}
|
||||
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer"
|
||||
disabled={locked}
|
||||
/>
|
||||
<div className="space-y-2">
|
||||
<svg
|
||||
className="mx-auto text-slate-300"
|
||||
width="32"
|
||||
height="32"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
>
|
||||
<path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4" />
|
||||
<polyline points="17 8 12 3 7 8" />
|
||||
<line x1="12" y1="3" x2="12" y2="15" />
|
||||
</svg>
|
||||
<p className="text-xs font-bold text-slate-500">{label}</p>
|
||||
<p className="text-[10px] text-slate-400">
|
||||
Oder klicken zum Auswählen
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{files.length > 0 && (
|
||||
<div className="space-y-1">
|
||||
{files.map((file, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex items-center gap-2 px-3 py-1.5 bg-slate-50 rounded-lg"
|
||||
>
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
className="text-slate-400 shrink-0"
|
||||
>
|
||||
<path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z" />
|
||||
<polyline points="14 2 14 8 20 8" />
|
||||
</svg>
|
||||
<span className="text-xs text-slate-600 font-medium truncate">{file.name}</span>
|
||||
<span className="text-[10px] text-slate-400 shrink-0">
|
||||
{(file.size / 1024).toFixed(0)} KB
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
92
apps/web/src/components/agent/widgets/SelectionGrid.tsx
Normal file
92
apps/web/src/components/agent/widgets/SelectionGrid.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
'use client';
|
||||
|
||||
import { cn } from '../../../utils/cn';
|
||||
|
||||
interface SelectionOption {
|
||||
id: string;
|
||||
label: string;
|
||||
desc?: string;
|
||||
}
|
||||
|
||||
interface SelectionGridProps {
|
||||
title: string;
|
||||
options: SelectionOption[];
|
||||
selected: string[];
|
||||
onSelectionChange: (selected: string[]) => void;
|
||||
locked?: boolean;
|
||||
}
|
||||
|
||||
export function SelectionGrid({
|
||||
title,
|
||||
options,
|
||||
selected,
|
||||
onSelectionChange,
|
||||
locked = false,
|
||||
}: SelectionGridProps) {
|
||||
const toggle = (id: string) => {
|
||||
if (locked) return;
|
||||
const next = selected.includes(id)
|
||||
? selected.filter((s) => s !== id)
|
||||
: [...selected, id];
|
||||
onSelectionChange(next);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-3 w-full">
|
||||
<h4 className="text-[10px] font-mono font-bold uppercase tracking-[0.2em] text-slate-400">
|
||||
{title}
|
||||
</h4>
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-2">
|
||||
{options.map((opt) => {
|
||||
const isSelected = selected.includes(opt.id);
|
||||
return (
|
||||
<button
|
||||
key={opt.id}
|
||||
onClick={() => toggle(opt.id)}
|
||||
disabled={locked}
|
||||
className={cn(
|
||||
'group relative flex flex-col items-start p-3 rounded-xl border text-left transition-all duration-200 cursor-pointer',
|
||||
isSelected
|
||||
? 'bg-slate-900 border-slate-800 text-white shadow-md'
|
||||
: 'bg-white border-slate-200 text-slate-700 hover:border-slate-400 hover:shadow-sm',
|
||||
locked && 'opacity-60 cursor-default',
|
||||
)}
|
||||
>
|
||||
<span className="text-xs font-bold tracking-tight">{opt.label}</span>
|
||||
{opt.desc && (
|
||||
<span
|
||||
className={cn(
|
||||
'text-[10px] mt-0.5 line-clamp-2',
|
||||
isSelected ? 'text-slate-400' : 'text-slate-400',
|
||||
)}
|
||||
>
|
||||
{opt.desc}
|
||||
</span>
|
||||
)}
|
||||
{isSelected && (
|
||||
<div className="absolute top-2 right-2">
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="3"
|
||||
className="text-green-400"
|
||||
>
|
||||
<polyline points="20 6 9 17 4 12" />
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{selected.length > 0 && (
|
||||
<p className="text-[10px] font-mono text-slate-400 mt-1">
|
||||
{selected.length} ausgewählt
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
48
apps/web/src/components/agent/widgets/TimelinePicker.tsx
Normal file
48
apps/web/src/components/agent/widgets/TimelinePicker.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
'use client';
|
||||
|
||||
import { cn } from '../../../utils/cn';
|
||||
|
||||
const TIMELINE_OPTIONS = [
|
||||
{ id: 'asap', label: 'So schnell wie möglich', icon: '⚡' },
|
||||
{ id: '2-3-months', label: 'In 2–3 Monaten', icon: '📅' },
|
||||
{ id: '3-6-months', label: 'In 3–6 Monaten', icon: '🗓️' },
|
||||
{ id: 'flexible', label: 'Flexibel', icon: '🔄' },
|
||||
];
|
||||
|
||||
interface TimelinePickerProps {
|
||||
selected: string;
|
||||
onSelect: (id: string) => void;
|
||||
locked?: boolean;
|
||||
}
|
||||
|
||||
export function TimelinePicker({ selected, onSelect, locked = false }: TimelinePickerProps) {
|
||||
return (
|
||||
<div className="space-y-3 w-full">
|
||||
<h4 className="text-[10px] font-mono font-bold uppercase tracking-[0.2em] text-slate-400">
|
||||
Zeitrahmen
|
||||
</h4>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{TIMELINE_OPTIONS.map((opt) => {
|
||||
const isSelected = selected === opt.id;
|
||||
return (
|
||||
<button
|
||||
key={opt.id}
|
||||
onClick={() => !locked && onSelect(opt.id)}
|
||||
disabled={locked}
|
||||
className={cn(
|
||||
'flex items-center gap-2 p-3 rounded-xl border text-left transition-all duration-200 cursor-pointer',
|
||||
isSelected
|
||||
? 'bg-slate-900 border-slate-800 text-white shadow-md'
|
||||
: 'bg-white border-slate-200 text-slate-700 hover:border-slate-400',
|
||||
locked && 'opacity-60 cursor-default',
|
||||
)}
|
||||
>
|
||||
<span className="text-lg">{opt.icon}</span>
|
||||
<span className="text-xs font-bold">{opt.label}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
66
apps/web/src/components/search/AIOrb.tsx
Normal file
66
apps/web/src/components/search/AIOrb.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
|
||||
interface AIOrbProps {
|
||||
isThinking: boolean;
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
}
|
||||
|
||||
export default function AIOrb({ isThinking = false, size = 'md' }: AIOrbProps) {
|
||||
const sizeMap = {
|
||||
sm: { container: 'w-8 h-8', orb: 'w-5 h-5' },
|
||||
md: { container: 'w-16 h-16', orb: 'w-10 h-10' },
|
||||
lg: { container: 'w-24 h-24', orb: 'w-16 h-16' },
|
||||
};
|
||||
|
||||
const s = sizeMap[size];
|
||||
|
||||
return (
|
||||
<div className={`${s.container} relative flex items-center justify-center`}>
|
||||
{/* Ambient glow */}
|
||||
<div
|
||||
className={`absolute inset-0 rounded-full blur-xl transition-all duration-1000 ${isThinking
|
||||
? 'bg-gradient-to-br from-emerald-400/60 to-cyan-400/40 animate-pulse'
|
||||
: 'bg-gradient-to-br from-slate-400/30 to-slate-300/20'
|
||||
}`}
|
||||
/>
|
||||
|
||||
{/* Orb */}
|
||||
<div
|
||||
className={`${s.orb} rounded-full relative z-10 transition-all duration-700 ${isThinking
|
||||
? 'bg-gradient-to-br from-emerald-400 via-cyan-400 to-blue-500 shadow-lg shadow-emerald-400/40'
|
||||
: 'bg-gradient-to-br from-slate-500 via-slate-400 to-slate-300 shadow-md shadow-slate-400/20'
|
||||
}`}
|
||||
style={{
|
||||
animation: isThinking
|
||||
? 'ai-orb-pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite, ai-orb-rotate 3s linear infinite'
|
||||
: 'ai-orb-float 4s ease-in-out infinite',
|
||||
}}
|
||||
>
|
||||
{/* Inner highlight */}
|
||||
<div
|
||||
className={`absolute inset-[15%] rounded-full transition-all duration-700 ${isThinking
|
||||
? 'bg-gradient-to-br from-white/40 to-transparent'
|
||||
: 'bg-gradient-to-br from-white/25 to-transparent'
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<style jsx>{`
|
||||
@keyframes ai-orb-pulse {
|
||||
0%, 100% { transform: scale(1); }
|
||||
50% { transform: scale(1.15); }
|
||||
}
|
||||
@keyframes ai-orb-rotate {
|
||||
from { filter: hue-rotate(0deg); }
|
||||
to { filter: hue-rotate(360deg); }
|
||||
}
|
||||
@keyframes ai-orb-float {
|
||||
0%, 100% { transform: translateY(0); }
|
||||
50% { transform: translateY(-3px); }
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
419
apps/web/src/components/search/AISearchResults.tsx
Normal file
419
apps/web/src/components/search/AISearchResults.tsx
Normal file
@@ -0,0 +1,419 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useRef, useEffect, KeyboardEvent } from 'react';
|
||||
import Link from 'next/link';
|
||||
import AIOrb from './AIOrb';
|
||||
|
||||
interface PostMatch {
|
||||
id: string;
|
||||
title: string;
|
||||
slug: string;
|
||||
description: string;
|
||||
tags?: string;
|
||||
}
|
||||
|
||||
interface Message {
|
||||
role: 'user' | 'assistant';
|
||||
content: string;
|
||||
posts?: PostMatch[];
|
||||
}
|
||||
|
||||
interface ComponentProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
initialQuery?: string;
|
||||
triggerSearch?: boolean;
|
||||
}
|
||||
|
||||
export function AISearchResults({
|
||||
isOpen,
|
||||
onClose,
|
||||
initialQuery = '',
|
||||
triggerSearch = false,
|
||||
}: ComponentProps) {
|
||||
const [query, setQuery] = useState('');
|
||||
const [messages, setMessages] = useState<Message[]>([]);
|
||||
const [honeypot, setHoneypot] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const modalRef = useRef<HTMLDivElement>(null);
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
document.body.style.overflow = 'hidden';
|
||||
setTimeout(() => inputRef.current?.focus(), 100);
|
||||
|
||||
if (triggerSearch && initialQuery && messages.length === 0) {
|
||||
setQuery(initialQuery);
|
||||
handleSearch(initialQuery);
|
||||
} else if (!triggerSearch) {
|
||||
setQuery('');
|
||||
}
|
||||
} else {
|
||||
document.body.style.overflow = 'unset';
|
||||
setQuery('');
|
||||
setMessages([]);
|
||||
setError(null);
|
||||
setIsLoading(false);
|
||||
}
|
||||
return () => {
|
||||
document.body.style.overflow = 'unset';
|
||||
};
|
||||
}, [isOpen, triggerSearch]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen && initialQuery && messages.length === 0) {
|
||||
setQuery(initialQuery);
|
||||
}
|
||||
}, [initialQuery, isOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
}, [messages, isLoading]);
|
||||
|
||||
// Keyboard shortcut: Cmd+K to open
|
||||
useEffect(() => {
|
||||
const handleGlobalKeyDown = (e: globalThis.KeyboardEvent) => {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
|
||||
e.preventDefault();
|
||||
if (!isOpen) {
|
||||
// Parent handles opening
|
||||
} else {
|
||||
inputRef.current?.focus();
|
||||
}
|
||||
}
|
||||
if (e.key === 'Escape' && isOpen) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleGlobalKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleGlobalKeyDown);
|
||||
}, [isOpen, onClose]);
|
||||
|
||||
const handleSearch = async (searchQuery: string = query) => {
|
||||
if (!searchQuery.trim() || isLoading) return;
|
||||
|
||||
const newUserMessage: Message = { role: 'user', content: searchQuery };
|
||||
const newMessagesContext = [...messages, newUserMessage];
|
||||
|
||||
setMessages(newMessagesContext);
|
||||
setQuery('');
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/ai-search', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
messages: newMessagesContext,
|
||||
honeypot,
|
||||
}),
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(data.error || 'Failed to fetch search results');
|
||||
}
|
||||
|
||||
setMessages((prev) => [
|
||||
...prev,
|
||||
{
|
||||
role: 'assistant',
|
||||
content: data.answerText,
|
||||
posts: data.posts,
|
||||
},
|
||||
]);
|
||||
|
||||
setTimeout(() => inputRef.current?.focus(), 100);
|
||||
} catch (err: any) {
|
||||
console.error(err);
|
||||
setError(err.message || 'Ein Fehler ist aufgetreten. Bitte versuche es erneut.');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const onKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
handleSearch();
|
||||
}
|
||||
if (e.key === 'Escape') {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const handleBackdropClick = (e: React.MouseEvent) => {
|
||||
if (e.target === e.currentTarget) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-[100] flex items-start justify-center pt-16 md:pt-24 px-4 transition-all duration-300"
|
||||
onClick={handleBackdropClick}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
style={{
|
||||
backgroundColor: 'rgba(15, 15, 15, 0.95)',
|
||||
backdropFilter: 'blur(20px)',
|
||||
animation: 'ai-modal-fade-in 0.3s ease-out',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
ref={modalRef}
|
||||
className="relative w-full max-w-4xl overflow-hidden flex flex-col"
|
||||
style={{
|
||||
height: '75vh',
|
||||
background: 'linear-gradient(180deg, rgba(30, 30, 30, 0.95) 0%, rgba(20, 20, 20, 0.98) 100%)',
|
||||
border: '1px solid rgba(255, 255, 255, 0.08)',
|
||||
borderRadius: '1.5rem',
|
||||
boxShadow: '0 25px 50px -12px rgba(0, 0, 0, 0.8)',
|
||||
animation: 'ai-modal-slide-up 0.4s cubic-bezier(0.16, 1, 0.3, 1)',
|
||||
}}
|
||||
>
|
||||
{/* Header */}
|
||||
<div
|
||||
className="p-4 md:p-6 flex items-center justify-between relative z-10"
|
||||
style={{
|
||||
borderBottom: '1px solid rgba(255, 255, 255, 0.06)',
|
||||
background: 'rgba(15, 15, 15, 0.8)',
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<AIOrb isThinking={isLoading} size="sm" />
|
||||
<h2
|
||||
className="font-bold tracking-widest uppercase text-sm"
|
||||
style={{ color: 'rgba(255, 255, 255, 0.9)' }}
|
||||
>
|
||||
AI Assistent
|
||||
</h2>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="transition-colors p-2 rounded-lg hover:bg-white/5"
|
||||
style={{ color: 'rgba(255, 255, 255, 0.4)' }}
|
||||
aria-label="Schließen"
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M18 6L6 18M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Chat History */}
|
||||
<div className="flex-1 overflow-y-auto p-4 md:p-8 relative space-y-6 scroll-smooth">
|
||||
{messages.length === 0 && !isLoading && !error && (
|
||||
<div
|
||||
className="flex flex-col items-center justify-center h-full text-center space-y-4"
|
||||
style={{ opacity: 0.5, animation: 'ai-modal-fade-in 0.5s ease-out 0.2s both' }}
|
||||
>
|
||||
<AIOrb isThinking={false} size="lg" />
|
||||
<p className="text-xl md:text-2xl font-bold mt-6" style={{ color: 'rgba(255, 255, 255, 0.9)' }}>
|
||||
Wie kann ich helfen?
|
||||
</p>
|
||||
<p className="text-sm" style={{ color: 'rgba(255, 255, 255, 0.5)' }}>
|
||||
Frag mich zu Blog-Themen, Technologien oder unseren Services.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{messages.map((msg, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={`flex ${msg.role === 'user' ? 'justify-end' : 'justify-start'}`}
|
||||
>
|
||||
<div
|
||||
className="max-w-[85%] rounded-2xl p-5"
|
||||
style={{
|
||||
...(msg.role === 'user'
|
||||
? {
|
||||
background: 'linear-gradient(135deg, #333 0%, #222 100%)',
|
||||
color: '#fff',
|
||||
borderTopRightRadius: '0.25rem',
|
||||
}
|
||||
: {
|
||||
background: 'rgba(255, 255, 255, 0.04)',
|
||||
border: '1px solid rgba(255, 255, 255, 0.06)',
|
||||
color: 'rgba(255, 255, 255, 0.9)',
|
||||
borderTopLeftRadius: '0.25rem',
|
||||
}),
|
||||
}}
|
||||
>
|
||||
{msg.role === 'assistant' && (
|
||||
<h3
|
||||
className="text-xs font-bold tracking-widest uppercase mb-2 flex items-center gap-1"
|
||||
style={{ color: 'rgba(255, 255, 255, 0.4)' }}
|
||||
>
|
||||
<AIOrb isThinking={false} size="sm" />
|
||||
AI Assistent
|
||||
</h3>
|
||||
)}
|
||||
<div className="text-base leading-relaxed whitespace-pre-wrap">
|
||||
{msg.content}
|
||||
</div>
|
||||
|
||||
{/* Post matches */}
|
||||
{msg.role === 'assistant' && msg.posts && msg.posts.length > 0 && (
|
||||
<div
|
||||
className="mt-6 space-y-3 pt-4"
|
||||
style={{ borderTop: '1px solid rgba(255, 255, 255, 0.08)' }}
|
||||
>
|
||||
<h4
|
||||
className="text-xs font-bold tracking-widest uppercase"
|
||||
style={{ color: 'rgba(255, 255, 255, 0.35)' }}
|
||||
>
|
||||
Relevante Artikel
|
||||
</h4>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
{msg.posts.map((post, idx) => (
|
||||
<Link
|
||||
key={idx}
|
||||
href={`/blog/${post.slug}`}
|
||||
onClick={onClose}
|
||||
className="group flex flex-col justify-between rounded-lg p-4 transition-all duration-300 hover:-translate-y-0.5"
|
||||
style={{
|
||||
background: 'rgba(255, 255, 255, 0.06)',
|
||||
border: '1px solid rgba(255, 255, 255, 0.08)',
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
{post.tags && (
|
||||
<p
|
||||
className="text-[10px] font-bold tracking-wider mb-1"
|
||||
style={{ color: 'rgba(255, 255, 255, 0.35)' }}
|
||||
>
|
||||
{post.tags}
|
||||
</p>
|
||||
)}
|
||||
<h5 className="text-sm font-extrabold mb-1 line-clamp-2 transition-colors" style={{ color: '#fff' }}>
|
||||
{post.title}
|
||||
</h5>
|
||||
<p className="text-xs line-clamp-2" style={{ color: 'rgba(255, 255, 255, 0.5)' }}>
|
||||
{post.description}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center justify-end mt-2">
|
||||
<span
|
||||
className="text-[10px] font-bold tracking-widest uppercase transition-colors"
|
||||
style={{ color: 'rgba(255, 255, 255, 0.5)' }}
|
||||
>
|
||||
Lesen →
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{isLoading && (
|
||||
<div className="flex justify-start">
|
||||
<div className="rounded-2xl p-2 w-20 flex justify-center">
|
||||
<AIOrb isThinking={true} size="md" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div
|
||||
className="flex items-start space-x-4 p-4 rounded-xl mt-4"
|
||||
style={{
|
||||
background: 'rgba(239, 68, 68, 0.1)',
|
||||
border: '1px solid rgba(239, 68, 68, 0.2)',
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<h3 className="text-sm font-bold" style={{ color: 'rgba(239, 68, 68, 0.8)' }}>Fehler</h3>
|
||||
<p className="text-xs mt-1" style={{ color: 'rgba(239, 68, 68, 0.6)' }}>{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
|
||||
{/* Input */}
|
||||
<div
|
||||
className="p-4 md:p-6"
|
||||
style={{
|
||||
borderTop: '1px solid rgba(255, 255, 255, 0.06)',
|
||||
background: 'rgba(15, 15, 15, 0.8)',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="relative flex items-center rounded-xl transition-all"
|
||||
style={{
|
||||
background: 'rgba(255, 255, 255, 0.04)',
|
||||
border: '1px solid rgba(255, 255, 255, 0.08)',
|
||||
}}
|
||||
>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
onKeyDown={onKeyDown}
|
||||
placeholder="Stelle eine Frage..."
|
||||
className="flex-1 bg-transparent border-none text-base md:text-lg p-4 focus:outline-none"
|
||||
style={{
|
||||
color: 'rgba(255, 255, 255, 0.9)',
|
||||
}}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
className="hidden"
|
||||
value={honeypot}
|
||||
onChange={(e) => setHoneypot(e.target.value)}
|
||||
tabIndex={-1}
|
||||
autoComplete="off"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<button
|
||||
onClick={() => handleSearch()}
|
||||
disabled={!query.trim() || isLoading}
|
||||
className="p-4 transition-colors shrink-0 cursor-pointer disabled:opacity-30"
|
||||
style={{ color: 'rgba(255, 255, 255, 0.5)' }}
|
||||
aria-label="Nachricht senden"
|
||||
>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M22 2L11 13M22 2l-7 20-4-9-9-4 20-7z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div className="text-center mt-3">
|
||||
<span
|
||||
className="text-[10px] uppercase tracking-widest font-bold"
|
||||
style={{ color: 'rgba(255, 255, 255, 0.2)' }}
|
||||
>
|
||||
Enter zum Senden • Esc zum Schließen
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style jsx>{`
|
||||
@keyframes ai-modal-fade-in {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
@keyframes ai-modal-slide-up {
|
||||
from { opacity: 0; transform: translateY(20px) scale(0.98); }
|
||||
to { opacity: 1; transform: translateY(0) scale(1); }
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,14 +1,16 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { ComponentShareButton } from '../ComponentShareButton';
|
||||
import { Reveal } from '../Reveal';
|
||||
import { Play, RotateCcw } from 'lucide-react';
|
||||
import { RotateCcw } from 'lucide-react';
|
||||
|
||||
export function LoadTimeSimulator({ className = '' }: { className?: string }) {
|
||||
const [isRunning, setIsRunning] = useState(false);
|
||||
const [timeElapsed, setTimeElapsed] = useState(0);
|
||||
const [legacyState, setLegacyState] = useState(0);
|
||||
const [hasAutoStarted, setHasAutoStarted] = useState(false);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [mintelState, setMintelState] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -36,6 +38,25 @@ export function LoadTimeSimulator({ className = '' }: { className?: string }) {
|
||||
return () => clearInterval(interval);
|
||||
}, [isRunning, timeElapsed]);
|
||||
|
||||
// Auto-start the race when scrolled into viewport
|
||||
useEffect(() => {
|
||||
if (hasAutoStarted) return;
|
||||
const el = containerRef.current;
|
||||
if (!el) return;
|
||||
const observer = new IntersectionObserver(
|
||||
([entry]) => {
|
||||
if (entry.isIntersecting) {
|
||||
setHasAutoStarted(true);
|
||||
setIsRunning(true);
|
||||
observer.disconnect();
|
||||
}
|
||||
},
|
||||
{ threshold: 0.4 }
|
||||
);
|
||||
observer.observe(el);
|
||||
return () => observer.disconnect();
|
||||
}, [hasAutoStarted]);
|
||||
|
||||
const startRace = () => {
|
||||
setTimeElapsed(0);
|
||||
setLegacyState(0);
|
||||
@@ -45,7 +66,7 @@ export function LoadTimeSimulator({ className = '' }: { className?: string }) {
|
||||
|
||||
return (
|
||||
<Reveal direction="up" delay={0.1}>
|
||||
<div className={`not-prose max-w-4xl mx-auto my-12 relative group ${className}`}>
|
||||
<div ref={containerRef} className={`not-prose max-w-4xl mx-auto my-12 relative group ${className}`}>
|
||||
<div className="absolute -inset-1 bg-gradient-to-r from-red-100 to-emerald-100 rounded-3xl blur opacity-30" />
|
||||
|
||||
<div id="sim-load-time" className="relative bg-white rounded-2xl border border-slate-200 shadow-sm overflow-hidden flex flex-col">
|
||||
@@ -63,13 +84,15 @@ export function LoadTimeSimulator({ className = '' }: { className?: string }) {
|
||||
Simulieren Sie den Unterschied zwischen dynamischem Server-Rendering (PHP/MySQL) und statischer Edge-Auslieferung (<span className="font-mono bg-slate-200 px-1 rounded text-[10px]">TTV < 500ms</span>).
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={startRace}
|
||||
className="shrink-0 flex items-center gap-2 px-6 py-2.5 bg-slate-900 !text-white rounded-full font-bold text-sm hover:hover:bg-black hover:scale-105 active:scale-95 transition-all shadow-md"
|
||||
>
|
||||
{timeElapsed > 0 ? <RotateCcw size={16} /> : <Play size={16} />}
|
||||
{timeElapsed > 0 ? "Neustart" : "Rennen Starten"}
|
||||
</button>
|
||||
{timeElapsed > 0 && !isRunning && (
|
||||
<button
|
||||
onClick={startRace}
|
||||
className="shrink-0 flex items-center gap-2 px-6 py-2.5 bg-slate-900 !text-white rounded-full font-bold text-sm hover:hover:bg-black hover:scale-105 active:scale-95 transition-all shadow-md"
|
||||
>
|
||||
<RotateCcw size={16} />
|
||||
Neustart
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid md:grid-cols-2 divide-y md:divide-y-0 md:divide-x divide-slate-100 bg-slate-50/50">
|
||||
|
||||
138
apps/web/src/lib/qdrant.ts
Normal file
138
apps/web/src/lib/qdrant.ts
Normal file
@@ -0,0 +1,138 @@
|
||||
import { QdrantClient } from '@qdrant/js-client-rest';
|
||||
import * as os from 'os';
|
||||
|
||||
const isDockerContainer =
|
||||
process.env.IS_DOCKER === 'true' || os.hostname().includes('mintel-me') || process.env.HOSTNAME?.includes('mintel-me');
|
||||
|
||||
let qdrantUrl = process.env.QDRANT_URL || 'http://localhost:6333';
|
||||
if (isDockerContainer && qdrantUrl.includes('localhost')) {
|
||||
qdrantUrl = qdrantUrl.replace('localhost', 'mintel-qdrant');
|
||||
}
|
||||
|
||||
const qdrantApiKey = process.env.QDRANT_API_KEY || '';
|
||||
|
||||
export const qdrant = new QdrantClient({
|
||||
url: qdrantUrl,
|
||||
apiKey: qdrantApiKey || undefined,
|
||||
});
|
||||
|
||||
export const COLLECTION_NAME = 'mintel_posts';
|
||||
export const VECTOR_SIZE = 1536; // OpenAI text-embedding-3-small
|
||||
|
||||
/**
|
||||
* Ensure the collection exists in Qdrant.
|
||||
*/
|
||||
export async function ensureCollection() {
|
||||
try {
|
||||
const collections = await qdrant.getCollections();
|
||||
const exists = collections.collections.some((c) => c.name === COLLECTION_NAME);
|
||||
if (!exists) {
|
||||
await qdrant.createCollection(COLLECTION_NAME, {
|
||||
vectors: {
|
||||
size: VECTOR_SIZE,
|
||||
distance: 'Cosine',
|
||||
},
|
||||
});
|
||||
console.log(`Successfully created Qdrant collection: ${COLLECTION_NAME}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error ensuring Qdrant collection:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate an embedding for a given text using OpenRouter (OpenAI embedding proxy)
|
||||
*/
|
||||
export async function generateEmbedding(text: string): Promise<number[]> {
|
||||
const openRouterKey = process.env.OPENROUTER_API_KEY;
|
||||
if (!openRouterKey) {
|
||||
throw new Error('OPENROUTER_API_KEY is not set');
|
||||
}
|
||||
|
||||
const response = await fetch('https://openrouter.ai/api/v1/embeddings', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${openRouterKey}`,
|
||||
'Content-Type': 'application/json',
|
||||
'HTTP-Referer': process.env.NEXT_PUBLIC_BASE_URL || 'https://mintel.me',
|
||||
'X-Title': 'Mintel.me AI Search',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: 'openai/text-embedding-3-small',
|
||||
input: text,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorBody = await response.text();
|
||||
throw new Error(
|
||||
`Failed to generate embedding: ${response.status} ${response.statusText} ${errorBody}`,
|
||||
);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return data.data[0].embedding;
|
||||
}
|
||||
|
||||
/**
|
||||
* Upsert a post into Qdrant
|
||||
*/
|
||||
export async function upsertPostVector(
|
||||
id: string | number,
|
||||
text: string,
|
||||
payload: Record<string, any>,
|
||||
) {
|
||||
try {
|
||||
await ensureCollection();
|
||||
const vector = await generateEmbedding(text);
|
||||
|
||||
await qdrant.upsert(COLLECTION_NAME, {
|
||||
wait: true,
|
||||
points: [
|
||||
{
|
||||
id,
|
||||
vector,
|
||||
payload,
|
||||
},
|
||||
],
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error writing to Qdrant:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a post from Qdrant
|
||||
*/
|
||||
export async function deletePostVector(id: string | number) {
|
||||
try {
|
||||
await ensureCollection();
|
||||
await qdrant.delete(COLLECTION_NAME, {
|
||||
wait: true,
|
||||
points: [id] as [string | number],
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error deleting from Qdrant:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Search posts in Qdrant
|
||||
*/
|
||||
export async function searchPosts(query: string, limit = 5) {
|
||||
try {
|
||||
await ensureCollection();
|
||||
const vector = await generateEmbedding(query);
|
||||
|
||||
const results = await qdrant.search(COLLECTION_NAME, {
|
||||
vector,
|
||||
limit,
|
||||
with_payload: true,
|
||||
});
|
||||
|
||||
return results;
|
||||
} catch (error) {
|
||||
console.error('Error searching in Qdrant:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
25
apps/web/src/lib/redis.ts
Normal file
25
apps/web/src/lib/redis.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import Redis from 'ioredis';
|
||||
import * as os from 'os';
|
||||
|
||||
const isDockerContainer =
|
||||
process.env.IS_DOCKER === 'true' || os.hostname().includes('mintel-me') || process.env.HOSTNAME?.includes('mintel-me');
|
||||
|
||||
let redisUrl = process.env.REDIS_URL || 'redis://localhost:6379';
|
||||
if (isDockerContainer && redisUrl.includes('localhost')) {
|
||||
redisUrl = redisUrl.replace('localhost', 'mintel-redis');
|
||||
}
|
||||
|
||||
// Only create a single instance in Node.js
|
||||
const globalForRedis = global as unknown as { redis: Redis };
|
||||
|
||||
export const redis =
|
||||
globalForRedis.redis ||
|
||||
new Redis(redisUrl, {
|
||||
maxRetriesPerRequest: 3,
|
||||
});
|
||||
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
globalForRedis.redis = redis;
|
||||
}
|
||||
|
||||
export default redis;
|
||||
@@ -1,191 +0,0 @@
|
||||
"use server";
|
||||
|
||||
import { config } from "../../../content-engine.config";
|
||||
import { getPayloadHMR } from "@payloadcms/next/utilities";
|
||||
import configPromise from "@payload-config";
|
||||
import * as fs from "node:fs/promises";
|
||||
import * as path from "node:path";
|
||||
import * as os from "node:os";
|
||||
|
||||
async function getOrchestrator() {
|
||||
const OPENROUTER_KEY =
|
||||
process.env.OPENROUTER_KEY || process.env.OPENROUTER_API_KEY;
|
||||
const REPLICATE_KEY = process.env.REPLICATE_API_KEY;
|
||||
|
||||
if (!OPENROUTER_KEY) {
|
||||
throw new Error(
|
||||
"Missing OPENROUTER_API_KEY in .env (Required for AI generation)",
|
||||
);
|
||||
}
|
||||
|
||||
const importDynamic = new Function("modulePath", "return import(modulePath)");
|
||||
const { AiBlogPostOrchestrator } = await importDynamic(
|
||||
"@mintel/content-engine",
|
||||
);
|
||||
|
||||
return new AiBlogPostOrchestrator({
|
||||
apiKey: OPENROUTER_KEY,
|
||||
replicateApiKey: REPLICATE_KEY,
|
||||
model: "google/gemini-3-flash-preview",
|
||||
});
|
||||
}
|
||||
|
||||
export async function generateSlugAction(
|
||||
title: string,
|
||||
draftContent: string,
|
||||
oldSlug?: string,
|
||||
instructions?: string,
|
||||
) {
|
||||
try {
|
||||
const orchestrator = await getOrchestrator();
|
||||
const newSlug = await orchestrator.generateSlug(
|
||||
draftContent,
|
||||
title,
|
||||
instructions,
|
||||
);
|
||||
|
||||
if (oldSlug && oldSlug !== newSlug) {
|
||||
const payload = await getPayloadHMR({ config: configPromise });
|
||||
await payload.create({
|
||||
collection: "redirects",
|
||||
data: {
|
||||
from: oldSlug,
|
||||
to: newSlug,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return { success: true, slug: newSlug };
|
||||
} catch (e: any) {
|
||||
return { success: false, error: e.message };
|
||||
}
|
||||
}
|
||||
|
||||
export async function generateThumbnailAction(
|
||||
draftContent: string,
|
||||
title?: string,
|
||||
instructions?: string,
|
||||
) {
|
||||
try {
|
||||
const payload = await getPayloadHMR({ config: configPromise });
|
||||
const OPENROUTER_KEY =
|
||||
process.env.OPENROUTER_KEY || process.env.OPENROUTER_API_KEY;
|
||||
const REPLICATE_KEY = process.env.REPLICATE_API_KEY;
|
||||
|
||||
if (!OPENROUTER_KEY) {
|
||||
throw new Error("Missing OPENROUTER_API_KEY in .env");
|
||||
}
|
||||
if (!REPLICATE_KEY) {
|
||||
throw new Error(
|
||||
"Missing REPLICATE_API_KEY in .env (Required for Thumbnails)",
|
||||
);
|
||||
}
|
||||
|
||||
const importDynamic = new Function(
|
||||
"modulePath",
|
||||
"return import(modulePath)",
|
||||
);
|
||||
const { AiBlogPostOrchestrator } = await importDynamic(
|
||||
"@mintel/content-engine",
|
||||
);
|
||||
const { ThumbnailGenerator } = await importDynamic(
|
||||
"@mintel/thumbnail-generator",
|
||||
);
|
||||
|
||||
const orchestrator = new AiBlogPostOrchestrator({
|
||||
apiKey: OPENROUTER_KEY,
|
||||
replicateApiKey: REPLICATE_KEY,
|
||||
model: "google/gemini-3-flash-preview",
|
||||
});
|
||||
|
||||
const tg = new ThumbnailGenerator({ replicateApiKey: REPLICATE_KEY });
|
||||
|
||||
const prompt = await orchestrator.generateVisualPrompt(
|
||||
draftContent || title || "Technology",
|
||||
instructions,
|
||||
);
|
||||
|
||||
const tmpPath = path.join(os.tmpdir(), `mintel-thumb-${Date.now()}.png`);
|
||||
await tg.generateImage(prompt, tmpPath);
|
||||
|
||||
const fileData = await fs.readFile(tmpPath);
|
||||
const stat = await fs.stat(tmpPath);
|
||||
const fileName = path.basename(tmpPath);
|
||||
|
||||
const newMedia = await payload.create({
|
||||
collection: "media",
|
||||
data: {
|
||||
alt: title ? `Thumbnail for ${title}` : "AI Generated Thumbnail",
|
||||
},
|
||||
file: {
|
||||
data: fileData,
|
||||
name: fileName,
|
||||
mimetype: "image/png",
|
||||
size: stat.size,
|
||||
},
|
||||
});
|
||||
|
||||
// Cleanup temp file
|
||||
await fs.unlink(tmpPath).catch(() => {});
|
||||
|
||||
return { success: true, mediaId: newMedia.id };
|
||||
} catch (e: any) {
|
||||
return { success: false, error: e.message };
|
||||
}
|
||||
}
|
||||
export async function generateSingleFieldAction(
|
||||
documentTitle: string,
|
||||
documentContent: string,
|
||||
fieldName: string,
|
||||
fieldDescription: string,
|
||||
instructions?: string,
|
||||
) {
|
||||
try {
|
||||
const OPENROUTER_KEY =
|
||||
process.env.OPENROUTER_KEY || process.env.OPENROUTER_API_KEY;
|
||||
if (!OPENROUTER_KEY) throw new Error("Missing OPENROUTER_API_KEY");
|
||||
|
||||
const payload = await getPayloadHMR({ config: configPromise });
|
||||
|
||||
// Fetch context documents from DB
|
||||
const contextDocsData = await payload.find({
|
||||
collection: "context-files",
|
||||
limit: 100,
|
||||
});
|
||||
const projectContext = contextDocsData.docs
|
||||
.map((doc) => `--- ${doc.filename} ---\n${doc.content}`)
|
||||
.join("\n\n");
|
||||
|
||||
const prompt = `You are an expert AI assistant perfectly trained for generating exact data values for CMS components.
|
||||
PROJECT STRATEGY & CONTEXT:
|
||||
${projectContext}
|
||||
|
||||
DOCUMENT TITLE: ${documentTitle}
|
||||
DOCUMENT DRAFT:\n${documentContent}\n
|
||||
YOUR TASK: Generate the exact value for a specific field named "${fieldName}".
|
||||
${fieldDescription ? `FIELD DESCRIPTION / CONSTRAINTS: ${fieldDescription}\n` : ""}
|
||||
${instructions ? `EDITOR INSTRUCTIONS for this field: ${instructions}\n` : ""}
|
||||
CRITICAL RULES:
|
||||
1. Respond ONLY with the requested content value.
|
||||
2. NO markdown wrapping blocks (like \`\`\`mermaid or \`\`\`html) around the output! Just the raw code or text.
|
||||
3. If the field implies a diagram or flow, output RAW Mermaid.js code.
|
||||
4. If it's standard text, write professional B2B German. No quotes, no conversational filler.`;
|
||||
|
||||
const res = await fetch("https://openrouter.ai/api/v1/chat/completions", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${OPENROUTER_KEY}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: "google/gemini-3-flash-preview",
|
||||
messages: [{ role: "user", content: prompt }],
|
||||
}),
|
||||
});
|
||||
const data = await res.json();
|
||||
const text = data.choices?.[0]?.message?.content?.trim() || "";
|
||||
return { success: true, text };
|
||||
} catch (e: any) {
|
||||
return { success: false, error: e.message };
|
||||
}
|
||||
}
|
||||
@@ -1,88 +0,0 @@
|
||||
"use server";
|
||||
|
||||
import { config } from "../../../content-engine.config";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { parseMarkdownToLexical } from "../utils/lexicalParser";
|
||||
import { getPayloadHMR } from "@payloadcms/next/utilities";
|
||||
import configPromise from "@payload-config";
|
||||
|
||||
export async function optimizePostText(
|
||||
draftContent: string,
|
||||
instructions?: string,
|
||||
) {
|
||||
try {
|
||||
const payload = await getPayloadHMR({ config: configPromise });
|
||||
const globalAiSettings = await payload.findGlobal({ slug: "ai-settings" });
|
||||
const customSources =
|
||||
globalAiSettings?.customSources?.map((s: any) => s.sourceName) || [];
|
||||
|
||||
const OPENROUTER_KEY =
|
||||
process.env.OPENROUTER_KEY || process.env.OPENROUTER_API_KEY;
|
||||
const REPLICATE_KEY = process.env.REPLICATE_API_KEY;
|
||||
|
||||
if (!OPENROUTER_KEY) {
|
||||
throw new Error(
|
||||
"OPENROUTER_KEY or OPENROUTER_API_KEY not found in environment.",
|
||||
);
|
||||
}
|
||||
|
||||
const importDynamic = new Function(
|
||||
"modulePath",
|
||||
"return import(modulePath)",
|
||||
);
|
||||
const { AiBlogPostOrchestrator } = await importDynamic(
|
||||
"@mintel/content-engine",
|
||||
);
|
||||
|
||||
const orchestrator = new AiBlogPostOrchestrator({
|
||||
apiKey: OPENROUTER_KEY,
|
||||
replicateApiKey: REPLICATE_KEY,
|
||||
model: "google/gemini-3-flash-preview",
|
||||
});
|
||||
|
||||
// Fetch context documents purely from DB
|
||||
const contextDocsData = await payload.find({
|
||||
collection: "context-files",
|
||||
limit: 100,
|
||||
});
|
||||
const projectContext = contextDocsData.docs.map((doc) => doc.content);
|
||||
|
||||
const optimizedMarkdown = await orchestrator.optimizeDocument({
|
||||
content: draftContent,
|
||||
projectContext,
|
||||
availableComponents: config.components,
|
||||
instructions,
|
||||
internalLinks: [],
|
||||
customSources,
|
||||
});
|
||||
|
||||
// The orchestrator currently returns Markdown + JSX tags.
|
||||
// We convert this mixed string into a basic Lexical AST map.
|
||||
|
||||
if (!optimizedMarkdown || typeof optimizedMarkdown !== "string") {
|
||||
throw new Error("AI returned invalid markup.");
|
||||
}
|
||||
|
||||
const blocks = parseMarkdownToLexical(optimizedMarkdown);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
lexicalAST: {
|
||||
root: {
|
||||
type: "root",
|
||||
format: "",
|
||||
indent: 0,
|
||||
version: 1,
|
||||
children: blocks,
|
||||
direction: "ltr",
|
||||
},
|
||||
},
|
||||
};
|
||||
} catch (error: any) {
|
||||
console.error("Failed to optimize post:", error);
|
||||
return {
|
||||
success: false,
|
||||
error: error.message || "An unknown error occurred during optimization.",
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -11,12 +11,6 @@ export const ArchitectureBuilderBlock: MintelBlock = {
|
||||
admin: {
|
||||
group: "MDX Components",
|
||||
},
|
||||
ai: {
|
||||
name: "ArchitectureBuilder",
|
||||
description:
|
||||
"Interactive comparison between a standard SaaS rental approach and a custom Built-First (Mintel) architecture. Useful for articles discussing digital ownership, software rent vs. build, or technological assets. Requires no props.",
|
||||
usageExample: "'<ArchitectureBuilder />'",
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: "preset",
|
||||
|
||||
@@ -11,12 +11,6 @@ export const ArticleBlockquoteBlock: MintelBlock = {
|
||||
admin: {
|
||||
group: "MDX Components",
|
||||
},
|
||||
ai: {
|
||||
name: "ArticleBlockquote",
|
||||
description: "Styled blockquote for expert quotes or key statements.",
|
||||
usageExample:
|
||||
"'<ArticleBlockquote>\n Performance ist keine IT-Kennzahl, sondern ein ökonomischer Hebel.\n</ArticleBlockquote>'",
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: "quote",
|
||||
@@ -25,7 +19,7 @@ export const ArticleBlockquoteBlock: MintelBlock = {
|
||||
admin: {
|
||||
components: {
|
||||
afterInput: [
|
||||
"@/src/payload/components/FieldGenerators/AiFieldButton#AiFieldButton",
|
||||
"@mintel/payload-ai/components/AiFieldButton#AiFieldButton",
|
||||
],
|
||||
},
|
||||
description: "Geben Sie den mehrzeiligen Text für quote ein.",
|
||||
@@ -37,7 +31,7 @@ export const ArticleBlockquoteBlock: MintelBlock = {
|
||||
admin: {
|
||||
components: {
|
||||
afterInput: [
|
||||
"@/src/payload/components/FieldGenerators/AiFieldButton#AiFieldButton",
|
||||
"@mintel/payload-ai/components/AiFieldButton#AiFieldButton",
|
||||
],
|
||||
},
|
||||
description: "Geben Sie den Text für author ein.",
|
||||
@@ -49,7 +43,7 @@ export const ArticleBlockquoteBlock: MintelBlock = {
|
||||
admin: {
|
||||
components: {
|
||||
afterInput: [
|
||||
"@/src/payload/components/FieldGenerators/AiFieldButton#AiFieldButton",
|
||||
"@mintel/payload-ai/components/AiFieldButton#AiFieldButton",
|
||||
],
|
||||
},
|
||||
description: "Geben Sie den Text für role ein.",
|
||||
|
||||
@@ -10,13 +10,6 @@ export const ArticleMemeBlock: MintelBlock = {
|
||||
admin: {
|
||||
group: "MDX Components",
|
||||
},
|
||||
ai: {
|
||||
name: "ArticleMeme",
|
||||
description:
|
||||
"Real image-based meme from the media library. Use for static screenshots or custom memes that are not available via memegen.link.",
|
||||
usageExample:
|
||||
'<ArticleMeme image="/media/my-meme.png" alt="Sarcastic dev meme" caption="When the code finally builds." />',
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: "image",
|
||||
@@ -32,7 +25,7 @@ export const ArticleMemeBlock: MintelBlock = {
|
||||
admin: {
|
||||
components: {
|
||||
afterInput: [
|
||||
"@/src/payload/components/FieldGenerators/AiFieldButton#AiFieldButton",
|
||||
"@mintel/payload-ai/components/AiFieldButton#AiFieldButton",
|
||||
],
|
||||
},
|
||||
description: "Geben Sie den Text für alt ein.",
|
||||
@@ -44,7 +37,7 @@ export const ArticleMemeBlock: MintelBlock = {
|
||||
admin: {
|
||||
components: {
|
||||
afterInput: [
|
||||
"@/src/payload/components/FieldGenerators/AiFieldButton#AiFieldButton",
|
||||
"@mintel/payload-ai/components/AiFieldButton#AiFieldButton",
|
||||
],
|
||||
},
|
||||
description: "Geben Sie den Text für caption ein.",
|
||||
|
||||
@@ -26,7 +26,7 @@ export const ArticleQuoteBlock: MintelBlock = {
|
||||
admin: {
|
||||
components: {
|
||||
afterInput: [
|
||||
"@/src/payload/components/FieldGenerators/AiFieldButton#AiFieldButton",
|
||||
"@mintel/payload-ai/components/AiFieldButton#AiFieldButton",
|
||||
],
|
||||
},
|
||||
description: "Geben Sie den mehrzeiligen Text für quote ein.",
|
||||
@@ -39,7 +39,7 @@ export const ArticleQuoteBlock: MintelBlock = {
|
||||
admin: {
|
||||
components: {
|
||||
afterInput: [
|
||||
"@/src/payload/components/FieldGenerators/AiFieldButton#AiFieldButton",
|
||||
"@mintel/payload-ai/components/AiFieldButton#AiFieldButton",
|
||||
],
|
||||
},
|
||||
description: "Geben Sie den Text für author ein.",
|
||||
@@ -51,7 +51,7 @@ export const ArticleQuoteBlock: MintelBlock = {
|
||||
admin: {
|
||||
components: {
|
||||
afterInput: [
|
||||
"@/src/payload/components/FieldGenerators/AiFieldButton#AiFieldButton",
|
||||
"@mintel/payload-ai/components/AiFieldButton#AiFieldButton",
|
||||
],
|
||||
},
|
||||
description: "Geben Sie den Text für role ein.",
|
||||
@@ -63,7 +63,7 @@ export const ArticleQuoteBlock: MintelBlock = {
|
||||
admin: {
|
||||
components: {
|
||||
afterInput: [
|
||||
"@/src/payload/components/FieldGenerators/AiFieldButton#AiFieldButton",
|
||||
"@mintel/payload-ai/components/AiFieldButton#AiFieldButton",
|
||||
],
|
||||
},
|
||||
description: "Geben Sie den Text für source ein.",
|
||||
@@ -75,7 +75,7 @@ export const ArticleQuoteBlock: MintelBlock = {
|
||||
admin: {
|
||||
components: {
|
||||
afterInput: [
|
||||
"@/src/payload/components/FieldGenerators/AiFieldButton#AiFieldButton",
|
||||
"@mintel/payload-ai/components/AiFieldButton#AiFieldButton",
|
||||
],
|
||||
},
|
||||
description: "Geben Sie den Text für sourceUrl ein.",
|
||||
|
||||
@@ -26,7 +26,7 @@ export const BoldNumberBlock: MintelBlock = {
|
||||
description: "e.g. 53% or 2.5M€",
|
||||
components: {
|
||||
afterInput: [
|
||||
"@/src/payload/components/FieldGenerators/AiFieldButton#AiFieldButton",
|
||||
"@mintel/payload-ai/components/AiFieldButton#AiFieldButton",
|
||||
],
|
||||
},
|
||||
},
|
||||
@@ -38,7 +38,7 @@ export const BoldNumberBlock: MintelBlock = {
|
||||
admin: {
|
||||
components: {
|
||||
afterInput: [
|
||||
"@/src/payload/components/FieldGenerators/AiFieldButton#AiFieldButton",
|
||||
"@mintel/payload-ai/components/AiFieldButton#AiFieldButton",
|
||||
],
|
||||
},
|
||||
description: "Geben Sie den Text für label ein.",
|
||||
@@ -50,7 +50,7 @@ export const BoldNumberBlock: MintelBlock = {
|
||||
admin: {
|
||||
components: {
|
||||
afterInput: [
|
||||
"@/src/payload/components/FieldGenerators/AiFieldButton#AiFieldButton",
|
||||
"@mintel/payload-ai/components/AiFieldButton#AiFieldButton",
|
||||
],
|
||||
},
|
||||
description: "Geben Sie den Text für source ein.",
|
||||
@@ -62,7 +62,7 @@ export const BoldNumberBlock: MintelBlock = {
|
||||
admin: {
|
||||
components: {
|
||||
afterInput: [
|
||||
"@/src/payload/components/FieldGenerators/AiFieldButton#AiFieldButton",
|
||||
"@mintel/payload-ai/components/AiFieldButton#AiFieldButton",
|
||||
],
|
||||
},
|
||||
description: "Geben Sie den Text für sourceUrl ein.",
|
||||
|
||||
@@ -11,13 +11,6 @@ export const ButtonBlock: MintelBlock = {
|
||||
admin: {
|
||||
group: "MDX Components",
|
||||
},
|
||||
ai: {
|
||||
name: "Button",
|
||||
description:
|
||||
"DEPRECATED: Use <LeadMagnet /> instead for main CTAs. Only use for small secondary links.",
|
||||
usageExample:
|
||||
'<Button href="/contact" variant="outline">Webprojekt anfragen</Button>',
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: "label",
|
||||
@@ -26,7 +19,7 @@ export const ButtonBlock: MintelBlock = {
|
||||
admin: {
|
||||
components: {
|
||||
afterInput: [
|
||||
"@/src/payload/components/FieldGenerators/AiFieldButton#AiFieldButton",
|
||||
"@mintel/payload-ai/components/AiFieldButton#AiFieldButton",
|
||||
],
|
||||
},
|
||||
description: "Geben Sie den Text für label ein.",
|
||||
|
||||
@@ -11,18 +11,21 @@ export const CarouselBlock: MintelBlock = {
|
||||
admin: {
|
||||
group: "MDX Components",
|
||||
},
|
||||
ai: {
|
||||
name: "Carousel",
|
||||
description:
|
||||
"Interactive swipeable slider for multi-step explanations. IMPORTANT: items array must contain at least 2 items with substantive title and content text (no empty content).",
|
||||
usageExample:
|
||||
'\'<Carousel items={[{ title: "Schritt 1", content: "Analyse der aktuellen Performance..."',
|
||||
},
|
||||
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",
|
||||
@@ -35,7 +38,7 @@ export const CarouselBlock: MintelBlock = {
|
||||
admin: {
|
||||
components: {
|
||||
afterInput: [
|
||||
"@/src/payload/components/FieldGenerators/AiFieldButton#AiFieldButton",
|
||||
"@mintel/payload-ai/components/AiFieldButton#AiFieldButton",
|
||||
],
|
||||
},
|
||||
description: "Geben Sie den Text für caption ein.",
|
||||
|
||||
@@ -31,7 +31,7 @@ export const ComparisonRowBlock: MintelBlock = {
|
||||
description: "Optional overarching description for the comparison.",
|
||||
components: {
|
||||
afterInput: [
|
||||
"@/src/payload/components/FieldGenerators/AiFieldButton#AiFieldButton",
|
||||
"@mintel/payload-ai/components/AiFieldButton#AiFieldButton",
|
||||
],
|
||||
},
|
||||
},
|
||||
@@ -44,7 +44,7 @@ export const ComparisonRowBlock: MintelBlock = {
|
||||
admin: {
|
||||
components: {
|
||||
afterInput: [
|
||||
"@/src/payload/components/FieldGenerators/AiFieldButton#AiFieldButton",
|
||||
"@mintel/payload-ai/components/AiFieldButton#AiFieldButton",
|
||||
],
|
||||
},
|
||||
description: "Geben Sie den Text für negativeLabel ein.",
|
||||
@@ -57,7 +57,7 @@ export const ComparisonRowBlock: MintelBlock = {
|
||||
admin: {
|
||||
components: {
|
||||
afterInput: [
|
||||
"@/src/payload/components/FieldGenerators/AiFieldButton#AiFieldButton",
|
||||
"@mintel/payload-ai/components/AiFieldButton#AiFieldButton",
|
||||
],
|
||||
},
|
||||
description: "Geben Sie den Text für negativeText ein.",
|
||||
@@ -71,7 +71,7 @@ export const ComparisonRowBlock: MintelBlock = {
|
||||
admin: {
|
||||
components: {
|
||||
afterInput: [
|
||||
"@/src/payload/components/FieldGenerators/AiFieldButton#AiFieldButton",
|
||||
"@mintel/payload-ai/components/AiFieldButton#AiFieldButton",
|
||||
],
|
||||
},
|
||||
description: "Geben Sie den Text für positiveLabel ein.",
|
||||
@@ -84,7 +84,7 @@ export const ComparisonRowBlock: MintelBlock = {
|
||||
admin: {
|
||||
components: {
|
||||
afterInput: [
|
||||
"@/src/payload/components/FieldGenerators/AiFieldButton#AiFieldButton",
|
||||
"@mintel/payload-ai/components/AiFieldButton#AiFieldButton",
|
||||
],
|
||||
},
|
||||
description: "Geben Sie den Text für positiveText ein.",
|
||||
|
||||
@@ -11,12 +11,6 @@ export const DiagramFlowBlock: MintelBlock = {
|
||||
admin: {
|
||||
group: "MDX Components",
|
||||
},
|
||||
ai: {
|
||||
name: "DiagramFlow",
|
||||
description:
|
||||
"Mermaid flowchart diagram defining the graph structure. MUST output raw mermaid code, no quotes or HTML.",
|
||||
usageExample: "graph TD\\n A[Start] --> B[End]",
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: "definition",
|
||||
@@ -25,7 +19,7 @@ export const DiagramFlowBlock: MintelBlock = {
|
||||
admin: {
|
||||
components: {
|
||||
afterInput: [
|
||||
"@/src/payload/components/FieldGenerators/AiFieldButton#AiFieldButton",
|
||||
"@mintel/payload-ai/components/AiFieldButton#AiFieldButton",
|
||||
],
|
||||
},
|
||||
description: "Geben Sie den mehrzeiligen Text für definition ein.",
|
||||
|
||||
@@ -11,12 +11,6 @@ export const DiagramGanttBlock: MintelBlock = {
|
||||
admin: {
|
||||
group: "MDX Components",
|
||||
},
|
||||
ai: {
|
||||
name: "DiagramGantt",
|
||||
description: "Mermaid Gantt timeline chart. MUST output raw mermaid code.",
|
||||
usageExample:
|
||||
"gantt\\n title Project Roadmap\\n dateFormat YYYY-MM-DD\\n Section Design\\n Draft UI :a1, 2024-01-01, 7d",
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: "definition",
|
||||
@@ -25,7 +19,7 @@ export const DiagramGanttBlock: MintelBlock = {
|
||||
admin: {
|
||||
components: {
|
||||
afterInput: [
|
||||
"@/src/payload/components/FieldGenerators/AiFieldButton#AiFieldButton",
|
||||
"@mintel/payload-ai/components/AiFieldButton#AiFieldButton",
|
||||
],
|
||||
},
|
||||
description: "Geben Sie den mehrzeiligen Text für definition ein.",
|
||||
|
||||
@@ -11,11 +11,6 @@ export const DiagramPieBlock: MintelBlock = {
|
||||
admin: {
|
||||
group: "MDX Components",
|
||||
},
|
||||
ai: {
|
||||
name: "DiagramPie",
|
||||
description: "Mermaid pie chart diagram. MUST output raw mermaid code.",
|
||||
usageExample: 'pie title Market Share\\n "Chrome" : 60\\n "Safari" : 20',
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: "definition",
|
||||
@@ -24,7 +19,7 @@ export const DiagramPieBlock: MintelBlock = {
|
||||
admin: {
|
||||
components: {
|
||||
afterInput: [
|
||||
"@/src/payload/components/FieldGenerators/AiFieldButton#AiFieldButton",
|
||||
"@mintel/payload-ai/components/AiFieldButton#AiFieldButton",
|
||||
],
|
||||
},
|
||||
description: "Geben Sie den mehrzeiligen Text für definition ein.",
|
||||
|
||||
@@ -11,13 +11,6 @@ export const DiagramSequenceBlock: MintelBlock = {
|
||||
admin: {
|
||||
group: "MDX Components",
|
||||
},
|
||||
ai: {
|
||||
name: "DiagramSequence",
|
||||
description:
|
||||
"Mermaid sequence diagram showing actor interactions. MUST output raw mermaid code.",
|
||||
usageExample:
|
||||
"sequenceDiagram\\n Client->>Server: GET /api\\n Server-->>Client: 200 OK",
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: "definition",
|
||||
@@ -26,7 +19,7 @@ export const DiagramSequenceBlock: MintelBlock = {
|
||||
admin: {
|
||||
components: {
|
||||
afterInput: [
|
||||
"@/src/payload/components/FieldGenerators/AiFieldButton#AiFieldButton",
|
||||
"@mintel/payload-ai/components/AiFieldButton#AiFieldButton",
|
||||
],
|
||||
},
|
||||
description: "Geben Sie den mehrzeiligen Text für definition ein.",
|
||||
|
||||
@@ -11,12 +11,6 @@ export const DiagramStateBlock: MintelBlock = {
|
||||
admin: {
|
||||
group: "MDX Components",
|
||||
},
|
||||
ai: {
|
||||
name: "DiagramState",
|
||||
description:
|
||||
"Mermaid state diagram showing states and transitions. MUST output raw mermaid code.",
|
||||
usageExample: "stateDiagram-v2\\n [*] --> Idle\\n Idle --> Loading",
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: "definition",
|
||||
@@ -25,7 +19,7 @@ export const DiagramStateBlock: MintelBlock = {
|
||||
admin: {
|
||||
components: {
|
||||
afterInput: [
|
||||
"@/src/payload/components/FieldGenerators/AiFieldButton#AiFieldButton",
|
||||
"@mintel/payload-ai/components/AiFieldButton#AiFieldButton",
|
||||
],
|
||||
},
|
||||
description: "Geben Sie den mehrzeiligen Text für definition ein.",
|
||||
|
||||
@@ -11,13 +11,6 @@ export const DiagramTimelineBlock: MintelBlock = {
|
||||
admin: {
|
||||
group: "MDX Components",
|
||||
},
|
||||
ai: {
|
||||
name: "DiagramTimeline",
|
||||
description:
|
||||
"Mermaid timeline or journey diagram. MUST output raw mermaid code.",
|
||||
usageExample:
|
||||
"timeline\\n title Project Timeline\\n 2024\\n : Q1 : Planning\\n : Q2 : Execution",
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: "definition",
|
||||
@@ -26,7 +19,7 @@ export const DiagramTimelineBlock: MintelBlock = {
|
||||
admin: {
|
||||
components: {
|
||||
afterInput: [
|
||||
"@/src/payload/components/FieldGenerators/AiFieldButton#AiFieldButton",
|
||||
"@mintel/payload-ai/components/AiFieldButton#AiFieldButton",
|
||||
],
|
||||
},
|
||||
description: "Geben Sie den mehrzeiligen Text für definition ein.",
|
||||
|
||||
@@ -11,12 +11,6 @@ export const DigitalAssetVisualizerBlock: MintelBlock = {
|
||||
admin: {
|
||||
group: "MDX Components",
|
||||
},
|
||||
ai: {
|
||||
name: "DigitalAssetVisualizer",
|
||||
description:
|
||||
"Interactive visualization illustrating the financial difference between software as a liability (SaaS/rent) and software as a digital asset (Custom IP). Great for articles concerning CTO strategies, business value of code, and digital independence. Requires no props.",
|
||||
usageExample: "'<DigitalAssetVisualizer />'",
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: "assetId",
|
||||
|
||||
@@ -32,7 +32,7 @@ export const ExternalLinkBlock: MintelBlock = {
|
||||
admin: {
|
||||
components: {
|
||||
afterInput: [
|
||||
"@/src/payload/components/FieldGenerators/AiFieldButton#AiFieldButton",
|
||||
"@mintel/payload-ai/components/AiFieldButton#AiFieldButton",
|
||||
],
|
||||
},
|
||||
description: "Geben Sie den Text für label ein.",
|
||||
|
||||
@@ -16,13 +16,6 @@ export const FAQSectionBlock: MintelBlock = {
|
||||
admin: {
|
||||
group: "MDX Components",
|
||||
},
|
||||
ai: {
|
||||
name: "FAQSection",
|
||||
description:
|
||||
"Semantic wrapper for FAQ questions at the end of the article. Put standard Markdown H3/Paragraphs inside.",
|
||||
usageExample:
|
||||
"'<FAQSection>\n <H3>Frage 1</H3>\n <Paragraph>Antwort 1</Paragraph>\n</FAQSection>'",
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: "content",
|
||||
|
||||
@@ -15,7 +15,7 @@ export const H2Block: MintelBlock = {
|
||||
description: "Geben Sie den Text für die H2-Überschrift ein.",
|
||||
components: {
|
||||
afterInput: [
|
||||
"@/src/payload/components/FieldGenerators/AiFieldButton#AiFieldButton",
|
||||
"@mintel/payload-ai/components/AiFieldButton#AiFieldButton",
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
@@ -15,7 +15,7 @@ export const H3Block: MintelBlock = {
|
||||
description: "Geben Sie den Text für die H3-Überschrift ein.",
|
||||
components: {
|
||||
afterInput: [
|
||||
"@/src/payload/components/FieldGenerators/AiFieldButton#AiFieldButton",
|
||||
"@mintel/payload-ai/components/AiFieldButton#AiFieldButton",
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
@@ -25,7 +25,7 @@ export const HeadingBlock: MintelBlock = {
|
||||
description: "Der Text der Überschrift.",
|
||||
components: {
|
||||
afterInput: [
|
||||
"@/src/payload/components/FieldGenerators/AiFieldButton#AiFieldButton",
|
||||
"@mintel/payload-ai/components/AiFieldButton#AiFieldButton",
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
@@ -44,7 +44,7 @@ export const IconListBlock: MintelBlock = {
|
||||
admin: {
|
||||
components: {
|
||||
afterInput: [
|
||||
"@/src/payload/components/FieldGenerators/AiFieldButton#AiFieldButton",
|
||||
"@mintel/payload-ai/components/AiFieldButton#AiFieldButton",
|
||||
],
|
||||
},
|
||||
description: "Geben Sie den Text für title ein.",
|
||||
@@ -56,7 +56,7 @@ export const IconListBlock: MintelBlock = {
|
||||
admin: {
|
||||
components: {
|
||||
afterInput: [
|
||||
"@/src/payload/components/FieldGenerators/AiFieldButton#AiFieldButton",
|
||||
"@mintel/payload-ai/components/AiFieldButton#AiFieldButton",
|
||||
],
|
||||
},
|
||||
description: "Geben Sie den mehrzeiligen Text für description ein.",
|
||||
|
||||
@@ -11,12 +11,6 @@ export const ImageTextBlock: MintelBlock = {
|
||||
admin: {
|
||||
group: "MDX Components",
|
||||
},
|
||||
ai: {
|
||||
name: "ImageText",
|
||||
description: "Layout component for image next to explanatory text.",
|
||||
usageExample:
|
||||
'\'<ImageText image="/img.jpg" title="Architektur">Erklärung...</ImageText>\'',
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: "image",
|
||||
@@ -32,7 +26,7 @@ export const ImageTextBlock: MintelBlock = {
|
||||
admin: {
|
||||
components: {
|
||||
afterInput: [
|
||||
"@/src/payload/components/FieldGenerators/AiFieldButton#AiFieldButton",
|
||||
"@mintel/payload-ai/components/AiFieldButton#AiFieldButton",
|
||||
],
|
||||
},
|
||||
description: "Geben Sie den mehrzeiligen Text für text ein.",
|
||||
|
||||
@@ -27,7 +27,7 @@ export const LeadMagnetBlock: MintelBlock = {
|
||||
description: "The strong headline for the Call-to-Action",
|
||||
components: {
|
||||
afterInput: [
|
||||
"@/src/payload/components/FieldGenerators/AiFieldButton#AiFieldButton",
|
||||
"@mintel/payload-ai/components/AiFieldButton#AiFieldButton",
|
||||
],
|
||||
},
|
||||
},
|
||||
@@ -40,7 +40,7 @@ export const LeadMagnetBlock: MintelBlock = {
|
||||
description: "The value proposition text.",
|
||||
components: {
|
||||
afterInput: [
|
||||
"@/src/payload/components/FieldGenerators/AiFieldButton#AiFieldButton",
|
||||
"@mintel/payload-ai/components/AiFieldButton#AiFieldButton",
|
||||
],
|
||||
},
|
||||
},
|
||||
@@ -53,7 +53,7 @@ export const LeadMagnetBlock: MintelBlock = {
|
||||
admin: {
|
||||
components: {
|
||||
afterInput: [
|
||||
"@/src/payload/components/FieldGenerators/AiFieldButton#AiFieldButton",
|
||||
"@mintel/payload-ai/components/AiFieldButton#AiFieldButton",
|
||||
],
|
||||
},
|
||||
description: "Geben Sie den Text für buttonText ein.",
|
||||
|
||||
@@ -26,7 +26,7 @@ export const LeadParagraphBlock: MintelBlock = {
|
||||
admin: {
|
||||
components: {
|
||||
afterInput: [
|
||||
"@/src/payload/components/FieldGenerators/AiFieldButton#AiFieldButton",
|
||||
"@mintel/payload-ai/components/AiFieldButton#AiFieldButton",
|
||||
],
|
||||
},
|
||||
description: "Geben Sie den mehrzeiligen Text für text ein.",
|
||||
|
||||
@@ -11,13 +11,6 @@ export const LinkedInEmbedBlock: MintelBlock = {
|
||||
admin: {
|
||||
group: "MDX Components",
|
||||
},
|
||||
ai: {
|
||||
name: "LinkedInEmbed",
|
||||
description:
|
||||
"Embeds a professional post from LinkedIn. Use the activity URN (e.g. urn:li:activity:1234567890).",
|
||||
usageExample:
|
||||
"'<LinkedInEmbed urn=\"urn:li:activity:7153664326573674496\" />'",
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: "url",
|
||||
|
||||
@@ -11,12 +11,6 @@ export const LoadTimeSimulatorBlock: MintelBlock = {
|
||||
admin: {
|
||||
group: "MDX Components",
|
||||
},
|
||||
ai: {
|
||||
name: "LoadTimeSimulator",
|
||||
description:
|
||||
"Interactive visual race simulating the loading experience of a slow legacy CMS vs a fast headless stack. Great for articles discussing load times, technical debt, or user frustration. Requires no props.",
|
||||
usageExample: "'<LoadTimeSimulator />'",
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: "initialLoadTime",
|
||||
|
||||
@@ -11,12 +11,6 @@ export const MarkerBlock: MintelBlock = {
|
||||
admin: {
|
||||
group: "MDX Components",
|
||||
},
|
||||
ai: {
|
||||
name: "Marker",
|
||||
description:
|
||||
"Inline highlight (yellow marker effect) for emphasizing key phrases within paragraphs.",
|
||||
usageExample: "'<Marker>entscheidender Wettbewerbsvorteil</Marker>'",
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: "text",
|
||||
@@ -25,7 +19,7 @@ export const MarkerBlock: MintelBlock = {
|
||||
admin: {
|
||||
components: {
|
||||
afterInput: [
|
||||
"@/src/payload/components/FieldGenerators/AiFieldButton#AiFieldButton",
|
||||
"@mintel/payload-ai/components/AiFieldButton#AiFieldButton",
|
||||
],
|
||||
},
|
||||
description: "Geben Sie den Text für text ein.",
|
||||
|
||||
@@ -13,7 +13,7 @@ export const MemeCardBlock: MintelBlock = {
|
||||
ai: {
|
||||
name: "MemeCard",
|
||||
description:
|
||||
'Real meme image from memegen.link. template must be a valid memegen.link ID. IMPORTANT: Captions must be EXTREMELY SARCASTIC and PUNCHY (mocking bad B2B agencies, max 6 words per line). Best templates: drake (2-line prefer/dislike), gru (4-step plan backfire), disastergirl (burning house), fine (this is fine dog). Use German captions. Wrap in div with className="my-8".',
|
||||
"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>`,
|
||||
@@ -28,7 +28,7 @@ export const MemeCardBlock: MintelBlock = {
|
||||
"The template ID from memegen.link (e.g. 'drake', 'disastergirl')",
|
||||
components: {
|
||||
afterInput: [
|
||||
"@/src/payload/components/FieldGenerators/AiFieldButton#AiFieldButton",
|
||||
"@mintel/payload-ai/components/AiFieldButton#AiFieldButton",
|
||||
],
|
||||
},
|
||||
},
|
||||
@@ -42,7 +42,7 @@ export const MemeCardBlock: MintelBlock = {
|
||||
"Pipe-separated captions for the meme (e.g. 'Legacy Code|Mintel Stack'). Maximum 6 words per line.",
|
||||
components: {
|
||||
afterInput: [
|
||||
"@/src/payload/components/FieldGenerators/AiFieldButton#AiFieldButton",
|
||||
"@mintel/payload-ai/components/AiFieldButton#AiFieldButton",
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
@@ -36,7 +36,7 @@ export const MermaidBlock: MintelBlock = {
|
||||
description: "Optional title displayed above the diagram.",
|
||||
components: {
|
||||
afterInput: [
|
||||
"@/src/payload/components/FieldGenerators/AiFieldButton#AiFieldButton",
|
||||
"@mintel/payload-ai/components/AiFieldButton#AiFieldButton",
|
||||
],
|
||||
},
|
||||
},
|
||||
@@ -59,7 +59,7 @@ export const MermaidBlock: MintelBlock = {
|
||||
"The raw Mermaid.js syntax (e.g. graph TD... shadowing, loops, etc.).",
|
||||
components: {
|
||||
afterInput: [
|
||||
"@/src/payload/components/FieldGenerators/AiFieldButton#AiFieldButton",
|
||||
"@mintel/payload-ai/components/AiFieldButton#AiFieldButton",
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
@@ -11,12 +11,6 @@ export const MetricBarBlock: MintelBlock = {
|
||||
admin: {
|
||||
group: "MDX Components",
|
||||
},
|
||||
ai: {
|
||||
name: "MetricBar",
|
||||
description:
|
||||
"Animated horizontal progress bar. Use multiple in sequence to compare metrics. IMPORTANT: value MUST be a real number > 0, never use 0 or placeholder values. Props: label, value (number), max (default 100), unit (default %), color (red|green|blue|slate).",
|
||||
usageExample: '<MetricBar label="WordPress Sites" value={33',
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: "label",
|
||||
@@ -25,7 +19,7 @@ export const MetricBarBlock: MintelBlock = {
|
||||
admin: {
|
||||
components: {
|
||||
afterInput: [
|
||||
"@/src/payload/components/FieldGenerators/AiFieldButton#AiFieldButton",
|
||||
"@mintel/payload-ai/components/AiFieldButton#AiFieldButton",
|
||||
],
|
||||
},
|
||||
description: "Geben Sie den Text für label ein.",
|
||||
@@ -50,7 +44,7 @@ export const MetricBarBlock: MintelBlock = {
|
||||
admin: {
|
||||
components: {
|
||||
afterInput: [
|
||||
"@/src/payload/components/FieldGenerators/AiFieldButton#AiFieldButton",
|
||||
"@mintel/payload-ai/components/AiFieldButton#AiFieldButton",
|
||||
],
|
||||
},
|
||||
description: "Geben Sie den Text für unit ein.",
|
||||
|
||||
@@ -26,7 +26,7 @@ export const ParagraphBlock: MintelBlock = {
|
||||
admin: {
|
||||
components: {
|
||||
afterInput: [
|
||||
"@/src/payload/components/FieldGenerators/AiFieldButton#AiFieldButton",
|
||||
"@mintel/payload-ai/components/AiFieldButton#AiFieldButton",
|
||||
],
|
||||
},
|
||||
description: "Geben Sie den mehrzeiligen Text für text ein.",
|
||||
|
||||
@@ -11,13 +11,6 @@ export const PerformanceChartBlock: MintelBlock = {
|
||||
admin: {
|
||||
group: "MDX Components",
|
||||
},
|
||||
ai: {
|
||||
name: "PerformanceChart",
|
||||
description:
|
||||
"A visual chart illustrating performance metrics (e.g. PageSpeed, TTFB) over time or in comparison. Use to emphasize technical improvements.",
|
||||
usageExample:
|
||||
'<PerformanceChart items={[{ label: "Vorher", value: 12 }, { label: "Nachher", value: 98 }]} />',
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: "title",
|
||||
@@ -26,7 +19,7 @@ export const PerformanceChartBlock: MintelBlock = {
|
||||
admin: {
|
||||
components: {
|
||||
afterInput: [
|
||||
"@/src/payload/components/FieldGenerators/AiFieldButton#AiFieldButton",
|
||||
"@mintel/payload-ai/components/AiFieldButton#AiFieldButton",
|
||||
],
|
||||
},
|
||||
description: "Geben Sie den Text für title ein.",
|
||||
|
||||
@@ -11,12 +11,6 @@ export const PerformanceROICalculatorBlock: MintelBlock = {
|
||||
admin: {
|
||||
group: "MDX Components",
|
||||
},
|
||||
ai: {
|
||||
name: "PerformanceROICalculator",
|
||||
description:
|
||||
"Interactive simulation calculator showing the monetary ROI of improving load times (based on Deloitte B2B metrics). Use exactly once in performance-related articles to provide a highly engaging simulation. Requires no props.",
|
||||
usageExample: "'<PerformanceROICalculator />'",
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: "baseConversionRate",
|
||||
|
||||
@@ -11,13 +11,6 @@ export const PremiumComparisonChartBlock: MintelBlock = {
|
||||
admin: {
|
||||
group: "MDX Components",
|
||||
},
|
||||
ai: {
|
||||
name: "PremiumComparisonChart",
|
||||
description:
|
||||
"Advanced chart for comparing performance metrics with industrial aesthetics.",
|
||||
usageExample:
|
||||
'\'<PremiumComparisonChart title="TTFB Vergleich" items={[{ label: "Alt", value: 800, max: 1000, color: "red"',
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: "title",
|
||||
@@ -25,7 +18,7 @@ export const PremiumComparisonChartBlock: MintelBlock = {
|
||||
admin: {
|
||||
components: {
|
||||
afterInput: [
|
||||
"@/src/payload/components/FieldGenerators/AiFieldButton#AiFieldButton",
|
||||
"@mintel/payload-ai/components/AiFieldButton#AiFieldButton",
|
||||
],
|
||||
},
|
||||
description: "Geben Sie den Text für title ein.",
|
||||
@@ -37,7 +30,7 @@ export const PremiumComparisonChartBlock: MintelBlock = {
|
||||
admin: {
|
||||
components: {
|
||||
afterInput: [
|
||||
"@/src/payload/components/FieldGenerators/AiFieldButton#AiFieldButton",
|
||||
"@mintel/payload-ai/components/AiFieldButton#AiFieldButton",
|
||||
],
|
||||
},
|
||||
description: "Geben Sie den Text für subtitle ein.",
|
||||
@@ -54,7 +47,7 @@ export const PremiumComparisonChartBlock: MintelBlock = {
|
||||
admin: {
|
||||
components: {
|
||||
afterInput: [
|
||||
"@/src/payload/components/FieldGenerators/AiFieldButton#AiFieldButton",
|
||||
"@mintel/payload-ai/components/AiFieldButton#AiFieldButton",
|
||||
],
|
||||
},
|
||||
description: "Geben Sie den Text für label ein.",
|
||||
@@ -82,7 +75,7 @@ export const PremiumComparisonChartBlock: MintelBlock = {
|
||||
admin: {
|
||||
components: {
|
||||
afterInput: [
|
||||
"@/src/payload/components/FieldGenerators/AiFieldButton#AiFieldButton",
|
||||
"@mintel/payload-ai/components/AiFieldButton#AiFieldButton",
|
||||
],
|
||||
},
|
||||
description: "Geben Sie den Text für unit ein.",
|
||||
@@ -102,7 +95,7 @@ export const PremiumComparisonChartBlock: MintelBlock = {
|
||||
admin: {
|
||||
components: {
|
||||
afterInput: [
|
||||
"@/src/payload/components/FieldGenerators/AiFieldButton#AiFieldButton",
|
||||
"@mintel/payload-ai/components/AiFieldButton#AiFieldButton",
|
||||
],
|
||||
},
|
||||
description: "Geben Sie den Text für description ein.",
|
||||
|
||||
@@ -12,13 +12,6 @@ export const RevealBlock: MintelBlock = {
|
||||
admin: {
|
||||
group: "MDX Components",
|
||||
},
|
||||
ai: {
|
||||
name: "Reveal",
|
||||
description:
|
||||
"Scroll-triggered reveal animation wrapper. Wrap any content to animate on scroll.",
|
||||
usageExample:
|
||||
'\'<Reveal>\n <StatsDisplay value="100" label="PageSpeed Score" />\n</Reveal>\'',
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: "direction",
|
||||
|
||||
@@ -11,12 +11,6 @@ export const RevenueLossCalculatorBlock: MintelBlock = {
|
||||
admin: {
|
||||
group: "MDX Components",
|
||||
},
|
||||
ai: {
|
||||
name: "RevenueLossCalculator",
|
||||
description:
|
||||
"Interactive calculator that estimates financial loss due to slow page load times. Use to build a business case for performance optimization.",
|
||||
usageExample: "<RevenueLossCalculator />",
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: "title",
|
||||
@@ -25,7 +19,7 @@ export const RevenueLossCalculatorBlock: MintelBlock = {
|
||||
admin: {
|
||||
components: {
|
||||
afterInput: [
|
||||
"@/src/payload/components/FieldGenerators/AiFieldButton#AiFieldButton",
|
||||
"@mintel/payload-ai/components/AiFieldButton#AiFieldButton",
|
||||
],
|
||||
},
|
||||
description: "Geben Sie den Text für title ein.",
|
||||
|
||||
@@ -12,12 +12,6 @@ export const SectionBlock: MintelBlock = {
|
||||
admin: {
|
||||
group: "MDX Components",
|
||||
},
|
||||
ai: {
|
||||
name: "Section",
|
||||
description: "Wraps a thematic section block with optional heading.",
|
||||
usageExample:
|
||||
"'<Section>\n <h3>Section Title</h3>\n <p>Content here.</p>\n</Section>'",
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: "title",
|
||||
@@ -25,7 +19,7 @@ export const SectionBlock: MintelBlock = {
|
||||
admin: {
|
||||
components: {
|
||||
afterInput: [
|
||||
"@/src/payload/components/FieldGenerators/AiFieldButton#AiFieldButton",
|
||||
"@mintel/payload-ai/components/AiFieldButton#AiFieldButton",
|
||||
],
|
||||
},
|
||||
description: "Geben Sie den Text für title ein.",
|
||||
|
||||
@@ -11,13 +11,6 @@ export const StatsDisplayBlock: MintelBlock = {
|
||||
admin: {
|
||||
group: "MDX Components",
|
||||
},
|
||||
ai: {
|
||||
name: "StatsDisplay",
|
||||
description:
|
||||
"A single large stat card with prominent value, label, and optional subtext.",
|
||||
usageExample:
|
||||
'\'<StatsDisplay value="-20%" label="Conversion" subtext="Jede Sekunde Verzögerung kostet." />\'',
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: "label",
|
||||
@@ -25,7 +18,7 @@ export const StatsDisplayBlock: MintelBlock = {
|
||||
admin: {
|
||||
components: {
|
||||
afterInput: [
|
||||
"@/src/payload/components/FieldGenerators/AiFieldButton#AiFieldButton",
|
||||
"@mintel/payload-ai/components/AiFieldButton#AiFieldButton",
|
||||
],
|
||||
},
|
||||
description: "Geben Sie den Text für label ein.",
|
||||
@@ -38,7 +31,7 @@ export const StatsDisplayBlock: MintelBlock = {
|
||||
admin: {
|
||||
components: {
|
||||
afterInput: [
|
||||
"@/src/payload/components/FieldGenerators/AiFieldButton#AiFieldButton",
|
||||
"@mintel/payload-ai/components/AiFieldButton#AiFieldButton",
|
||||
],
|
||||
},
|
||||
description: "Geben Sie den Text für value ein.",
|
||||
@@ -50,7 +43,7 @@ export const StatsDisplayBlock: MintelBlock = {
|
||||
admin: {
|
||||
components: {
|
||||
afterInput: [
|
||||
"@/src/payload/components/FieldGenerators/AiFieldButton#AiFieldButton",
|
||||
"@mintel/payload-ai/components/AiFieldButton#AiFieldButton",
|
||||
],
|
||||
},
|
||||
description: "Geben Sie den Text für subtext ein.",
|
||||
|
||||
@@ -11,13 +11,6 @@ export const StatsGridBlock: MintelBlock = {
|
||||
admin: {
|
||||
group: "MDX Components",
|
||||
},
|
||||
ai: {
|
||||
name: "StatsGrid",
|
||||
description:
|
||||
"Grid of 2–4 stat cards in a row. Use tilde (~) to separate stats, pipe (|) to separate value|label|subtext within each stat.",
|
||||
usageExample:
|
||||
"'<StatsGrid stats=\"53%|Mehr Umsatz|Rakuten 24~33%|Conversion Boost|nach CWV Fix~24%|Top 3 Ranking|bei bestandenen CWV\" />'",
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: "stats",
|
||||
@@ -30,7 +23,7 @@ export const StatsGridBlock: MintelBlock = {
|
||||
admin: {
|
||||
components: {
|
||||
afterInput: [
|
||||
"@/src/payload/components/FieldGenerators/AiFieldButton#AiFieldButton",
|
||||
"@mintel/payload-ai/components/AiFieldButton#AiFieldButton",
|
||||
],
|
||||
},
|
||||
description: "Geben Sie den Text für label ein.",
|
||||
@@ -43,7 +36,7 @@ export const StatsGridBlock: MintelBlock = {
|
||||
admin: {
|
||||
components: {
|
||||
afterInput: [
|
||||
"@/src/payload/components/FieldGenerators/AiFieldButton#AiFieldButton",
|
||||
"@mintel/payload-ai/components/AiFieldButton#AiFieldButton",
|
||||
],
|
||||
},
|
||||
description: "Geben Sie den Text für value ein.",
|
||||
|
||||
@@ -26,7 +26,7 @@ export const TLDRBlock: MintelBlock = {
|
||||
description: "The summary content for the TLDR box.",
|
||||
components: {
|
||||
afterInput: [
|
||||
"@/src/payload/components/FieldGenerators/AiFieldButton#AiFieldButton",
|
||||
"@mintel/payload-ai/components/AiFieldButton#AiFieldButton",
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
@@ -11,13 +11,6 @@ export const TrackedLinkBlock: MintelBlock = {
|
||||
admin: {
|
||||
group: "MDX Components",
|
||||
},
|
||||
ai: {
|
||||
name: "TrackedLink",
|
||||
description:
|
||||
"A wrapper around next/link that tracks clicks. Use for all INTERNAL navigational links that should be tracked.",
|
||||
usageExample:
|
||||
'\'<TrackedLink href="/contact" className="text-blue-600 font-bold">Jetzt anfragen</TrackedLink>\'',
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: "href",
|
||||
@@ -32,7 +25,7 @@ export const TrackedLinkBlock: MintelBlock = {
|
||||
admin: {
|
||||
components: {
|
||||
afterInput: [
|
||||
"@/src/payload/components/FieldGenerators/AiFieldButton#AiFieldButton",
|
||||
"@mintel/payload-ai/components/AiFieldButton#AiFieldButton",
|
||||
],
|
||||
},
|
||||
description: "Geben Sie den Text für label ein.",
|
||||
|
||||
@@ -11,13 +11,6 @@ export const TwitterEmbedBlock: MintelBlock = {
|
||||
admin: {
|
||||
group: "MDX Components",
|
||||
},
|
||||
ai: {
|
||||
name: "TwitterEmbed",
|
||||
description:
|
||||
"Embeds a post from X.com (Twitter). Used to provide social proof, industry quotes, or examples. Provide the numerical tweetId.",
|
||||
usageExample:
|
||||
'\'<TwitterEmbed tweetId="1753464161943834945" theme="light" />\'',
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: "url",
|
||||
|
||||
@@ -11,15 +11,6 @@ export const WaterfallChartBlock: MintelBlock = {
|
||||
admin: {
|
||||
group: "MDX Components",
|
||||
},
|
||||
ai: {
|
||||
name: "WaterfallChart",
|
||||
description:
|
||||
"A timeline visualization of network requests (waterfall). Use to show loading sequences or bottlenecks. Labels auto-color coded by type (JS, HTML, IMG).",
|
||||
usageExample: `<WaterfallChart
|
||||
title="Initial Load"
|
||||
events={[
|
||||
{ name: "Document", start: 0, duration: 150`,
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: "title",
|
||||
@@ -27,7 +18,7 @@ export const WaterfallChartBlock: MintelBlock = {
|
||||
admin: {
|
||||
components: {
|
||||
afterInput: [
|
||||
"@/src/payload/components/FieldGenerators/AiFieldButton#AiFieldButton",
|
||||
"@mintel/payload-ai/components/AiFieldButton#AiFieldButton",
|
||||
],
|
||||
},
|
||||
description: "Geben Sie den Text für title ein.",
|
||||
@@ -44,7 +35,7 @@ export const WaterfallChartBlock: MintelBlock = {
|
||||
admin: {
|
||||
components: {
|
||||
afterInput: [
|
||||
"@/src/payload/components/FieldGenerators/AiFieldButton#AiFieldButton",
|
||||
"@mintel/payload-ai/components/AiFieldButton#AiFieldButton",
|
||||
],
|
||||
},
|
||||
description: "Geben Sie den Text für label ein.",
|
||||
|
||||
@@ -11,12 +11,6 @@ export const WebVitalsScoreBlock: MintelBlock = {
|
||||
admin: {
|
||||
group: "MDX Components",
|
||||
},
|
||||
ai: {
|
||||
name: "WebVitalsScore",
|
||||
description:
|
||||
"Displays Core Web Vitals (LCP, INP, CLS) in a premium card layout with automatic traffic light coloring (Good/Needs Improvement/Poor). Use for performance audits or comparisons.",
|
||||
usageExample: "'<WebVitalsScore values={{ lcp: 2.5, inp: 200, cls: 0.1",
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: "lcp",
|
||||
@@ -42,7 +36,7 @@ export const WebVitalsScoreBlock: MintelBlock = {
|
||||
admin: {
|
||||
components: {
|
||||
afterInput: [
|
||||
"@/src/payload/components/FieldGenerators/AiFieldButton#AiFieldButton",
|
||||
"@mintel/payload-ai/components/AiFieldButton#AiFieldButton",
|
||||
],
|
||||
},
|
||||
description: "Geben Sie den Text für description ein.",
|
||||
|
||||
@@ -31,7 +31,7 @@ export const YouTubeEmbedBlock: MintelBlock = {
|
||||
admin: {
|
||||
components: {
|
||||
afterInput: [
|
||||
"@/src/payload/components/FieldGenerators/AiFieldButton#AiFieldButton",
|
||||
"@mintel/payload-ai/components/AiFieldButton#AiFieldButton",
|
||||
],
|
||||
},
|
||||
description: "Geben Sie den Text für title ein.",
|
||||
|
||||
@@ -93,6 +93,40 @@ export const CrmAccounts: CollectionConfig = {
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "row",
|
||||
fields: [
|
||||
{
|
||||
name: "industry",
|
||||
type: "text",
|
||||
admin: {
|
||||
width: "50%",
|
||||
description: "Industry or category of this account (e.g. Messebauer, Handwerk).",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "websiteStatus",
|
||||
type: "select",
|
||||
options: [
|
||||
{ label: "🟢 Good", value: "gut" },
|
||||
{ label: "🟡 OK / Average", value: "ok" },
|
||||
{ label: "🔴 Bad / Old", value: "schlecht" },
|
||||
{ label: "❓ Unknown", value: "unknown" },
|
||||
],
|
||||
admin: {
|
||||
width: "50%",
|
||||
description: "Quality assessment of their current website.",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "notes",
|
||||
type: "textarea",
|
||||
admin: {
|
||||
description: "Internal notes and research findings for this account.",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "assignedTo",
|
||||
type: "relationship",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { CollectionConfig } from "payload";
|
||||
import path from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
import { replicateMediaHandler } from "@mintel/payload-ai/endpoints/replicateMediaEndpoint.js";
|
||||
|
||||
const filename = fileURLToPath(import.meta.url);
|
||||
const dirname = path.dirname(filename);
|
||||
@@ -14,6 +15,13 @@ export const Media: CollectionConfig = {
|
||||
access: {
|
||||
read: () => true, // Publicly readable
|
||||
},
|
||||
endpoints: [
|
||||
{
|
||||
path: "/:id/ai-process",
|
||||
method: "post",
|
||||
handler: replicateMediaHandler as any,
|
||||
},
|
||||
],
|
||||
upload: {
|
||||
staticDir: path.resolve(dirname, "../../../../public/media"),
|
||||
adminThumbnail: "thumbnail",
|
||||
@@ -39,6 +47,15 @@ export const Media: CollectionConfig = {
|
||||
],
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: "aiProcessButtons",
|
||||
type: "ui",
|
||||
admin: {
|
||||
components: {
|
||||
Field: "@mintel/payload-ai/components/AiMediaButtons#AiMediaButtons",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "alt",
|
||||
type: "text",
|
||||
|
||||
@@ -21,7 +21,7 @@ export const Posts: CollectionConfig = {
|
||||
admin: {
|
||||
position: "sidebar",
|
||||
components: {
|
||||
Field: "@/src/payload/components/OptimizeButton#OptimizeButton",
|
||||
Field: "@mintel/payload-ai/components/OptimizeButton#OptimizeButton",
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -39,7 +39,7 @@ export const Posts: CollectionConfig = {
|
||||
position: "sidebar",
|
||||
components: {
|
||||
afterInput: [
|
||||
"@/src/payload/components/FieldGenerators/GenerateSlugButton#GenerateSlugButton",
|
||||
"@mintel/payload-ai/components/GenerateSlugButton#GenerateSlugButton",
|
||||
],
|
||||
},
|
||||
},
|
||||
@@ -100,7 +100,7 @@ export const Posts: CollectionConfig = {
|
||||
position: "sidebar",
|
||||
components: {
|
||||
afterInput: [
|
||||
"@/src/payload/components/FieldGenerators/GenerateThumbnailButton#GenerateThumbnailButton",
|
||||
"@mintel/payload-ai/components/GenerateThumbnailButton#GenerateThumbnailButton",
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1,136 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState } from "react";
|
||||
import { useField, useDocumentInfo, useForm } from "@payloadcms/ui";
|
||||
import { generateSingleFieldAction } from "../../actions/generateField";
|
||||
|
||||
export function AiFieldButton({ path, field }: { path: string; field: any }) {
|
||||
const [isGenerating, setIsGenerating] = useState(false);
|
||||
const [instructions, setInstructions] = useState("");
|
||||
const [showInstructions, setShowInstructions] = useState(false);
|
||||
|
||||
// Payload hooks
|
||||
const { value, setValue } = useField<string>({ path });
|
||||
const { title } = useDocumentInfo();
|
||||
const { fields } = useForm();
|
||||
|
||||
const extractText = (lexicalRoot: any): string => {
|
||||
if (!lexicalRoot) return "";
|
||||
let text = "";
|
||||
const iterate = (node: any) => {
|
||||
if (node.text) text += node.text + " ";
|
||||
if (node.children) node.children.forEach(iterate);
|
||||
};
|
||||
iterate(lexicalRoot);
|
||||
return text;
|
||||
};
|
||||
|
||||
const handleGenerate = async (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
const lexicalValue = fields?.content?.value as any;
|
||||
const legacyValue = fields?.legacyMdx?.value as string;
|
||||
let draftContent = legacyValue || "";
|
||||
if (!draftContent && lexicalValue?.root) {
|
||||
draftContent = extractText(lexicalValue.root);
|
||||
}
|
||||
|
||||
setIsGenerating(true);
|
||||
try {
|
||||
// Field name is passed as a label usually, fallback to path
|
||||
const fieldName = typeof field?.label === "string" ? field.label : path;
|
||||
const fieldDescription =
|
||||
typeof field?.admin?.description === "string"
|
||||
? field.admin.description
|
||||
: "";
|
||||
|
||||
const res = await generateSingleFieldAction(
|
||||
(title as string) || "",
|
||||
draftContent,
|
||||
fieldName,
|
||||
fieldDescription,
|
||||
instructions,
|
||||
);
|
||||
if (res.success && res.text) {
|
||||
setValue(res.text);
|
||||
} else {
|
||||
alert("Fehler: " + res.error);
|
||||
}
|
||||
} catch (e) {
|
||||
alert("Fehler bei der Generierung.");
|
||||
} finally {
|
||||
setIsGenerating(false);
|
||||
setShowInstructions(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
marginTop: "8px",
|
||||
marginBottom: "8px",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "8px",
|
||||
}}
|
||||
>
|
||||
<div style={{ display: "flex", gap: "8px", alignItems: "center" }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleGenerate}
|
||||
disabled={isGenerating}
|
||||
style={{
|
||||
background: "var(--theme-elevation-150)",
|
||||
border: "1px solid var(--theme-elevation-200)",
|
||||
color: "var(--theme-text)",
|
||||
padding: "4px 12px",
|
||||
borderRadius: "4px",
|
||||
fontSize: "12px",
|
||||
cursor: isGenerating ? "not-allowed" : "pointer",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "6px",
|
||||
opacity: isGenerating ? 0.6 : 1,
|
||||
}}
|
||||
>
|
||||
{isGenerating ? "✨ AI arbeitet..." : "✨ AI Ausfüllen"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
setShowInstructions(!showInstructions);
|
||||
}}
|
||||
style={{
|
||||
background: "transparent",
|
||||
border: "none",
|
||||
color: "var(--theme-elevation-500)",
|
||||
fontSize: "12px",
|
||||
cursor: "pointer",
|
||||
textDecoration: "underline",
|
||||
}}
|
||||
>
|
||||
{showInstructions ? "Prompt verbergen" : "Mit Prompt..."}
|
||||
</button>
|
||||
</div>
|
||||
{showInstructions && (
|
||||
<textarea
|
||||
value={instructions}
|
||||
onChange={(e) => setInstructions(e.target.value)}
|
||||
placeholder="Eigene Anweisung an AI (z.B. 'als catchy slogan')"
|
||||
disabled={isGenerating}
|
||||
style={{
|
||||
width: "100%",
|
||||
padding: "6px 8px",
|
||||
fontSize: "12px",
|
||||
borderRadius: "4px",
|
||||
border: "1px solid var(--theme-elevation-200)",
|
||||
background: "var(--theme-elevation-50)",
|
||||
color: "var(--theme-text)",
|
||||
}}
|
||||
rows={2}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,108 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { useForm, useField } from "@payloadcms/ui";
|
||||
import { generateSlugAction } from "../../actions/generateField";
|
||||
import { Button } from "@payloadcms/ui";
|
||||
|
||||
export function GenerateSlugButton({ path }: { path: string }) {
|
||||
const [isGenerating, setIsGenerating] = useState(false);
|
||||
const [instructions, setInstructions] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
if (!isGenerating) return;
|
||||
const handleBeforeUnload = (e: BeforeUnloadEvent) => {
|
||||
e.preventDefault();
|
||||
e.returnValue =
|
||||
"Slug-Generierung läuft noch. Wenn Sie neu laden, bricht der Vorgang ab!";
|
||||
};
|
||||
window.addEventListener("beforeunload", handleBeforeUnload);
|
||||
return () => window.removeEventListener("beforeunload", handleBeforeUnload);
|
||||
}, [isGenerating]);
|
||||
|
||||
const { fields, replaceState } = useForm();
|
||||
const { value, initialValue, setValue } = useField({ path });
|
||||
|
||||
const extractText = (lexicalRoot: any): string => {
|
||||
if (!lexicalRoot) return "";
|
||||
let text = "";
|
||||
const iterate = (node: any) => {
|
||||
if (node.text) text += node.text + " ";
|
||||
if (node.children) node.children.forEach(iterate);
|
||||
};
|
||||
iterate(lexicalRoot);
|
||||
return text;
|
||||
};
|
||||
|
||||
const handleGenerate = async () => {
|
||||
const title = (fields?.title?.value as string) || "";
|
||||
const lexicalValue = fields?.content?.value as any;
|
||||
const legacyValue = fields?.legacyMdx?.value as string;
|
||||
|
||||
let draftContent = legacyValue || "";
|
||||
if (!draftContent && lexicalValue?.root) {
|
||||
draftContent = extractText(lexicalValue.root);
|
||||
}
|
||||
|
||||
setIsGenerating(true);
|
||||
try {
|
||||
const res = await generateSlugAction(
|
||||
title,
|
||||
draftContent,
|
||||
initialValue as string,
|
||||
instructions,
|
||||
);
|
||||
if (res.success && res.slug) {
|
||||
setValue(res.slug);
|
||||
} else {
|
||||
alert("Fehler: " + res.error);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
alert("Unerwarteter Fehler.");
|
||||
} finally {
|
||||
setIsGenerating(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex gap-2 items-center mb-4">
|
||||
<textarea
|
||||
value={instructions}
|
||||
onChange={(e) => setInstructions(e.target.value)}
|
||||
placeholder="Optionale AI Anweisung für den Slug..."
|
||||
disabled={isGenerating}
|
||||
style={{
|
||||
width: "100%",
|
||||
minHeight: "40px",
|
||||
padding: "8px 12px",
|
||||
fontSize: "14px",
|
||||
borderRadius: "4px",
|
||||
border: "1px solid var(--theme-elevation-200)",
|
||||
background: "var(--theme-elevation-50)",
|
||||
color: "var(--theme-text)",
|
||||
marginBottom: "8px",
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleGenerate}
|
||||
disabled={isGenerating}
|
||||
className="btn btn--icon-style-none btn--size-medium ml-auto"
|
||||
style={{
|
||||
background: "var(--theme-elevation-150)",
|
||||
border: "1px solid var(--theme-elevation-200)",
|
||||
color: "var(--theme-text)",
|
||||
boxShadow: "0 2px 4px rgba(0,0,0,0.05)",
|
||||
transition: "all 0.2s ease",
|
||||
opacity: isGenerating ? 0.6 : 1,
|
||||
cursor: isGenerating ? "not-allowed" : "pointer",
|
||||
}}
|
||||
>
|
||||
<span className="btn__content">
|
||||
{isGenerating ? "✨ Generiere (ca 10s)..." : "✨ AI Slug Generieren"}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,109 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { useForm, useField } from "@payloadcms/ui";
|
||||
import { generateThumbnailAction } from "../../actions/generateField";
|
||||
import { Button } from "@payloadcms/ui";
|
||||
|
||||
export function GenerateThumbnailButton({ path }: { path: string }) {
|
||||
const [isGenerating, setIsGenerating] = useState(false);
|
||||
const [instructions, setInstructions] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
if (!isGenerating) return;
|
||||
const handleBeforeUnload = (e: BeforeUnloadEvent) => {
|
||||
e.preventDefault();
|
||||
e.returnValue =
|
||||
"Bild-Generierung läuft noch (dies dauert bis zu 2 Minuten). Wenn Sie neu laden, bricht der Vorgang ab!";
|
||||
};
|
||||
window.addEventListener("beforeunload", handleBeforeUnload);
|
||||
return () => window.removeEventListener("beforeunload", handleBeforeUnload);
|
||||
}, [isGenerating]);
|
||||
|
||||
const { fields } = useForm();
|
||||
const { value, setValue } = useField({ path });
|
||||
|
||||
const extractText = (lexicalRoot: any): string => {
|
||||
if (!lexicalRoot) return "";
|
||||
let text = "";
|
||||
const iterate = (node: any) => {
|
||||
if (node.text) text += node.text + " ";
|
||||
if (node.children) node.children.forEach(iterate);
|
||||
};
|
||||
iterate(lexicalRoot);
|
||||
return text;
|
||||
};
|
||||
|
||||
const handleGenerate = async () => {
|
||||
const title = (fields?.title?.value as string) || "";
|
||||
const lexicalValue = fields?.content?.value as any;
|
||||
const legacyValue = fields?.legacyMdx?.value as string;
|
||||
|
||||
let draftContent = legacyValue || "";
|
||||
if (!draftContent && lexicalValue?.root) {
|
||||
draftContent = extractText(lexicalValue.root);
|
||||
}
|
||||
|
||||
setIsGenerating(true);
|
||||
try {
|
||||
const res = await generateThumbnailAction(
|
||||
draftContent,
|
||||
title,
|
||||
instructions,
|
||||
);
|
||||
if (res.success && res.mediaId) {
|
||||
setValue(res.mediaId);
|
||||
} else {
|
||||
alert("Fehler: " + res.error);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
alert("Unerwarteter Fehler.");
|
||||
} finally {
|
||||
setIsGenerating(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex gap-2 items-center mt-2 mb-4">
|
||||
<textarea
|
||||
value={instructions}
|
||||
onChange={(e) => setInstructions(e.target.value)}
|
||||
placeholder="Optionale Thumbnail-Detailanweisung (Farben, Stimmung, etc.)..."
|
||||
disabled={isGenerating}
|
||||
style={{
|
||||
width: "100%",
|
||||
minHeight: "40px",
|
||||
padding: "8px 12px",
|
||||
fontSize: "14px",
|
||||
borderRadius: "4px",
|
||||
border: "1px solid var(--theme-elevation-200)",
|
||||
background: "var(--theme-elevation-50)",
|
||||
color: "var(--theme-text)",
|
||||
marginBottom: "8px",
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleGenerate}
|
||||
disabled={isGenerating}
|
||||
className="btn btn--icon-style-none btn--size-medium"
|
||||
style={{
|
||||
background: "var(--theme-elevation-150)",
|
||||
border: "1px solid var(--theme-elevation-200)",
|
||||
color: "var(--theme-text)",
|
||||
boxShadow: "0 2px 4px rgba(0,0,0,0.05)",
|
||||
transition: "all 0.2s ease",
|
||||
opacity: isGenerating ? 0.6 : 1,
|
||||
cursor: isGenerating ? "not-allowed" : "pointer",
|
||||
}}
|
||||
>
|
||||
<span className="btn__content">
|
||||
{isGenerating
|
||||
? "✨ AI arbeitet (dauert ca. 1-2 Min)..."
|
||||
: "✨ AI Thumbnail Generieren"}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,136 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { useForm, useDocumentInfo } from "@payloadcms/ui";
|
||||
import { optimizePostText } from "../actions/optimizePost";
|
||||
import { Button } from "@payloadcms/ui";
|
||||
|
||||
export function OptimizeButton() {
|
||||
const [isOptimizing, setIsOptimizing] = useState(false);
|
||||
const [instructions, setInstructions] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOptimizing) return;
|
||||
const handleBeforeUnload = (e: BeforeUnloadEvent) => {
|
||||
e.preventDefault();
|
||||
e.returnValue =
|
||||
"Lexical Block-Optimierung läuft noch (dies dauert bis zu 45 Sekunden). Wenn Sie neu laden, bricht der Vorgang ab!";
|
||||
};
|
||||
window.addEventListener("beforeunload", handleBeforeUnload);
|
||||
return () => window.removeEventListener("beforeunload", handleBeforeUnload);
|
||||
}, [isOptimizing]);
|
||||
|
||||
const { fields, setModified, replaceState } = useForm();
|
||||
const { title } = useDocumentInfo();
|
||||
|
||||
const handleOptimize = async () => {
|
||||
// ... gathering draftContent logic
|
||||
const lexicalValue = fields?.content?.value as any;
|
||||
const legacyValue = fields?.legacyMdx?.value as string;
|
||||
|
||||
let draftContent = legacyValue || "";
|
||||
|
||||
const extractText = (lexicalRoot: any): string => {
|
||||
if (!lexicalRoot) return "";
|
||||
let text = "";
|
||||
const iterate = (node: any) => {
|
||||
if (node.text) text += node.text + " ";
|
||||
if (node.children) node.children.forEach(iterate);
|
||||
};
|
||||
iterate(lexicalRoot);
|
||||
return text;
|
||||
};
|
||||
|
||||
if (!draftContent && lexicalValue?.root) {
|
||||
draftContent = extractText(lexicalValue.root);
|
||||
}
|
||||
|
||||
if (!draftContent || draftContent.trim().length < 50) {
|
||||
alert(
|
||||
"Der Entwurf ist zu kurz. Bitte tippe zuerst ein paar Stichpunkte oder einen Rohling ein.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsOptimizing(true);
|
||||
try {
|
||||
// 2. We inject the title so the AI knows what it's writing about
|
||||
const payloadText = `---\ntitle: "${title}"\n---\n\n${draftContent}`;
|
||||
|
||||
const response = await optimizePostText(payloadText, instructions);
|
||||
|
||||
if (response.success && response.lexicalAST) {
|
||||
// 3. Inject the new Lexical AST directly into the field form state
|
||||
// We use Payload's useForm hook replacing the value of the 'content' field.
|
||||
|
||||
replaceState({
|
||||
...fields,
|
||||
content: {
|
||||
...fields.content,
|
||||
value: response.lexicalAST,
|
||||
initialValue: response.lexicalAST,
|
||||
},
|
||||
});
|
||||
|
||||
setModified(true);
|
||||
alert(
|
||||
"🎉 Artikel wurde erfolgreich von der AI optimiert und mit Lexical Components angereichert.",
|
||||
);
|
||||
} else {
|
||||
alert("❌ Fehler: " + response.error);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Optimization failed:", error);
|
||||
alert("Ein unerwarteter Fehler ist aufgetreten.");
|
||||
} finally {
|
||||
setIsOptimizing(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mb-8 p-4 bg-slate-50 border border-slate-200 rounded-md">
|
||||
<h3 className="text-sm font-semibold mb-2">AI Post Optimizer</h3>
|
||||
<p className="text-xs text-slate-500 mb-4">
|
||||
Lass Mintel AI deinen Text-Rohentwurf analysieren und automatisch in
|
||||
einen voll formatierten Lexical Artikel mit passenden B2B Komponenten
|
||||
(MemeCards, Mermaids) umwandeln.
|
||||
</p>
|
||||
<textarea
|
||||
value={instructions}
|
||||
onChange={(e) => setInstructions(e.target.value)}
|
||||
placeholder="Optionale Anweisungen an die AI (z.B. 'schreibe etwas lockerer' oder 'fokussiere dich auf SEO')..."
|
||||
disabled={isOptimizing}
|
||||
style={{
|
||||
width: "100%",
|
||||
minHeight: "60px",
|
||||
padding: "8px 12px",
|
||||
fontSize: "14px",
|
||||
borderRadius: "4px",
|
||||
border: "1px solid var(--theme-elevation-200)",
|
||||
background: "var(--theme-elevation-50)",
|
||||
color: "var(--theme-text)",
|
||||
marginBottom: "16px",
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleOptimize}
|
||||
disabled={isOptimizing}
|
||||
className="btn btn--icon-style-none btn--size-medium mt-4"
|
||||
style={{
|
||||
background: "var(--theme-elevation-150)",
|
||||
border: "1px solid var(--theme-elevation-200)",
|
||||
color: "var(--theme-text)",
|
||||
boxShadow: "0 2px 4px rgba(0,0,0,0.05)",
|
||||
transition: "all 0.2s ease",
|
||||
opacity: isOptimizing ? 0.7 : 1,
|
||||
cursor: isOptimizing ? "not-allowed" : "pointer",
|
||||
}}
|
||||
>
|
||||
<span className="btn__content" style={{ fontWeight: 600 }}>
|
||||
{isOptimizing ? "✨ AI arbeitet (ca 30s)..." : "✨ Jetzt optimieren"}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
import type { GlobalConfig } from "payload";
|
||||
export const AiSettings: GlobalConfig = {
|
||||
slug: "ai-settings",
|
||||
label: "AI Settings",
|
||||
access: {
|
||||
read: () => true, // Needed if the Next.js frontend or server actions need to fetch it
|
||||
},
|
||||
admin: {
|
||||
group: "Configuration",
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: "customSources",
|
||||
type: "array",
|
||||
label: "Custom Trusted Sources",
|
||||
admin: {
|
||||
description:
|
||||
"List of trusted B2B/Tech sources (e.g. 'Vercel Blog', 'Fireship', 'Theo - t3.gg') the AI should prioritize when researching facts or videos. This overrides the hardcoded defaults.",
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: "sourceName",
|
||||
type: "text",
|
||||
required: true,
|
||||
label: "Channel or Publication Name",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -1,640 +0,0 @@
|
||||
/**
|
||||
* Converts a Markdown+JSX string into a Lexical AST node array.
|
||||
* Handles all registered Payload blocks and standard markdown formatting.
|
||||
*/
|
||||
|
||||
function propValue(chunk: string, prop: string): string {
|
||||
// Match prop="value" or prop='value' or prop={value}
|
||||
const match =
|
||||
chunk.match(new RegExp(`${prop}=["']([^"']+)["']`)) ||
|
||||
chunk.match(new RegExp(`${prop}=\\{([^}]+)\\}`));
|
||||
return match ? match[1] : "";
|
||||
}
|
||||
|
||||
function innerContent(chunk: string, tag: string): string {
|
||||
const match = chunk.match(
|
||||
new RegExp(`<${tag}(?:\\s[^>]*)?>([\\s\\S]*?)<\\/${tag}>`),
|
||||
);
|
||||
return match ? match[1].trim() : "";
|
||||
}
|
||||
|
||||
function blockNode(blockType: string, fields: Record<string, any>) {
|
||||
return {
|
||||
type: "block",
|
||||
format: "",
|
||||
version: 2,
|
||||
fields: { blockType, ...fields },
|
||||
};
|
||||
}
|
||||
|
||||
export function parseMarkdownToLexical(markdown: string): any[] {
|
||||
const textNode = (text: string) => ({
|
||||
type: "paragraph",
|
||||
format: "",
|
||||
indent: 0,
|
||||
version: 1,
|
||||
children: [{ mode: "normal", type: "text", text, version: 1 }],
|
||||
});
|
||||
|
||||
const nodes: any[] = [];
|
||||
|
||||
// Strip frontmatter
|
||||
let content = markdown;
|
||||
const fm = content.match(/^---\s*\n[\s\S]*?\n---/);
|
||||
if (fm) content = content.replace(fm[0], "").trim();
|
||||
|
||||
// Pre-process: reassemble multi-line JSX tags that got split by double-newline chunking.
|
||||
// This handles tags like <IconList>\n\n<IconListItem ... />\n\n</IconList>
|
||||
content = reassembleMultiLineJSX(content);
|
||||
|
||||
const rawChunks = content.split(/\n\s*\n/);
|
||||
|
||||
for (let chunk of rawChunks) {
|
||||
chunk = chunk.trim();
|
||||
if (!chunk) continue;
|
||||
|
||||
// === Self-closing tags (no children) ===
|
||||
|
||||
// ArticleMeme / MemeCard
|
||||
if (chunk.includes("<ArticleMeme") || chunk.includes("<MemeCard")) {
|
||||
nodes.push(
|
||||
blockNode("memeCard", {
|
||||
template: propValue(chunk, "template"),
|
||||
captions: propValue(chunk, "captions"),
|
||||
}),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
// BoldNumber
|
||||
if (chunk.includes("<BoldNumber")) {
|
||||
nodes.push(
|
||||
blockNode("boldNumber", {
|
||||
value: propValue(chunk, "value"),
|
||||
label: propValue(chunk, "label"),
|
||||
source: propValue(chunk, "source"),
|
||||
sourceUrl: propValue(chunk, "sourceUrl"),
|
||||
}),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
// WebVitalsScore
|
||||
if (chunk.includes("<WebVitalsScore")) {
|
||||
nodes.push(
|
||||
blockNode("webVitalsScore", {
|
||||
lcp: parseFloat(propValue(chunk, "lcp")) || 0,
|
||||
inp: parseFloat(propValue(chunk, "inp")) || 0,
|
||||
cls: parseFloat(propValue(chunk, "cls")) || 0,
|
||||
description: propValue(chunk, "description"),
|
||||
}),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
// LeadMagnet
|
||||
if (chunk.includes("<LeadMagnet")) {
|
||||
nodes.push(
|
||||
blockNode("leadMagnet", {
|
||||
title: propValue(chunk, "title"),
|
||||
description: propValue(chunk, "description"),
|
||||
buttonText: propValue(chunk, "buttonText") || "Jetzt anfragen",
|
||||
href: propValue(chunk, "href") || "/contact",
|
||||
variant: propValue(chunk, "variant") || "standard",
|
||||
}),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
// ComparisonRow
|
||||
if (chunk.includes("<ComparisonRow")) {
|
||||
nodes.push(
|
||||
blockNode("comparisonRow", {
|
||||
description: propValue(chunk, "description"),
|
||||
negativeLabel: propValue(chunk, "negativeLabel"),
|
||||
negativeText: propValue(chunk, "negativeText"),
|
||||
positiveLabel: propValue(chunk, "positiveLabel"),
|
||||
positiveText: propValue(chunk, "positiveText"),
|
||||
reverse: chunk.includes("reverse={true}"),
|
||||
}),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
// StatsDisplay
|
||||
if (chunk.includes("<StatsDisplay")) {
|
||||
nodes.push(
|
||||
blockNode("statsDisplay", {
|
||||
label: propValue(chunk, "label"),
|
||||
value: propValue(chunk, "value"),
|
||||
subtext: propValue(chunk, "subtext"),
|
||||
}),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
// MetricBar
|
||||
if (chunk.includes("<MetricBar")) {
|
||||
nodes.push(
|
||||
blockNode("metricBar", {
|
||||
label: propValue(chunk, "label"),
|
||||
value: parseFloat(propValue(chunk, "value")) || 0,
|
||||
max: parseFloat(propValue(chunk, "max")) || 100,
|
||||
unit: propValue(chunk, "unit") || "%",
|
||||
}),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
// ExternalLink
|
||||
if (chunk.includes("<ExternalLink")) {
|
||||
nodes.push(
|
||||
blockNode("externalLink", {
|
||||
href: propValue(chunk, "href"),
|
||||
label:
|
||||
propValue(chunk, "label") || innerContent(chunk, "ExternalLink"),
|
||||
}),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
// TrackedLink
|
||||
if (chunk.includes("<TrackedLink")) {
|
||||
nodes.push(
|
||||
blockNode("trackedLink", {
|
||||
href: propValue(chunk, "href"),
|
||||
label:
|
||||
propValue(chunk, "label") || innerContent(chunk, "TrackedLink"),
|
||||
eventName: propValue(chunk, "eventName"),
|
||||
}),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
// YouTube
|
||||
if (chunk.includes("<YouTubeEmbed")) {
|
||||
nodes.push(
|
||||
blockNode("youTubeEmbed", {
|
||||
videoId: propValue(chunk, "videoId"),
|
||||
}),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
// LinkedIn
|
||||
if (chunk.includes("<LinkedInEmbed")) {
|
||||
nodes.push(
|
||||
blockNode("linkedInEmbed", {
|
||||
url: propValue(chunk, "url"),
|
||||
}),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Twitter
|
||||
if (chunk.includes("<TwitterEmbed")) {
|
||||
nodes.push(
|
||||
blockNode("twitterEmbed", {
|
||||
url: propValue(chunk, "url"),
|
||||
}),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Interactive (self-closing, defaults only)
|
||||
if (chunk.includes("<RevenueLossCalculator")) {
|
||||
nodes.push(
|
||||
blockNode("revenueLossCalculator", {
|
||||
title: propValue(chunk, "title") || "Performance Revenue Simulator",
|
||||
}),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if (chunk.includes("<PerformanceChart")) {
|
||||
nodes.push(
|
||||
blockNode("performanceChart", {
|
||||
title: propValue(chunk, "title") || "Website Performance",
|
||||
}),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if (chunk.includes("<PerformanceROICalculator")) {
|
||||
nodes.push(
|
||||
blockNode("performanceROICalculator", {
|
||||
baseConversionRate:
|
||||
parseFloat(propValue(chunk, "baseConversionRate")) || 2.5,
|
||||
monthlyVisitors:
|
||||
parseInt(propValue(chunk, "monthlyVisitors")) || 50000,
|
||||
}),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if (chunk.includes("<LoadTimeSimulator")) {
|
||||
nodes.push(
|
||||
blockNode("loadTimeSimulator", {
|
||||
initialLoadTime:
|
||||
parseFloat(propValue(chunk, "initialLoadTime")) || 3.5,
|
||||
}),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if (chunk.includes("<ArchitectureBuilder")) {
|
||||
nodes.push(
|
||||
blockNode("architectureBuilder", {
|
||||
preset: propValue(chunk, "preset") || "standard",
|
||||
}),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if (chunk.includes("<DigitalAssetVisualizer")) {
|
||||
nodes.push(
|
||||
blockNode("digitalAssetVisualizer", {
|
||||
assetId: propValue(chunk, "assetId"),
|
||||
}),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
// === Tags with inner content ===
|
||||
|
||||
// TLDR
|
||||
if (chunk.includes("<TLDR>")) {
|
||||
const inner = innerContent(chunk, "TLDR");
|
||||
if (inner) {
|
||||
nodes.push(blockNode("mintelTldr", { content: inner }));
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Paragraph (handles <Paragraph>, <Paragraph ...attrs>)
|
||||
if (/<Paragraph[\s>]/.test(chunk)) {
|
||||
const inner = innerContent(chunk, "Paragraph");
|
||||
if (inner) {
|
||||
nodes.push(blockNode("mintelP", { text: inner }));
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// H2 (handles <H2>, <H2 id="...">)
|
||||
if (/<H2[\s>]/.test(chunk)) {
|
||||
const inner = innerContent(chunk, "H2");
|
||||
if (inner) {
|
||||
nodes.push(
|
||||
blockNode("mintelHeading", {
|
||||
text: inner,
|
||||
seoLevel: "h2",
|
||||
displayLevel: "h2",
|
||||
}),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// H3 (handles <H3>, <H3 id="...">)
|
||||
if (/<H3[\s>]/.test(chunk)) {
|
||||
const inner = innerContent(chunk, "H3");
|
||||
if (inner) {
|
||||
nodes.push(
|
||||
blockNode("mintelHeading", {
|
||||
text: inner,
|
||||
seoLevel: "h3",
|
||||
displayLevel: "h3",
|
||||
}),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Marker (inline highlight, usually inside Paragraph – store as standalone block)
|
||||
if (chunk.includes("<Marker>") && !chunk.includes("<Paragraph")) {
|
||||
const inner = innerContent(chunk, "Marker");
|
||||
if (inner) {
|
||||
nodes.push(blockNode("marker", { text: inner }));
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// LeadParagraph
|
||||
if (chunk.includes("<LeadParagraph>")) {
|
||||
const inner = innerContent(chunk, "LeadParagraph");
|
||||
if (inner) {
|
||||
nodes.push(blockNode("leadParagraph", { text: inner }));
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// ArticleBlockquote
|
||||
if (chunk.includes("<ArticleBlockquote")) {
|
||||
nodes.push(
|
||||
blockNode("articleBlockquote", {
|
||||
quote: innerContent(chunk, "ArticleBlockquote"),
|
||||
author: propValue(chunk, "author"),
|
||||
role: propValue(chunk, "role"),
|
||||
}),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
// ArticleQuote
|
||||
if (chunk.includes("<ArticleQuote")) {
|
||||
nodes.push(
|
||||
blockNode("articleQuote", {
|
||||
quote:
|
||||
innerContent(chunk, "ArticleQuote") || propValue(chunk, "quote"),
|
||||
author: propValue(chunk, "author"),
|
||||
role: propValue(chunk, "role"),
|
||||
source: propValue(chunk, "source"),
|
||||
}),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Mermaid
|
||||
if (chunk.includes("<Mermaid")) {
|
||||
nodes.push(
|
||||
blockNode("mermaid", {
|
||||
id: propValue(chunk, "id") || `chart-${Date.now()}`,
|
||||
title: propValue(chunk, "title"),
|
||||
showShare: chunk.includes("showShare={true}"),
|
||||
chartDefinition: innerContent(chunk, "Mermaid"),
|
||||
}),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Diagram variants (prefer inner definition, fall back to raw chunk text)
|
||||
if (chunk.includes("<DiagramFlow")) {
|
||||
nodes.push(
|
||||
blockNode("diagramFlow", {
|
||||
definition:
|
||||
innerContent(chunk, "DiagramFlow") ||
|
||||
propValue(chunk, "definition") ||
|
||||
chunk,
|
||||
}),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if (chunk.includes("<DiagramSequence")) {
|
||||
nodes.push(
|
||||
blockNode("diagramSequence", {
|
||||
definition:
|
||||
innerContent(chunk, "DiagramSequence") ||
|
||||
propValue(chunk, "definition") ||
|
||||
chunk,
|
||||
}),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if (chunk.includes("<DiagramGantt")) {
|
||||
nodes.push(
|
||||
blockNode("diagramGantt", {
|
||||
definition:
|
||||
innerContent(chunk, "DiagramGantt") ||
|
||||
propValue(chunk, "definition") ||
|
||||
chunk,
|
||||
}),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if (chunk.includes("<DiagramPie")) {
|
||||
nodes.push(
|
||||
blockNode("diagramPie", {
|
||||
definition:
|
||||
innerContent(chunk, "DiagramPie") ||
|
||||
propValue(chunk, "definition") ||
|
||||
chunk,
|
||||
}),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if (chunk.includes("<DiagramState")) {
|
||||
nodes.push(
|
||||
blockNode("diagramState", {
|
||||
definition:
|
||||
innerContent(chunk, "DiagramState") ||
|
||||
propValue(chunk, "definition") ||
|
||||
chunk,
|
||||
}),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if (chunk.includes("<DiagramTimeline")) {
|
||||
nodes.push(
|
||||
blockNode("diagramTimeline", {
|
||||
definition:
|
||||
innerContent(chunk, "DiagramTimeline") ||
|
||||
propValue(chunk, "definition") ||
|
||||
chunk,
|
||||
}),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Section (wrapping container – unwrap and parse inner content as top-level blocks)
|
||||
if (chunk.includes("<Section")) {
|
||||
const inner = innerContent(chunk, "Section");
|
||||
if (inner) {
|
||||
const innerNodes = parseMarkdownToLexical(inner);
|
||||
nodes.push(...innerNodes);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// FAQSection (wrapping container)
|
||||
if (chunk.includes("<FAQSection")) {
|
||||
// FAQSection contains nested H3/Paragraph pairs.
|
||||
// We extract them as individual blocks instead.
|
||||
const faqContent = innerContent(chunk, "FAQSection");
|
||||
if (faqContent) {
|
||||
// Parse nested content recursively
|
||||
const innerNodes = parseMarkdownToLexical(faqContent);
|
||||
nodes.push(...innerNodes);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// IconList with IconListItems
|
||||
if (chunk.includes("<IconList")) {
|
||||
const items: any[] = [];
|
||||
// Self-closing: <IconListItem icon="Check" title="..." description="..." />
|
||||
const itemMatches = chunk.matchAll(/<IconListItem\s+([^>]*?)\/>/g);
|
||||
for (const m of itemMatches) {
|
||||
const attrs = m[1];
|
||||
const title = (attrs.match(/title=["']([^"']+)["']/) || [])[1] || "";
|
||||
const desc =
|
||||
(attrs.match(/description=["']([^"']+)["']/) || [])[1] || "";
|
||||
items.push({
|
||||
icon: (attrs.match(/icon=["']([^"']+)["']/) || [])[1] || "Check",
|
||||
title: title || "•",
|
||||
description: desc,
|
||||
});
|
||||
}
|
||||
// Content-wrapped: <IconListItem check>HTML content</IconListItem>
|
||||
const itemMatches2 = chunk.matchAll(
|
||||
/<IconListItem([^>]*)>([\s\S]*?)<\/IconListItem>/g,
|
||||
);
|
||||
for (const m of itemMatches2) {
|
||||
const attrs = m[1] || "";
|
||||
const innerHtml = m[2].trim();
|
||||
// Use title attr if present, otherwise use inner HTML (stripped of tags) as title
|
||||
const titleAttr = (attrs.match(/title=["']([^"']+)["']/) || [])[1];
|
||||
const strippedInner = innerHtml.replace(/<[^>]+>/g, "").trim();
|
||||
items.push({
|
||||
icon: (attrs.match(/icon=["']([^"']+)["']/) || [])[1] || "Check",
|
||||
title: titleAttr || strippedInner || "•",
|
||||
description: "",
|
||||
});
|
||||
}
|
||||
if (items.length > 0) {
|
||||
nodes.push(blockNode("iconList", { items }));
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// StatsGrid
|
||||
if (chunk.includes("<StatsGrid")) {
|
||||
const stats: any[] = [];
|
||||
const statMatches = chunk.matchAll(/<StatItem\s+([^>]*?)\/>/g);
|
||||
for (const m of statMatches) {
|
||||
const attrs = m[1];
|
||||
stats.push({
|
||||
label: (attrs.match(/label=["']([^"']+)["']/) || [])[1] || "",
|
||||
value: (attrs.match(/value=["']([^"']+)["']/) || [])[1] || "",
|
||||
});
|
||||
}
|
||||
// Also try inline props pattern
|
||||
if (stats.length === 0) {
|
||||
const innerStats = innerContent(chunk, "StatsGrid");
|
||||
if (innerStats) {
|
||||
// fallback: store the raw content
|
||||
nodes.push(blockNode("statsGrid", { stats: [] }));
|
||||
continue;
|
||||
}
|
||||
}
|
||||
nodes.push(blockNode("statsGrid", { stats }));
|
||||
continue;
|
||||
}
|
||||
|
||||
// PremiumComparisonChart
|
||||
if (chunk.includes("<PremiumComparisonChart")) {
|
||||
nodes.push(
|
||||
blockNode("premiumComparisonChart", {
|
||||
title: propValue(chunk, "title"),
|
||||
}),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
// WaterfallChart
|
||||
if (chunk.includes("<WaterfallChart")) {
|
||||
nodes.push(
|
||||
blockNode("waterfallChart", {
|
||||
title: propValue(chunk, "title"),
|
||||
}),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Reveal (animation wrapper – just pass through)
|
||||
if (chunk.includes("<Reveal")) {
|
||||
const inner = innerContent(chunk, "Reveal");
|
||||
if (inner) {
|
||||
// Parse inner content as regular nodes
|
||||
const innerNodes = parseMarkdownToLexical(inner);
|
||||
nodes.push(...innerNodes);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Standalone IconListItem (outside IconList context)
|
||||
if (chunk.includes("<IconListItem")) {
|
||||
// Skip – these should be inside an IconList
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip wrapper divs (like <div className="my-8">)
|
||||
if (/^<div\s/.test(chunk) || chunk === "</div>") {
|
||||
continue;
|
||||
}
|
||||
|
||||
// === Standard Markdown ===
|
||||
// CarouselBlock
|
||||
if (chunk.includes("<Carousel")) {
|
||||
const slides: any[] = [];
|
||||
const slideMatches = chunk.matchAll(/<Slide\s+([^>]*?)\/>/g);
|
||||
for (const m of slideMatches) {
|
||||
const attrs = m[1];
|
||||
slides.push({
|
||||
image: (attrs.match(/image=["']([^"']+)["']/) || [])[1] || "",
|
||||
caption: (attrs.match(/caption=["']([^"']+)["']/) || [])[1] || "",
|
||||
});
|
||||
}
|
||||
if (slides.length > 0) {
|
||||
nodes.push(blockNode("carousel", { slides }));
|
||||
continue;
|
||||
}
|
||||
}
|
||||
// Headings
|
||||
const headingMatch = chunk.match(/^(#{1,6})\s+(.*)/);
|
||||
if (headingMatch) {
|
||||
nodes.push({
|
||||
type: "heading",
|
||||
tag: `h${headingMatch[1].length}`,
|
||||
format: "",
|
||||
indent: 0,
|
||||
version: 1,
|
||||
direction: "ltr",
|
||||
children: [
|
||||
{ mode: "normal", type: "text", text: headingMatch[2], version: 1 },
|
||||
],
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// Default: plain text paragraph
|
||||
nodes.push(textNode(chunk));
|
||||
}
|
||||
|
||||
return nodes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reassembles multi-line JSX tags that span across double-newline boundaries.
|
||||
* E.g. <IconList>\n\n<IconListItem.../>\n\n</IconList> becomes a single chunk.
|
||||
*/
|
||||
function reassembleMultiLineJSX(content: string): string {
|
||||
// Tags that wrap other content across paragraph breaks
|
||||
const wrapperTags = [
|
||||
"IconList",
|
||||
"StatsGrid",
|
||||
"FAQSection",
|
||||
"Section",
|
||||
"Reveal",
|
||||
"Carousel",
|
||||
];
|
||||
|
||||
for (const tag of wrapperTags) {
|
||||
const openRegex = new RegExp(`<${tag}[^>]*>`, "g");
|
||||
let match;
|
||||
while ((match = openRegex.exec(content)) !== null) {
|
||||
const openPos = match.index;
|
||||
const closeTag = `</${tag}>`;
|
||||
const closePos = content.indexOf(closeTag, openPos);
|
||||
if (closePos === -1) continue;
|
||||
|
||||
const fullEnd = closePos + closeTag.length;
|
||||
const fullBlock = content.substring(openPos, fullEnd);
|
||||
|
||||
// Replace double newlines inside this block with single newlines
|
||||
// so it stays as one chunk during splitting
|
||||
const collapsed = fullBlock.replace(/\n\s*\n/g, "\n");
|
||||
content =
|
||||
content.substring(0, openPos) + collapsed + content.substring(fullEnd);
|
||||
|
||||
// Adjust regex position
|
||||
openRegex.lastIndex = openPos + collapsed.length;
|
||||
}
|
||||
}
|
||||
|
||||
return content;
|
||||
}
|
||||
11
apps/web/src/utils/cache/redis-adapter.ts
vendored
11
apps/web/src/utils/cache/redis-adapter.ts
vendored
@@ -4,6 +4,7 @@
|
||||
*/
|
||||
|
||||
import type { CacheAdapter, CacheConfig } from './interfaces';
|
||||
import * as os from 'os';
|
||||
|
||||
export class RedisCacheAdapter implements CacheAdapter {
|
||||
private client: any = null;
|
||||
@@ -14,7 +15,13 @@ export class RedisCacheAdapter implements CacheAdapter {
|
||||
constructor(config: CacheConfig & { redisUrl?: string } = {}) {
|
||||
this.prefix = config.prefix || 'mintel:';
|
||||
this.defaultTTL = config.defaultTTL || 3600;
|
||||
this.redisUrl = config.redisUrl || process.env.REDIS_URL || 'redis://localhost:6379';
|
||||
|
||||
let url = config.redisUrl || process.env.REDIS_URL || 'redis://localhost:6379';
|
||||
const isDocker = process.env.IS_DOCKER === 'true' || os.hostname().includes('mintel-me') || process.env.HOSTNAME?.includes('mintel-me');
|
||||
if (isDocker && url.includes('localhost')) {
|
||||
url = url.replace('localhost', 'mintel-redis');
|
||||
}
|
||||
this.redisUrl = url;
|
||||
}
|
||||
|
||||
private async init(): Promise<boolean> {
|
||||
@@ -23,7 +30,7 @@ export class RedisCacheAdapter implements CacheAdapter {
|
||||
try {
|
||||
const Redis = await import('ioredis');
|
||||
this.client = new Redis.default(this.redisUrl);
|
||||
|
||||
|
||||
this.client.on('error', (err: Error) => {
|
||||
console.warn('Redis connection error:', err.message);
|
||||
this.client = null;
|
||||
|
||||
@@ -26,12 +26,15 @@ services:
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
- IS_DOCKER=true
|
||||
- NODE_ENV=development
|
||||
- NEXT_TELEMETRY_DISABLED=1
|
||||
# - CI=true
|
||||
- NPM_TOKEN=${NPM_TOKEN:-}
|
||||
- DATABASE_URI=postgres://${postgres_DB_USER:-payload}:${postgres_DB_PASSWORD:-payload}@postgres-db:5432/${postgres_DB_NAME:-payload}
|
||||
- PAYLOAD_SECRET=dev-secret
|
||||
- QDRANT_URL=http://mintel-qdrant:6333
|
||||
- REDIS_URL=redis://mintel-redis:6379
|
||||
command: >
|
||||
sh -c "pnpm install --no-frozen-lockfile && pnpm --filter @mintel/web dev"
|
||||
|
||||
@@ -57,10 +60,28 @@ services:
|
||||
POSTGRES_USER: ${postgres_DB_USER:-payload}
|
||||
POSTGRES_PASSWORD: ${postgres_DB_PASSWORD:-payload}
|
||||
volumes:
|
||||
- payload-db-data:/var/lib/postgresql/data
|
||||
- ./data/postgres:/var/lib/postgresql/data
|
||||
ports:
|
||||
- "54321:5432"
|
||||
|
||||
mintel-redis:
|
||||
image: redis:7-alpine
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- default
|
||||
ports:
|
||||
- "6379:6379"
|
||||
|
||||
mintel-qdrant:
|
||||
image: qdrant/qdrant:v1.13.2
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- mintel_qdrant_data:/qdrant/storage
|
||||
networks:
|
||||
- default
|
||||
ports:
|
||||
- "26333:6333"
|
||||
|
||||
networks:
|
||||
default:
|
||||
name: ${PROJECT_NAME:-mintel-me}-internal
|
||||
@@ -68,7 +89,7 @@ networks:
|
||||
external: true
|
||||
|
||||
volumes:
|
||||
payload-db-data:
|
||||
node_modules:
|
||||
apps_node_modules:
|
||||
pnpm_store:
|
||||
mintel_qdrant_data:
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user