Compare commits
33 Commits
v1.15.2
...
7bceb6cd2b
| Author | SHA1 | Date | |
|---|---|---|---|
| 7bceb6cd2b | |||
| 569bb4e270 | |||
| 0a6f5f34ce | |||
| 5f27bb9950 | |||
| d83199ab1a | |||
| 2cfe9115d2 | |||
| d4b586f29f | |||
| 9013d38e31 | |||
| f3cbdd8076 | |||
| f143fc6099 | |||
| 83190f1d00 | |||
| 12848a4f23 | |||
| 0a9cb81841 | |||
| c505050cb4 | |||
| d5d0d77180 | |||
| 9399be3ea9 | |||
| 7ec82027a8 | |||
| 631d051c2e | |||
| 1a8758a88c | |||
| 7d6d34dd7b | |||
| 1f0de18755 | |||
| eeb0920afd | |||
| f8eec731c0 | |||
| 8b80af3d1a | |||
| de42890476 | |||
| fbd33da925 | |||
| f3bab24bd3 | |||
| 3b668fc876 | |||
| 2827239796 | |||
| 7cc1e49ba6 | |||
| 258eb9464e | |||
| 015386ba4a | |||
| 49abaaf2fd |
@@ -118,151 +118,7 @@ jobs:
|
|||||||
echo "target=skip" >> "$GITHUB_OUTPUT"
|
echo "target=skip" >> "$GITHUB_OUTPUT"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# ──────────────────────────────────────────────────────────────────────────────
|
# (JOB 2: QA was removed to reduce pipeline noise)
|
||||||
# JOB 2: QA (Lint, Typecheck, Test)
|
|
||||||
# ──────────────────────────────────────────────────────────────────────────────
|
|
||||||
qa:
|
|
||||||
name: 🧪 QA
|
|
||||||
needs: [prepare, deploy]
|
|
||||||
if: needs.prepare.outputs.target != 'skip'
|
|
||||||
runs-on: docker
|
|
||||||
container:
|
|
||||||
image: catthehacker/ubuntu:act-latest
|
|
||||||
steps:
|
|
||||||
- name: Checkout repository
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
- name: Setup Node.js
|
|
||||||
uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: 20
|
|
||||||
- name: Setup pnpm
|
|
||||||
uses: pnpm/action-setup@v3
|
|
||||||
with:
|
|
||||||
version: 10
|
|
||||||
- name: Provide sibling monorepo
|
|
||||||
run: |
|
|
||||||
git clone https://git.infra.mintel.me/mmintel/at-mintel.git _at-mintel
|
|
||||||
|
|
||||||
# Force ALL @mintel packages to use the local clone instead of the registry
|
|
||||||
# This handles root package.json
|
|
||||||
perl -pi -e 's/"\@mintel\/([^"]+)"\s*:\s*"[^"]+"/"\@mintel\/$1": "link:.\/_at-mintel\/packages\/$1"/g' package.json
|
|
||||||
# Special case for pdf -> pdf-library
|
|
||||||
perl -pi -e 's/link:\.\/_at-mintel\/packages\/pdf"/link:.\/_at-mintel\/packages\/pdf-library"/g' package.json
|
|
||||||
|
|
||||||
# Handle apps/web/package.json
|
|
||||||
perl -pi -e 's/"\@mintel\/([^"]+)"\s*:\s*"[^"]+"/"\@mintel\/$1": "link:..\/\.\.\/_at-mintel\/packages\/$1"/g' apps/web/package.json
|
|
||||||
# Special case for pdf -> pdf-library
|
|
||||||
perl -pi -e 's/link:\.\.\/\.\.\/_at-mintel\/packages\/pdf"/link:..\/\.\.\/_at-mintel\/packages\/pdf-library"/g' apps/web/package.json
|
|
||||||
|
|
||||||
# Fix tsconfig paths if they exist
|
|
||||||
sed -i 's|../../../at-mintel|../../_at-mintel|g' apps/web/tsconfig.json || true
|
|
||||||
|
|
||||||
# Fix tsconfig paths if they exist
|
|
||||||
sed -i 's|../../../at-mintel|../../_at-mintel|g' apps/web/tsconfig.json || true
|
|
||||||
- name: 🔐 Registry Auth
|
|
||||||
run: |
|
|
||||||
echo "Testing available secrets against git.infra.mintel.me Docker registry..."
|
|
||||||
TOKENS="${{ secrets.GITHUB_TOKEN }} ${{ secrets.GITEA_PAT }} ${{ secrets.MINTEL_PRIVATE_TOKEN }} ${{ secrets.NPM_TOKEN }}"
|
|
||||||
USERS="${{ github.repository_owner }} ${{ github.actor }} marcmintel mintel mmintel"
|
|
||||||
|
|
||||||
VALID_TOKEN=""
|
|
||||||
VALID_USER=""
|
|
||||||
|
|
||||||
for T_RAW in $TOKENS; do
|
|
||||||
if [ -n "$T_RAW" ]; then
|
|
||||||
T=$(echo "$T_RAW" | tr -d ' ' | tr -d '\n' | tr -d '\r')
|
|
||||||
|
|
||||||
echo "Testing API with token..."
|
|
||||||
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" -H "Authorization: token $T" https://git.infra.mintel.me/api/v1/user || echo "failed")
|
|
||||||
echo "API returned: $HTTP_CODE"
|
|
||||||
|
|
||||||
for U in $USERS; do
|
|
||||||
if [ -n "$U" ]; then
|
|
||||||
echo "Attempting docker login for a token with user $U..."
|
|
||||||
if echo "$T" | docker login git.infra.mintel.me -u "$U" --password-stdin > /dev/null 2>&1; then
|
|
||||||
echo "✅ Successfully authenticated with a token."
|
|
||||||
VALID_TOKEN="$T"
|
|
||||||
VALID_USER="$U"
|
|
||||||
break 2
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
|
|
||||||
if [ -z "$VALID_TOKEN" ]; then
|
|
||||||
echo "❌ All token/user combinations failed to authenticate!"
|
|
||||||
T=$(echo "$TOKENS" | awk '{print $1}')
|
|
||||||
echo "Attempting open diagnostic login with first token and user mmintel..."
|
|
||||||
echo "$T" | docker login git.infra.mintel.me -u "mmintel" --password-stdin || true
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
TOKEN="$VALID_TOKEN"
|
|
||||||
echo "::add-mask::$TOKEN"
|
|
||||||
echo "token=$TOKEN" >> $GITHUB_OUTPUT
|
|
||||||
echo "user=$VALID_USER" >> $GITHUB_OUTPUT
|
|
||||||
|
|
||||||
echo "Configuring .npmrc for git.infra.mintel.me..."
|
|
||||||
echo "@mintel:registry=https://git.infra.mintel.me/api/packages/mmintel/npm/" > .npmrc
|
|
||||||
echo "//git.infra.mintel.me/api/packages/mmintel/npm/:_authToken=${TOKEN}" >> .npmrc
|
|
||||||
echo "always-auth=true" >> .npmrc
|
|
||||||
|
|
||||||
# Also export for pnpm to pick it up from env if needed
|
|
||||||
echo "NPM_TOKEN=${TOKEN}" >> $GITHUB_ENV
|
|
||||||
- name: 🏗️ Compile Sibling Monorepo
|
|
||||||
timeout-minutes: 15
|
|
||||||
run: |
|
|
||||||
mkdir -p ci-logs
|
|
||||||
echo "=== Compile Sibling Monorepo ===" >> ci-logs/summary.txt
|
|
||||||
cp .npmrc _at-mintel/
|
|
||||||
cd _at-mintel
|
|
||||||
pnpm install --no-frozen-lockfile --loglevel info 2>&1 | tee -a ../ci-logs/summary.txt
|
|
||||||
pnpm --filter "...@mintel/payload-ai" \
|
|
||||||
--filter @mintel/pdf... \
|
|
||||||
--filter @mintel/concept-engine... \
|
|
||||||
--filter @mintel/estimation-engine... \
|
|
||||||
--filter @mintel/meme-generator... \
|
|
||||||
build --loglevel info 2>&1 | tee -a ../ci-logs/summary.txt
|
|
||||||
- name: Install dependencies
|
|
||||||
timeout-minutes: 10
|
|
||||||
run: |
|
|
||||||
echo "=== Install dependencies (Root) ===" >> ci-logs/summary.txt
|
|
||||||
pnpm install --no-frozen-lockfile --loglevel info 2>&1 | tee -a ci-logs/summary.txt
|
|
||||||
- name: 🧪 Test
|
|
||||||
if: github.event.inputs.skip_checks != 'true'
|
|
||||||
timeout-minutes: 10
|
|
||||||
run: |
|
|
||||||
echo "=== Test (@mintel/web) ===" >> ci-logs/summary.txt
|
|
||||||
pnpm --filter @mintel/web test --loglevel info 2>&1 | tee -a ci-logs/summary.txt
|
|
||||||
- name: Inspect on Failure
|
|
||||||
if: failure()
|
|
||||||
run: |
|
|
||||||
echo "==== runner state ===="
|
|
||||||
ls -la
|
|
||||||
echo "==== _at-mintel state ===="
|
|
||||||
ls -la _at-mintel || true
|
|
||||||
echo "==== .npmrc check ===="
|
|
||||||
cat .npmrc | sed -E 's/authToken=[a-f0-9]{5}.*/authToken=REDACTED/'
|
|
||||||
echo "==== pnpm debug logs ===="
|
|
||||||
[ -f pnpm-debug.log ] && tail -n 100 pnpm-debug.log || echo "No root pnpm-debug.log"
|
|
||||||
[ -f _at-mintel/pnpm-debug.log ] && tail -n 100 _at-mintel/pnpm-debug.log || echo "No sibling pnpm-debug.log"
|
|
||||||
- name: Extract QA Error Logs
|
|
||||||
if: failure()
|
|
||||||
run: |
|
|
||||||
mkdir -p ci-logs
|
|
||||||
echo "QA Failure Report" > ci-logs/summary.txt
|
|
||||||
ls -R >> ci-logs/summary.txt
|
|
||||||
[ -f pnpm-debug.log ] && cp pnpm-debug.log ci-logs/ || true
|
|
||||||
[ -f _at-mintel/pnpm-debug.log ] && cp _at-mintel/pnpm-debug.log ci-logs/at-mintel-pnpm-debug.log || true
|
|
||||||
|
|
||||||
SSH_KEY_FILE=$(mktemp)
|
|
||||||
echo "${{ secrets.ALPHA_SSH_KEY }}" > "$SSH_KEY_FILE"
|
|
||||||
chmod 600 "$SSH_KEY_FILE"
|
|
||||||
|
|
||||||
ssh -o StrictHostKeyChecking=no -i "$SSH_KEY_FILE" root@alpha.mintel.me "mkdir -p ~/logs"
|
|
||||||
scp -r -o StrictHostKeyChecking=no -i "$SSH_KEY_FILE" ci-logs/* root@alpha.mintel.me:~/logs/ || true
|
|
||||||
rm "$SSH_KEY_FILE"
|
|
||||||
|
|
||||||
# ──────────────────────────────────────────────────────────────────────────────
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
# JOB 3: Build & Push
|
# JOB 3: Build & Push
|
||||||
@@ -298,6 +154,30 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
echo "${{ secrets.REGISTRY_PASS }}" | docker login registry.infra.mintel.me -u "${{ secrets.REGISTRY_USER }}" --password-stdin
|
echo "${{ secrets.REGISTRY_PASS }}" | docker login registry.infra.mintel.me -u "${{ secrets.REGISTRY_USER }}" --password-stdin
|
||||||
|
|
||||||
|
- name: 🧹 Nuclear Runner Cleanup
|
||||||
|
run: |
|
||||||
|
echo "Disk space before nuclear prune:"
|
||||||
|
df -h
|
||||||
|
|
||||||
|
# Massive cleanup of pre-installed runner software to free up ~5GB+
|
||||||
|
# These are standard on VM-based runners and often unnecessary
|
||||||
|
sudo rm -rf /usr/share/dotnet || true
|
||||||
|
sudo rm -rf /usr/local/lib/android || true
|
||||||
|
sudo rm -rf /opt/ghc || true
|
||||||
|
sudo rm -rf /opt/hostedtoolcache/CodeQL || true
|
||||||
|
|
||||||
|
# Comprehensive Docker purge
|
||||||
|
docker system prune -af --volumes
|
||||||
|
docker builder prune -af
|
||||||
|
docker buildx prune -af
|
||||||
|
|
||||||
|
# Clean temp build artifacts
|
||||||
|
rm -rf /tmp/docker-actions-toolkit-* || true
|
||||||
|
|
||||||
|
echo "Disk space after nuclear prune:"
|
||||||
|
df -h
|
||||||
|
continue-on-error: true
|
||||||
|
|
||||||
- name: 🏗️ Build and Push
|
- name: 🏗️ Build and Push
|
||||||
uses: docker/build-push-action@v5
|
uses: docker/build-push-action@v5
|
||||||
with:
|
with:
|
||||||
@@ -316,10 +196,13 @@ jobs:
|
|||||||
S3_BUCKET=${{ secrets.S3_BUCKET || vars.S3_BUCKET || 'mintel' }}
|
S3_BUCKET=${{ secrets.S3_BUCKET || vars.S3_BUCKET || 'mintel' }}
|
||||||
S3_REGION=${{ secrets.S3_REGION || vars.S3_REGION || 'fsn1' }}
|
S3_REGION=${{ secrets.S3_REGION || vars.S3_REGION || 'fsn1' }}
|
||||||
S3_PREFIX=${{ secrets.S3_PREFIX || vars.S3_PREFIX || 'mintel.me' }}
|
S3_PREFIX=${{ secrets.S3_PREFIX || vars.S3_PREFIX || 'mintel.me' }}
|
||||||
|
DATABASE_URI=${{ secrets.DATABASE_URI || (needs.prepare.outputs.target == 'testing' && secrets.TESTING_DIRECTUS_DB_PASSWORD && format('postgres://directus:{0}@postgres-db:5432/directus', secrets.TESTING_DIRECTUS_DB_PASSWORD)) || 'postgres://payload:payload@127.0.0.1:5432/payload' }}
|
||||||
|
PAYLOAD_SECRET=${{ secrets.PAYLOAD_SECRET || 'secret' }}
|
||||||
BUILD_ID=${{ github.sha }}
|
BUILD_ID=${{ github.sha }}
|
||||||
tags: registry.infra.mintel.me/mintel/mintel.me:${{ needs.prepare.outputs.image_tag }}
|
tags: registry.infra.mintel.me/mintel/mintel.me:${{ needs.prepare.outputs.image_tag }}
|
||||||
|
# Temporarily disable registry cache export to save runner disk/bandwidth
|
||||||
cache-from: type=registry,ref=registry.infra.mintel.me/mintel/mintel.me:buildcache-${{ needs.prepare.outputs.target }}
|
cache-from: type=registry,ref=registry.infra.mintel.me/mintel/mintel.me:buildcache-${{ needs.prepare.outputs.target }}
|
||||||
cache-to: type=registry,ref=registry.infra.mintel.me/mintel/mintel.me:buildcache-${{ needs.prepare.outputs.target }},mode=max
|
# cache-to: type=registry,ref=registry.infra.mintel.me/mintel/mintel.me:buildcache-${{ needs.prepare.outputs.target }},mode=max
|
||||||
secrets: |
|
secrets: |
|
||||||
NPM_TOKEN=${{ secrets.NPM_TOKEN }}
|
NPM_TOKEN=${{ secrets.NPM_TOKEN }}
|
||||||
|
|
||||||
@@ -530,7 +413,7 @@ jobs:
|
|||||||
docker volume create 'mintel-me_payload-db-data' || true
|
docker volume create 'mintel-me_payload-db-data' || true
|
||||||
cd $SITE_DIR
|
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 pull
|
||||||
docker compose -p '${{ needs.prepare.outputs.project_name }}' --env-file $ENV_FILE up -d --remove-orphans
|
docker compose -p '${{ needs.prepare.outputs.project_name }}' --env-file $ENV_FILE up -d --wait --remove-orphans
|
||||||
"
|
"
|
||||||
|
|
||||||
- name: 🧹 Purge S3 Cache
|
- name: 🧹 Purge S3 Cache
|
||||||
@@ -564,112 +447,69 @@ jobs:
|
|||||||
# JOB 5: Post-Deploy Verification
|
# JOB 5: Post-Deploy Verification
|
||||||
# ──────────────────────────────────────────────────────────────────────────────
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
post_deploy_checks:
|
post_deploy_checks:
|
||||||
name: 🧪 Post-Deploy Verification
|
name: 🩺 Smoke Test
|
||||||
needs: [prepare, deploy, qa]
|
needs: [prepare, deploy]
|
||||||
if: success() || failure() # Run even if QA fails (due to E2E noise)
|
|
||||||
runs-on: docker
|
runs-on: docker
|
||||||
|
if: needs.deploy.result == 'success'
|
||||||
container:
|
container:
|
||||||
image: catthehacker/ubuntu:act-latest
|
image: alpine:latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: 🌐 Check Production URL
|
||||||
uses: actions/checkout@v4
|
shell: sh
|
||||||
- name: Setup Node.js
|
|
||||||
uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: 20
|
|
||||||
- name: Setup pnpm
|
|
||||||
uses: pnpm/action-setup@v3
|
|
||||||
with:
|
|
||||||
version: 10
|
|
||||||
- name: Provide sibling monorepo
|
|
||||||
run: |
|
run: |
|
||||||
git clone https://git.infra.mintel.me/mmintel/at-mintel.git _at-mintel
|
apk add --no-cache curl
|
||||||
|
# Wait longer (up to 2 minutes) for Next.js to fully prime
|
||||||
# Force ALL @mintel packages to use the local clone instead of the registry
|
COUNT=0
|
||||||
perl -pi -e 's/"\@mintel\/([^"]+)"\s*:\s*"[^"]+"/"\@mintel\/$1": "link:.\/_at-mintel\/packages\/$1"/g' package.json
|
MAX=24
|
||||||
perl -pi -e 's/link:\.\/_at-mintel\/packages\/pdf"/link:.\/_at-mintel\/packages\/pdf-library"/g' package.json
|
URL="${{ needs.prepare.outputs.next_public_url }}"
|
||||||
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
|
echo "Verifying $URL is responsive..."
|
||||||
|
while [ $COUNT -lt $MAX ]; do
|
||||||
# Fix tsconfig paths if they exist
|
STATUS=$(curl -s -o /dev/null -w "%{http_code}" "$URL/" || echo "000")
|
||||||
sed -i 's|../../../at-mintel|../../_at-mintel|g' apps/web/tsconfig.json || true
|
if [ "$STATUS" = "200" ]; then
|
||||||
- name: 🔐 Registry Auth
|
echo "✅ Site is UP (200 OK)"
|
||||||
run: |
|
break
|
||||||
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
|
fi
|
||||||
|
echo "⏳ Wait for 200 OK (Status: $STATUS)..."
|
||||||
|
sleep 5
|
||||||
|
COUNT=$((COUNT + 1))
|
||||||
done
|
done
|
||||||
if [ -z "$VALID_TOKEN" ]; then echo "❌ All tokens failed to authenticate!"; exit 1; fi
|
|
||||||
TOKEN="$VALID_TOKEN"
|
if [ "$STATUS" != "200" ]; then
|
||||||
echo "Configuring .npmrc for git.infra.mintel.me..."
|
echo "❌ Site failed smoke test after 2 minutes! (Status: $STATUS)"
|
||||||
echo "@mintel:registry=https://git.infra.mintel.me/api/packages/mmintel/npm/" > .npmrc
|
exit 1
|
||||||
echo "//git.infra.mintel.me/api/packages/mmintel/npm/:_authToken=${TOKEN}" >> .npmrc
|
fi
|
||||||
echo "always-auth=true" >> .npmrc
|
|
||||||
echo "NPM_TOKEN=${TOKEN}" >> $GITHUB_ENV
|
- name: 🌐 Check Case Study Assets and Rewrites
|
||||||
- name: Install dependencies
|
shell: sh
|
||||||
run: pnpm install --no-frozen-lockfile
|
|
||||||
- name: 🏥 App Health Check
|
|
||||||
shell: bash
|
|
||||||
env:
|
|
||||||
DEPLOY_URL: ${{ needs.prepare.outputs.next_public_url }}
|
|
||||||
run: |
|
run: |
|
||||||
echo "Waiting for app to start at $DEPLOY_URL ..."
|
BASE_URL="${{ needs.prepare.outputs.next_public_url }}"
|
||||||
for i in {1..30}; do
|
|
||||||
HTTP_CODE=$(curl -sk -o /dev/null -w '%{http_code}' "$DEPLOY_URL" 2>&1) || true
|
echo "Verifying case study page..."
|
||||||
echo "Attempt $i: HTTP $HTTP_CODE"
|
STATUS=$(curl -s -o /dev/null -w "%{http_code}" "$BASE_URL/case-studies/klz-cables")
|
||||||
if [[ "$HTTP_CODE" =~ ^2 ]]; then
|
if [ "$STATUS" != "200" ]; then
|
||||||
echo "✅ App is up (HTTP $HTTP_CODE)"
|
echo "❌ Case study page failed! (Status: $STATUS)"
|
||||||
exit 0
|
exit 1
|
||||||
fi
|
fi
|
||||||
echo "⏳ Waiting... (got $HTTP_CODE)"
|
echo "✅ Case study page UP"
|
||||||
sleep 10
|
|
||||||
done
|
echo "Verifying breeze CSS (root path)..."
|
||||||
echo "❌ App health check failed after 30 attempts"
|
CSS_PATH="/assets/klz-cables.com/wp-content/cache/breeze-minification/css/breeze_klz-cables-com-1-10895.css"
|
||||||
exit 1
|
STATUS=$(curl -s -o /dev/null -w "%{http_code}" "$BASE_URL$CSS_PATH")
|
||||||
- name: 🚀 OG Image Check
|
if [ "$STATUS" != "200" ]; then
|
||||||
continue-on-error: true
|
echo "❌ Root asset path failed! (Status: $STATUS)"
|
||||||
env:
|
exit 1
|
||||||
TEST_URL: ${{ needs.prepare.outputs.next_public_url }}
|
fi
|
||||||
run: pnpm --filter @mintel/web check:og
|
echo "✅ Root asset path OK"
|
||||||
- name: 📝 E2E Smoke Test
|
|
||||||
continue-on-error: true
|
echo "Verifying breeze CSS (relative path from case-study)..."
|
||||||
env:
|
REL_CSS_PATH="/case-studies/assets/klz-cables.com/wp-content/cache/breeze-minification/css/breeze_klz-cables-com-1-10895.css"
|
||||||
TEST_URL: ${{ needs.prepare.outputs.next_public_url }}
|
STATUS=$(curl -s -o /dev/null -w "%{http_code}" "$BASE_URL$REL_CSS_PATH")
|
||||||
GATEKEEPER_PASSWORD: ${{ secrets.GATEKEEPER_PASSWORD }}
|
if [ "$STATUS" != "200" ]; then
|
||||||
PUPPETEER_SKIP_DOWNLOAD: "true"
|
echo "❌ Relative asset path failed! (Status: $STATUS)"
|
||||||
PUPPETEER_EXECUTABLE_PATH: /usr/bin/chromium
|
exit 1
|
||||||
run: |
|
fi
|
||||||
# Install system Chromium + dependencies (KLZ pattern)
|
echo "✅ Relative asset path OK"
|
||||||
# Ubuntu's default 'chromium' is a snap wrapper, so we use xtradeb PPA for native binary
|
|
||||||
sudo apt-get update && sudo apt-get install -y gnupg wget ca-certificates
|
|
||||||
|
|
||||||
# Setup xtradeb PPA for native chromium
|
|
||||||
CODENAME=$(. /etc/os-release && echo $VERSION_CODENAME)
|
|
||||||
sudo mkdir -p /etc/apt/keyrings
|
|
||||||
wget -qO- "https://keyserver.ubuntu.com/pks/lookup?op=get&search=0x82BB6851C64F6880" | sudo gpg --dearmor -o /etc/apt/keyrings/xtradeb.gpg || true
|
|
||||||
echo "deb [signed-by=/etc/apt/keyrings/xtradeb.gpg] http://ppa.launchpad.net/xtradeb/apps/ubuntu $CODENAME main" | sudo tee /etc/apt/sources.list.d/xtradeb-ppa.list
|
|
||||||
printf "Package: *\nPin: release o=LP-PPA-xtradeb-apps\nPin-Priority: 1001\n" | sudo tee /etc/apt/preferences.d/xtradeb
|
|
||||||
|
|
||||||
sudo apt-get update
|
|
||||||
sudo apt-get install -y --allow-downgrades chromium libnss3 libatk1.0-0 libatk-bridge2.0-0 libcups2 libdrm2 libxkbcommon0 libxcomposite1 libxdamage1 libxrandr2 libgbm1 libasound2t64 || sudo apt-get install -y --allow-downgrades chromium libnss3 libatk1.0-0 libatk-bridge2.0-0 libcups2 libdrm2 libxkbcommon0 libxcomposite1 libxdamage1 libxrandr2 libgbm1 libasound2
|
|
||||||
|
|
||||||
[ -f /usr/bin/chromium ] && sudo ln -sf /usr/bin/chromium /usr/bin/google-chrome
|
|
||||||
pnpm --filter @mintel/web check:forms
|
|
||||||
|
|
||||||
# ──────────────────────────────────────────────────────────────────────────────
|
# ──────────────────────────────────────────────────────────────────────────────
|
||||||
# JOB 6: Notifications
|
# JOB 6: Notifications
|
||||||
@@ -689,7 +529,7 @@ jobs:
|
|||||||
TARGET="${{ needs.prepare.outputs.target }}"
|
TARGET="${{ needs.prepare.outputs.target }}"
|
||||||
VERSION="${{ needs.prepare.outputs.image_tag }}"
|
VERSION="${{ needs.prepare.outputs.image_tag }}"
|
||||||
|
|
||||||
if [[ "$DEPLOY" == "success" ]] && [[ "$SMOKE" == "success" || "$SMOKE" == "skipped" ]]; then
|
if [[ "$DEPLOY" == "success" ]]; then
|
||||||
PRIORITY=5
|
PRIORITY=5
|
||||||
EMOJI="✅"
|
EMOJI="✅"
|
||||||
else
|
else
|
||||||
@@ -699,5 +539,5 @@ jobs:
|
|||||||
|
|
||||||
curl -s -k -X POST "${{ secrets.GOTIFY_URL }}/message?token=${{ secrets.GOTIFY_TOKEN }}" \
|
curl -s -k -X POST "${{ secrets.GOTIFY_URL }}/message?token=${{ secrets.GOTIFY_TOKEN }}" \
|
||||||
-F "title=$EMOJI mintel.me $VERSION -> $TARGET" \
|
-F "title=$EMOJI mintel.me $VERSION -> $TARGET" \
|
||||||
-F "message=Deploy: $DEPLOY | Smoke: $SMOKE" \
|
-F "message=Deploy: $DEPLOY" \
|
||||||
-F "priority=$PRIORITY" || true
|
-F "priority=$PRIORITY" || true
|
||||||
|
|||||||
31
Dockerfile
31
Dockerfile
@@ -13,6 +13,8 @@ ARG S3_SECRET_KEY
|
|||||||
ARG S3_BUCKET
|
ARG S3_BUCKET
|
||||||
ARG S3_REGION
|
ARG S3_REGION
|
||||||
ARG S3_PREFIX
|
ARG S3_PREFIX
|
||||||
|
ARG DATABASE_URI
|
||||||
|
ARG PAYLOAD_SECRET
|
||||||
ARG BUILD_ID
|
ARG BUILD_ID
|
||||||
|
|
||||||
# Environment variables for Next.js build
|
# Environment variables for Next.js build
|
||||||
@@ -25,12 +27,21 @@ ENV S3_SECRET_KEY=$S3_SECRET_KEY
|
|||||||
ENV S3_BUCKET=$S3_BUCKET
|
ENV S3_BUCKET=$S3_BUCKET
|
||||||
ENV S3_REGION=$S3_REGION
|
ENV S3_REGION=$S3_REGION
|
||||||
ENV S3_PREFIX=$S3_PREFIX
|
ENV S3_PREFIX=$S3_PREFIX
|
||||||
|
ENV DATABASE_URI=$DATABASE_URI
|
||||||
|
ENV PAYLOAD_SECRET=$PAYLOAD_SECRET
|
||||||
ENV SKIP_RUNTIME_ENV_VALIDATION=true
|
ENV SKIP_RUNTIME_ENV_VALIDATION=true
|
||||||
|
ENV NEXT_BUILD_WORKERS=1
|
||||||
|
ENV NEXT_DISABLE_SOURCEMAPS=true
|
||||||
|
ENV SENTRY_SKIP_LOCAL_SOURCES=true
|
||||||
|
ENV NEXT_PRIVATE_LOCAL_WEBPACK=true
|
||||||
ENV CI=true
|
ENV CI=true
|
||||||
|
|
||||||
# Copy manifest files specifically for better layer caching
|
# Copy manifest files specifically for better layer caching
|
||||||
COPY pnpm-lock.yaml pnpm-workspace.yaml package.json .npmrc* ./
|
COPY pnpm-lock.yaml pnpm-workspace.yaml package.json .npmrc* ./
|
||||||
COPY apps/web/package.json ./apps/web/package.json
|
COPY apps/web/package.json ./apps/web/package.json
|
||||||
|
# Install build dependencies for native modules (like canvas)
|
||||||
|
RUN apk add --no-cache python3 make g++ pkgconfig pixman-dev cairo-dev pango-dev libjpeg-turbo-dev giflib-dev librsvg-dev
|
||||||
|
|
||||||
# Copy sibling monorepo for linked dependencies (cloned during CI)
|
# Copy sibling monorepo for linked dependencies (cloned during CI)
|
||||||
# Placing it inside /app so relative links like ../../_at-mintel resolve correctly!
|
# Placing it inside /app so relative links like ../../_at-mintel resolve correctly!
|
||||||
COPY _at-mintel* /app/_at-mintel/
|
COPY _at-mintel* /app/_at-mintel/
|
||||||
@@ -56,7 +67,7 @@ COPY . .
|
|||||||
|
|
||||||
# Build application (monorepo filter)
|
# Build application (monorepo filter)
|
||||||
ENV NODE_OPTIONS="--max_old_space_size=4096"
|
ENV NODE_OPTIONS="--max_old_space_size=4096"
|
||||||
RUN pnpm --filter @mintel/web build
|
RUN NEXT_BUILD_WORKERS=1 pnpm --filter @mintel/web build
|
||||||
|
|
||||||
# Stage 2: Runner
|
# Stage 2: Runner
|
||||||
FROM git.infra.mintel.me/mmintel/runtime:latest AS runner
|
FROM git.infra.mintel.me/mmintel/runtime:latest AS runner
|
||||||
@@ -64,19 +75,19 @@ WORKDIR /app
|
|||||||
|
|
||||||
# Copy standalone output and static files (Monorepo paths)
|
# Copy standalone output and static files (Monorepo paths)
|
||||||
# Note: Base image already handles the non-root user and basic env
|
# Note: Base image already handles the non-root user and basic env
|
||||||
COPY --from=builder /app/apps/web/public ./apps/web/public
|
COPY --from=builder --chown=1001:65533 /app/apps/web/public ./apps/web/public
|
||||||
COPY --from=builder /app/apps/web/.next/standalone ./
|
# Fallback target: Next.js sometimes resolves from the process root depending on version
|
||||||
COPY --from=builder /app/apps/web/.next/static ./apps/web/.next/static
|
COPY --from=builder --chown=1001:65533 /app/apps/web/public ./public
|
||||||
|
COPY --from=builder --chown=1001:65533 /app/apps/web/.next/standalone ./
|
||||||
|
COPY --from=builder --chown=1001:65533 /app/apps/web/.next/static ./apps/web/.next/static
|
||||||
|
|
||||||
# Explicitly copy Payload dynamically generated importMap.js excluded by Standalone tracing
|
# Explicitly copy Payload dynamically generated importMap.js excluded by Standalone tracing
|
||||||
COPY --from=builder /app/apps/web/app/(payload)/admin/importMap.js ./apps/web/app/(payload)/admin/importMap.js
|
COPY --from=builder --chown=1001:65533 /app/apps/web/app/(payload)/admin/importMap.js ./apps/web/app/(payload)/admin/importMap.js
|
||||||
|
|
||||||
# Fix permissions for the non-root user (Standard uid/gid from base image)
|
# Fix permissions for the non-root user (Standard uid/gid from base image)
|
||||||
# We do this as root before switching users
|
|
||||||
USER root
|
|
||||||
RUN chown -R 1001:65533 /app
|
|
||||||
USER nextjs
|
USER nextjs
|
||||||
|
|
||||||
# Start from the app directory to ensure references solve correctly
|
# Start from the app directory to ensure references solve correctly
|
||||||
WORKDIR /app/apps/web
|
# In Standalone mode, Next.js expects node_modules and public relative to the server.js
|
||||||
CMD ["node", "server.js"]
|
WORKDIR /app
|
||||||
|
CMD ["node", "apps/web/server.js"]
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
FROM node:20-alpine
|
FROM node:20-alpine
|
||||||
|
|
||||||
# Install essential build tools if needed (e.g., for node-gyp)
|
# Install essential build tools if needed (e.g., for node-gyp)
|
||||||
RUN apk add --no-cache libc6-compat python3 make g++
|
RUN apk add --no-cache libc6-compat git python3 make g++
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
|
|||||||
@@ -78,6 +78,28 @@
|
|||||||
box-shadow: none !important;
|
box-shadow: none !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Interactive elements pointer */
|
||||||
|
a,
|
||||||
|
button,
|
||||||
|
[role="button"],
|
||||||
|
input[type="button"],
|
||||||
|
input[type="submit"],
|
||||||
|
input[type="reset"],
|
||||||
|
select,
|
||||||
|
summary,
|
||||||
|
label[for] {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ensure disabled elements don't show pointer */
|
||||||
|
button:disabled,
|
||||||
|
[role="button"]:disabled,
|
||||||
|
input:disabled,
|
||||||
|
select:disabled,
|
||||||
|
button[disabled] {
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
/* Remove default tap highlight on mobile */
|
/* Remove default tap highlight on mobile */
|
||||||
* {
|
* {
|
||||||
-webkit-tap-highlight-color: transparent !important;
|
-webkit-tap-highlight-color: transparent !important;
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import { HeroSection } from "@/src/components/HeroSection";
|
|||||||
import { GlitchText } from "@/src/components/GlitchText";
|
import { GlitchText } from "@/src/components/GlitchText";
|
||||||
import { Marker } from "@/src/components/Marker";
|
import { Marker } from "@/src/components/Marker";
|
||||||
import { PenCircle } from "@/src/components/PenCircle";
|
import { PenCircle } from "@/src/components/PenCircle";
|
||||||
|
import { Availability } from "@/src/components/Availability";
|
||||||
|
|
||||||
export default function LandingPage() {
|
export default function LandingPage() {
|
||||||
return (
|
return (
|
||||||
@@ -313,7 +314,7 @@ export default function LandingPage() {
|
|||||||
</div>
|
</div>
|
||||||
<BodyText className="text-sm md:text-base leading-snug">
|
<BodyText className="text-sm md:text-base leading-snug">
|
||||||
Aktuell nehme ich Projekte für{" "}
|
Aktuell nehme ich Projekte für{" "}
|
||||||
<span className="font-bold text-slate-900">Q2 2026</span>{" "}
|
<span className="font-bold text-slate-900"><Availability /></span>{" "}
|
||||||
an.
|
an.
|
||||||
</BodyText>
|
</BodyText>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ const nextConfig = {
|
|||||||
'sharp',
|
'sharp',
|
||||||
'puppeteer',
|
'puppeteer',
|
||||||
'require-in-the-middle',
|
'require-in-the-middle',
|
||||||
'import-in-the-middle' // Sentry 10+ instrumentation dependencies
|
'import-in-the-middle'
|
||||||
],
|
],
|
||||||
transpilePackages: [
|
transpilePackages: [
|
||||||
'@mintel/content-engine',
|
'@mintel/content-engine',
|
||||||
@@ -37,12 +37,6 @@ const nextConfig = {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
async rewrites() {
|
|
||||||
return [
|
|
||||||
// Umami proxy rewrite handled in app/stats/api/send/route.ts
|
|
||||||
// Sentry relay handled in app/errors/api/relay/route.ts
|
|
||||||
];
|
|
||||||
},
|
|
||||||
async redirects() {
|
async redirects() {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
@@ -52,10 +46,38 @@ const nextConfig = {
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
},
|
},
|
||||||
|
async rewrites() {
|
||||||
|
return {
|
||||||
|
beforeFiles: [
|
||||||
|
// Map Patttern: Global assets moved into KLZ showcase folder
|
||||||
|
{
|
||||||
|
source: '/assets/klz-cables.com/:path*',
|
||||||
|
destination: '/showcase/klz-cables.com/assets/klz-cables.com/:path*',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
source: '/wp-content/:path*',
|
||||||
|
destination: '/showcase/klz-cables.com/wp-content/:path*',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
source: '/wp-includes/:path*',
|
||||||
|
destination: '/showcase/klz-cables.com/wp-includes/:path*',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
};
|
||||||
|
},
|
||||||
|
// In Standalone mode, Next.js expects the tracing root to be the monorepo root
|
||||||
outputFileTracingRoot: path.join(dirname, '../../'),
|
outputFileTracingRoot: path.join(dirname, '../../'),
|
||||||
};
|
};
|
||||||
|
|
||||||
const withMDX = createMDX({
|
const withMDX = createMDX({});
|
||||||
// Add markdown plugins here, as desired
|
|
||||||
});
|
// Clean, standard wrapper application
|
||||||
export default withPayload(withMintelConfig(withMDX(nextConfig)));
|
// Rewrites are now handled by src/middleware.ts for maximum robustness
|
||||||
|
const config = withMintelConfig(withMDX(nextConfig));
|
||||||
|
|
||||||
|
// Cleanup config to prevent Next.js 16 warnings about deprecated keys
|
||||||
|
if (config.serverActions) {
|
||||||
|
delete config.serverActions;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default withPayload(config);
|
||||||
|
|||||||
@@ -28,7 +28,8 @@ const dirname = path.dirname(filename);
|
|||||||
|
|
||||||
const isCLI =
|
const isCLI =
|
||||||
process.argv.includes("migrate") ||
|
process.argv.includes("migrate") ||
|
||||||
process.argv.includes("generate:importmap");
|
process.argv.includes("generate:importmap") ||
|
||||||
|
process.argv.includes("build");
|
||||||
let aiPlugin: any;
|
let aiPlugin: any;
|
||||||
if (!isCLI) {
|
if (!isCLI) {
|
||||||
const { payloadChatPlugin } = await import("@mintel/payload-ai");
|
const { payloadChatPlugin } = await import("@mintel/payload-ai");
|
||||||
@@ -67,19 +68,21 @@ export default buildConfig({
|
|||||||
globals: [
|
globals: [
|
||||||
/* AiSettings as any */
|
/* AiSettings as any */
|
||||||
],
|
],
|
||||||
email: nodemailerAdapter({
|
email: isCLI
|
||||||
defaultFromAddress: process.env.MAIL_FROM || "info@mintel.me",
|
? undefined
|
||||||
defaultFromName: "Mintel.me",
|
: nodemailerAdapter({
|
||||||
transportOptions: {
|
defaultFromAddress: process.env.MAIL_FROM || "info@mintel.me",
|
||||||
host: process.env.MAIL_HOST || "localhost",
|
defaultFromName: "Mintel.me",
|
||||||
port: parseInt(process.env.MAIL_PORT || "587", 10),
|
transportOptions: {
|
||||||
auth: {
|
host: process.env.MAIL_HOST || "localhost",
|
||||||
user: process.env.MAIL_USERNAME || "user",
|
port: parseInt(process.env.MAIL_PORT || "587", 10),
|
||||||
pass: process.env.MAIL_PASSWORD || "pass",
|
auth: {
|
||||||
},
|
user: process.env.MAIL_USERNAME || "user",
|
||||||
...(process.env.MAIL_HOST ? {} : { ignoreTLS: true }),
|
pass: process.env.MAIL_PASSWORD || "pass",
|
||||||
},
|
},
|
||||||
}),
|
...(process.env.MAIL_HOST ? {} : { ignoreTLS: true }),
|
||||||
|
},
|
||||||
|
}),
|
||||||
editor: lexicalEditor({
|
editor: lexicalEditor({
|
||||||
features: ({ defaultFeatures }) => [
|
features: ({ defaultFeatures }) => [
|
||||||
...defaultFeatures,
|
...defaultFeatures,
|
||||||
|
|||||||
26
apps/web/src/components/Availability.tsx
Normal file
26
apps/web/src/components/Availability.tsx
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Automatisierte Anzeige der Projekt-Verfügbarkeit nach Quartalen.
|
||||||
|
* Berechnet das aktuelle Quartal basierend auf dem heutigen Datum.
|
||||||
|
*/
|
||||||
|
export const Availability: React.FC = () => {
|
||||||
|
const [text, setText] = useState<string>("");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const now = new Date();
|
||||||
|
const month = now.getMonth(); // 0-11
|
||||||
|
const year = now.getFullYear();
|
||||||
|
const quarter = Math.floor(month / 3) + 1;
|
||||||
|
|
||||||
|
// Aktuelles Quartal formatiert (z.B. Q2 2026)
|
||||||
|
setText(`Q${quarter} ${year}`);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Während Hydrierung nichts rendern, um Mismatches zu vermeiden (bei Client-only Logic)
|
||||||
|
if (!text) return <span className="opacity-0">Q- 202-</span>;
|
||||||
|
|
||||||
|
return <>{text}</>;
|
||||||
|
};
|
||||||
@@ -246,6 +246,7 @@ export function ContactForm({
|
|||||||
onBack={() => setFlow("discovery")} // Can keep, but won't be seen if we hide button
|
onBack={() => setFlow("discovery")} // Can keep, but won't be seen if we hide button
|
||||||
onSubmit={() => handleSubmit()}
|
onSubmit={() => handleSubmit()}
|
||||||
isSubmitting={isSubmitting}
|
isSubmitting={isSubmitting}
|
||||||
|
error={error}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ interface DirectMessageFlowProps {
|
|||||||
onBack: () => void;
|
onBack: () => void;
|
||||||
onSubmit: () => void;
|
onSubmit: () => void;
|
||||||
isSubmitting: boolean;
|
isSubmitting: boolean;
|
||||||
|
error?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DirectMessageFlow = ({
|
export const DirectMessageFlow = ({
|
||||||
@@ -57,10 +58,32 @@ export const DirectMessageFlow = ({
|
|||||||
onBack,
|
onBack,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
isSubmitting,
|
isSubmitting,
|
||||||
|
error,
|
||||||
}: DirectMessageFlowProps) => {
|
}: DirectMessageFlowProps) => {
|
||||||
return (
|
return (
|
||||||
<div className="w-full max-w-3xl mx-auto px-4 py-12">
|
<div className="w-full max-w-3xl mx-auto px-4 py-12">
|
||||||
<div className="space-y-12">
|
<div className="space-y-12">
|
||||||
|
{error && (
|
||||||
|
<Reveal width="100%" delay={0.1}>
|
||||||
|
<div className="bg-red-50 border border-red-100 rounded-2xl p-6 flex items-start gap-4 mb-8">
|
||||||
|
<div className="p-2 bg-red-100 rounded-lg text-red-600">
|
||||||
|
<Mail size={20} className="animate-pulse" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<h3 className="text-red-900 font-bold text-sm uppercase tracking-wider">
|
||||||
|
Übertragungsfehler
|
||||||
|
</h3>
|
||||||
|
<p className="text-red-700/80 font-medium text-base">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
<div className="mt-2 text-[10px] font-mono text-red-400 uppercase tracking-widest">
|
||||||
|
Status: FEHLER_BEI_SEQUENZ_INIT
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Reveal>
|
||||||
|
)}
|
||||||
|
|
||||||
<Reveal width="100%" delay={0.2}>
|
<Reveal width="100%" delay={0.2}>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<span className="text-[10px] font-mono text-green-600 uppercase tracking-[0.3em] font-bold">
|
<span className="text-[10px] font-mono text-green-600 uppercase tracking-[0.3em] font-bold">
|
||||||
|
|||||||
@@ -4,59 +4,86 @@ export const getInquiryEmailHtml = (data: any) => `
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html>
|
<html>
|
||||||
<head>
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
<style>
|
<style>
|
||||||
body { font-family: 'Courier New', Courier, monospace; background-color: #0f172a; color: #f8fafc; margin: 0; padding: 20px; }
|
body { font-family: Georgia, 'Times New Roman', Times, serif; background-color: #f8fafc; color: #1e293b; margin: 0; padding: 40px 20px; line-height: 1.6; }
|
||||||
.container { max-width: 600px; margin: 0 auto; background-color: #1e293b; border: 1px solid #334155; padding: 40px; border-radius: 8px; }
|
.wrapper { max-width: 600px; margin: 0 auto; background-color: #ffffff; border: 1px solid #e2e8f0; border-radius: 4px; overflow: hidden; box-shadow: 0 1px 3px rgba(0,0,0,0.05); }
|
||||||
.header { border-bottom: 2px solid #22c55e; padding-bottom: 20px; margin-bottom: 30px; }
|
.top-bar { background-color: #0f172a; color: #ffffff; padding: 12px 24px; display: flex; align-items: center; justify-content: space-between; }
|
||||||
.title { font-size: 24px; font-weight: bold; letter-spacing: 2px; color: #f8fafc; }
|
.brand { font-family: system-ui, sans-serif; font-size: 10px; font-weight: 800; letter-spacing: 0.2em; text-transform: uppercase; }
|
||||||
.label { color: #94a3b8; font-size: 12px; text-transform: uppercase; letter-spacing: 1px; margin-bottom: 4px; }
|
.status { font-family: ui-monospace, monospace; font-size: 9px; color: #10b981; text-transform: uppercase; font-weight: bold; }
|
||||||
.value { font-size: 16px; margin-bottom: 20px; color: #22c55e; }
|
.header { padding: 40px 32px 24px 32px; border-bottom: 2px solid #f1f5f9; }
|
||||||
.section { margin-bottom: 30px; }
|
.heading { font-family: system-ui, sans-serif; font-size: 24px; font-weight: 800; color: #0f172a; margin: 0; letter-spacing: -0.02em; }
|
||||||
.footer { font-size: 10px; color: #64748b; margin-top: 40px; border-top: 1px solid #334155; padding-top: 20px; }
|
.content { padding: 32px; }
|
||||||
|
.data-grid { width: 100%; border-collapse: collapse; margin-bottom: 32px; }
|
||||||
|
.data-row td { padding: 12px 0; border-bottom: 1px solid #f8fafc; vertical-align: top; }
|
||||||
|
.label { font-family: system-ui, sans-serif; font-size: 9px; font-weight: 700; color: #94a3b8; text-transform: uppercase; letter-spacing: 0.1em; width: 140px; }
|
||||||
|
.value { font-size: 15px; color: #334155; }
|
||||||
|
.message-container { background-color: #f8fafc; border: 1px solid #f1f5f9; border-radius: 4px; padding: 24px; margin-top: 12px; }
|
||||||
|
.message-text { font-family: Georgia, serif; font-size: 16px; color: #1e293b; white-space: pre-wrap; margin: 0; }
|
||||||
|
.config-code { font-family: ui-monospace, monospace; font-size: 12px; color: #64748b; background: #f1f5f9; padding: 16px; border-radius: 4px; display: block; overflow-x: auto; }
|
||||||
|
.footer { padding: 32px; color: #94a3b8; font-size: 11px; text-align: center; border-top: 1px solid #f1f5f9; }
|
||||||
|
.footer-meta { font-family: ui-monospace, monospace; font-size: 9px; margin-top: 12px; text-transform: uppercase; letter-spacing: 0.05em; }
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="container">
|
<div class="wrapper">
|
||||||
<div class="header">
|
<div class="top-bar">
|
||||||
<div class="title">NEUE_ANFRAGE_INPUT</div>
|
<div class="brand">MINTEL_TECHNICAL_OPERATIONS</div>
|
||||||
|
<div class="status">● INCOMING_INQUIRY</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="label">ABSENDER</div>
|
<div class="header">
|
||||||
<div class="value">${data.name} (${data.email})</div>
|
<h1 class="heading">${data.isFreeText ? "Direktanfrage" : "System-Konfiguration"}</h1>
|
||||||
|
<p style="margin: 8px 0 0 0; color: #64748b; font-size: 14px;">Eingang für ${data.companyName || data.name}</p>
|
||||||
${data.phone ? `<div class="label">TELEFON</div><div class="value">${data.phone}</div>` : ""}
|
|
||||||
${data.role ? `<div class="label">POSITION</div><div class="value">${data.role}</div>` : ""}
|
|
||||||
|
|
||||||
<div class="label">UNTERNEHMEN</div>
|
|
||||||
<div class="value">${data.companyName || "N/A"}</div>
|
|
||||||
|
|
||||||
<div class="label">PROJEKT_TYP</div>
|
|
||||||
<div class="value">${data.projectType}</div>
|
|
||||||
|
|
||||||
${data.deadline ? `<div class="label">ZEITRAUM</div><div class="value">${data.deadline}</div>` : ""}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
${
|
<div class="content">
|
||||||
data.isFreeText
|
<table class="data-grid">
|
||||||
? `
|
<tr class="data-row">
|
||||||
<div class="section">
|
<td class="label">Absender</td>
|
||||||
<div class="label">NACHRICHT (FREITEXT)</div>
|
<td class="value"><strong>${data.name}</strong><br/><span style="color: #94a3b8; font-size: 13px;">${data.email}</span></td>
|
||||||
<div class="value" style="white-space: pre-wrap; color: #f8fafc;">${data.message}</div>
|
</tr>
|
||||||
</div>
|
${data.phone ? `
|
||||||
`
|
<tr class="data-row">
|
||||||
: `
|
<td class="label">Telefon</td>
|
||||||
<div class="section">
|
<td class="value">${data.phone}</td>
|
||||||
<div class="label">KONFIGURATION</div>
|
</tr>` : ""}
|
||||||
<div class="value" style="font-size: 12px; color: #94a3b8; background: #0f172a; padding: 15px; border-radius: 4px;">
|
${data.role ? `
|
||||||
${JSON.stringify(data.config, null, 2)}
|
<tr class="data-row">
|
||||||
|
<td class="label">Position</td>
|
||||||
|
<td class="value">${data.role}</td>
|
||||||
|
</tr>` : ""}
|
||||||
|
<tr class="data-row">
|
||||||
|
<td class="label">Unternehmen</td>
|
||||||
|
<td class="value">${data.companyName || "—"}</td>
|
||||||
|
</tr>
|
||||||
|
<tr class="data-row">
|
||||||
|
<td class="label">Projekt-Typ</td>
|
||||||
|
<td class="value">${data.projectType}</td>
|
||||||
|
</tr>
|
||||||
|
${data.deadline ? `
|
||||||
|
<tr class="data-row">
|
||||||
|
<td class="label">Zeitraum</td>
|
||||||
|
<td class="value">${data.deadline}</td>
|
||||||
|
</tr>` : ""}
|
||||||
|
</table>
|
||||||
|
|
||||||
|
${data.isFreeText ? `
|
||||||
|
<div class="label" style="margin-bottom: 8px;">Nachricht</div>
|
||||||
|
<div class="message-container">
|
||||||
|
<div class="message-text">${data.message}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
` : `
|
||||||
`
|
<div class="label" style="margin-bottom: 8px;">Konfigurations-Daten</div>
|
||||||
}
|
<code class="config-code">${JSON.stringify(data.config, null, 2)}</code>
|
||||||
|
`}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="footer">
|
<div class="footer">
|
||||||
SISTEM_STATUS: VALIDATED<br>
|
Dies ist eine systemgenerierte Benachrichtigung von mintel.me.<br/>
|
||||||
TIMESTAMP: ${new Date().toISOString()}
|
<div class="footer-meta">
|
||||||
|
TIMESTAMP: ${new Date().toISOString()} | STATUS: VALIDATED
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
@@ -67,32 +94,42 @@ export const getConfirmationEmailHtml = (data: any) => `
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html>
|
<html>
|
||||||
<head>
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
<style>
|
<style>
|
||||||
body { font-family: 'Courier New', Courier, monospace; background-color: #f8fafc; color: #0f172a; margin: 0; padding: 20px; }
|
body { font-family: Georgia, 'Times New Roman', Times, serif; background-color: #ffffff; color: #1e293b; margin: 0; padding: 40px 20px; line-height: 1.8; }
|
||||||
.container { max-width: 600px; margin: 0 auto; background-color: #ffffff; border: 1px solid #e2e8f0; padding: 40px; border-radius: 12px; box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1); }
|
.wrapper { max-width: 600px; margin: 0 auto; }
|
||||||
.header { text-align: center; margin-bottom: 40px; }
|
.logo-container { padding-bottom: 48px; border-bottom: 1px solid #f1f5f9; margin-bottom: 48px; }
|
||||||
.status-badge { display: inline-block; padding: 4px 12px; background-color: #22c55e; color: #0f172a; font-size: 10px; font-weight: bold; border-radius: 9999px; margin-bottom: 16px; }
|
.status-badge { display: inline-block; font-family: system-ui, sans-serif; font-size: 9px; font-weight: 800; color: #3b82f6; text-transform: uppercase; letter-spacing: 0.2em; border: 1px solid #dbeafe; background: #eff6ff; padding: 4px 12px; border-radius: 99px; margin-bottom: 24px; }
|
||||||
.title { font-size: 28px; font-weight: bold; letter-spacing: -0.02em; margin-bottom: 8px; }
|
.greeting { font-family: system-ui, sans-serif; font-size: 32px; font-weight: 800; color: #0f172a; margin: 0 0 16px 0; letter-spacing: -0.03em; }
|
||||||
.subtitle { color: #64748b; font-size: 16px; line-height: 1.5; }
|
.body-text { font-size: 18px; color: #334155; margin-bottom: 32px; }
|
||||||
.content { line-height: 1.6; color: #334155; margin-bottom: 40px; }
|
.cta-box { border-left: 2px solid #0f172a; padding: 8px 0 8px 24px; margin: 40px 0; }
|
||||||
.footer { text-align: center; font-size: 12px; color: #94a3b8; border-top: 1px solid #f1f5f9; padding-top: 30px; }
|
.footer { margin-top: 80px; padding-top: 24px; border-top: 1px solid #f1f5f9; font-family: system-ui, sans-serif; font-size: 12px; color: #94a3b8; text-align: center; }
|
||||||
|
.footer a { color: #0f172a; text-decoration: none; font-weight: 600; }
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="container">
|
<div class="wrapper">
|
||||||
<div class="header">
|
<div class="logo-container">
|
||||||
<div class="status-badge">SEQUENZ_INITIIERT</div>
|
<div class="status-badge">Anfrage eingegangen</div>
|
||||||
<div class="title">Hallo ${data.name.split(" ")[0]},</div>
|
<h1 class="greeting">Hallo ${data.name.split(" ")[0]},</h1>
|
||||||
<div class="subtitle">vielen Dank für deine Anfrage.</div>
|
<p class="body-text">
|
||||||
|
vielen Dank für dein Interesse an einer Zusammenarbeit. Deine Nachricht bezüglich <strong>${data.companyName || "deines Projekts"}</strong> ist sicher bei mir angekommen.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="content">
|
<div class="body-text">
|
||||||
<p>Ich habe deine Nachricht erhalten und schaue mir die Details zu <strong>${data.companyName || "deinem Projekt"}</strong> umgehend an.</p>
|
Ich werde mir die Details umgehend ansehen und mich in der Regel innerhalb der nächsten 24 Stunden persönlich bei dir zurückmelden, um die nächsten Schritte zu besprechen.
|
||||||
<p>Normalerweise melde ich mich innerhalb von 24 Stunden bei dir zurück, um die nächsten Schritte zu besprechen.</p>
|
</div>
|
||||||
|
|
||||||
|
<div class="cta-box">
|
||||||
|
<p style="margin:0; font-style: italic; color: #64748b;">
|
||||||
|
„Technical problems are just puzzles with more moving parts.“
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="footer">
|
<div class="footer">
|
||||||
© ${new Date().getFullYear()} mintel.me — Technical Problem Solving
|
© ${new Date().getFullYear()} <a href="https://mintel.me">mintel.me</a> — Marc Mintel<br/>
|
||||||
|
Professional Website Systems & Technical Architecture
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -196,11 +196,13 @@ export const IframeSection: React.FC<IframeSectionProps> = ({
|
|||||||
|
|
||||||
const updateScrollState = React.useCallback(() => {
|
const updateScrollState = React.useCallback(() => {
|
||||||
try {
|
try {
|
||||||
|
const win = iframeRef.current?.contentWindow;
|
||||||
const doc = iframeRef.current?.contentDocument?.documentElement;
|
const doc = iframeRef.current?.contentDocument?.documentElement;
|
||||||
if (doc) {
|
if (doc && win) {
|
||||||
const atTop = doc.scrollTop <= 5;
|
const scrollTop = doc.scrollTop || win.scrollY || 0;
|
||||||
|
const atTop = scrollTop <= 5;
|
||||||
const atBottom =
|
const atBottom =
|
||||||
doc.scrollTop + doc.clientHeight >= doc.scrollHeight - 5;
|
scrollTop + doc.clientHeight >= doc.scrollHeight - 5;
|
||||||
const isScrollable = doc.scrollHeight > doc.clientHeight + 10;
|
const isScrollable = doc.scrollHeight > doc.clientHeight + 10;
|
||||||
setScrollState({ atTop, atBottom, isScrollable });
|
setScrollState({ atTop, atBottom, isScrollable });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,11 +45,23 @@ export async function sendEmail({
|
|||||||
subject,
|
subject,
|
||||||
html,
|
html,
|
||||||
}: SendEmailOptions) {
|
}: SendEmailOptions) {
|
||||||
const recipients = to || env.MAIL_RECIPIENTS;
|
let recipients = to || env.MAIL_RECIPIENTS;
|
||||||
|
let from = env.MAIL_FROM;
|
||||||
|
|
||||||
|
if (!from) {
|
||||||
|
from = "info@mintel.me";
|
||||||
|
console.warn("MAIL_FROM is empty. Using fallback: info@mintel.me");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!recipients) {
|
||||||
|
recipients = "marc@mintel.me";
|
||||||
|
console.warn("MAIL_RECIPIENTS is empty. Using fallback: marc@mintel.me");
|
||||||
|
}
|
||||||
|
|
||||||
const transporter = getTransporter();
|
const transporter = getTransporter();
|
||||||
|
|
||||||
const mailOptions = {
|
const mailOptions = {
|
||||||
from: env.MAIL_FROM,
|
from,
|
||||||
to: recipients,
|
to: recipients,
|
||||||
replyTo,
|
replyTo,
|
||||||
subject,
|
subject,
|
||||||
|
|||||||
58
apps/web/src/middleware.ts
Normal file
58
apps/web/src/middleware.ts
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import type { NextRequest } from 'next/server';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PRODUCTION STABILIZATION MIDDLEWARE
|
||||||
|
* This middleware handles legacy asset routing for the KLZ showcase,
|
||||||
|
* ensuring assets are correctly mapped regardless of Next.js config wrapper behavior.
|
||||||
|
*/
|
||||||
|
export function middleware(request: NextRequest) {
|
||||||
|
const { pathname } = request.nextUrl;
|
||||||
|
|
||||||
|
|
||||||
|
// 2. Legacy Showcase Asset Mapping
|
||||||
|
// Handles:
|
||||||
|
// - Global assets (/wp-content/*, /wp-includes/*, /assets/*)
|
||||||
|
// - Showcase-specific resources (/case-studies/klz-cables/index.html, etc.)
|
||||||
|
|
||||||
|
// Normalize pathname to ensure we're matching correctly
|
||||||
|
const path = pathname;
|
||||||
|
|
||||||
|
// Map Pattern 2: Showcase-prefixed assets (used for relative links within the showcase)
|
||||||
|
const showcaseMatch = path.match(/^\/(?:case-studies|work|blog)\/([^\/]+)\/(.*)/);
|
||||||
|
if (showcaseMatch) {
|
||||||
|
const [, slug, remainingPath] = showcaseMatch;
|
||||||
|
|
||||||
|
// Safety: Only rewrite if it looks like a static asset (has extension)
|
||||||
|
// or belongs to known legacy folders. This prevents catching Next.js routes.
|
||||||
|
if (remainingPath.match(/\.(?:html|php|js|css|png|jpg|jpeg|svg|gif|webp|woff2?|ttf|pdf|json|xml)$/) ||
|
||||||
|
remainingPath.includes('wp-content/') ||
|
||||||
|
remainingPath.includes('wp-includes/') ||
|
||||||
|
remainingPath.includes('assets/')) {
|
||||||
|
|
||||||
|
// Normalize slug: klz-cables -> klz-cables.com (literal folder name)
|
||||||
|
const directory = slug === 'klz-cables' ? 'klz-cables.com' : slug;
|
||||||
|
|
||||||
|
// If the remaining path already starts with assets/directory, don't double it
|
||||||
|
const targetPath = remainingPath.startsWith(`assets/${directory}/`)
|
||||||
|
? `/${remainingPath}`
|
||||||
|
: `/${remainingPath}`;
|
||||||
|
|
||||||
|
return NextResponse.rewrite(new URL(`/showcase/${directory}${targetPath}`, request.url));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.next();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Optimization: Only run middleware for legacy asset paths to minimize overhead
|
||||||
|
export const config = {
|
||||||
|
matcher: [
|
||||||
|
'/wp-content/:path*',
|
||||||
|
'/wp-includes/:path*',
|
||||||
|
'/assets/:path*',
|
||||||
|
'/case-studies/:path*',
|
||||||
|
'/work/:path*',
|
||||||
|
'/blog/:path*',
|
||||||
|
],
|
||||||
|
};
|
||||||
@@ -79,6 +79,28 @@
|
|||||||
outline: 2px solid #3b82f6;
|
outline: 2px solid #3b82f6;
|
||||||
outline-offset: 2px;
|
outline-offset: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Interactive elements pointer */
|
||||||
|
a,
|
||||||
|
button,
|
||||||
|
[role="button"],
|
||||||
|
input[type="button"],
|
||||||
|
input[type="submit"],
|
||||||
|
input[type="reset"],
|
||||||
|
select,
|
||||||
|
summary,
|
||||||
|
label[for] {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ensure disabled elements don't show pointer */
|
||||||
|
button:disabled,
|
||||||
|
[role="button"]:disabled,
|
||||||
|
input:disabled,
|
||||||
|
select:disabled,
|
||||||
|
button[disabled] {
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Components - Tailwind utility classes */
|
/* Components - Tailwind utility classes */
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ services:
|
|||||||
# - CI=true
|
# - CI=true
|
||||||
- NPM_TOKEN=${NPM_TOKEN:-}
|
- NPM_TOKEN=${NPM_TOKEN:-}
|
||||||
- DATABASE_URI=postgres://${postgres_DB_USER:-payload}:${postgres_DB_PASSWORD:-payload}@postgres-db:5432/${postgres_DB_NAME:-payload}
|
- DATABASE_URI=postgres://${postgres_DB_USER:-payload}:${postgres_DB_PASSWORD:-payload}@postgres-db:5432/${postgres_DB_NAME:-payload}
|
||||||
|
- QDRANT_URL=http://qdrant:6333
|
||||||
- PAYLOAD_SECRET=dev-secret
|
- PAYLOAD_SECRET=dev-secret
|
||||||
command: >
|
command: >
|
||||||
sh -c "pnpm install --no-frozen-lockfile && pnpm --filter @mintel/web dev"
|
sh -c "pnpm install --no-frozen-lockfile && pnpm --filter @mintel/web dev"
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ services:
|
|||||||
mintel-me-app:
|
mintel-me-app:
|
||||||
image: registry.infra.mintel.me/mintel/mintel.me:${IMAGE_TAG:-latest}
|
image: registry.infra.mintel.me/mintel/mintel.me:${IMAGE_TAG:-latest}
|
||||||
restart: always
|
restart: always
|
||||||
|
command: node apps/web/server.js
|
||||||
networks:
|
networks:
|
||||||
- default
|
- default
|
||||||
- infra
|
- infra
|
||||||
@@ -12,7 +13,6 @@ services:
|
|||||||
# HTTP ⇒ HTTPS redirect
|
# HTTP ⇒ HTTPS redirect
|
||||||
- 'traefik.http.routers.${PROJECT_NAME}-web.rule=${TRAEFIK_HOST_RULE:-Host("${TRAEFIK_HOST:-mintel.localhost}")}'
|
- 'traefik.http.routers.${PROJECT_NAME}-web.rule=${TRAEFIK_HOST_RULE:-Host("${TRAEFIK_HOST:-mintel.localhost}")}'
|
||||||
- "traefik.http.routers.${PROJECT_NAME}-web.entrypoints=web"
|
- "traefik.http.routers.${PROJECT_NAME}-web.entrypoints=web"
|
||||||
# - "traefik.http.routers.${PROJECT_NAME}-web.middlewares=redirect-https"
|
|
||||||
# HTTPS router (Standard)
|
# HTTPS router (Standard)
|
||||||
- 'traefik.http.routers.${PROJECT_NAME}.rule=${TRAEFIK_HOST_RULE:-Host("${TRAEFIK_HOST:-mintel.localhost}")}'
|
- 'traefik.http.routers.${PROJECT_NAME}.rule=${TRAEFIK_HOST_RULE:-Host("${TRAEFIK_HOST:-mintel.localhost}")}'
|
||||||
- "traefik.http.routers.${PROJECT_NAME}.entrypoints=${TRAEFIK_ENTRYPOINT:-web}"
|
- "traefik.http.routers.${PROJECT_NAME}.entrypoints=${TRAEFIK_ENTRYPOINT:-web}"
|
||||||
@@ -26,7 +26,7 @@ services:
|
|||||||
- "caddy.reverse_proxy={{upstreams 3000}}"
|
- "caddy.reverse_proxy={{upstreams 3000}}"
|
||||||
|
|
||||||
# Public Router (Whitelist for OG Images, Sitemaps, Health)
|
# Public Router (Whitelist for OG Images, Sitemaps, Health)
|
||||||
- 'traefik.http.routers.${PROJECT_NAME}-public.rule=(${TRAEFIK_HOST_RULE:-Host("${TRAEFIK_HOST:-mintel.localhost}")}) && (PathPrefix("/health") || PathPrefix("/api/health") || PathPrefix("/sitemap.xml") || PathPrefix("/robots.txt") || PathPrefix("/manifest.webmanifest") || PathPrefix("/api/og") || PathRegexp(".*opengraph-image.*") || PathRegexp(".*sitemap.*"))'
|
- 'traefik.http.routers.${PROJECT_NAME}-public.rule=(${TRAEFIK_HOST_RULE:-Host("${TRAEFIK_HOST:-mintel.localhost}")}) && (PathPrefix("/health") || PathPrefix("/api/health") || PathPrefix("/sitemap.xml") || PathPrefix("/robots.txt") || PathPrefix("/manifest.webmanifest") || PathPrefix("/api/og") || PathPrefix("/assets") || PathPrefix("/wp-content") || PathPrefix("/wp-includes") || PathPrefix("/showcase") || PathRegexp(".*opengraph-image.*") || PathRegexp(".*sitemap.*") || PathRegexp(".*\\.(png|jpg|jpeg|webp|svg|ico|pdf|mp4|webm|woff2)$"))'
|
||||||
- "traefik.http.routers.${PROJECT_NAME}-public.entrypoints=${TRAEFIK_ENTRYPOINT:-web}"
|
- "traefik.http.routers.${PROJECT_NAME}-public.entrypoints=${TRAEFIK_ENTRYPOINT:-web}"
|
||||||
- "traefik.http.routers.${PROJECT_NAME}-public.tls.certresolver=${TRAEFIK_CERT_RESOLVER:-}"
|
- "traefik.http.routers.${PROJECT_NAME}-public.tls.certresolver=${TRAEFIK_CERT_RESOLVER:-}"
|
||||||
- "traefik.http.routers.${PROJECT_NAME}-public.tls=${TRAEFIK_TLS:-false}"
|
- "traefik.http.routers.${PROJECT_NAME}-public.tls=${TRAEFIK_TLS:-false}"
|
||||||
@@ -36,6 +36,7 @@ services:
|
|||||||
|
|
||||||
# Middlewares
|
# Middlewares
|
||||||
- "traefik.http.middlewares.${PROJECT_NAME}-ratelimit.ratelimit.average=100"
|
- "traefik.http.middlewares.${PROJECT_NAME}-ratelimit.ratelimit.average=100"
|
||||||
|
- "traefik.http.middlewares.${PROJECT_NAME}-ratelimit.ratelimit.burst=200"
|
||||||
- "traefik.http.middlewares.${PROJECT_NAME}-ratelimit.ratelimit.burst=50"
|
- "traefik.http.middlewares.${PROJECT_NAME}-ratelimit.ratelimit.burst=50"
|
||||||
|
|
||||||
# Gatekeeper Router (Path-based)
|
# Gatekeeper Router (Path-based)
|
||||||
@@ -53,6 +54,12 @@ services:
|
|||||||
# Forwarded Headers
|
# Forwarded Headers
|
||||||
- "traefik.http.middlewares.${PROJECT_NAME}-forward.headers.customrequestheaders.X-Forwarded-Proto=https"
|
- "traefik.http.middlewares.${PROJECT_NAME}-forward.headers.customrequestheaders.X-Forwarded-Proto=https"
|
||||||
- "traefik.http.middlewares.${PROJECT_NAME}-forward.headers.customrequestheaders.X-Forwarded-Ssl=on"
|
- "traefik.http.middlewares.${PROJECT_NAME}-forward.headers.customrequestheaders.X-Forwarded-Ssl=on"
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "wget --no-verbose --tries=1 --spider http://127.0.0.1:3000/ || exit 1"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
start_period: 20s
|
||||||
|
|
||||||
gatekeeper:
|
gatekeeper:
|
||||||
profiles: ["gatekeeper"]
|
profiles: ["gatekeeper"]
|
||||||
|
|||||||
36
scripts/visual_debug.mjs
Normal file
36
scripts/visual_debug.mjs
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import puppeteer from 'puppeteer';
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
// Launch browser
|
||||||
|
const browser = await puppeteer.launch({ headless: true });
|
||||||
|
const page = await browser.newPage();
|
||||||
|
|
||||||
|
// Set viewport
|
||||||
|
await page.setViewport({ width: 1920, height: 1080 });
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.log("Navigating to case study page...");
|
||||||
|
await page.goto('https://mintel.me/case-studies/klz-cables', { waitUntil: 'networkidle2' });
|
||||||
|
|
||||||
|
console.log("Waiting a bit for iframes to load...");
|
||||||
|
await new Promise(r => setTimeout(r, 5000));
|
||||||
|
|
||||||
|
// Let's get console logs from the page too
|
||||||
|
page.on('console', msg => console.log('PAGE LOG:', msg.text()));
|
||||||
|
|
||||||
|
console.log("Taking screenshot...");
|
||||||
|
await page.screenshot({ path: '/Users/marcmintel/Projects/mintel.me/screenshot_case_study.png', fullPage: true });
|
||||||
|
|
||||||
|
// Also take a screenshot of the actual showcase HTML page directly to compare
|
||||||
|
console.log("Navigating directly to showcase HTML...");
|
||||||
|
await page.goto('https://mintel.me/showcase/klz-cables.com/power-cables-medium-voltage-cables.html', { waitUntil: 'networkidle2' });
|
||||||
|
await new Promise(r => setTimeout(r, 3000));
|
||||||
|
await page.screenshot({ path: '/Users/marcmintel/Projects/mintel.me/screenshot_showcase.png', fullPage: true });
|
||||||
|
|
||||||
|
console.log("Done!");
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Error:", e);
|
||||||
|
} finally {
|
||||||
|
await browser.close();
|
||||||
|
}
|
||||||
|
})();
|
||||||
1
wget_out.txt
Normal file
1
wget_out.txt
Normal file
@@ -0,0 +1 @@
|
|||||||
|
zsh:1: command not found: wget
|
||||||
Reference in New Issue
Block a user