Compare commits
83 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| afa586c833 | |||
| 9b55c42f35 | |||
| 554f958ba2 | |||
| c9f174e828 | |||
| 8dce4890c4 | |||
| 963e572291 | |||
| 9887324469 | |||
| 78da0fdea9 | |||
| 91db336c0e | |||
| cfbff88e45 | |||
| 90b41d2a15 | |||
| 3f45293c2e | |||
| 7e957d6fb4 | |||
| 4334d31445 | |||
| 1559037029 | |||
| b7438f2718 | |||
| a090373825 | |||
| 3c3d019924 | |||
| c6d20119c7 | |||
| 04a19742da | |||
| 39b96a51db | |||
| d27e1f91ad | |||
| 18cd576ee9 | |||
| 3d2f240cf6 | |||
| 6260b40b91 | |||
| 109c8389f3 | |||
| 55cb073a6d | |||
| fb87fd52f7 | |||
| da9b2fb9cf | |||
| 5032700c2c | |||
| d44838254c | |||
| 1742604a7a | |||
| ca59f32b99 | |||
| 0e98659506 | |||
| 744e1da716 | |||
| f2e38f9c29 | |||
| b85312c433 | |||
| 081ebec567 | |||
| d9dece37e5 | |||
| 9495772d1a | |||
| 248a0dc1f0 | |||
| eecc1b6108 | |||
| e0b38e617d | |||
| 8a9339f00f | |||
| f23fa4e2c8 | |||
| e177693aae | |||
| 39920bf432 | |||
| 04d3dac627 | |||
| bc0a6627c0 | |||
| 8030e45920 | |||
| fbc7b9bba0 | |||
| 05a90df512 | |||
| 817ee05710 | |||
| 5d01c2e963 | |||
| 1e32b8fbea | |||
| 1919d8bc2a | |||
| 67d47e3ec7 | |||
| 2f8d015823 | |||
| e18bd0b6f3 | |||
| 2ca79ee23a | |||
| e28c3c0f96 | |||
| 8f3f56a12c | |||
| 8d547c559e | |||
| 8ff4503270 | |||
| ad08c6c1f3 | |||
| 1f188c84b4 | |||
| e50cdade6c | |||
| 17bbb2f0e0 | |||
| ffb73e4b06 | |||
| 71b30ba8c5 | |||
| e9ea253021 | |||
| 237bd46593 | |||
| 40ebdb31d9 | |||
| 8f39ec3d35 | |||
| 7734440b90 | |||
| 42295c3c41 | |||
| 1e00690dd8 | |||
| 90e9f37849 | |||
| 9eaaa798a3 | |||
| f7685fdb2f | |||
| 609422b5b9 | |||
| 76cf6e7b62 | |||
| cc04b71327 |
33
.gitea/workflows/ci.yml
Normal file
33
.gitea/workflows/ci.yml
Normal file
@@ -0,0 +1,33 @@
|
||||
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
|
||||
@@ -7,138 +7,137 @@ on:
|
||||
tags:
|
||||
- 'v*'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
skip_checks:
|
||||
description: 'Skip tests? (true/false)'
|
||||
required: false
|
||||
default: 'false'
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}
|
||||
cancel-in-progress: false
|
||||
group: ${{ github.workflow }}-${{ (github.ref_type == 'tag' && !contains(github.ref_name, '-')) && 'prod' || (github.ref_name == 'main' && 'testing' || github.ref_name) }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# JOB 1: Prepare Environment
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
prepare:
|
||||
name: 🔍 Prepare Environment
|
||||
name: 🔍 Prepare
|
||||
runs-on: docker
|
||||
container:
|
||||
image: catthehacker/ubuntu:act-latest
|
||||
outputs:
|
||||
target: ${{ steps.determine.outputs.target }}
|
||||
image_tag: ${{ steps.determine.outputs.image_tag }}
|
||||
env_file: ${{ steps.determine.outputs.env_file }}
|
||||
traefik_host: ${{ steps.determine.outputs.traefik_host }}
|
||||
next_public_base_url: ${{ steps.determine.outputs.next_public_base_url }}
|
||||
directus_url: ${{ steps.determine.outputs.directus_url }}
|
||||
directus_host: ${{ steps.determine.outputs.directus_host }}
|
||||
gatekeeper_host: ${{ steps.determine.outputs.gatekeeper_host }}
|
||||
traefik_rule: ${{ steps.determine.outputs.traefik_rule }}
|
||||
gatekeeper_rule: ${{ steps.determine.outputs.gatekeeper_rule }}
|
||||
traefik_middlewares: ${{ steps.determine.outputs.traefik_middlewares }}
|
||||
next_public_url: ${{ steps.determine.outputs.next_public_url }}
|
||||
project_name: ${{ steps.determine.outputs.project_name }}
|
||||
short_sha: ${{ steps.determine.outputs.short_sha }}
|
||||
container:
|
||||
image: catthehacker/ubuntu:act-latest
|
||||
steps:
|
||||
- name: 🔍 Debug Info
|
||||
- name: 🧹 Maintenance (High Density Cleanup)
|
||||
shell: bash
|
||||
run: |
|
||||
echo "ref_name: ${{ github.ref_name }}"
|
||||
echo "ref_type: ${{ github.ref_type }}"
|
||||
echo "tag: ${{ github.ref_name }}"
|
||||
|
||||
- name: 🧹 Maintenance (Runner Cleanup)
|
||||
continue-on-error: true
|
||||
shell: bash
|
||||
run: |
|
||||
docker image prune -f || true
|
||||
docker builder prune -f --filter "until=24h" || true
|
||||
echo "Purging old build layers and dangling images..."
|
||||
docker image prune -f
|
||||
docker builder prune -f --filter "until=6h"
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 2
|
||||
|
||||
- name: 🔍 Determine Environment
|
||||
- name: 🔍 Environment ermitteln
|
||||
id: determine
|
||||
shell: bash
|
||||
run: |
|
||||
REF="${{ github.ref }}"
|
||||
REF_NAME="${{ github.ref_name }}"
|
||||
REF_TYPE="${{ github.ref_type }}"
|
||||
REF="${{ github.ref_name }}"
|
||||
SHORT_SHA=$(echo "${{ github.sha }}" | cut -c1-7)
|
||||
DOMAIN_BASE="mb-grid-solutions.com"
|
||||
PRJ_ID="mb-grid-solutions"
|
||||
DOMAIN="mb-grid-solutions.com"
|
||||
PRJ="mb-grid-solutions"
|
||||
|
||||
echo "Detecting environment for ref: $REF ($REF_NAME, type: $REF_TYPE)"
|
||||
|
||||
# Fallback for REF_TYPE if missing
|
||||
if [[ -z "$REF_TYPE" ]]; then
|
||||
if [[ "$REF" == refs/tags/* ]]; then
|
||||
REF_TYPE="tag"
|
||||
elif [[ "$REF" == refs/heads/* ]]; then
|
||||
REF_TYPE="branch"
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ "$REF_TYPE" == "branch" && "$REF_NAME" == "main" ]]; then
|
||||
if [[ "${{ github.ref_type }}" == "branch" && "$REF" == "main" ]]; then
|
||||
TARGET="testing"
|
||||
IMAGE_TAG="testing-${SHORT_SHA}"
|
||||
IMAGE_TAG="main-${SHORT_SHA}"
|
||||
ENV_FILE=".env.testing"
|
||||
TRAEFIK_HOST="testing.${DOMAIN_BASE}"
|
||||
GATEKEEPER_HOST="gatekeeper.testing.${DOMAIN_BASE}"
|
||||
NEXT_PUBLIC_BASE_URL="https://testing.${DOMAIN_BASE}"
|
||||
DIRECTUS_URL="https://cms.testing.${DOMAIN_BASE}"
|
||||
DIRECTUS_HOST="cms.testing.${DOMAIN_BASE}"
|
||||
elif [[ "$REF_TYPE" == "tag" ]]; then
|
||||
if [[ "$REF_NAME" =~ ^v[0-9]+\.[0-9]+(\.[0-9]+)?$ ]]; then
|
||||
TRAEFIK_HOST="testing.${DOMAIN}"
|
||||
elif [[ "${{ github.ref_type }}" == "tag" ]]; then
|
||||
if [[ "$REF" =~ ^v[0-9]+\.[0-9]+(\.[0-9]+)?$ ]]; then
|
||||
TARGET="production"
|
||||
IMAGE_TAG="$REF_NAME"
|
||||
IMAGE_TAG="$REF"
|
||||
ENV_FILE=".env.prod"
|
||||
TRAEFIK_HOST="${DOMAIN_BASE}" # Primary domain
|
||||
GATEKEEPER_HOST="gatekeeper.${DOMAIN_BASE}"
|
||||
NEXT_PUBLIC_BASE_URL="https://${DOMAIN_BASE}"
|
||||
DIRECTUS_URL="https://cms.${DOMAIN_BASE}"
|
||||
DIRECTUS_HOST="cms.${DOMAIN_BASE}"
|
||||
elif [[ "$REF_NAME" =~ -rc || "$REF_NAME" =~ -beta || "$REF_NAME" =~ -alpha ]]; then
|
||||
TARGET="staging"
|
||||
IMAGE_TAG="$REF_NAME"
|
||||
ENV_FILE=".env.staging"
|
||||
TRAEFIK_HOST="staging.${DOMAIN_BASE}"
|
||||
GATEKEEPER_HOST="gatekeeper.staging.${DOMAIN_BASE}"
|
||||
NEXT_PUBLIC_BASE_URL="https://staging.${DOMAIN_BASE}"
|
||||
DIRECTUS_URL="https://cms.staging.${DOMAIN_BASE}"
|
||||
DIRECTUS_HOST="cms.staging.${DOMAIN_BASE}"
|
||||
TRAEFIK_HOST="${DOMAIN}, www.${DOMAIN}"
|
||||
else
|
||||
TARGET="skip"
|
||||
echo "Tag $REF_NAME did not match any environment pattern."
|
||||
TARGET="staging"
|
||||
IMAGE_TAG="$REF"
|
||||
ENV_FILE=".env.staging"
|
||||
TRAEFIK_HOST="staging.${DOMAIN}"
|
||||
fi
|
||||
else
|
||||
TARGET="skip"
|
||||
echo "Ref type $REF_TYPE is not handled for deployment."
|
||||
fi
|
||||
|
||||
# Determine Rules based on target (if not skipped)
|
||||
if [[ "$TARGET" != "skip" ]]; then
|
||||
if [[ "$TARGET" == "production" ]]; then
|
||||
TRAEFIK_RULE="Host(\`${DOMAIN_BASE}\`) || Host(\`www.${DOMAIN_BASE}\`)"
|
||||
GATEKEEPER_RULE="(Host(\`${DOMAIN_BASE}\`) || Host(\`www.${DOMAIN_BASE}\`)) && PathPrefix(\`/gatekeeper\`) || Host(\`gatekeeper.${DOMAIN_BASE}\`)"
|
||||
TRAEFIK_MIDDLEWARES="compress"
|
||||
# Standardize Traefik Rule
|
||||
if [[ "$TRAEFIK_HOST" == *","* ]]; then
|
||||
TRAEFIK_RULE=$(echo "$TRAEFIK_HOST" | sed 's/,/ /g' | awk '{for(i=1;i<=NF;i++) printf "Host(\`%s\`)%s", $i, (i==NF?"":" || ")}')
|
||||
PRIMARY_HOST=$(echo "$TRAEFIK_HOST" | cut -d',' -f1 | sed 's/ //g')
|
||||
else
|
||||
TRAEFIK_RULE="Host(\`${TRAEFIK_HOST}\`)"
|
||||
GATEKEEPER_RULE="(Host(\`${TRAEFIK_HOST}\`) && PathPrefix(\`/gatekeeper\`)) || Host(\`gatekeeper.${TRAEFIK_HOST}\`)"
|
||||
TRAEFIK_MIDDLEWARES="${PRJ_ID}-${TARGET}-auth"
|
||||
TRAEFIK_RULE="Host(\`$TRAEFIK_HOST\`)"
|
||||
PRIMARY_HOST="$TRAEFIK_HOST"
|
||||
fi
|
||||
|
||||
{
|
||||
echo "target=$TARGET"
|
||||
echo "image_tag=$IMAGE_TAG"
|
||||
echo "env_file=$ENV_FILE"
|
||||
echo "traefik_host=$PRIMARY_HOST"
|
||||
echo "traefik_rule=$TRAEFIK_RULE"
|
||||
echo "next_public_url=https://$PRIMARY_HOST"
|
||||
echo "project_name=$PRJ-$TARGET"
|
||||
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..."
|
||||
# Extract any @mintel/ version (they should be synced in monorepo)
|
||||
UPSTREAM_VERSION=$(grep -o '"@mintel/.*": "[^"]*"' package.json | grep -v "next-utils" | cut -d'"' -f4 | sed 's/\^//; s/\~//' | sort -V | tail -1)
|
||||
TAG_TO_WAIT="v$UPSTREAM_VERSION"
|
||||
|
||||
if [[ -n "$UPSTREAM_VERSION" && "$UPSTREAM_VERSION" != "workspace:"* ]]; then
|
||||
# 1. Discovery (Works without token for public repositories)
|
||||
UPSTREAM_SHA=$(git ls-remote --tags https://git.infra.mintel.me/mmintel/at-mintel.git "$TAG_TO_WAIT" | grep "$TAG_TO_WAIT" | tail -n1 | awk '{print $1}')
|
||||
|
||||
if [[ -z "$UPSTREAM_SHA" ]]; then
|
||||
echo "❌ Error: Tag $TAG_TO_WAIT not found in mmintel/at-mintel."
|
||||
exit 1
|
||||
fi
|
||||
echo "✅ Tag verified: Found upstream SHA $UPSTREAM_SHA for $TAG_TO_WAIT"
|
||||
|
||||
# 2. Status Check (Requires PAT for cross-repo API access)
|
||||
POLL_TOKEN="${{ secrets.GITEA_PAT || secrets.MINTEL_PRIVATE_TOKEN }}"
|
||||
|
||||
if [[ -n "$POLL_TOKEN" ]]; then
|
||||
echo "⏳ POLL_TOKEN found. Checking upstream build status..."
|
||||
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="$POLL_TOKEN" ./wait-for-upstream.sh "mmintel/at-mintel" "$TAG_TO_WAIT"
|
||||
else
|
||||
echo "ℹ️ No PAT secret found. Skipping build status wait (Actions API is restricted)."
|
||||
echo " If this build fails, ensure that mmintel/at-mintel $TAG_TO_WAIT has finished its Docker build."
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
else
|
||||
echo "target=skip" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
echo "Target determined: $TARGET"
|
||||
echo "Image tag: $IMAGE_TAG"
|
||||
|
||||
echo "target=$TARGET" >> "$GITHUB_OUTPUT"
|
||||
echo "image_tag=$IMAGE_TAG" >> "$GITHUB_OUTPUT"
|
||||
echo "env_file=$ENV_FILE" >> "$GITHUB_OUTPUT"
|
||||
echo "traefik_host=$TRAEFIK_HOST" >> "$GITHUB_OUTPUT"
|
||||
echo "traefik_rule=$TRAEFIK_RULE" >> "$GITHUB_OUTPUT"
|
||||
echo "gatekeeper_rule=$GATEKEEPER_RULE" >> "$GITHUB_OUTPUT"
|
||||
echo "traefik_middlewares=$TRAEFIK_MIDDLEWARES" >> "$GITHUB_OUTPUT"
|
||||
echo "gatekeeper_host=$GATEKEEPER_HOST" >> "$GITHUB_OUTPUT"
|
||||
echo "next_public_base_url=$NEXT_PUBLIC_BASE_URL" >> "$GITHUB_OUTPUT"
|
||||
echo "directus_url=$DIRECTUS_URL" >> "$GITHUB_OUTPUT"
|
||||
echo "directus_host=$DIRECTUS_HOST" >> "$GITHUB_OUTPUT"
|
||||
echo "project_name=$PRJ_ID-$TARGET" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# JOB 2: QA (Lint, Build Test)
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
qa:
|
||||
name: 🧪 QA
|
||||
needs: prepare
|
||||
@@ -153,25 +152,31 @@ jobs:
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
- name: Install dependencies
|
||||
shell: bash
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v3
|
||||
with:
|
||||
version: 10
|
||||
- name: 🔐 Registry Auth
|
||||
run: |
|
||||
corepack enable
|
||||
pnpm install --frozen-lockfile
|
||||
env:
|
||||
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
- name: 🧪 Lint
|
||||
shell: bash
|
||||
run: pnpm lint
|
||||
env:
|
||||
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
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 store prune
|
||||
pnpm install --no-frozen-lockfile
|
||||
- name: 🧪 QA Checks
|
||||
if: github.event.inputs.skip_checks != 'true'
|
||||
run: |
|
||||
pnpm lint
|
||||
pnpm exec tsc --noEmit
|
||||
pnpm test run
|
||||
- name: 🏗️ Build Test
|
||||
shell: bash
|
||||
if: github.event.inputs.skip_checks != 'true'
|
||||
run: pnpm build
|
||||
env:
|
||||
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
NEXT_PUBLIC_BASE_URL: https://dummy.test
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# JOB 3: Build & Push
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
build:
|
||||
name: 🏗️ Build
|
||||
needs: prepare
|
||||
@@ -182,133 +187,317 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- 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
|
||||
|
||||
- name: 🏗️ Build and Push
|
||||
shell: bash
|
||||
run: |
|
||||
docker buildx build \
|
||||
--pull \
|
||||
--platform linux/arm64 \
|
||||
--build-arg NPM_TOKEN=${{ secrets.NPM_TOKEN }} \
|
||||
--build-arg NEXT_PUBLIC_BASE_URL=${{ needs.prepare.outputs.next_public_base_url }} \
|
||||
--build-arg NEXT_PUBLIC_TARGET=${{ needs.prepare.outputs.target }} \
|
||||
--build-arg DIRECTUS_URL=${{ needs.prepare.outputs.directus_url }} \
|
||||
--build-arg UMAMI_API_ENDPOINT=${{ secrets.UMAMI_API_ENDPOINT || secrets.NEXT_PUBLIC_UMAMI_SCRIPT_URL || vars.UMAMI_API_ENDPOINT || 'https://analytics.infra.mintel.me' }} \
|
||||
-t registry.infra.mintel.me/mintel/mb-grid-solutions:${{ needs.prepare.outputs.image_tag }} \
|
||||
--push .
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
platforms: linux/amd64
|
||||
build-args: |
|
||||
NEXT_PUBLIC_BASE_URL=${{ needs.prepare.outputs.next_public_url }}
|
||||
NEXT_PUBLIC_TARGET=${{ needs.prepare.outputs.target }}
|
||||
NPM_TOKEN=${{ secrets.REGISTRY_PASS }}
|
||||
tags: registry.infra.mintel.me/mintel/mb-grid-solutions:${{ needs.prepare.outputs.image_tag }}
|
||||
cache-from: type=registry,ref=registry.infra.mintel.me/mintel/mb-grid-solutions:buildcache
|
||||
cache-to: type=registry,ref=registry.infra.mintel.me/mintel/mb-grid-solutions:buildcache,mode=max
|
||||
secrets: |
|
||||
"NPM_TOKEN=${{ secrets.REGISTRY_PASS }}"
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# JOB 4: Deploy
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
deploy:
|
||||
name: 🚀 Deploy
|
||||
needs: [prepare, build, qa]
|
||||
if: needs.prepare.outputs.target != 'skip'
|
||||
runs-on: docker
|
||||
container:
|
||||
image: catthehacker/ubuntu:act-latest
|
||||
env:
|
||||
TARGET: ${{ needs.prepare.outputs.target }}
|
||||
IMAGE_TAG: ${{ needs.prepare.outputs.image_tag }}
|
||||
PROJECT_NAME: ${{ needs.prepare.outputs.project_name }}
|
||||
NEXT_PUBLIC_BASE_URL: ${{ needs.prepare.outputs.next_public_url }}
|
||||
|
||||
# Secrets mapping (Database & CMS)
|
||||
PAYLOAD_SECRET: ${{ (needs.prepare.outputs.target == 'testing' && secrets.TESTING_PAYLOAD_SECRET) || (needs.prepare.outputs.target == 'staging' && secrets.STAGING_PAYLOAD_SECRET) || secrets.PAYLOAD_SECRET || secrets.DIRECTUS_SECRET || vars.PAYLOAD_SECRET || 'you-need-to-set-a-payload-secret' }}
|
||||
DATABASE_URI: ${{ (needs.prepare.outputs.target == 'testing' && secrets.TESTING_DATABASE_URI) || (needs.prepare.outputs.target == 'staging' && secrets.STAGING_DATABASE_URI) || secrets.DATABASE_URI || vars.DATABASE_URI }}
|
||||
DIRECTUS_DB_NAME: ${{ secrets.DIRECTUS_DB_NAME || vars.DIRECTUS_DB_NAME || 'directus' }}
|
||||
DIRECTUS_DB_USER: ${{ secrets.DIRECTUS_DB_USER || vars.DIRECTUS_DB_USER || 'directus' }}
|
||||
DIRECTUS_DB_PASSWORD: ${{ (needs.prepare.outputs.target == 'testing' && secrets.TESTING_DIRECTUS_DB_PASSWORD) || (needs.prepare.outputs.target == 'staging' && secrets.STAGING_DIRECTUS_DB_PASSWORD) || secrets.DIRECTUS_DB_PASSWORD || vars.DIRECTUS_DB_PASSWORD || 'directus' }}
|
||||
|
||||
|
||||
# Secrets mapping (Mail)
|
||||
MAIL_HOST: ${{ secrets.SMTP_HOST || vars.SMTP_HOST }}
|
||||
MAIL_PORT: ${{ secrets.SMTP_PORT || vars.SMTP_PORT || '587' }}
|
||||
MAIL_USERNAME: ${{ secrets.SMTP_USER || vars.SMTP_USER }}
|
||||
MAIL_PASSWORD: ${{ secrets.SMTP_PASS || vars.SMTP_PASS }}
|
||||
MAIL_FROM: ${{ secrets.SMTP_FROM || vars.SMTP_FROM }}
|
||||
MAIL_RECIPIENTS: ${{ secrets.CONTACT_RECIPIENT || vars.CONTACT_RECIPIENT }}
|
||||
|
||||
# Authentication
|
||||
GATEKEEPER_PASSWORD: ${{ secrets.GATEKEEPER_PASSWORD || vars.GATEKEEPER_PASSWORD }}
|
||||
AUTH_COOKIE_NAME: ${{ secrets.AUTH_COOKIE_NAME || vars.AUTH_COOKIE_NAME || 'mintel_gatekeeper_session' }}
|
||||
COOKIE_DOMAIN: ${{ secrets.COOKIE_DOMAIN || vars.COOKIE_DOMAIN || '.mb-grid-solutions.com' }}
|
||||
|
||||
# Monitoring & Services
|
||||
SENTRY_DSN: ${{ secrets.SENTRY_DSN || vars.SENTRY_DSN }}
|
||||
UMAMI_WEBSITE_ID: ${{ secrets.UMAMI_WEBSITE_ID || secrets.NEXT_PUBLIC_UMAMI_WEBSITE_ID || vars.UMAMI_WEBSITE_ID || vars.NEXT_PUBLIC_UMAMI_WEBSITE_ID }}
|
||||
UMAMI_API_ENDPOINT: ${{ secrets.UMAMI_API_ENDPOINT || secrets.NEXT_PUBLIC_UMAMI_SCRIPT_URL || vars.UMAMI_API_ENDPOINT || 'https://analytics.infra.mintel.me' }}
|
||||
PROJECT_COLOR: ${{ secrets.PROJECT_COLOR || vars.PROJECT_COLOR || '#82ed20' }}
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
- name: 📝 Generate Environment
|
||||
shell: bash
|
||||
env:
|
||||
TRAEFIK_HOST: ${{ needs.prepare.outputs.traefik_host }}
|
||||
ENV_FILE: ${{ needs.prepare.outputs.env_file }}
|
||||
run: |
|
||||
# Middleware & Auth Logic
|
||||
LOG_LEVEL=$( [[ "$TARGET" == "testing" || "$TARGET" == "development" ]] && echo "debug" || echo "info" )
|
||||
STD_MW="${PROJECT_NAME}-forward,compress"
|
||||
|
||||
if [[ "$TARGET" == "production" ]]; then
|
||||
AUTH_MIDDLEWARE="$STD_MW"
|
||||
COMPOSE_PROFILES=""
|
||||
else
|
||||
# Order: Forward (Proto) -> Auth -> Compression
|
||||
AUTH_MIDDLEWARE="${PROJECT_NAME}-forward,${PROJECT_NAME}-auth,compress"
|
||||
COMPOSE_PROFILES="gatekeeper"
|
||||
fi
|
||||
|
||||
# Gatekeeper Origin
|
||||
COOKIE_DOMAIN=.$(echo $NEXT_PUBLIC_BASE_URL | sed 's|https://||')
|
||||
GATEKEEPER_ORIGIN="$NEXT_PUBLIC_BASE_URL/gatekeeper"
|
||||
|
||||
# Generate Environment File
|
||||
cat > .env.deploy << EOF
|
||||
# Generated by CI - $TARGET
|
||||
IMAGE_TAG=$IMAGE_TAG
|
||||
NEXT_PUBLIC_BASE_URL=$NEXT_PUBLIC_BASE_URL
|
||||
GATEKEEPER_ORIGIN=$GATEKEEPER_ORIGIN
|
||||
SENTRY_DSN=$SENTRY_DSN
|
||||
PROJECT_COLOR=$PROJECT_COLOR
|
||||
LOG_LEVEL=$LOG_LEVEL
|
||||
|
||||
# Database & Payload
|
||||
DATABASE_URI=\${DATABASE_URI:-postgresql://$DIRECTUS_DB_USER:$DIRECTUS_DB_PASSWORD@mb-grid-db:5432/$DIRECTUS_DB_NAME}
|
||||
PAYLOAD_SECRET=${PAYLOAD_SECRET:-you-need-to-set-a-payload-secret}
|
||||
DIRECTUS_DB_NAME=$DIRECTUS_DB_NAME
|
||||
DIRECTUS_DB_USER=$DIRECTUS_DB_USER
|
||||
DIRECTUS_DB_PASSWORD=$DIRECTUS_DB_PASSWORD
|
||||
|
||||
# Mail
|
||||
MAIL_HOST=$MAIL_HOST
|
||||
MAIL_PORT=$MAIL_PORT
|
||||
MAIL_USERNAME=$MAIL_USERNAME
|
||||
MAIL_PASSWORD=$MAIL_PASSWORD
|
||||
MAIL_FROM=$MAIL_FROM
|
||||
MAIL_RECIPIENTS=$MAIL_RECIPIENTS
|
||||
|
||||
# Authentication
|
||||
GATEKEEPER_PASSWORD=$GATEKEEPER_PASSWORD
|
||||
AUTH_COOKIE_NAME=$AUTH_COOKIE_NAME
|
||||
COOKIE_DOMAIN=$COOKIE_DOMAIN
|
||||
|
||||
# Analytics
|
||||
UMAMI_WEBSITE_ID=$UMAMI_WEBSITE_ID
|
||||
NEXT_PUBLIC_UMAMI_WEBSITE_ID=$UMAMI_WEBSITE_ID
|
||||
UMAMI_API_ENDPOINT=$UMAMI_API_ENDPOINT
|
||||
|
||||
TARGET=$TARGET
|
||||
SENTRY_ENVIRONMENT=$TARGET
|
||||
PROJECT_NAME=$PROJECT_NAME
|
||||
ENV_FILE=$ENV_FILE
|
||||
TRAEFIK_HOST=$TRAEFIK_HOST
|
||||
COMPOSE_PROFILES=$COMPOSE_PROFILES
|
||||
TRAEFIK_MIDDLEWARES=$AUTH_MIDDLEWARE
|
||||
EOF
|
||||
|
||||
- name: 🚀 SSH Deploy
|
||||
shell: bash
|
||||
env:
|
||||
ENV_FILE: ${{ needs.prepare.outputs.env_file }}
|
||||
run: |
|
||||
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
|
||||
|
||||
# Transfer and Restart
|
||||
SITE_DIR="/home/deploy/sites/mb-grid-solutions.com"
|
||||
ssh root@alpha.mintel.me "mkdir -p $SITE_DIR"
|
||||
|
||||
scp .env.deploy root@alpha.mintel.me:$SITE_DIR/$ENV_FILE
|
||||
scp docker-compose.yaml root@alpha.mintel.me:$SITE_DIR/docker-compose.yaml
|
||||
|
||||
ssh root@alpha.mintel.me "cd $SITE_DIR && echo '${{ secrets.REGISTRY_PASS }}' | docker login registry.infra.mintel.me -u '${{ secrets.REGISTRY_USER }}' --password-stdin"
|
||||
ssh root@alpha.mintel.me "cd $SITE_DIR && docker compose -p '${{ needs.prepare.outputs.project_name }}' --env-file '$ENV_FILE' pull"
|
||||
ssh root@alpha.mintel.me "cd $SITE_DIR && docker compose -p '${{ needs.prepare.outputs.project_name }}' --env-file '$ENV_FILE' up -d --remove-orphans"
|
||||
|
||||
# Apply Payload Migrations using the target app container's programmatic endpoint
|
||||
ssh root@alpha.mintel.me "cd $SITE_DIR && echo '→ Waiting for DB and Running Payload Migrations...' && \
|
||||
for i in {1..5}; do \
|
||||
echo \"Attempt \$i...\"; \
|
||||
docker compose -p '${{ needs.prepare.outputs.project_name }}' --env-file '$ENV_FILE' exec -T mb-grid-app sh -c 'curl -s -f -X POST -H \"Authorization: Bearer \$PAYLOAD_SECRET\" http://localhost:3000/api/payload/migrate \
|
||||
|| { echo \"HTTP error or DB not ready.\"; exit 1; }' && { echo '✅ Migrations successful!'; break; } \
|
||||
|| { if [ \$i -eq 5 ]; then echo '❌ Migration failed after 5 attempts!'; exit 1; else echo '⏳ Retrying in 5s...'; sleep 5; fi; }; \
|
||||
done"
|
||||
|
||||
ssh root@alpha.mintel.me "docker system prune -f --filter 'until=24h'"
|
||||
|
||||
- name: 🧹 Post-Deploy Cleanup (Runner)
|
||||
if: always()
|
||||
run: docker builder prune -f --filter "until=1h"
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# JOB 5: Post-Deploy Verification (Smoke Tests + Quality Gates)
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
post_deploy_checks:
|
||||
name: 🧪 Post-Deploy Verification
|
||||
needs: [prepare, deploy]
|
||||
if: needs.deploy.result == 'success' && needs.prepare.outputs.target != 'branch'
|
||||
runs-on: docker
|
||||
container:
|
||||
image: catthehacker/ubuntu:act-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v3
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: 🚀 Deploy via SSH
|
||||
shell: bash
|
||||
version: 10
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
- name: 🔐 Registry Auth
|
||||
run: |
|
||||
echo "Deploying to alpha.mintel.me"
|
||||
|
||||
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 "@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
|
||||
id: deps
|
||||
run: |
|
||||
pnpm store prune
|
||||
pnpm install --no-frozen-lockfile
|
||||
- name: 📦 Cache APT Packages
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: /var/cache/apt/archives
|
||||
key: apt-cache-${{ runner.os }}-${{ runner.arch }}-chromium
|
||||
|
||||
# Generate Environment File
|
||||
cat > .env.deploy << 'EOF'
|
||||
ENV_FILE=${{ needs.prepare.outputs.env_file }}
|
||||
IMAGE_TAG=${{ needs.prepare.outputs.image_tag }}
|
||||
TRAEFIK_HOST=${{ needs.prepare.outputs.traefik_host }}
|
||||
TRAEFIK_RULE=${{ needs.prepare.outputs.traefik_rule }}
|
||||
TRAEFIK_MIDDLEWARES=${{ needs.prepare.outputs.traefik_middlewares }}
|
||||
GATEKEEPER_RULE=${{ needs.prepare.outputs.gatekeeper_rule }}
|
||||
GATEKEEPER_HOST=${{ needs.prepare.outputs.gatekeeper_host }}
|
||||
PROJECT_NAME=${{ needs.prepare.outputs.project_name }}
|
||||
NEXT_PUBLIC_BASE_URL=${{ needs.prepare.outputs.next_public_base_url }}
|
||||
- name: 💾 Cache Chromium
|
||||
id: cache-chromium
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: /usr/bin/chromium
|
||||
key: ${{ runner.os }}-chromium-native-${{ hashFiles('package.json') }}
|
||||
|
||||
# Directus
|
||||
DIRECTUS_URL=${{ needs.prepare.outputs.directus_url }}
|
||||
DIRECTUS_HOST=${{ needs.prepare.outputs.directus_host }}
|
||||
INTERNAL_DIRECTUS_URL=http://directus:8055
|
||||
DIRECTUS_API_TOKEN=${{ secrets.DIRECTUS_API_TOKEN || vars.DIRECTUS_API_TOKEN }}
|
||||
DIRECTUS_ADMIN_EMAIL=${{ secrets.DIRECTUS_ADMIN_EMAIL || vars.DIRECTUS_ADMIN_EMAIL || 'admin@mintel.me' }}
|
||||
DIRECTUS_ADMIN_PASSWORD=${{ secrets.DIRECTUS_ADMIN_PASSWORD || vars.DIRECTUS_ADMIN_PASSWORD }}
|
||||
DIRECTUS_DB_NAME=${{ secrets.DIRECTUS_DB_NAME || vars.DIRECTUS_DB_NAME || 'directus' }}
|
||||
DIRECTUS_DB_USER=${{ secrets.DIRECTUS_DB_USER || vars.DIRECTUS_DB_USER || 'directus' }}
|
||||
DIRECTUS_DB_PASSWORD=${{ secrets.DIRECTUS_DB_PASSWORD || vars.DIRECTUS_DB_PASSWORD }}
|
||||
DIRECTUS_KEY=${{ secrets.DIRECTUS_KEY || vars.DIRECTUS_KEY }}
|
||||
DIRECTUS_SECRET=${{ secrets.DIRECTUS_SECRET || vars.DIRECTUS_SECRET }}
|
||||
|
||||
# SMTP Config
|
||||
SMTP_HOST=${{ secrets.SMTP_HOST || vars.SMTP_HOST }}
|
||||
SMTP_PORT=${{ secrets.SMTP_PORT || vars.SMTP_PORT || '587' }}
|
||||
SMTP_SECURE=${{ secrets.SMTP_SECURE || vars.SMTP_SECURE || 'false' }}
|
||||
SMTP_USER=${{ secrets.SMTP_USER || vars.SMTP_USER }}
|
||||
SMTP_PASS=${{ secrets.SMTP_PASS || vars.SMTP_PASS }}
|
||||
SMTP_FROM=${{ secrets.SMTP_FROM || vars.SMTP_FROM }}
|
||||
CONTACT_RECIPIENT=${{ secrets.CONTACT_RECIPIENT || vars.CONTACT_RECIPIENT }}
|
||||
- name: 🔍 Install Chromium (Native & ARM64)
|
||||
if: steps.cache-chromium.outputs.cache-hit != 'true'
|
||||
run: |
|
||||
rm -f /etc/apt/apt.conf.d/docker-clean
|
||||
apt-get update
|
||||
apt-get install -y gnupg wget ca-certificates
|
||||
OS_ID=$(. /etc/os-release && echo $ID)
|
||||
CODENAME=$(. /etc/os-release && echo $VERSION_CODENAME)
|
||||
if [ "$OS_ID" = "debian" ]; then
|
||||
apt-get install -y chromium
|
||||
else
|
||||
mkdir -p /etc/apt/keyrings
|
||||
KEY_ID="82BB6851C64F6880"
|
||||
wget -qO- "https://keyserver.ubuntu.com/pks/lookup?op=get&search=0x$KEY_ID" | gpg --dearmor > /etc/apt/keyrings/xtradeb.gpg
|
||||
echo "deb [signed-by=/etc/apt/keyrings/xtradeb.gpg] http://ppa.launchpad.net/xtradeb/apps/ubuntu $CODENAME main" > /etc/apt/sources.list.d/xtradeb-ppa.list
|
||||
printf "Package: *\nPin: release o=LP-PPA-xtradeb-apps\nPin-Priority: 1001\n" > /etc/apt/preferences.d/xtradeb
|
||||
apt-get update
|
||||
apt-get install -y --allow-downgrades chromium
|
||||
fi
|
||||
[ -f /usr/bin/chromium ] && ln -sf /usr/bin/chromium /usr/bin/google-chrome
|
||||
[ -f /usr/bin/chromium ] && ln -sf /usr/bin/chromium /usr/bin/chromium-browser
|
||||
|
||||
# Authentication
|
||||
GATEKEEPER_PASSWORD=${{ secrets.GATEKEEPER_PASSWORD || vars.GATEKEEPER_PASSWORD }}
|
||||
AUTH_COOKIE_NAME=${{ secrets.AUTH_COOKIE_NAME || vars.AUTH_COOKIE_NAME || 'mintel_gatekeeper_session' }}
|
||||
COOKIE_DOMAIN=${{ secrets.COOKIE_DOMAIN || vars.COOKIE_DOMAIN || '.mb-grid-solutions.com' }}
|
||||
# ── Critical Smoke Tests (MUST pass) ──────────────────────────────────
|
||||
- name: 🏥 CMS Deep Health Check
|
||||
env:
|
||||
DEPLOY_URL: ${{ needs.prepare.outputs.next_public_url }}
|
||||
GK_PASS: ${{ secrets.GATEKEEPER_PASSWORD || vars.GATEKEEPER_PASSWORD }}
|
||||
run: |
|
||||
echo "Waiting 10s for app to fully start..."
|
||||
sleep 10
|
||||
echo "Checking basic health..."
|
||||
curl -sf "$DEPLOY_URL/api/health" || { echo "❌ Basic health check failed"; exit 1; }
|
||||
echo "✅ Basic health OK"
|
||||
|
||||
# External Services
|
||||
SENTRY_DSN=${{ secrets.SENTRY_DSN || vars.SENTRY_DSN }}
|
||||
GOTIFY_URL=${{ secrets.GOTIFY_URL || vars.GOTIFY_URL }}
|
||||
GOTIFY_TOKEN=${{ secrets.GOTIFY_TOKEN || vars.GOTIFY_TOKEN }}
|
||||
UMAMI_WEBSITE_ID=${{ secrets.UMAMI_WEBSITE_ID || secrets.NEXT_PUBLIC_UMAMI_WEBSITE_ID || vars.UMAMI_WEBSITE_ID || vars.NEXT_PUBLIC_UMAMI_WEBSITE_ID }}
|
||||
UMAMI_API_ENDPOINT=${{ secrets.UMAMI_API_ENDPOINT || secrets.NEXT_PUBLIC_UMAMI_SCRIPT_URL || vars.UMAMI_API_ENDPOINT || 'https://analytics.infra.mintel.me' }}
|
||||
|
||||
# Project
|
||||
PROJECT_COLOR=${{ secrets.PROJECT_COLOR || vars.PROJECT_COLOR || '#82ed20' }}
|
||||
EOF
|
||||
- name: 🌐 Core Smoke Tests (HTTP, API, Locale)
|
||||
if: always() && steps.deps.outcome == 'success'
|
||||
uses: https://git.infra.mintel.me/mmintel/at-mintel/.gitea/actions/core-smoke-tests@main
|
||||
with:
|
||||
TARGET_URL: ${{ needs.prepare.outputs.next_public_url }}
|
||||
GATEKEEPER_PASSWORD: ${{ secrets.GATEKEEPER_PASSWORD || vars.GATEKEEPER_PASSWORD }}
|
||||
UMAMI_API_ENDPOINT: ${{ secrets.UMAMI_API_ENDPOINT || secrets.NEXT_PUBLIC_UMAMI_SCRIPT_URL || vars.UMAMI_API_ENDPOINT || 'https://analytics.infra.mintel.me' }}
|
||||
SENTRY_DSN: ${{ secrets.SENTRY_DSN || vars.SENTRY_DSN }}
|
||||
|
||||
APP_DIR="/home/deploy/sites/mb-grid-solutions.com"
|
||||
ssh -o StrictHostKeyChecking=accept-new root@alpha.mintel.me "mkdir -p $APP_DIR"
|
||||
|
||||
scp -o StrictHostKeyChecking=accept-new .env.deploy root@alpha.mintel.me:$APP_DIR/${{ needs.prepare.outputs.env_file }}
|
||||
scp -o StrictHostKeyChecking=accept-new docker-compose.yaml root@alpha.mintel.me:$APP_DIR/docker-compose.yaml
|
||||
|
||||
ssh -o StrictHostKeyChecking=accept-new root@alpha.mintel.me bash << 'EOF'
|
||||
set -e
|
||||
APP_DIR="/home/deploy/sites/mb-grid-solutions.com"
|
||||
cd $APP_DIR
|
||||
|
||||
echo "${{ secrets.REGISTRY_PASS }}" | docker login registry.infra.mintel.me -u "${{ secrets.REGISTRY_USER }}" --password-stdin
|
||||
docker compose -p "${{ needs.prepare.outputs.project_name }}" --env-file ${{ needs.prepare.outputs.env_file }} pull
|
||||
docker compose -p "${{ needs.prepare.outputs.project_name }}" --env-file ${{ needs.prepare.outputs.env_file }} up -d --remove-orphans
|
||||
docker system prune -f --filter "until=24h"
|
||||
EOF
|
||||
- name: 📝 E2E Form Submission Test
|
||||
if: always() && steps.deps.outcome == 'success'
|
||||
env:
|
||||
NEXT_PUBLIC_BASE_URL: ${{ needs.prepare.outputs.next_public_url }}
|
||||
GATEKEEPER_PASSWORD: ${{ secrets.GATEKEEPER_PASSWORD || vars.GATEKEEPER_PASSWORD }}
|
||||
PUPPETEER_EXECUTABLE_PATH: /usr/bin/chromium
|
||||
run: pnpm test run
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# JOB 6: Notifications
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
notifications:
|
||||
name: 🔔 Notifications
|
||||
needs: [prepare, deploy]
|
||||
name: 🔔 Notify
|
||||
needs: [prepare, deploy, post_deploy_checks]
|
||||
if: always()
|
||||
runs-on: docker
|
||||
container:
|
||||
image: catthehacker/ubuntu:act-latest
|
||||
steps:
|
||||
- name: Notify Gotify
|
||||
- name: 🔔 Gotify
|
||||
shell: bash
|
||||
run: |
|
||||
STATUS="${{ needs.deploy.result }}"
|
||||
COLOR="info"
|
||||
[[ "$STATUS" == "success" ]] && PRIORITY=5 || PRIORITY=8
|
||||
|
||||
curl -X POST "${{ secrets.GOTIFY_URL }}/message?token=${{ secrets.GOTIFY_TOKEN }}" \
|
||||
-F "title=mb-grid-solutions Deployment" \
|
||||
-F "message=Status: $STATUS for ${{ needs.prepare.outputs.target }} (${{ needs.prepare.outputs.image_tag }})" \
|
||||
-F "priority=$PRIORITY"
|
||||
DEPLOY="${{ needs.deploy.result }}"
|
||||
SMOKE="${{ needs.post_deploy_checks.result }}"
|
||||
PERF="${{ needs.post_deploy_checks.result }}"
|
||||
TARGET="${{ needs.prepare.outputs.target }}"
|
||||
VERSION="${{ needs.prepare.outputs.image_tag }}"
|
||||
URL="${{ needs.prepare.outputs.next_public_url }}"
|
||||
|
||||
# Gotify priority scale:
|
||||
# 1-3 = low (silent/info)
|
||||
# 4-5 = normal
|
||||
# 6-7 = high (warning)
|
||||
# 8-10 = critical (alarm)
|
||||
if [[ "$DEPLOY" != "success" ]]; then
|
||||
PRIORITY=10
|
||||
EMOJI="🚨"
|
||||
STATUS_LINE="DEPLOY FAILED"
|
||||
elif [[ "$SMOKE" != "success" ]]; then
|
||||
PRIORITY=8
|
||||
EMOJI="⚠️"
|
||||
STATUS_LINE="Smoke tests failed"
|
||||
elif [[ "$PERF" != "success" ]]; then
|
||||
PRIORITY=5
|
||||
EMOJI="📉"
|
||||
STATUS_LINE="Performance degraded"
|
||||
else
|
||||
PRIORITY=2
|
||||
EMOJI="✅"
|
||||
STATUS_LINE="All checks passed"
|
||||
fi
|
||||
|
||||
TITLE="$EMOJI mb-grid-solutions.com $VERSION → $TARGET"
|
||||
MESSAGE="$STATUS_LINE
|
||||
Deploy: $DEPLOY | Smoke: $SMOKE | Perf: $PERF
|
||||
$URL"
|
||||
|
||||
curl -s -k -X POST "${{ secrets.GOTIFY_URL }}/message?token=${{ secrets.GOTIFY_TOKEN }}" \
|
||||
-F "title=$TITLE" \
|
||||
-F "message=$MESSAGE" \
|
||||
-F "priority=$PRIORITY" || true
|
||||
|
||||
17
.gitea/workflows/qa.yml
Normal file
17
.gitea/workflows/qa.yml
Normal file
@@ -0,0 +1,17 @@
|
||||
name: Nightly QA
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 4 * * *'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
call-qa-workflow:
|
||||
uses: mmintel/at-mintel/.gitea/workflows/quality-assurance-template.yml@main
|
||||
with:
|
||||
TARGET_URL: 'https://testing.mb-grid-solutions.com'
|
||||
PROJECT_NAME: 'mb-grid-solutions'
|
||||
secrets:
|
||||
GOTIFY_URL: ${{ secrets.GOTIFY_URL }}
|
||||
GOTIFY_TOKEN: ${{ secrets.GOTIFY_TOKEN }}
|
||||
GATEKEEPER_PASSWORD: ${{ secrets.GATEKEEPER_PASSWORD || vars.GATEKEEPER_PASSWORD }}
|
||||
59
Dockerfile
59
Dockerfile
@@ -1,49 +1,68 @@
|
||||
# Start from the pre-built Nextjs Base image
|
||||
FROM registry.infra.mintel.me/mintel/nextjs:latest AS builder
|
||||
|
||||
# Stage 1: Builder
|
||||
FROM node:20-alpine AS builder
|
||||
WORKDIR /app
|
||||
|
||||
# Ensure we are in a clean, standalone environment
|
||||
RUN rm -rf packages apps pnpm-workspace.yaml 2>/dev/null || true
|
||||
# Clean the workspace
|
||||
RUN rm -rf ./*
|
||||
|
||||
# Build-time environment variables for Next.js
|
||||
# Arguments for build-time configuration
|
||||
ARG NEXT_PUBLIC_BASE_URL
|
||||
ARG UMAMI_API_ENDPOINT
|
||||
ARG NEXT_PUBLIC_TARGET
|
||||
ARG DIRECTUS_URL
|
||||
ARG UMAMI_API_ENDPOINT
|
||||
ARG NEXT_PUBLIC_UMAMI_WEBSITE_ID
|
||||
ARG NPM_TOKEN
|
||||
|
||||
# Environment variables for Next.js build
|
||||
ENV NEXT_PUBLIC_BASE_URL=$NEXT_PUBLIC_BASE_URL
|
||||
ENV UMAMI_API_ENDPOINT=$UMAMI_API_ENDPOINT
|
||||
ENV NEXT_PUBLIC_TARGET=$NEXT_PUBLIC_TARGET
|
||||
ENV DIRECTUS_URL=$DIRECTUS_URL
|
||||
ENV NPM_TOKEN=$NPM_TOKEN
|
||||
ENV SENTRY_SUPPRESS_TURBOPACK_WARNING=1
|
||||
ENV UMAMI_API_ENDPOINT=$UMAMI_API_ENDPOINT
|
||||
ENV NEXT_PUBLIC_UMAMI_WEBSITE_ID=$NEXT_PUBLIC_UMAMI_WEBSITE_ID
|
||||
ENV SKIP_RUNTIME_ENV_VALIDATION=true
|
||||
ENV CI=true
|
||||
|
||||
# Enable corepack
|
||||
RUN corepack enable
|
||||
# Enable pnpm
|
||||
RUN corepack enable && corepack prepare pnpm@10.3.0 --activate
|
||||
|
||||
# Copy package files
|
||||
COPY package.json pnpm-lock.yaml* .npmrc ./
|
||||
# Copy lockfile and manifest for dependency installation caching
|
||||
COPY pnpm-lock.yaml package.json .npmrc* ./
|
||||
|
||||
# Install dependencies
|
||||
RUN pnpm install --no-frozen-lockfile
|
||||
# Install dependencies with cache mount
|
||||
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) && \
|
||||
pnpm store prune && \
|
||||
pnpm install --no-frozen-lockfile
|
||||
|
||||
# Copy local files
|
||||
# Copy source code
|
||||
COPY . .
|
||||
|
||||
# Build the specific application
|
||||
# Build application
|
||||
RUN pnpm build
|
||||
|
||||
# Production runner image
|
||||
FROM registry.infra.mintel.me/mintel/runtime:latest AS runner
|
||||
# Stage 2: Runner
|
||||
FROM node:20-alpine AS runner
|
||||
WORKDIR /app
|
||||
|
||||
# Install curl for health checks
|
||||
RUN apk add --no-cache curl
|
||||
|
||||
# Create nextjs user and group for security
|
||||
RUN addgroup --system --gid 1001 nodejs && \
|
||||
adduser --system --uid 1001 nextjs && \
|
||||
chown -R nextjs:nodejs /app
|
||||
|
||||
ENV HOSTNAME="0.0.0.0"
|
||||
ENV PORT=3000
|
||||
ENV NODE_ENV=production
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
|
||||
# Copy standalone output and static files
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/public ./public
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/cache ./.next/cache
|
||||
|
||||
USER nextjs
|
||||
|
||||
|
||||
17
app/(payload)/admin/[[...segments]]/page.tsx
Normal file
17
app/(payload)/admin/[[...segments]]/page.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import configPromise from "@payload-config";
|
||||
import { RootPage } from "@payloadcms/next/views";
|
||||
import { importMap } from "../importMap";
|
||||
|
||||
type Args = {
|
||||
params: Promise<{
|
||||
segments: string[];
|
||||
}>;
|
||||
searchParams: Promise<{
|
||||
[key: string]: string | string[];
|
||||
}>;
|
||||
};
|
||||
|
||||
const Page = ({ params, searchParams }: Args) =>
|
||||
RootPage({ config: configPromise, importMap, params, searchParams });
|
||||
|
||||
export default Page;
|
||||
78
app/(payload)/admin/importMap.js
Normal file
78
app/(payload)/admin/importMap.js
Normal file
@@ -0,0 +1,78 @@
|
||||
import { RscEntryLexicalCell as RscEntryLexicalCell_44fe37237e0ebf4470c9990d8cb7b07e } from "@payloadcms/richtext-lexical/rsc";
|
||||
import { RscEntryLexicalField as RscEntryLexicalField_44fe37237e0ebf4470c9990d8cb7b07e } from "@payloadcms/richtext-lexical/rsc";
|
||||
import { LexicalDiffComponent as LexicalDiffComponent_44fe37237e0ebf4470c9990d8cb7b07e } from "@payloadcms/richtext-lexical/rsc";
|
||||
import { BlocksFeatureClient as BlocksFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from "@payloadcms/richtext-lexical/client";
|
||||
import { InlineToolbarFeatureClient as InlineToolbarFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from "@payloadcms/richtext-lexical/client";
|
||||
import { HorizontalRuleFeatureClient as HorizontalRuleFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from "@payloadcms/richtext-lexical/client";
|
||||
import { UploadFeatureClient as UploadFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from "@payloadcms/richtext-lexical/client";
|
||||
import { BlockquoteFeatureClient as BlockquoteFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from "@payloadcms/richtext-lexical/client";
|
||||
import { RelationshipFeatureClient as RelationshipFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from "@payloadcms/richtext-lexical/client";
|
||||
import { LinkFeatureClient as LinkFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from "@payloadcms/richtext-lexical/client";
|
||||
import { ChecklistFeatureClient as ChecklistFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from "@payloadcms/richtext-lexical/client";
|
||||
import { OrderedListFeatureClient as OrderedListFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from "@payloadcms/richtext-lexical/client";
|
||||
import { UnorderedListFeatureClient as UnorderedListFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from "@payloadcms/richtext-lexical/client";
|
||||
import { IndentFeatureClient as IndentFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from "@payloadcms/richtext-lexical/client";
|
||||
import { AlignFeatureClient as AlignFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from "@payloadcms/richtext-lexical/client";
|
||||
import { HeadingFeatureClient as HeadingFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from "@payloadcms/richtext-lexical/client";
|
||||
import { ParagraphFeatureClient as ParagraphFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from "@payloadcms/richtext-lexical/client";
|
||||
import { InlineCodeFeatureClient as InlineCodeFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from "@payloadcms/richtext-lexical/client";
|
||||
import { SuperscriptFeatureClient as SuperscriptFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from "@payloadcms/richtext-lexical/client";
|
||||
import { SubscriptFeatureClient as SubscriptFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from "@payloadcms/richtext-lexical/client";
|
||||
import { StrikethroughFeatureClient as StrikethroughFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from "@payloadcms/richtext-lexical/client";
|
||||
import { UnderlineFeatureClient as UnderlineFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from "@payloadcms/richtext-lexical/client";
|
||||
import { BoldFeatureClient as BoldFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from "@payloadcms/richtext-lexical/client";
|
||||
import { ItalicFeatureClient as ItalicFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from "@payloadcms/richtext-lexical/client";
|
||||
import { CollectionCards as CollectionCards_f9c02e79a4aed9a3924487c0cd4cafb1 } from "@payloadcms/next/rsc";
|
||||
|
||||
export const importMap = {
|
||||
"@payloadcms/richtext-lexical/rsc#RscEntryLexicalCell":
|
||||
RscEntryLexicalCell_44fe37237e0ebf4470c9990d8cb7b07e,
|
||||
"@payloadcms/richtext-lexical/rsc#RscEntryLexicalField":
|
||||
RscEntryLexicalField_44fe37237e0ebf4470c9990d8cb7b07e,
|
||||
"@payloadcms/richtext-lexical/rsc#LexicalDiffComponent":
|
||||
LexicalDiffComponent_44fe37237e0ebf4470c9990d8cb7b07e,
|
||||
"@payloadcms/richtext-lexical/client#BlocksFeatureClient":
|
||||
BlocksFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#InlineToolbarFeatureClient":
|
||||
InlineToolbarFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#HorizontalRuleFeatureClient":
|
||||
HorizontalRuleFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#UploadFeatureClient":
|
||||
UploadFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#BlockquoteFeatureClient":
|
||||
BlockquoteFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#RelationshipFeatureClient":
|
||||
RelationshipFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#LinkFeatureClient":
|
||||
LinkFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#ChecklistFeatureClient":
|
||||
ChecklistFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#OrderedListFeatureClient":
|
||||
OrderedListFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#UnorderedListFeatureClient":
|
||||
UnorderedListFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#IndentFeatureClient":
|
||||
IndentFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#AlignFeatureClient":
|
||||
AlignFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#HeadingFeatureClient":
|
||||
HeadingFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#ParagraphFeatureClient":
|
||||
ParagraphFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#InlineCodeFeatureClient":
|
||||
InlineCodeFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#SuperscriptFeatureClient":
|
||||
SuperscriptFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#SubscriptFeatureClient":
|
||||
SubscriptFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#StrikethroughFeatureClient":
|
||||
StrikethroughFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#UnderlineFeatureClient":
|
||||
UnderlineFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#BoldFeatureClient":
|
||||
BoldFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/richtext-lexical/client#ItalicFeatureClient":
|
||||
ItalicFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
|
||||
"@payloadcms/next/rsc#CollectionCards":
|
||||
CollectionCards_f9c02e79a4aed9a3924487c0cd4cafb1,
|
||||
};
|
||||
1
app/(payload)/admin/importMap.ts
Normal file
1
app/(payload)/admin/importMap.ts
Normal file
@@ -0,0 +1 @@
|
||||
export const importMap = {};
|
||||
14
app/(payload)/api/[...slug]/route.ts
Normal file
14
app/(payload)/api/[...slug]/route.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import config from "@payload-config";
|
||||
import {
|
||||
REST_GET,
|
||||
REST_OPTIONS,
|
||||
REST_PATCH,
|
||||
REST_POST,
|
||||
REST_DELETE,
|
||||
} from "@payloadcms/next/routes";
|
||||
|
||||
export const GET = REST_GET(config);
|
||||
export const POST = REST_POST(config);
|
||||
export const DELETE = REST_DELETE(config);
|
||||
export const PATCH = REST_PATCH(config);
|
||||
export const OPTIONS = REST_OPTIONS(config);
|
||||
1
app/(payload)/custom.scss
Normal file
1
app/(payload)/custom.scss
Normal file
@@ -0,0 +1 @@
|
||||
/* Custom SCSS for Payload Admin Panel */
|
||||
36
app/(payload)/layout.tsx
Normal file
36
app/(payload)/layout.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import configPromise from "@payload-config";
|
||||
import { RootLayout } from "@payloadcms/next/layouts";
|
||||
import React from "react";
|
||||
|
||||
import "@payloadcms/next/css";
|
||||
import "./custom.scss";
|
||||
import { handleServerFunctions } from "@payloadcms/next/layouts";
|
||||
import { importMap } from "./admin/importMap";
|
||||
|
||||
type Args = {
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const serverFunction: any = async function (args: any) {
|
||||
"use server";
|
||||
return handleServerFunctions({
|
||||
...args,
|
||||
config: configPromise,
|
||||
importMap,
|
||||
});
|
||||
};
|
||||
|
||||
const Layout = ({ children }: Args) => {
|
||||
return (
|
||||
<RootLayout
|
||||
config={configPromise}
|
||||
importMap={importMap}
|
||||
serverFunction={serverFunction}
|
||||
>
|
||||
{children}
|
||||
</RootLayout>
|
||||
);
|
||||
};
|
||||
|
||||
export default Layout;
|
||||
@@ -6,11 +6,14 @@ import { NextIntlClientProvider } from "next-intl";
|
||||
import { getMessages } from "next-intl/server";
|
||||
import { notFound } from "next/navigation";
|
||||
import { LazyMotion, domAnimation } from "framer-motion";
|
||||
import AnalyticsProvider from "@/components/analytics/AnalyticsProvider";
|
||||
import { config } from "@/lib/config";
|
||||
|
||||
const inter = Inter({
|
||||
subsets: ["latin"],
|
||||
display: "swap",
|
||||
variable: "--font-inter",
|
||||
weight: ["400", "700", "800"], // Explicit weights to optimize download
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
@@ -108,10 +111,26 @@ export default async function RootLayout({
|
||||
|
||||
// Track pageview on the server
|
||||
// This is safe to call here because layout is a Server Component
|
||||
const services = (
|
||||
const serverServices = (
|
||||
await import("@/lib/services/create-services.server")
|
||||
).getServerAppServices();
|
||||
services.analytics.trackPageview();
|
||||
|
||||
// Populate analytics context with headers for high-fidelity server-side tracking
|
||||
const { headers } = await import("next/headers");
|
||||
const requestHeaders = await headers();
|
||||
|
||||
if (serverServices.analytics.setServerContext) {
|
||||
serverServices.analytics.setServerContext({
|
||||
userAgent: requestHeaders.get("user-agent") || undefined,
|
||||
language:
|
||||
requestHeaders.get("accept-language")?.split(",")[0] || undefined,
|
||||
referrer: requestHeaders.get("referer") || undefined,
|
||||
ip: requestHeaders.get("x-forwarded-for")?.split(",")[0] || undefined,
|
||||
});
|
||||
}
|
||||
|
||||
// Track server-side (initial load)
|
||||
// serverServices.analytics.trackPageview("/"); // Removed to avoid double-tracking and incorrect path reporting
|
||||
|
||||
return (
|
||||
<html lang={locale} className={`${inter.variable}`}>
|
||||
@@ -123,6 +142,7 @@ export default async function RootLayout({
|
||||
</head>
|
||||
<body className="antialiased">
|
||||
<NextIntlClientProvider messages={messages}>
|
||||
<AnalyticsProvider websiteId={config.analytics.umami.websiteId} />
|
||||
<LazyMotion features={domAnimation}>
|
||||
<Layout>{children}</Layout>
|
||||
</LazyMotion>
|
||||
|
||||
@@ -86,7 +86,7 @@ export default async function Image() {
|
||||
letterSpacing: "0.1em",
|
||||
}}
|
||||
>
|
||||
Engineering Excellence
|
||||
Technische Beratung
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -118,6 +118,8 @@ export default async function Image() {
|
||||
{/* Title */}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "row",
|
||||
fontSize: "72px",
|
||||
fontWeight: "900",
|
||||
color: "#0f172a",
|
||||
@@ -126,12 +128,19 @@ export default async function Image() {
|
||||
letterSpacing: "-0.02em",
|
||||
}}
|
||||
>
|
||||
MB Grid <span style={{ color: "#10b981" }}>Solutions</span>
|
||||
MB Grid{" "}
|
||||
<span
|
||||
style={{ color: "#10b981", display: "flex", marginLeft: "16px" }}
|
||||
>
|
||||
Solutions
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Subtitle */}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
fontSize: "32px",
|
||||
fontWeight: "500",
|
||||
color: "#64748b",
|
||||
@@ -140,9 +149,8 @@ export default async function Image() {
|
||||
lineHeight: 1.4,
|
||||
}}
|
||||
>
|
||||
Energiekabelprojekte & Technische Beratung
|
||||
<br />
|
||||
bis 110 kV
|
||||
<span>Energiekabelprojekte & Technische Beratung</span>
|
||||
<span>bis 110 kV</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -86,7 +86,7 @@ export default async function Image() {
|
||||
letterSpacing: "0.1em",
|
||||
}}
|
||||
>
|
||||
Engineering Excellence
|
||||
Technische Beratung
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -118,6 +118,8 @@ export default async function Image() {
|
||||
{/* Title */}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "row",
|
||||
fontSize: "72px",
|
||||
fontWeight: "900",
|
||||
color: "#0f172a",
|
||||
@@ -126,12 +128,19 @@ export default async function Image() {
|
||||
letterSpacing: "-0.02em",
|
||||
}}
|
||||
>
|
||||
MB Grid <span style={{ color: "#10b981" }}>Solutions</span>
|
||||
MB Grid{" "}
|
||||
<span
|
||||
style={{ color: "#10b981", display: "flex", marginLeft: "16px" }}
|
||||
>
|
||||
Solutions
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Subtitle */}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
fontSize: "32px",
|
||||
fontWeight: "500",
|
||||
color: "#64748b",
|
||||
@@ -140,9 +149,8 @@ export default async function Image() {
|
||||
lineHeight: 1.4,
|
||||
}}
|
||||
>
|
||||
Energiekabelprojekte & Technische Beratung
|
||||
<br />
|
||||
bis 110 kV
|
||||
<span>Energiekabelprojekte & Technische Beratung</span>
|
||||
<span>bis 110 kV</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,16 +1,34 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import * as nodemailer from "nodemailer";
|
||||
import directus, { ensureAuthenticated } from "@/lib/directus";
|
||||
import { createItem } from "@directus/sdk";
|
||||
import { getPayload } from "payload";
|
||||
import configPromise from "@payload-config";
|
||||
import { getServerAppServices } from "@/lib/services/create-services.server";
|
||||
import {
|
||||
render,
|
||||
ContactFormNotification,
|
||||
ConfirmationMessage,
|
||||
} from "@mintel/mail";
|
||||
import React from "react";
|
||||
|
||||
export async function POST(req: Request) {
|
||||
const services = getServerAppServices();
|
||||
const logger = services.logger.child({ action: "contact_submission" });
|
||||
|
||||
// Set analytics context from request headers for high-fidelity server-side tracking
|
||||
if (services.analytics.setServerContext) {
|
||||
services.analytics.setServerContext({
|
||||
userAgent: req.headers.get("user-agent") || undefined,
|
||||
language: req.headers.get("accept-language")?.split(",")[0] || undefined,
|
||||
referrer: req.headers.get("referer") || undefined,
|
||||
ip: req.headers.get("x-forwarded-for")?.split(",")[0] || undefined,
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const { name, email, company, message, website } = await req.json();
|
||||
|
||||
// Track attempt
|
||||
services.analytics.track("contact-form-attempt");
|
||||
|
||||
// Honeypot check
|
||||
if (website) {
|
||||
logger.info("Spam detected (honeypot)");
|
||||
@@ -27,62 +45,87 @@ export async function POST(req: Request) {
|
||||
if (!message || message.length < 20) {
|
||||
return NextResponse.json({ error: "message_too_short" }, { status: 400 });
|
||||
}
|
||||
|
||||
if (message.length > 4000) {
|
||||
return NextResponse.json({ error: "message_too_long" }, { status: 400 });
|
||||
}
|
||||
|
||||
// 1. Directus save
|
||||
let directusSaved = false;
|
||||
const payload = await getPayload({ config: configPromise });
|
||||
|
||||
// 1. Payload save
|
||||
let payloadSaved = false;
|
||||
try {
|
||||
await ensureAuthenticated();
|
||||
await directus.request(
|
||||
createItem("contact_submissions", {
|
||||
await payload.create({
|
||||
collection: "form-submissions",
|
||||
data: {
|
||||
name,
|
||||
email,
|
||||
company: company || "Nicht angegeben",
|
||||
message,
|
||||
}),
|
||||
);
|
||||
logger.info("Contact submission saved to Directus");
|
||||
directusSaved = true;
|
||||
} catch (directusError) {
|
||||
logger.error("Failed to save to Directus", { error: directusError });
|
||||
services.errors.captureException(directusError, {
|
||||
phase: "directus_save",
|
||||
});
|
||||
// We still try to send the email even if Directus fails
|
||||
}
|
||||
|
||||
// 2. Email sending
|
||||
try {
|
||||
const transporter = nodemailer.createTransport({
|
||||
host: process.env.SMTP_HOST,
|
||||
port: parseInt(process.env.SMTP_PORT || "587"),
|
||||
secure: process.env.SMTP_SECURE === "true",
|
||||
auth: {
|
||||
user: process.env.SMTP_USER,
|
||||
pass: process.env.SMTP_PASS,
|
||||
},
|
||||
});
|
||||
logger.info("Contact submission saved to PayloadCMS");
|
||||
payloadSaved = true;
|
||||
} catch (payloadError) {
|
||||
const errorMessage =
|
||||
payloadError instanceof Error
|
||||
? payloadError.message
|
||||
: String(payloadError);
|
||||
logger.error("Failed to save to Payload", {
|
||||
error: errorMessage,
|
||||
details: payloadError,
|
||||
});
|
||||
services.errors.captureException(payloadError, { phase: "payload_save" });
|
||||
}
|
||||
|
||||
await transporter.sendMail({
|
||||
from: process.env.SMTP_FROM,
|
||||
to: process.env.CONTACT_RECIPIENT || "info@mb-grid-solutions.com",
|
||||
// 2. Email sending via Payload (which uses configured nodemailer)
|
||||
try {
|
||||
const { config } = await import("@/lib/config");
|
||||
const clientName = "MB Grid Solutions";
|
||||
|
||||
// 2a. Notification to MB Grid
|
||||
const notificationHtml = await render(
|
||||
React.createElement(ContactFormNotification, {
|
||||
name,
|
||||
email,
|
||||
message,
|
||||
company,
|
||||
}),
|
||||
);
|
||||
|
||||
await payload.sendEmail({
|
||||
from: config.mail.from,
|
||||
to:
|
||||
config.mail.recipients.join(",") ||
|
||||
process.env.CONTACT_RECIPIENT ||
|
||||
"info@mb-grid-solutions.com",
|
||||
replyTo: email,
|
||||
subject: `Kontaktanfrage von ${name}`,
|
||||
text: `
|
||||
Name: ${name}
|
||||
Firma: ${company || "Nicht angegeben"}
|
||||
E-Mail: ${email}
|
||||
Zeitpunkt: ${new Date().toISOString()}
|
||||
|
||||
Nachricht:
|
||||
${message}
|
||||
`,
|
||||
html: notificationHtml,
|
||||
});
|
||||
|
||||
logger.info("Email sent successfully");
|
||||
// 2b. Confirmation to the User
|
||||
try {
|
||||
const confirmationHtml = await render(
|
||||
React.createElement(ConfirmationMessage, {
|
||||
name,
|
||||
clientName,
|
||||
}),
|
||||
);
|
||||
|
||||
await payload.sendEmail({
|
||||
from: config.mail.from,
|
||||
to: email,
|
||||
subject: `Ihre Kontaktanfrage bei ${clientName}`,
|
||||
html: confirmationHtml,
|
||||
});
|
||||
} catch (confirmError) {
|
||||
logger.warn(
|
||||
"Failed to send confirmation email, but notification was sent",
|
||||
{ error: confirmError },
|
||||
);
|
||||
}
|
||||
|
||||
logger.info("Emails sent successfully");
|
||||
|
||||
// Notify success for important leads
|
||||
await services.notifications.notify({
|
||||
@@ -94,22 +137,25 @@ ${message}
|
||||
logger.error("SMTP Error", { error: smtpError });
|
||||
services.errors.captureException(smtpError, { phase: "smtp_send" });
|
||||
|
||||
// If Directus failed AND SMTP failed, then we really have a problem
|
||||
if (!directusSaved) {
|
||||
if (!payloadSaved) {
|
||||
return NextResponse.json(
|
||||
{ error: "Systemfehler (Speicherung und Versand fehlgeschlagen)" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
|
||||
// If Directus was successful, we tell the user "Ok" but we know internally it was a partial failure
|
||||
await services.notifications.notify({
|
||||
title: "🚨 SMTP Fehler (Kontaktformular)",
|
||||
message: `Anfrage von ${name} (${email}) in Directus gespeichert, aber E-Mail-Versand fehlgeschlagen: ${smtpError instanceof Error ? smtpError.message : String(smtpError)}`,
|
||||
message: `Anfrage von ${name} (${email}) in Payload gespeichert, aber E-Mail-Versand fehlgeschlagen: ${smtpError instanceof Error ? smtpError.message : String(smtpError)}`,
|
||||
priority: 8,
|
||||
});
|
||||
}
|
||||
|
||||
// Track success
|
||||
services.analytics.track("contact-form-success", {
|
||||
has_company: Boolean(company),
|
||||
});
|
||||
|
||||
return NextResponse.json({ message: "Ok" });
|
||||
} catch (error) {
|
||||
logger.error("Global API Error", { error });
|
||||
|
||||
35
app/api/payload/migrate/route.ts
Normal file
35
app/api/payload/migrate/route.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getPayload } from "payload";
|
||||
import configPromise from "@payload-config";
|
||||
import { getServerAppServices } from "@/lib/services/create-services.server";
|
||||
|
||||
export async function POST(req: Request) {
|
||||
const authHeader = req.headers.get("authorization");
|
||||
if (authHeader !== `Bearer ${process.env.PAYLOAD_SECRET}`) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const { logger } = getServerAppServices();
|
||||
|
||||
try {
|
||||
logger.info("Starting programmatic Payload migrations...");
|
||||
const payload = await getPayload({ config: configPromise });
|
||||
|
||||
await payload.db.migrate();
|
||||
|
||||
logger.info("Successfully executed Payload migrations.");
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: "Migrations executed successfully.",
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error("Failed to run migrations remotely", { error });
|
||||
return NextResponse.json(
|
||||
{
|
||||
error:
|
||||
error instanceof Error ? error.message : "Unknown error occurred",
|
||||
},
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -197,6 +197,7 @@ export default function Contact() {
|
||||
) : (
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
aria-label={t("form.submit")}
|
||||
className="space-y-6 relative z-10"
|
||||
>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
|
||||
@@ -1,21 +1,29 @@
|
||||
"use client";
|
||||
|
||||
import { m } from "framer-motion";
|
||||
import {
|
||||
BarChart3,
|
||||
CheckCircle2,
|
||||
ChevronRight,
|
||||
Shield,
|
||||
Zap,
|
||||
} from "lucide-react";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { Button } from "./Button";
|
||||
import { Counter } from "./Counter";
|
||||
import { Reveal } from "./Reveal";
|
||||
import { TechBackground } from "./TechBackground";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
import dynamic from "next/dynamic";
|
||||
|
||||
const PortfolioSection = dynamic(() =>
|
||||
import("./sections/PortfolioSection").then((mod) => mod.PortfolioSection),
|
||||
);
|
||||
const ExpertiseSection = dynamic(() =>
|
||||
import("./sections/ExpertiseSection").then((mod) => mod.ExpertiseSection),
|
||||
);
|
||||
const TechnicalSpecsSection = dynamic(() =>
|
||||
import("./sections/TechnicalSpecsSection").then(
|
||||
(mod) => mod.TechnicalSpecsSection,
|
||||
),
|
||||
);
|
||||
const CTASection = dynamic(() =>
|
||||
import("./sections/CTASection").then((mod) => mod.CTASection),
|
||||
);
|
||||
|
||||
export default function Home() {
|
||||
const t = useTranslations("Index");
|
||||
|
||||
@@ -74,7 +82,7 @@ export default function Home() {
|
||||
fill
|
||||
className="object-cover"
|
||||
priority
|
||||
quality={90}
|
||||
quality={75}
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-slate-100/80 via-white/90 to-white/40 md:to-transparent" />
|
||||
<TechBackground />
|
||||
@@ -127,272 +135,11 @@ export default function Home() {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Portfolio Section */}
|
||||
<section className="bg-slate-950 text-accent relative overflow-hidden">
|
||||
<TechBackground />
|
||||
<div className="container-custom relative z-10">
|
||||
<Counter value={2} className="section-number !text-white/5" />
|
||||
<Reveal className="flex flex-col md:flex-row md:items-end justify-between gap-8 mb-16">
|
||||
<div>
|
||||
<span className="text-accent font-bold uppercase tracking-widest text-sm mb-4 block">
|
||||
{t("portfolio.tag")}
|
||||
</span>
|
||||
<h2 className="text-3xl md:text-5xl font-bold text-white mb-6">
|
||||
{t("portfolio.title")}
|
||||
</h2>
|
||||
<p className="text-slate-400 text-base md:text-xl">
|
||||
{t("portfolio.description")}
|
||||
</p>
|
||||
</div>
|
||||
<Link
|
||||
href="/ueber-uns"
|
||||
className="text-accent font-bold flex items-center gap-2 hover:text-white transition-colors group"
|
||||
>
|
||||
{t("portfolio.link")}{" "}
|
||||
<ChevronRight
|
||||
className="transition-transform group-hover:translate-x-1"
|
||||
size={20}
|
||||
/>
|
||||
</Link>
|
||||
</Reveal>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
|
||||
{[
|
||||
{
|
||||
icon: <Zap size={32} />,
|
||||
title: t("portfolio.items.beratung.title"),
|
||||
desc: t("portfolio.items.beratung.desc"),
|
||||
},
|
||||
{
|
||||
icon: <Shield size={32} />,
|
||||
title: t("portfolio.items.begleitung.title"),
|
||||
desc: t("portfolio.items.begleitung.desc"),
|
||||
},
|
||||
{
|
||||
icon: <BarChart3 size={32} />,
|
||||
title: t("portfolio.items.beschaffung.title"),
|
||||
desc: t("portfolio.items.beschaffung.desc"),
|
||||
},
|
||||
].map((item, i) => (
|
||||
<Reveal key={i} delay={i * 0.1}>
|
||||
<div className="bg-white/5 p-8 rounded-2xl border border-white/10 group hover:-translate-y-2 transition-[box-shadow,transform] duration-300 h-full relative overflow-hidden">
|
||||
<div className="absolute top-0 right-0 w-16 h-16 bg-accent/5 -mr-8 -mt-8 rounded-full group-hover:bg-accent/10 transition-colors" />
|
||||
<div className="w-16 h-16 rounded-2xl bg-accent/10 text-accent flex items-center justify-center mb-8 group-hover:bg-accent group-hover:text-white transition-colors relative z-10">
|
||||
{item.icon}
|
||||
</div>
|
||||
<h3 className="text-2xl font-bold text-white mb-4 relative z-10">
|
||||
{item.title}
|
||||
</h3>
|
||||
<p className="text-slate-400 leading-relaxed relative z-10">
|
||||
{item.desc}
|
||||
</p>
|
||||
</div>
|
||||
</Reveal>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Expertise Section */}
|
||||
<section className="bg-white relative overflow-hidden">
|
||||
<TechBackground />
|
||||
<div className="container-custom relative z-10">
|
||||
<Counter value={3} className="section-number" />
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 items-center gap-16 md:gap-24">
|
||||
<Reveal direction="right">
|
||||
<div className="relative overflow-hidden rounded-2xl shadow-lg group">
|
||||
<div className="absolute inset-0 bg-accent/10 opacity-0 group-hover:opacity-100 transition-opacity duration-500 z-10 pointer-events-none" />
|
||||
<Image
|
||||
src="/media/cables/hs-kabel.png"
|
||||
alt="Technical Engineering"
|
||||
width={800}
|
||||
height={600}
|
||||
className="w-full h-[400px] md:h-[500px] object-cover hover:scale-105 transition-transform duration-700"
|
||||
/>
|
||||
<div className="tech-corner top-4 left-4 border-t-2 border-l-2 z-20" />
|
||||
<div className="tech-corner bottom-4 right-4 border-b-2 border-r-2 z-20" />
|
||||
</div>
|
||||
</Reveal>
|
||||
<div>
|
||||
<Reveal>
|
||||
<span className="text-accent font-bold uppercase tracking-widest text-sm mb-4 block">
|
||||
{t("expertise.tag")}
|
||||
</span>
|
||||
<h2 className="text-3xl md:text-5xl font-bold text-primary mb-6 md:mb-8">
|
||||
{t("expertise.title")}
|
||||
</h2>
|
||||
<p className="text-slate-600 text-base md:text-xl mb-8 md:mb-12">
|
||||
{t("expertise.description")}
|
||||
</p>
|
||||
</Reveal>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
{t.raw("expertise.groups").map((item: string, i: number) => (
|
||||
<Reveal key={i} delay={i * 0.05}>
|
||||
<div className="flex items-center gap-4 p-4 bg-slate-50 rounded-xl border border-slate-100 hover:border-accent/30 transition-colors group relative overflow-hidden">
|
||||
<div className="absolute top-0 left-0 w-1 h-full bg-accent/0 group-hover:bg-accent/100 transition-all duration-300" />
|
||||
<div className="w-8 h-8 rounded-full bg-white flex items-center justify-center shadow-sm group-hover:bg-accent group-hover:text-white transition-colors">
|
||||
<CheckCircle2 size={16} />
|
||||
</div>
|
||||
<span className="text-primary font-semibold">{item}</span>
|
||||
</div>
|
||||
</Reveal>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Technical Specs Section */}
|
||||
<section className="relative py-24 md:py-32 text-white overflow-hidden bg-slate-900">
|
||||
<div className="absolute inset-0 opacity-20">
|
||||
<Image
|
||||
src="/media/drums/about-hero.jpg"
|
||||
alt="Background"
|
||||
fill
|
||||
className="object-cover"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-b from-slate-900 via-slate-900/80 to-slate-900" />
|
||||
</div>
|
||||
<TechBackground />
|
||||
|
||||
<div className="container-custom relative z-10">
|
||||
<Counter value={4} className="section-number !text-white/5" />
|
||||
{/* Data Stream Effect */}
|
||||
<div className="absolute -top-10 right-0 w-px h-64 bg-gradient-to-b from-transparent via-accent/50 to-transparent animate-pulse" />
|
||||
<div className="absolute -bottom-10 left-10 w-px h-64 bg-gradient-to-b from-transparent via-accent/30 to-transparent animate-pulse delay-700" />
|
||||
|
||||
<Reveal className="mb-20">
|
||||
<span className="text-accent font-bold uppercase tracking-widest text-sm mb-4 block">
|
||||
{t("specs.tag")}
|
||||
</span>
|
||||
<h2 className="text-3xl md:text-5xl font-bold text-white mb-6">
|
||||
{t("specs.title")}
|
||||
</h2>
|
||||
</Reveal>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 md:gap-8">
|
||||
{[
|
||||
{
|
||||
label: t("specs.items.kabel.label"),
|
||||
value: t("specs.items.kabel.value"),
|
||||
desc: t("specs.items.kabel.desc"),
|
||||
},
|
||||
{
|
||||
label: t("specs.items.spannung.label"),
|
||||
value: t("specs.items.spannung.value"),
|
||||
desc: t("specs.items.spannung.desc"),
|
||||
},
|
||||
{
|
||||
label: t("specs.items.technologie.label"),
|
||||
value: t("specs.items.technologie.value"),
|
||||
desc: t("specs.items.technologie.desc"),
|
||||
},
|
||||
].map((item, i) => (
|
||||
<Reveal key={i} delay={i * 0.1}>
|
||||
<div className="p-10 rounded-3xl bg-white/5 border border-white/10 backdrop-blur-sm hover:bg-white/10 transition-colors h-full relative group overflow-hidden">
|
||||
<div className="absolute top-0 left-0 w-full h-1 bg-accent/0 group-hover:bg-accent/50 transition-all duration-500" />
|
||||
<h4 className="text-accent font-bold text-xs uppercase tracking-widest mb-6">
|
||||
{item.label}
|
||||
</h4>
|
||||
<p className="text-2xl font-bold text-white mb-4 leading-tight">
|
||||
{item.value}
|
||||
</p>
|
||||
<p className="text-slate-400 leading-relaxed">{item.desc}</p>
|
||||
</div>
|
||||
</Reveal>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* CTA Section */}
|
||||
<section className="bg-white relative overflow-hidden">
|
||||
<TechBackground />
|
||||
{/* Decorative Background Elements */}
|
||||
<div className="absolute top-0 left-0 w-64 h-64 bg-accent/5 rounded-full blur-3xl -translate-x-1/2 -translate-y-1/2" />
|
||||
<div className="absolute bottom-0 right-0 w-96 h-96 bg-primary/5 rounded-full blur-3xl translate-x-1/2 translate-y-1/2" />
|
||||
|
||||
<div className="container-custom relative z-10">
|
||||
<Counter value={5} className="section-number" />
|
||||
<Reveal>
|
||||
<div className="relative rounded-3xl md:rounded-[2.5rem] bg-primary p-8 md:p-24 overflow-hidden group">
|
||||
{/* Corner Accents */}
|
||||
<div className="tech-corner top-8 left-8 border-t-2 border-l-2" />
|
||||
<div className="tech-corner top-8 right-8 border-t-2 border-r-2" />
|
||||
<div className="tech-corner bottom-8 left-8 border-b-2 border-l-2" />
|
||||
<div className="tech-corner bottom-8 right-8 border-b-2 border-r-2" />
|
||||
|
||||
<div className="absolute top-0 right-0 w-1/2 h-full opacity-10 pointer-events-none">
|
||||
<svg
|
||||
viewBox="0 0 400 400"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<m.circle
|
||||
animate={{ r: [400, 410, 400], opacity: [0.1, 0.2, 0.1] }}
|
||||
transition={{
|
||||
duration: 5,
|
||||
repeat: Infinity,
|
||||
ease: "easeInOut",
|
||||
}}
|
||||
cx="400"
|
||||
cy="0"
|
||||
r="400"
|
||||
stroke="white"
|
||||
strokeWidth="2"
|
||||
/>
|
||||
<m.circle
|
||||
animate={{ r: [300, 310, 300], opacity: [0.1, 0.2, 0.1] }}
|
||||
transition={{
|
||||
duration: 4,
|
||||
repeat: Infinity,
|
||||
ease: "easeInOut",
|
||||
delay: 0.5,
|
||||
}}
|
||||
cx="400"
|
||||
cy="0"
|
||||
r="300"
|
||||
stroke="white"
|
||||
strokeWidth="2"
|
||||
/>
|
||||
<m.circle
|
||||
animate={{ r: [200, 210, 200], opacity: [0.1, 0.2, 0.1] }}
|
||||
transition={{
|
||||
duration: 3,
|
||||
repeat: Infinity,
|
||||
ease: "easeInOut",
|
||||
delay: 1,
|
||||
}}
|
||||
cx="400"
|
||||
cy="0"
|
||||
r="200"
|
||||
stroke="white"
|
||||
strokeWidth="2"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div className="relative z-10">
|
||||
<h2 className="text-3xl md:text-6xl font-bold text-white mb-6 md:mb-8 leading-tight">
|
||||
{t("cta.title")}
|
||||
</h2>
|
||||
<p className="text-slate-300 text-lg md:text-xl mb-8 md:mb-12 leading-relaxed">
|
||||
{t("cta.subtitle")}
|
||||
</p>
|
||||
<Button
|
||||
href="/kontakt"
|
||||
variant="accent"
|
||||
showArrow
|
||||
className="w-full sm:w-auto !px-10 !py-5 text-lg"
|
||||
>
|
||||
{t("cta.button")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Reveal>
|
||||
</div>
|
||||
</section>
|
||||
{/* Dynamic Sections */}
|
||||
<PortfolioSection />
|
||||
<ExpertiseSection />
|
||||
<TechnicalSpecsSection />
|
||||
<CTASection />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -275,7 +275,7 @@ const Layout = ({ children }: { children: React.ReactNode }) => {
|
||||
<div className="bg-slate-950 py-6 border-t border-white/5">
|
||||
<div className="container-custom">
|
||||
<p className="text-[10px] uppercase tracking-[0.2em] text-slate-600 text-center md:text-left">
|
||||
Website developed by{" "}
|
||||
Website entwickelt von{" "}
|
||||
<a
|
||||
href="https://mintel.me"
|
||||
target="_blank"
|
||||
|
||||
102
components/sections/CTASection.tsx
Normal file
102
components/sections/CTASection.tsx
Normal file
@@ -0,0 +1,102 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { m } from "framer-motion";
|
||||
import { Reveal } from "../Reveal";
|
||||
import { Counter } from "../Counter";
|
||||
import { TechBackground } from "../TechBackground";
|
||||
import { Button } from "../Button";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
export const CTASection = () => {
|
||||
const t = useTranslations("Index");
|
||||
|
||||
return (
|
||||
<section className="bg-white relative overflow-hidden">
|
||||
<TechBackground />
|
||||
{/* Decorative Background Elements */}
|
||||
<div className="absolute top-0 left-0 w-64 h-64 bg-accent/5 rounded-full blur-3xl -translate-x-1/2 -translate-y-1/2" />
|
||||
<div className="absolute bottom-0 right-0 w-96 h-96 bg-primary/5 rounded-full blur-3xl translate-x-1/2 translate-y-1/2" />
|
||||
|
||||
<div className="container-custom relative z-10">
|
||||
<Counter value={5} className="section-number" />
|
||||
<Reveal>
|
||||
<div className="relative rounded-3xl md:rounded-[2.5rem] bg-primary p-8 md:p-24 overflow-hidden group">
|
||||
{/* Corner Accents */}
|
||||
<div className="tech-corner top-8 left-8 border-t-2 border-l-2" />
|
||||
<div className="tech-corner top-8 right-8 border-t-2 border-r-2" />
|
||||
<div className="tech-corner bottom-8 left-8 border-b-2 border-l-2" />
|
||||
<div className="tech-corner bottom-8 right-8 border-b-2 border-r-2" />
|
||||
|
||||
<div className="absolute top-0 right-0 w-1/2 h-full opacity-10 pointer-events-none">
|
||||
<svg
|
||||
viewBox="0 0 400 400"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<m.circle
|
||||
animate={{ r: [400, 410, 400], opacity: [0.1, 0.2, 0.1] }}
|
||||
transition={{
|
||||
duration: 5,
|
||||
repeat: Infinity,
|
||||
ease: "easeInOut",
|
||||
}}
|
||||
cx="400"
|
||||
cy="0"
|
||||
r="400"
|
||||
stroke="white"
|
||||
strokeWidth="2"
|
||||
/>
|
||||
<m.circle
|
||||
animate={{ r: [300, 310, 300], opacity: [0.1, 0.2, 0.1] }}
|
||||
transition={{
|
||||
duration: 4,
|
||||
repeat: Infinity,
|
||||
ease: "easeInOut",
|
||||
delay: 0.5,
|
||||
}}
|
||||
cx="400"
|
||||
cy="0"
|
||||
r="300"
|
||||
stroke="white"
|
||||
strokeWidth="2"
|
||||
/>
|
||||
<m.circle
|
||||
animate={{ r: [200, 210, 200], opacity: [0.1, 0.2, 0.1] }}
|
||||
transition={{
|
||||
duration: 3,
|
||||
repeat: Infinity,
|
||||
ease: "easeInOut",
|
||||
delay: 1,
|
||||
}}
|
||||
cx="400"
|
||||
cy="0"
|
||||
r="200"
|
||||
stroke="white"
|
||||
strokeWidth="2"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div className="relative z-10">
|
||||
<h2 className="text-3xl md:text-6xl font-bold text-white mb-6 md:mb-8 leading-tight">
|
||||
{t("cta.title")}
|
||||
</h2>
|
||||
<p className="text-slate-300 text-lg md:text-xl mb-8 md:mb-12 leading-relaxed">
|
||||
{t("cta.subtitle")}
|
||||
</p>
|
||||
<Button
|
||||
href="/kontakt"
|
||||
variant="accent"
|
||||
showArrow
|
||||
className="w-full sm:w-auto !px-10 !py-5 text-lg"
|
||||
>
|
||||
{t("cta.button")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Reveal>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
64
components/sections/ExpertiseSection.tsx
Normal file
64
components/sections/ExpertiseSection.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import Image from "next/image";
|
||||
import { CheckCircle2 } from "lucide-react";
|
||||
import { Reveal } from "../Reveal";
|
||||
import { Counter } from "../Counter";
|
||||
import { TechBackground } from "../TechBackground";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
export const ExpertiseSection = () => {
|
||||
const t = useTranslations("Index");
|
||||
|
||||
return (
|
||||
<section className="bg-white relative overflow-hidden">
|
||||
<TechBackground />
|
||||
<div className="container-custom relative z-10">
|
||||
<Counter value={3} className="section-number" />
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 items-center gap-16 md:gap-24">
|
||||
<Reveal direction="right">
|
||||
<div className="relative overflow-hidden rounded-2xl shadow-lg group">
|
||||
<div className="absolute inset-0 bg-accent/10 opacity-0 group-hover:opacity-100 transition-opacity duration-500 z-10 pointer-events-none" />
|
||||
<Image
|
||||
src="/media/cables/hs-kabel.png"
|
||||
alt="Technische Beratung"
|
||||
width={800}
|
||||
height={600}
|
||||
className="w-full h-[400px] md:h-[500px] object-cover hover:scale-105 transition-transform duration-700"
|
||||
/>
|
||||
<div className="tech-corner top-4 left-4 border-t-2 border-l-2 z-20" />
|
||||
<div className="tech-corner bottom-4 right-4 border-b-2 border-r-2 z-20" />
|
||||
</div>
|
||||
</Reveal>
|
||||
<div>
|
||||
<Reveal>
|
||||
<span className="text-accent font-bold uppercase tracking-widest text-sm mb-4 block">
|
||||
{t("expertise.tag")}
|
||||
</span>
|
||||
<h2 className="text-3xl md:text-5xl font-bold text-primary mb-6 md:mb-8">
|
||||
{t("expertise.title")}
|
||||
</h2>
|
||||
<p className="text-slate-600 text-base md:text-xl mb-8 md:mb-12">
|
||||
{t("expertise.description")}
|
||||
</p>
|
||||
</Reveal>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
{t.raw("expertise.groups").map((item: string, i: number) => (
|
||||
<Reveal key={i} delay={i * 0.05}>
|
||||
<div className="flex items-center gap-4 p-4 bg-slate-50 rounded-xl border border-slate-100 hover:border-accent/30 transition-colors group relative overflow-hidden">
|
||||
<div className="absolute top-0 left-0 w-1 h-full bg-accent/0 group-hover:bg-accent/100 transition-all duration-300" />
|
||||
<div className="w-8 h-8 rounded-full bg-white flex items-center justify-center shadow-sm group-hover:bg-accent group-hover:text-white transition-colors">
|
||||
<CheckCircle2 size={16} />
|
||||
</div>
|
||||
<span className="text-primary font-semibold">{item}</span>
|
||||
</div>
|
||||
</Reveal>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
80
components/sections/PortfolioSection.tsx
Normal file
80
components/sections/PortfolioSection.tsx
Normal file
@@ -0,0 +1,80 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import Link from "next/link";
|
||||
import { ChevronRight, Zap, Shield, BarChart3 } from "lucide-react";
|
||||
import { Reveal } from "../Reveal";
|
||||
import { Counter } from "../Counter";
|
||||
import { TechBackground } from "../TechBackground";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
export const PortfolioSection = () => {
|
||||
const t = useTranslations("Index");
|
||||
|
||||
return (
|
||||
<section className="bg-slate-950 text-accent relative overflow-hidden">
|
||||
<TechBackground />
|
||||
<div className="container-custom relative z-10">
|
||||
<Counter value={2} className="section-number !text-white/5" />
|
||||
<Reveal className="flex flex-col md:flex-row md:items-end justify-between gap-8 mb-16">
|
||||
<div>
|
||||
<span className="text-accent font-bold uppercase tracking-widest text-sm mb-4 block">
|
||||
{t("portfolio.tag")}
|
||||
</span>
|
||||
<h2 className="text-3xl md:text-5xl font-bold text-white mb-6">
|
||||
{t("portfolio.title")}
|
||||
</h2>
|
||||
<p className="text-slate-400 text-base md:text-xl">
|
||||
{t("portfolio.description")}
|
||||
</p>
|
||||
</div>
|
||||
<Link
|
||||
href="/ueber-uns"
|
||||
className="text-accent font-bold flex items-center gap-2 hover:text-white transition-colors group"
|
||||
>
|
||||
{t("portfolio.link")}{" "}
|
||||
<ChevronRight
|
||||
className="transition-transform group-hover:translate-x-1"
|
||||
size={20}
|
||||
/>
|
||||
</Link>
|
||||
</Reveal>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
|
||||
{[
|
||||
{
|
||||
icon: <Zap size={32} />,
|
||||
title: t("portfolio.items.beratung.title"),
|
||||
desc: t("portfolio.items.beratung.desc"),
|
||||
},
|
||||
{
|
||||
icon: <Shield size={32} />,
|
||||
title: t("portfolio.items.begleitung.title"),
|
||||
desc: t("portfolio.items.begleitung.desc"),
|
||||
},
|
||||
{
|
||||
icon: <BarChart3 size={32} />,
|
||||
title: t("portfolio.items.beschaffung.title"),
|
||||
desc: t("portfolio.items.beschaffung.desc"),
|
||||
},
|
||||
].map((item, i) => (
|
||||
<Reveal key={i} delay={i * 0.1}>
|
||||
<div className="bg-white/5 p-8 rounded-2xl border border-white/10 group hover:-translate-y-2 transition-[box-shadow,transform] duration-300 h-full relative overflow-hidden">
|
||||
<div className="absolute top-0 right-0 w-16 h-16 bg-accent/5 -mr-8 -mt-8 rounded-full group-hover:bg-accent/10 transition-colors" />
|
||||
<div className="w-16 h-16 rounded-2xl bg-accent/10 text-accent flex items-center justify-center mb-8 group-hover:bg-accent group-hover:text-white transition-colors relative z-10">
|
||||
{item.icon}
|
||||
</div>
|
||||
<h3 className="text-2xl font-bold text-white mb-4 relative z-10">
|
||||
{item.title}
|
||||
</h3>
|
||||
<p className="text-slate-400 leading-relaxed relative z-10">
|
||||
{item.desc}
|
||||
</p>
|
||||
</div>
|
||||
</Reveal>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
76
components/sections/TechnicalSpecsSection.tsx
Normal file
76
components/sections/TechnicalSpecsSection.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import Image from "next/image";
|
||||
import { Reveal } from "../Reveal";
|
||||
import { Counter } from "../Counter";
|
||||
import { TechBackground } from "../TechBackground";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
export const TechnicalSpecsSection = () => {
|
||||
const t = useTranslations("Index");
|
||||
|
||||
return (
|
||||
<section className="relative py-24 md:py-32 text-white overflow-hidden bg-slate-900">
|
||||
<div className="absolute inset-0 opacity-20">
|
||||
<Image
|
||||
src="/media/drums/about-hero.jpg"
|
||||
alt="Background"
|
||||
fill
|
||||
className="object-cover"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-b from-slate-900 via-slate-900/80 to-slate-900" />
|
||||
</div>
|
||||
<TechBackground />
|
||||
|
||||
<div className="container-custom relative z-10">
|
||||
<Counter value={4} className="section-number !text-white/5" />
|
||||
{/* Data Stream Effect */}
|
||||
<div className="absolute -top-10 right-0 w-px h-64 bg-gradient-to-b from-transparent via-accent/50 to-transparent animate-pulse" />
|
||||
<div className="absolute -bottom-10 left-10 w-px h-64 bg-gradient-to-b from-transparent via-accent/30 to-transparent animate-pulse delay-700" />
|
||||
|
||||
<Reveal className="mb-20">
|
||||
<span className="text-accent font-bold uppercase tracking-widest text-sm mb-4 block">
|
||||
{t("specs.tag")}
|
||||
</span>
|
||||
<h2 className="text-3xl md:text-5xl font-bold text-white mb-6">
|
||||
{t("specs.title")}
|
||||
</h2>
|
||||
</Reveal>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 md:gap-8">
|
||||
{[
|
||||
{
|
||||
label: t("specs.items.kabel.label"),
|
||||
value: t("specs.items.kabel.value"),
|
||||
desc: t("specs.items.kabel.desc"),
|
||||
},
|
||||
{
|
||||
label: t("specs.items.spannung.label"),
|
||||
value: t("specs.items.spannung.value"),
|
||||
desc: t("specs.items.spannung.desc"),
|
||||
},
|
||||
{
|
||||
label: t("specs.items.technologie.label"),
|
||||
value: t("specs.items.technologie.value"),
|
||||
desc: t("specs.items.technologie.desc"),
|
||||
},
|
||||
].map((item, i) => (
|
||||
<Reveal key={i} delay={i * 0.1}>
|
||||
<div className="p-10 rounded-3xl bg-white/5 border border-white/10 backdrop-blur-sm hover:bg-white/10 transition-colors h-full relative group overflow-hidden">
|
||||
<div className="absolute top-0 left-0 w-full h-1 bg-accent/0 group-hover:bg-accent/50 transition-all duration-500" />
|
||||
<h4 className="text-accent font-bold text-xs uppercase tracking-widest mb-6">
|
||||
{item.label}
|
||||
</h4>
|
||||
<p className="text-2xl font-bold text-white mb-4 leading-tight">
|
||||
{item.value}
|
||||
</p>
|
||||
<p className="text-slate-400 leading-relaxed">{item.desc}</p>
|
||||
</div>
|
||||
</Reveal>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
@@ -8,13 +8,12 @@ services:
|
||||
- .:/app
|
||||
environment:
|
||||
NODE_ENV: development
|
||||
# Docker Internal Communication
|
||||
DIRECTUS_URL: http://directus:8055
|
||||
DATABASE_URI: postgresql://directus:directus@mb-grid-db:5432/directus
|
||||
# Build / dependency installation
|
||||
NPM_TOKEN: ${NPM_TOKEN}
|
||||
CI: 'true'
|
||||
ports:
|
||||
- "3000:3000"
|
||||
# ports:
|
||||
# - "3000:3000"
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
# Clear all production-related TLS/Middleware settings for the main routers
|
||||
@@ -26,15 +25,3 @@ services:
|
||||
# Actually, gatekeeper is a separate service. We can keep it or ignore it.
|
||||
# But the app router normally points to gatekeeper middleware.
|
||||
# By clearing middlewares above, we bypass gatekeeper for local dev.
|
||||
|
||||
directus:
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.http.routers.${PROJECT_NAME:-mb-grid-solutions}-directus.entrypoints=web"
|
||||
- "traefik.http.routers.${PROJECT_NAME:-mb-grid-solutions}-directus.rule=Host(`${DIRECTUS_HOST:-cms.mb-grid-solutions.localhost}`)"
|
||||
- "traefik.http.routers.${PROJECT_NAME:-mb-grid-solutions}-directus.tls=false"
|
||||
- "traefik.http.routers.${PROJECT_NAME:-mb-grid-solutions}-directus.middlewares="
|
||||
ports:
|
||||
- "8055:8055"
|
||||
environment:
|
||||
PUBLIC_URL: http://${DIRECTUS_HOST:-cms.mb-grid-solutions.localhost}
|
||||
|
||||
@@ -1,101 +1,91 @@
|
||||
services:
|
||||
app:
|
||||
mb-grid-app:
|
||||
image: registry.infra.mintel.me/mintel/mb-grid-solutions:${IMAGE_TAG:-latest}
|
||||
restart: always
|
||||
networks:
|
||||
- default
|
||||
- infra
|
||||
env_file:
|
||||
- ${ENV_FILE:-.env}
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.http.routers.${PROJECT_NAME}.rule=${TRAEFIK_RULE:-Host(`${TRAEFIK_HOST:-mb-grid-solutions.localhost}`)}"
|
||||
- "traefik.http.routers.${PROJECT_NAME}.entrypoints=websecure"
|
||||
- "traefik.http.routers.${PROJECT_NAME}.tls.certresolver=le"
|
||||
- "traefik.http.routers.${PROJECT_NAME}.tls=true"
|
||||
- "traefik.http.services.${PROJECT_NAME}.loadbalancer.server.port=3000"
|
||||
- "traefik.http.routers.${PROJECT_NAME}.middlewares=${TRAEFIK_MIDDLEWARES:-${PROJECT_NAME}-auth}"
|
||||
- "traefik.http.routers.${PROJECT_NAME:-mb-grid}.rule=Host(`${TRAEFIK_HOST:-mb-grid-solutions.localhost}`)"
|
||||
- "traefik.http.routers.${PROJECT_NAME:-mb-grid}.entrypoints=websecure"
|
||||
- "traefik.http.routers.${PROJECT_NAME:-mb-grid}.tls.certresolver=le"
|
||||
- "traefik.http.routers.${PROJECT_NAME:-mb-grid}.tls=true"
|
||||
- "traefik.http.routers.${PROJECT_NAME:-mb-grid}.priority=1000"
|
||||
- "traefik.http.routers.${PROJECT_NAME:-mb-grid}.service=${PROJECT_NAME:-mb-grid}-app-svc"
|
||||
- "traefik.http.services.${PROJECT_NAME:-mb-grid}-app-svc.loadbalancer.server.port=3000"
|
||||
- "traefik.http.routers.${PROJECT_NAME:-mb-grid}.middlewares=${TRAEFIK_MIDDLEWARES:-mb-grid-auth,mb-grid-forward,compress}"
|
||||
- "traefik.docker.network=infra"
|
||||
|
||||
# Gatekeeper Router (Shared Host + dedicated Subdomain)
|
||||
- "traefik.http.routers.${PROJECT_NAME}-gatekeeper.rule=${GATEKEEPER_RULE:-(Host(`${TRAEFIK_HOST:-mb-grid-solutions.localhost}`) && PathPrefix(`/gatekeeper`)) || Host(`gatekeeper.${TRAEFIK_HOST:-mb-grid-solutions.localhost}`)}"
|
||||
- "traefik.http.routers.${PROJECT_NAME}-gatekeeper.entrypoints=websecure"
|
||||
- "traefik.http.routers.${PROJECT_NAME}-gatekeeper.tls.certresolver=le"
|
||||
- "traefik.http.routers.${PROJECT_NAME}-gatekeeper.tls=true"
|
||||
- "traefik.http.routers.${PROJECT_NAME}-gatekeeper.service=${PROJECT_NAME}-gatekeeper"
|
||||
# Public Router – paths that bypass Gatekeeper auth
|
||||
- "traefik.http.routers.${PROJECT_NAME:-mb-grid}-public.rule=Host(`${TRAEFIK_HOST:-mb-grid-solutions.localhost}`) && PathRegexp(`^/([a-z]{2}/)?(health|login|gatekeeper|uploads|media|robots\\.txt|manifest\\.webmanifest|sitemap(-[0-9]+)?\\.xml|(.*/)?api/og(/.*)?|(.*/)?opengraph-image.*)`)"
|
||||
- "traefik.http.routers.${PROJECT_NAME:-mb-grid}-public.entrypoints=websecure"
|
||||
- "traefik.http.routers.${PROJECT_NAME:-mb-grid}-public.tls.certresolver=le"
|
||||
- "traefik.http.routers.${PROJECT_NAME:-mb-grid}-public.tls=true"
|
||||
- "traefik.http.routers.${PROJECT_NAME:-mb-grid}-public.service=${PROJECT_NAME:-mb-grid}-app-svc"
|
||||
- "traefik.http.routers.${PROJECT_NAME:-mb-grid}-public.priority=2000"
|
||||
|
||||
- "traefik.http.middlewares.${PROJECT_NAME}-auth.forwardauth.address=http://${PROJECT_NAME}-gatekeeper:3000/api/verify"
|
||||
- "traefik.http.middlewares.${PROJECT_NAME}-auth.forwardauth.trustForwardHeader=true"
|
||||
- "traefik.http.middlewares.${PROJECT_NAME}-auth.forwardauth.authResponseHeaders=X-Auth-User"
|
||||
- "traefik.docker.network=infra"
|
||||
# Forwarded Headers (Protocol Normalization)
|
||||
- "traefik.http.middlewares.${PROJECT_NAME:-mb-grid}-forward.headers.customrequestheaders.X-Forwarded-Proto=https"
|
||||
- "traefik.http.middlewares.${PROJECT_NAME:-mb-grid}-forward.headers.customrequestheaders.X-Forwarded-Ssl=on"
|
||||
- "traefik.http.middlewares.compress.compress=true"
|
||||
healthcheck:
|
||||
test: [ "CMD", "node", "-e", "fetch('http://127.0.0.1:3000/api/health').then(r => r.ok ? process.exit(0) : process.exit(1)).catch(() => process.exit(1))" ]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 30s
|
||||
|
||||
gatekeeper:
|
||||
image: registry.infra.mintel.me/mintel/gatekeeper:latest
|
||||
mb-grid-gatekeeper:
|
||||
image: registry.infra.mintel.me/mintel/gatekeeper:testing
|
||||
container_name: ${PROJECT_NAME:-mb-grid-solutions}-gatekeeper
|
||||
restart: always
|
||||
profiles: [ "gatekeeper" ]
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
infra:
|
||||
aliases:
|
||||
- ${PROJECT_NAME:-mb-grid-solutions}-gatekeeper
|
||||
- mb-grid-gatekeeper
|
||||
env_file:
|
||||
- ${ENV_FILE:-.env}
|
||||
environment:
|
||||
PORT: ${PORT:-3000}
|
||||
PROJECT_NAME: ${PROJECT_NAME:-MB Grid Solutions}
|
||||
PROJECT_COLOR: ${PROJECT_COLOR:-#82ed20}
|
||||
COOKIE_DOMAIN: ${COOKIE_DOMAIN:-.mb-grid-solutions.com}
|
||||
AUTH_COOKIE_NAME: ${AUTH_COOKIE_NAME:-mintel_gatekeeper_session}
|
||||
GATEKEEPER_PASSWORD: ${GATEKEEPER_PASSWORD:-mintel}
|
||||
# Dedicated Base URL for Gatekeeper subdomain to prevent redirect loops
|
||||
NEXT_PUBLIC_BASE_URL: https://${GATEKEEPER_HOST:-gatekeeper.mb-grid-solutions.localhost}
|
||||
COOKIE_DOMAIN: ${COOKIE_DOMAIN}
|
||||
AUTH_COOKIE_NAME: ${AUTH_COOKIE_NAME}
|
||||
GATEKEEPER_PASSWORD: ${GATEKEEPER_PASSWORD}
|
||||
NEXT_PUBLIC_BASE_URL: ${GATEKEEPER_ORIGIN}
|
||||
healthcheck:
|
||||
test: [ "CMD", "node", "-e", "fetch('http://127.0.0.1:3000/gatekeeper/login').then(r => r.ok ? process.exit(0) : process.exit(1)).catch(() => process.exit(1))" ]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.http.services.${PROJECT_NAME}-gatekeeper.loadbalancer.server.port=3000"
|
||||
- "traefik.http.services.mb-grid-gatekeeper-svc.loadbalancer.server.port=3000"
|
||||
|
||||
# Gatekeeper Verification Middleware
|
||||
- "traefik.http.middlewares.${PROJECT_NAME:-mb-grid}-auth.forwardauth.address=http://${PROJECT_NAME:-mb-grid}-gatekeeper:3000/gatekeeper/api/verify"
|
||||
- "traefik.http.middlewares.${PROJECT_NAME:-mb-grid}-auth.forwardauth.trustForwardHeader=true"
|
||||
- "traefik.http.middlewares.${PROJECT_NAME:-mb-grid}-auth.forwardauth.authRequestHeaders=X-Forwarded-Host,X-Forwarded-Proto,X-Forwarded-For,Cookie"
|
||||
- "traefik.http.middlewares.${PROJECT_NAME:-mb-grid}-auth.forwardauth.authResponseHeaders=X-Auth-User"
|
||||
|
||||
# Gatekeeper Public Router (Login/Auth UI)
|
||||
- "traefik.http.routers.${PROJECT_NAME:-mb-grid}-gatekeeper.rule=(Host(`${TRAEFIK_HOST:-mb-grid-solutions.localhost}`) && PathPrefix(`/gatekeeper`))"
|
||||
- "traefik.http.routers.${PROJECT_NAME:-mb-grid}-gatekeeper.entrypoints=websecure"
|
||||
- "traefik.http.routers.${PROJECT_NAME:-mb-grid}-gatekeeper.tls.certresolver=le"
|
||||
- "traefik.http.routers.${PROJECT_NAME:-mb-grid}-gatekeeper.tls=true"
|
||||
- "traefik.http.routers.${PROJECT_NAME:-mb-grid}-gatekeeper.priority=2000"
|
||||
- "traefik.http.routers.${PROJECT_NAME:-mb-grid}-gatekeeper.service=${PROJECT_NAME:-mb-grid}-gatekeeper-svc"
|
||||
- "traefik.docker.network=infra"
|
||||
|
||||
directus:
|
||||
image: directus/directus:11
|
||||
restart: always
|
||||
networks:
|
||||
- infra
|
||||
- backend
|
||||
env_file:
|
||||
- ${ENV_FILE:-.env}
|
||||
environment:
|
||||
DB_CLIENT: 'pg'
|
||||
DB_HOST: 'directus-db'
|
||||
DB_PORT: '5432'
|
||||
WEBSOCKETS_ENABLED: 'true'
|
||||
PUBLIC_URL: ${DIRECTUS_URL}
|
||||
KEY: ${DIRECTUS_KEY}
|
||||
SECRET: ${DIRECTUS_SECRET}
|
||||
ADMIN_EMAIL: ${DIRECTUS_ADMIN_EMAIL}
|
||||
ADMIN_PASSWORD: ${DIRECTUS_ADMIN_PASSWORD}
|
||||
DB_DATABASE: ${DIRECTUS_DB_NAME:-directus}
|
||||
DB_USER: ${DIRECTUS_DB_USER:-directus}
|
||||
DB_PASSWORD: ${DIRECTUS_DB_PASSWORD:-directus}
|
||||
# Telemetry & Performance
|
||||
LOGGER_LEVEL: ${LOG_LEVEL:-info}
|
||||
SENTRY_DSN: ${SENTRY_DSN}
|
||||
SENTRY_ENVIRONMENT: ${TARGET:-development}
|
||||
volumes:
|
||||
- ./directus/uploads:/directus/uploads
|
||||
- ./directus/extensions:/directus/extensions
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.http.routers.${PROJECT_NAME}-directus.rule=Host(`${DIRECTUS_HOST:-cms.mb-grid-solutions.localhost}`)"
|
||||
- "traefik.http.routers.${PROJECT_NAME}-directus.entrypoints=websecure"
|
||||
- "traefik.http.routers.${PROJECT_NAME}-directus.tls.certresolver=le"
|
||||
- "traefik.http.routers.${PROJECT_NAME}-directus.tls=true"
|
||||
- "traefik.http.routers.${PROJECT_NAME}-directus.middlewares=${PROJECT_NAME}-forward,compress"
|
||||
- "traefik.http.services.${PROJECT_NAME}-directus.loadbalancer.server.port=8055"
|
||||
- "traefik.http.middlewares.${PROJECT_NAME}-forward.headers.customrequestheaders.X-Forwarded-Proto=https"
|
||||
- "traefik.docker.network=infra"
|
||||
|
||||
directus-db:
|
||||
mb-grid-db:
|
||||
image: postgres:15-alpine
|
||||
restart: always
|
||||
networks:
|
||||
- backend
|
||||
- default
|
||||
env_file:
|
||||
- ${ENV_FILE:-.env}
|
||||
environment:
|
||||
@@ -103,13 +93,13 @@ services:
|
||||
POSTGRES_USER: ${DIRECTUS_DB_USER:-directus}
|
||||
POSTGRES_PASSWORD: ${DIRECTUS_DB_PASSWORD:-directus}
|
||||
volumes:
|
||||
- directus-db-data:/var/lib/postgresql/data
|
||||
- mb-grid-db-data:/var/lib/postgresql/data
|
||||
|
||||
networks:
|
||||
default:
|
||||
name: mb-grid-solutions-internal
|
||||
infra:
|
||||
external: true
|
||||
backend:
|
||||
internal: true
|
||||
|
||||
volumes:
|
||||
directus-db-data:
|
||||
mb-grid-db-data:
|
||||
|
||||
@@ -20,7 +20,7 @@ CI (Woodpecker)
|
||||
https://ci.infra.mintel.me
|
||||
|
||||
Container Registry
|
||||
https://registry.infra.mintel.me
|
||||
https://git.infra.mintel.me
|
||||
|
||||
Errors (GlitchTip)
|
||||
https://errors.infra.mintel.me
|
||||
@@ -76,13 +76,13 @@ This directory contains:
|
||||
All production images must be built by CI and pushed to the Mintel Registry.
|
||||
|
||||
Registry:
|
||||
registry.infra.mintel.me
|
||||
git.infra.mintel.me
|
||||
|
||||
Image naming:
|
||||
registry.infra.mintel.me/ORG/APP_NAME:TAG
|
||||
git.infra.mintel.me/mmintel/APP_NAME:TAG
|
||||
|
||||
Example:
|
||||
registry.infra.mintel.me/mintel/mb-grid-solutions:latest
|
||||
git.infra.mintel.me/mmintel/mb-grid-solutions:latest
|
||||
|
||||
---
|
||||
|
||||
@@ -204,8 +204,8 @@ steps:
|
||||
build:
|
||||
image: woodpeckerci/plugin-docker
|
||||
settings:
|
||||
registry: registry.infra.mintel.me
|
||||
repo: registry.infra.mintel.me/mintel/mb-grid-solutions
|
||||
registry: git.infra.mintel.me
|
||||
repo: git.infra.mintel.me/mmintel/mb-grid-solutions
|
||||
username:
|
||||
from_secret: REGISTRY_USER
|
||||
password:
|
||||
|
||||
@@ -27,9 +27,11 @@ function createConfig() {
|
||||
|
||||
analytics: {
|
||||
umami: {
|
||||
websiteId: env.UMAMI_WEBSITE_ID,
|
||||
websiteId: env.NEXT_PUBLIC_UMAMI_WEBSITE_ID || env.UMAMI_WEBSITE_ID,
|
||||
apiEndpoint: env.UMAMI_API_ENDPOINT,
|
||||
enabled: Boolean(env.UMAMI_WEBSITE_ID),
|
||||
enabled: Boolean(
|
||||
env.NEXT_PUBLIC_UMAMI_WEBSITE_ID || env.UMAMI_WEBSITE_ID,
|
||||
),
|
||||
},
|
||||
},
|
||||
|
||||
@@ -59,14 +61,6 @@ function createConfig() {
|
||||
from: env.MAIL_FROM,
|
||||
recipients: env.MAIL_RECIPIENTS,
|
||||
},
|
||||
directus: {
|
||||
url: env.DIRECTUS_URL,
|
||||
adminEmail: env.DIRECTUS_ADMIN_EMAIL,
|
||||
password: env.DIRECTUS_ADMIN_PASSWORD,
|
||||
token: env.DIRECTUS_API_TOKEN,
|
||||
internalUrl: env.INTERNAL_DIRECTUS_URL,
|
||||
proxyPath: "/cms",
|
||||
},
|
||||
notifications: {
|
||||
gotify: {
|
||||
url: env.GOTIFY_URL,
|
||||
@@ -129,9 +123,6 @@ export const config = {
|
||||
get mail() {
|
||||
return getConfig().mail;
|
||||
},
|
||||
get directus() {
|
||||
return getConfig().directus;
|
||||
},
|
||||
get notifications() {
|
||||
return getConfig().notifications;
|
||||
},
|
||||
@@ -174,12 +165,6 @@ export function getMaskedConfig() {
|
||||
from: c.mail.from,
|
||||
recipients: c.mail.recipients,
|
||||
},
|
||||
directus: {
|
||||
url: c.directus.url,
|
||||
adminEmail: mask(c.directus.adminEmail),
|
||||
password: mask(c.directus.password),
|
||||
token: mask(c.directus.token),
|
||||
},
|
||||
notifications: {
|
||||
gotify: {
|
||||
url: c.notifications.gotify.url,
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
import { createDirectus, rest, authentication } from "@directus/sdk";
|
||||
import { config } from "./config";
|
||||
import { getServerAppServices } from "./services/create-services.server";
|
||||
|
||||
const { url, adminEmail, password, token, internalUrl } = config.directus;
|
||||
|
||||
// Use internal URL if on server to bypass Gatekeeper/Auth/Proxy issues
|
||||
const effectiveUrl =
|
||||
typeof window === "undefined" && internalUrl ? internalUrl : url;
|
||||
|
||||
const client = createDirectus(effectiveUrl).with(rest()).with(authentication());
|
||||
|
||||
/**
|
||||
* Ensures the client is authenticated.
|
||||
* Falls back to login with admin credentials if no static token is provided.
|
||||
*/
|
||||
export async function ensureAuthenticated() {
|
||||
if (token) {
|
||||
client.setToken(token);
|
||||
return;
|
||||
}
|
||||
|
||||
if (adminEmail && password) {
|
||||
try {
|
||||
await client.login({ email: adminEmail, password: password });
|
||||
} catch (e) {
|
||||
if (typeof window === "undefined") {
|
||||
getServerAppServices().errors.captureException(e, {
|
||||
phase: "directus_auth",
|
||||
});
|
||||
}
|
||||
console.error("Failed to authenticate with Directus login fallback:", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default client;
|
||||
70
lib/env.test.ts
Normal file
70
lib/env.test.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { envSchema } from "./env";
|
||||
|
||||
describe("envSchema", () => {
|
||||
it("should allow missing MAIL_HOST in development", () => {
|
||||
const result = envSchema.safeParse({
|
||||
NEXT_PUBLIC_BASE_URL: "http://localhost:3000",
|
||||
TARGET: "development",
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it("should require MAIL_HOST in production", () => {
|
||||
const result = envSchema.safeParse({
|
||||
NEXT_PUBLIC_BASE_URL: "https://example.com",
|
||||
TARGET: "production",
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
if (!result.success) {
|
||||
expect(result.error.issues[0].message).toBe(
|
||||
"MAIL_HOST is required in non-development environments",
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("should require MAIL_HOST in testing", () => {
|
||||
const result = envSchema.safeParse({
|
||||
NEXT_PUBLIC_BASE_URL: "https://testing.example.com",
|
||||
TARGET: "testing",
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
if (!result.success) {
|
||||
expect(result.error.issues[0].message).toBe(
|
||||
"MAIL_HOST is required in non-development environments",
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("should require MAIL_HOST in staging", () => {
|
||||
const result = envSchema.safeParse({
|
||||
NEXT_PUBLIC_BASE_URL: "https://staging.example.com",
|
||||
TARGET: "staging",
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
if (!result.success) {
|
||||
expect(result.error.issues[0].message).toBe(
|
||||
"MAIL_HOST is required in non-development environments",
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("should pass if MAIL_HOST is provided in production", () => {
|
||||
const result = envSchema.safeParse({
|
||||
NEXT_PUBLIC_BASE_URL: "https://example.com",
|
||||
TARGET: "production",
|
||||
MAIL_HOST: "smtp.example.com",
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it("should skip MAIL_HOST requirement if SKIP_RUNTIME_ENV_VALIDATION is true", () => {
|
||||
process.env.SKIP_RUNTIME_ENV_VALIDATION = "true";
|
||||
const result = envSchema.safeParse({
|
||||
NEXT_PUBLIC_BASE_URL: "https://example.com",
|
||||
TARGET: "production",
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
delete process.env.SKIP_RUNTIME_ENV_VALIDATION;
|
||||
});
|
||||
});
|
||||
159
lib/env.ts
159
lib/env.ts
@@ -1,139 +1,42 @@
|
||||
import { z } from "zod";
|
||||
|
||||
/**
|
||||
* Helper to treat empty strings as undefined.
|
||||
*/
|
||||
const preprocessEmptyString = (val: unknown) => (val === "" ? undefined : val);
|
||||
import {
|
||||
validateMintelEnv,
|
||||
mintelEnvSchema,
|
||||
withMintelRefinements,
|
||||
} from "@mintel/next-utils";
|
||||
|
||||
/**
|
||||
* Environment variable schema.
|
||||
* Extends the default Mintel environment schema which already includes:
|
||||
* - Directus (URL, TOKEN, INTERNAL_URL, etc.)
|
||||
* - Mail (HOST, PORT, etc.)
|
||||
* - Gotify
|
||||
* - Logging
|
||||
* - Analytics
|
||||
*/
|
||||
export const envSchema = z
|
||||
.object({
|
||||
NODE_ENV: z
|
||||
.enum(["development", "production", "test"])
|
||||
.default("development"),
|
||||
NEXT_PUBLIC_BASE_URL: z.preprocess(
|
||||
preprocessEmptyString,
|
||||
z.string().url().optional(),
|
||||
),
|
||||
NEXT_PUBLIC_TARGET: z
|
||||
.enum(["development", "testing", "staging", "production"])
|
||||
.optional(),
|
||||
const envExtension = {
|
||||
// Project specific overrides or additions
|
||||
AUTH_COOKIE_NAME: z.string().default("mb_gatekeeper_session"),
|
||||
|
||||
// Analytics
|
||||
UMAMI_WEBSITE_ID: z.preprocess(
|
||||
preprocessEmptyString,
|
||||
z.string().optional(),
|
||||
),
|
||||
UMAMI_API_ENDPOINT: z.preprocess(
|
||||
preprocessEmptyString,
|
||||
z.string().url().default("https://analytics.infra.mintel.me"),
|
||||
),
|
||||
|
||||
// Error Tracking
|
||||
SENTRY_DSN: z.preprocess(preprocessEmptyString, z.string().optional()),
|
||||
|
||||
// Logging
|
||||
LOG_LEVEL: z.enum(["debug", "info", "warn", "error"]).default("info"),
|
||||
|
||||
// Mail
|
||||
MAIL_HOST: z.preprocess(preprocessEmptyString, z.string().optional()),
|
||||
MAIL_PORT: z.preprocess(
|
||||
preprocessEmptyString,
|
||||
z.coerce.number().default(587),
|
||||
),
|
||||
MAIL_USERNAME: z.preprocess(preprocessEmptyString, z.string().optional()),
|
||||
MAIL_PASSWORD: z.preprocess(preprocessEmptyString, z.string().optional()),
|
||||
MAIL_FROM: z.preprocess(preprocessEmptyString, z.string().optional()),
|
||||
MAIL_RECIPIENTS: z.preprocess(
|
||||
(val) => (typeof val === "string" ? val.split(",").filter(Boolean) : val),
|
||||
z.array(z.string()).default([]),
|
||||
),
|
||||
|
||||
// Directus
|
||||
DIRECTUS_URL: z.preprocess(
|
||||
preprocessEmptyString,
|
||||
z.string().url().default("http://localhost:8055"),
|
||||
),
|
||||
DIRECTUS_ADMIN_EMAIL: z.preprocess(
|
||||
preprocessEmptyString,
|
||||
z.string().optional(),
|
||||
),
|
||||
DIRECTUS_ADMIN_PASSWORD: z.preprocess(
|
||||
preprocessEmptyString,
|
||||
z.string().optional(),
|
||||
),
|
||||
DIRECTUS_API_TOKEN: z.preprocess(
|
||||
preprocessEmptyString,
|
||||
z.string().optional(),
|
||||
),
|
||||
INTERNAL_DIRECTUS_URL: z.preprocess(
|
||||
preprocessEmptyString,
|
||||
z.string().url().optional(),
|
||||
),
|
||||
|
||||
// Deploy Target
|
||||
TARGET: z
|
||||
.enum(["development", "testing", "staging", "production"])
|
||||
.optional(),
|
||||
// Gotify
|
||||
GOTIFY_URL: z.preprocess(
|
||||
preprocessEmptyString,
|
||||
z.string().url().optional(),
|
||||
),
|
||||
GOTIFY_TOKEN: z.preprocess(preprocessEmptyString, z.string().optional()),
|
||||
})
|
||||
.superRefine((data, ctx) => {
|
||||
const target = data.NEXT_PUBLIC_TARGET || data.TARGET;
|
||||
const isDev = target === "development" || !target;
|
||||
const isBuildTimeValidation =
|
||||
process.env.SKIP_RUNTIME_ENV_VALIDATION === "true";
|
||||
const isServer = typeof window === "undefined";
|
||||
|
||||
// Only enforce server-only variables when running on the server.
|
||||
// In the browser, non-NEXT_PUBLIC_ variables are undefined and should not trigger validation errors.
|
||||
if (isServer && !isDev && !isBuildTimeValidation && !data.MAIL_HOST) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "MAIL_HOST is required in non-development environments",
|
||||
path: ["MAIL_HOST"],
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export type Env = z.infer<typeof envSchema>;
|
||||
INFRA_DIRECTUS_URL: z.string().url().optional(),
|
||||
INFRA_DIRECTUS_TOKEN: z.string().optional(),
|
||||
};
|
||||
|
||||
/**
|
||||
* Collects all environment variables from the process.
|
||||
* Explicitly references NEXT_PUBLIC_ variables for Next.js inlining.
|
||||
* Full schema including Mintel base and refinements
|
||||
*/
|
||||
export const envSchema = withMintelRefinements(
|
||||
z.object(mintelEnvSchema).extend(envExtension),
|
||||
);
|
||||
|
||||
/**
|
||||
* Validated environment object.
|
||||
*/
|
||||
export const env = validateMintelEnv(envExtension);
|
||||
|
||||
/**
|
||||
* For legacy compatibility with existing code.
|
||||
*/
|
||||
export function getRawEnv() {
|
||||
return {
|
||||
NODE_ENV: process.env.NODE_ENV,
|
||||
NEXT_PUBLIC_BASE_URL: process.env.NEXT_PUBLIC_BASE_URL,
|
||||
NEXT_PUBLIC_TARGET: process.env.NEXT_PUBLIC_TARGET,
|
||||
UMAMI_WEBSITE_ID:
|
||||
process.env.UMAMI_WEBSITE_ID || process.env.NEXT_PUBLIC_UMAMI_WEBSITE_ID,
|
||||
UMAMI_API_ENDPOINT:
|
||||
process.env.UMAMI_API_ENDPOINT ||
|
||||
process.env.UMAMI_SCRIPT_URL ||
|
||||
process.env.NEXT_PUBLIC_UMAMI_SCRIPT_URL,
|
||||
SENTRY_DSN: process.env.SENTRY_DSN,
|
||||
LOG_LEVEL: process.env.LOG_LEVEL,
|
||||
MAIL_HOST: process.env.MAIL_HOST,
|
||||
MAIL_PORT: process.env.MAIL_PORT,
|
||||
MAIL_USERNAME: process.env.MAIL_USERNAME,
|
||||
MAIL_PASSWORD: process.env.MAIL_PASSWORD,
|
||||
MAIL_FROM: process.env.MAIL_FROM,
|
||||
MAIL_RECIPIENTS: process.env.MAIL_RECIPIENTS,
|
||||
DIRECTUS_URL: process.env.DIRECTUS_URL,
|
||||
DIRECTUS_ADMIN_EMAIL: process.env.DIRECTUS_ADMIN_EMAIL,
|
||||
DIRECTUS_ADMIN_PASSWORD: process.env.DIRECTUS_ADMIN_PASSWORD,
|
||||
DIRECTUS_API_TOKEN: process.env.DIRECTUS_API_TOKEN,
|
||||
INTERNAL_DIRECTUS_URL: process.env.INTERNAL_DIRECTUS_URL,
|
||||
TARGET: process.env.TARGET,
|
||||
GOTIFY_URL: process.env.GOTIFY_URL,
|
||||
GOTIFY_TOKEN: process.env.GOTIFY_TOKEN,
|
||||
};
|
||||
return env;
|
||||
}
|
||||
|
||||
@@ -73,4 +73,15 @@ export interface AnalyticsService {
|
||||
* ```
|
||||
*/
|
||||
trackPageview(url?: string): void;
|
||||
|
||||
/**
|
||||
* Set the server-side context for the current request.
|
||||
* This is used for server-side tracking (e.g. from Next.js proxy).
|
||||
*/
|
||||
setServerContext?(context: {
|
||||
userAgent?: string;
|
||||
language?: string;
|
||||
referrer?: string;
|
||||
ip?: string;
|
||||
}): void;
|
||||
}
|
||||
|
||||
@@ -68,4 +68,16 @@ export class NoopAnalyticsService implements AnalyticsService {
|
||||
trackPageview(_url?: string) {
|
||||
// intentionally noop - analytics are disabled
|
||||
}
|
||||
|
||||
/**
|
||||
* No-op implementation of setServerContext.
|
||||
*/
|
||||
setServerContext(_context: {
|
||||
userAgent?: string;
|
||||
language?: string;
|
||||
referrer?: string;
|
||||
ip?: string;
|
||||
}) {
|
||||
// intentionally noop - analytics are disabled
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,6 +25,12 @@ export type UmamiAnalyticsServiceOptions = {
|
||||
export class UmamiAnalyticsService implements AnalyticsService {
|
||||
private websiteId?: string;
|
||||
private endpoint: string;
|
||||
private serverContext?: {
|
||||
userAgent?: string;
|
||||
language?: string;
|
||||
referrer?: string;
|
||||
ip?: string;
|
||||
};
|
||||
|
||||
constructor(private readonly options: UmamiAnalyticsServiceOptions) {
|
||||
this.websiteId = config.analytics.umami.websiteId;
|
||||
@@ -36,6 +42,19 @@ export class UmamiAnalyticsService implements AnalyticsService {
|
||||
: "/stats";
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the server-side context for the current request.
|
||||
* This allows the service to use real request headers for tracking.
|
||||
*/
|
||||
setServerContext(context: {
|
||||
userAgent?: string;
|
||||
language?: string;
|
||||
referrer?: string;
|
||||
ip?: string;
|
||||
}) {
|
||||
this.serverContext = context;
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal method to send the payload to Umami API.
|
||||
*/
|
||||
@@ -47,24 +66,47 @@ export class UmamiAnalyticsService implements AnalyticsService {
|
||||
const payload = {
|
||||
website: this.websiteId,
|
||||
hostname:
|
||||
typeof window !== "undefined" ? window.location.hostname : "server",
|
||||
typeof window !== "undefined"
|
||||
? window.location.hostname
|
||||
: this.serverContext?.referrer
|
||||
? new URL(this.serverContext.referrer).hostname
|
||||
: "server",
|
||||
screen:
|
||||
typeof window !== "undefined"
|
||||
? `${window.screen.width}x${window.screen.height}`
|
||||
: undefined,
|
||||
language:
|
||||
typeof window !== "undefined" ? navigator.language : undefined,
|
||||
referrer: typeof window !== "undefined" ? document.referrer : undefined,
|
||||
typeof window !== "undefined"
|
||||
? navigator.language
|
||||
: this.serverContext?.language,
|
||||
referrer:
|
||||
typeof window !== "undefined"
|
||||
? document.referrer
|
||||
: this.serverContext?.referrer,
|
||||
...data,
|
||||
};
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": "application/json",
|
||||
};
|
||||
|
||||
// Set User-Agent
|
||||
if (typeof window !== "undefined") {
|
||||
headers["User-Agent"] = navigator.userAgent;
|
||||
} else if (this.serverContext?.userAgent) {
|
||||
headers["User-Agent"] = this.serverContext.userAgent;
|
||||
} else {
|
||||
headers["User-Agent"] = "Mintel-Server-Proxy";
|
||||
}
|
||||
|
||||
// Forward client IP if available (Umami must be configured to trust this)
|
||||
if (this.serverContext?.ip) {
|
||||
headers["X-Forwarded-For"] = this.serverContext.ip;
|
||||
}
|
||||
|
||||
const response = await fetch(`${this.endpoint}/api/send`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"User-Agent":
|
||||
typeof window === "undefined" ? "KLZ-Server" : navigator.userAgent,
|
||||
},
|
||||
headers,
|
||||
body: JSON.stringify({ type, payload }),
|
||||
keepalive: true,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
@@ -93,7 +135,9 @@ export class UmamiAnalyticsService implements AnalyticsService {
|
||||
url:
|
||||
typeof window !== "undefined"
|
||||
? window.location.pathname + window.location.search
|
||||
: undefined,
|
||||
: this.serverContext?.referrer
|
||||
? new URL(this.serverContext.referrer).pathname
|
||||
: "/",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -106,7 +150,9 @@ export class UmamiAnalyticsService implements AnalyticsService {
|
||||
url ||
|
||||
(typeof window !== "undefined"
|
||||
? window.location.pathname + window.location.search
|
||||
: undefined),
|
||||
: this.serverContext?.referrer
|
||||
? new URL(this.serverContext.referrer).pathname
|
||||
: "/"),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,33 +31,63 @@ export class PinoLoggerService implements LoggerService {
|
||||
}
|
||||
|
||||
trace(msg: string, ...args: unknown[]) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
this.logger.trace(msg, ...(args as any));
|
||||
if (args.length > 0 && typeof args[0] === "object" && args[0] !== null) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(this.logger as any).trace(args[0] as object, msg, ...args.slice(1));
|
||||
} else {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(this.logger as any).trace(msg, ...args);
|
||||
}
|
||||
}
|
||||
|
||||
debug(msg: string, ...args: unknown[]) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
this.logger.debug(msg, ...(args as any));
|
||||
if (args.length > 0 && typeof args[0] === "object" && args[0] !== null) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(this.logger as any).debug(args[0] as object, msg, ...args.slice(1));
|
||||
} else {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(this.logger as any).debug(msg, ...args);
|
||||
}
|
||||
}
|
||||
|
||||
info(msg: string, ...args: unknown[]) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
this.logger.info(msg, ...(args as any));
|
||||
if (args.length > 0 && typeof args[0] === "object" && args[0] !== null) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(this.logger as any).info(args[0] as object, msg, ...args.slice(1));
|
||||
} else {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(this.logger as any).info(msg, ...args);
|
||||
}
|
||||
}
|
||||
|
||||
warn(msg: string, ...args: unknown[]) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
this.logger.warn(msg, ...(args as any));
|
||||
if (args.length > 0 && typeof args[0] === "object" && args[0] !== null) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(this.logger as any).warn(args[0] as object, msg, ...args.slice(1));
|
||||
} else {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(this.logger as any).warn(msg, ...args);
|
||||
}
|
||||
}
|
||||
|
||||
error(msg: string, ...args: unknown[]) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
this.logger.error(msg, ...(args as any));
|
||||
if (args.length > 0 && typeof args[0] === "object" && args[0] !== null) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(this.logger as any).error(args[0] as object, msg, ...args.slice(1));
|
||||
} else {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(this.logger as any).error(msg, ...args);
|
||||
}
|
||||
}
|
||||
|
||||
fatal(msg: string, ...args: unknown[]) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
this.logger.fatal(msg, ...(args as any));
|
||||
if (args.length > 0 && typeof args[0] === "object" && args[0] !== null) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(this.logger as any).fatal(args[0] as object, msg, ...args.slice(1));
|
||||
} else {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(this.logger as any).fatal(msg, ...args);
|
||||
}
|
||||
}
|
||||
|
||||
child(bindings: Record<string, unknown>): LoggerService {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"Index": {
|
||||
"hero": {
|
||||
"tag": "Engineering Excellence",
|
||||
"tag": "Technische Beratung",
|
||||
"title": "Spezialisierter Partner für Energiekabelprojekte",
|
||||
"titleHighlight": "Energiekabelprojekte",
|
||||
"subtitle": "Herstellerneutrale technische Beratung für Ihre Projekte in Mittel- und Hochspannungsnetzen bis zu 110 kV.",
|
||||
@@ -31,7 +31,7 @@
|
||||
"expertise": {
|
||||
"tag": "Expertise",
|
||||
"title": "Anwendungen & Zielgruppen",
|
||||
"description": "Wir unterstützen Akteure der Energiewende bei der Realisierung komplexer Kabelprojekte mit höchster Präzision.",
|
||||
"description": "Wir unterstützen Sie bei der Realisierung Ihrer Kabelprojekte.",
|
||||
"groups": [
|
||||
"Energieversorger",
|
||||
"Ingenieurbüros",
|
||||
@@ -83,16 +83,16 @@
|
||||
"datenschutz": "Datenschutz",
|
||||
"agb": "AGB",
|
||||
"rights": "Alle Rechte vorbehalten.",
|
||||
"madeWith": "Made with",
|
||||
"precision": "precision",
|
||||
"inGermany": "in Germany"
|
||||
"madeWith": "Entwickelt mit",
|
||||
"precision": "Präzision",
|
||||
"inGermany": "in Deutschland"
|
||||
}
|
||||
},
|
||||
"About": {
|
||||
"hero": {
|
||||
"tagline": "Über uns",
|
||||
"title": "Wir gestalten die Infrastructure der Zukunft",
|
||||
"subtitle": "MB Grid Solution steht für technische Exzellenz in der Energiekabeltechnologie. Wir verstehen uns als Ihr technischer Lotse."
|
||||
"title": "Zuverlässige Begleitung für Ihre Netzinfrastruktur",
|
||||
"subtitle": "Herstellerneutrale Beratung in der Energiekabeltechnologie. Wir verstehen uns als Ihr technischer Lotse."
|
||||
},
|
||||
"intro": {
|
||||
"p1": "Unsere Wurzeln liegen in der tiefen praktischen Erfahrung unserer technischen Berater und unserer Netzwerke im globalem Kabelmarkt. Wir vereinen Tradition mit modernster Innovation, um zuverlässige Energielösungen für Projekte bis 110 kV zu realisieren.",
|
||||
|
||||
@@ -14,5 +14,9 @@ export default createMiddleware({
|
||||
export const config = {
|
||||
// Matcher for all pages and internationalized pathnames
|
||||
// excluding api, _next, static files, etc.
|
||||
matcher: ["/((?!api|_next|_vercel|.*\\..*).*)", "/", "/(de)/:path*"],
|
||||
matcher: [
|
||||
"/((?!api|admin|stats|errors|_next|_vercel|.*\\..*).*)",
|
||||
"/",
|
||||
"/(de)/:path*",
|
||||
],
|
||||
};
|
||||
|
||||
1278
migrations/20260227_113637_v1_initial.json
Normal file
1278
migrations/20260227_113637_v1_initial.json
Normal file
File diff suppressed because it is too large
Load Diff
183
migrations/20260227_113637_v1_initial.ts
Normal file
183
migrations/20260227_113637_v1_initial.ts
Normal file
@@ -0,0 +1,183 @@
|
||||
import { MigrateUpArgs, MigrateDownArgs, sql } from "@payloadcms/db-postgres";
|
||||
|
||||
export async function up({ db, payload, req }: MigrateUpArgs): Promise<void> {
|
||||
await db.execute(sql`
|
||||
CREATE TABLE "users_sessions" (
|
||||
"_order" integer NOT NULL,
|
||||
"_parent_id" integer NOT NULL,
|
||||
"id" varchar PRIMARY KEY NOT NULL,
|
||||
"created_at" timestamp(3) with time zone,
|
||||
"expires_at" timestamp(3) with time zone NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE "users" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
|
||||
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
|
||||
"email" varchar NOT NULL,
|
||||
"reset_password_token" varchar,
|
||||
"reset_password_expiration" timestamp(3) with time zone,
|
||||
"salt" varchar,
|
||||
"hash" varchar,
|
||||
"login_attempts" numeric DEFAULT 0,
|
||||
"lock_until" timestamp(3) with time zone
|
||||
);
|
||||
|
||||
CREATE TABLE "media" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"alt" varchar NOT NULL,
|
||||
"prefix" varchar DEFAULT 'mb-grid-solutions/media',
|
||||
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
|
||||
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
|
||||
"url" varchar,
|
||||
"thumbnail_u_r_l" varchar,
|
||||
"filename" varchar,
|
||||
"mime_type" varchar,
|
||||
"filesize" numeric,
|
||||
"width" numeric,
|
||||
"height" numeric,
|
||||
"focal_x" numeric,
|
||||
"focal_y" numeric,
|
||||
"sizes_thumbnail_url" varchar,
|
||||
"sizes_thumbnail_width" numeric,
|
||||
"sizes_thumbnail_height" numeric,
|
||||
"sizes_thumbnail_mime_type" varchar,
|
||||
"sizes_thumbnail_filesize" numeric,
|
||||
"sizes_thumbnail_filename" varchar,
|
||||
"sizes_card_url" varchar,
|
||||
"sizes_card_width" numeric,
|
||||
"sizes_card_height" numeric,
|
||||
"sizes_card_mime_type" varchar,
|
||||
"sizes_card_filesize" numeric,
|
||||
"sizes_card_filename" varchar
|
||||
);
|
||||
|
||||
CREATE TABLE "form_submissions" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"name" varchar NOT NULL,
|
||||
"email" varchar NOT NULL,
|
||||
"company" varchar,
|
||||
"message" varchar NOT NULL,
|
||||
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
|
||||
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE "pages" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"title" varchar NOT NULL,
|
||||
"slug" varchar NOT NULL,
|
||||
"content" jsonb NOT NULL,
|
||||
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
|
||||
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE "payload_kv" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"key" varchar NOT NULL,
|
||||
"data" jsonb NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE "payload_locked_documents" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"global_slug" varchar,
|
||||
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
|
||||
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE "payload_locked_documents_rels" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"order" integer,
|
||||
"parent_id" integer NOT NULL,
|
||||
"path" varchar NOT NULL,
|
||||
"users_id" integer,
|
||||
"media_id" integer,
|
||||
"form_submissions_id" integer,
|
||||
"pages_id" integer
|
||||
);
|
||||
|
||||
CREATE TABLE "payload_preferences" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"key" varchar,
|
||||
"value" jsonb,
|
||||
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
|
||||
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE "payload_preferences_rels" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"order" integer,
|
||||
"parent_id" integer NOT NULL,
|
||||
"path" varchar NOT NULL,
|
||||
"users_id" integer
|
||||
);
|
||||
|
||||
CREATE TABLE "payload_migrations" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"name" varchar,
|
||||
"batch" numeric,
|
||||
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
|
||||
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
|
||||
ALTER TABLE "users_sessions" ADD CONSTRAINT "users_sessions_parent_id_fk" FOREIGN KEY ("_parent_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;
|
||||
ALTER TABLE "payload_locked_documents_rels" ADD CONSTRAINT "payload_locked_documents_rels_parent_fk" FOREIGN KEY ("parent_id") REFERENCES "public"."payload_locked_documents"("id") ON DELETE cascade ON UPDATE no action;
|
||||
ALTER TABLE "payload_locked_documents_rels" ADD CONSTRAINT "payload_locked_documents_rels_users_fk" FOREIGN KEY ("users_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;
|
||||
ALTER TABLE "payload_locked_documents_rels" ADD CONSTRAINT "payload_locked_documents_rels_media_fk" FOREIGN KEY ("media_id") REFERENCES "public"."media"("id") ON DELETE cascade ON UPDATE no action;
|
||||
ALTER TABLE "payload_locked_documents_rels" ADD CONSTRAINT "payload_locked_documents_rels_form_submissions_fk" FOREIGN KEY ("form_submissions_id") REFERENCES "public"."form_submissions"("id") ON DELETE cascade ON UPDATE no action;
|
||||
ALTER TABLE "payload_locked_documents_rels" ADD CONSTRAINT "payload_locked_documents_rels_pages_fk" FOREIGN KEY ("pages_id") REFERENCES "public"."pages"("id") ON DELETE cascade ON UPDATE no action;
|
||||
ALTER TABLE "payload_preferences_rels" ADD CONSTRAINT "payload_preferences_rels_parent_fk" FOREIGN KEY ("parent_id") REFERENCES "public"."payload_preferences"("id") ON DELETE cascade ON UPDATE no action;
|
||||
ALTER TABLE "payload_preferences_rels" ADD CONSTRAINT "payload_preferences_rels_users_fk" FOREIGN KEY ("users_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;
|
||||
CREATE INDEX "users_sessions_order_idx" ON "users_sessions" USING btree ("_order");
|
||||
CREATE INDEX "users_sessions_parent_id_idx" ON "users_sessions" USING btree ("_parent_id");
|
||||
CREATE INDEX "users_updated_at_idx" ON "users" USING btree ("updated_at");
|
||||
CREATE INDEX "users_created_at_idx" ON "users" USING btree ("created_at");
|
||||
CREATE UNIQUE INDEX "users_email_idx" ON "users" USING btree ("email");
|
||||
CREATE INDEX "media_updated_at_idx" ON "media" USING btree ("updated_at");
|
||||
CREATE INDEX "media_created_at_idx" ON "media" USING btree ("created_at");
|
||||
CREATE UNIQUE INDEX "media_filename_idx" ON "media" USING btree ("filename");
|
||||
CREATE INDEX "media_sizes_thumbnail_sizes_thumbnail_filename_idx" ON "media" USING btree ("sizes_thumbnail_filename");
|
||||
CREATE INDEX "media_sizes_card_sizes_card_filename_idx" ON "media" USING btree ("sizes_card_filename");
|
||||
CREATE INDEX "form_submissions_updated_at_idx" ON "form_submissions" USING btree ("updated_at");
|
||||
CREATE INDEX "form_submissions_created_at_idx" ON "form_submissions" USING btree ("created_at");
|
||||
CREATE INDEX "pages_updated_at_idx" ON "pages" USING btree ("updated_at");
|
||||
CREATE INDEX "pages_created_at_idx" ON "pages" USING btree ("created_at");
|
||||
CREATE UNIQUE INDEX "payload_kv_key_idx" ON "payload_kv" USING btree ("key");
|
||||
CREATE INDEX "payload_locked_documents_global_slug_idx" ON "payload_locked_documents" USING btree ("global_slug");
|
||||
CREATE INDEX "payload_locked_documents_updated_at_idx" ON "payload_locked_documents" USING btree ("updated_at");
|
||||
CREATE INDEX "payload_locked_documents_created_at_idx" ON "payload_locked_documents" USING btree ("created_at");
|
||||
CREATE INDEX "payload_locked_documents_rels_order_idx" ON "payload_locked_documents_rels" USING btree ("order");
|
||||
CREATE INDEX "payload_locked_documents_rels_parent_idx" ON "payload_locked_documents_rels" USING btree ("parent_id");
|
||||
CREATE INDEX "payload_locked_documents_rels_path_idx" ON "payload_locked_documents_rels" USING btree ("path");
|
||||
CREATE INDEX "payload_locked_documents_rels_users_id_idx" ON "payload_locked_documents_rels" USING btree ("users_id");
|
||||
CREATE INDEX "payload_locked_documents_rels_media_id_idx" ON "payload_locked_documents_rels" USING btree ("media_id");
|
||||
CREATE INDEX "payload_locked_documents_rels_form_submissions_id_idx" ON "payload_locked_documents_rels" USING btree ("form_submissions_id");
|
||||
CREATE INDEX "payload_locked_documents_rels_pages_id_idx" ON "payload_locked_documents_rels" USING btree ("pages_id");
|
||||
CREATE INDEX "payload_preferences_key_idx" ON "payload_preferences" USING btree ("key");
|
||||
CREATE INDEX "payload_preferences_updated_at_idx" ON "payload_preferences" USING btree ("updated_at");
|
||||
CREATE INDEX "payload_preferences_created_at_idx" ON "payload_preferences" USING btree ("created_at");
|
||||
CREATE INDEX "payload_preferences_rels_order_idx" ON "payload_preferences_rels" USING btree ("order");
|
||||
CREATE INDEX "payload_preferences_rels_parent_idx" ON "payload_preferences_rels" USING btree ("parent_id");
|
||||
CREATE INDEX "payload_preferences_rels_path_idx" ON "payload_preferences_rels" USING btree ("path");
|
||||
CREATE INDEX "payload_preferences_rels_users_id_idx" ON "payload_preferences_rels" USING btree ("users_id");
|
||||
CREATE INDEX "payload_migrations_updated_at_idx" ON "payload_migrations" USING btree ("updated_at");
|
||||
CREATE INDEX "payload_migrations_created_at_idx" ON "payload_migrations" USING btree ("created_at");`);
|
||||
}
|
||||
|
||||
export async function down({
|
||||
db,
|
||||
payload,
|
||||
req,
|
||||
}: MigrateDownArgs): Promise<void> {
|
||||
await db.execute(sql`
|
||||
DROP TABLE "users_sessions" CASCADE;
|
||||
DROP TABLE "users" CASCADE;
|
||||
DROP TABLE "media" CASCADE;
|
||||
DROP TABLE "form_submissions" CASCADE;
|
||||
DROP TABLE "pages" CASCADE;
|
||||
DROP TABLE "payload_kv" CASCADE;
|
||||
DROP TABLE "payload_locked_documents" CASCADE;
|
||||
DROP TABLE "payload_locked_documents_rels" CASCADE;
|
||||
DROP TABLE "payload_preferences" CASCADE;
|
||||
DROP TABLE "payload_preferences_rels" CASCADE;
|
||||
DROP TABLE "payload_migrations" CASCADE;`);
|
||||
}
|
||||
9
migrations/index.ts
Normal file
9
migrations/index.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import * as migration_20260227_113637_v1_initial from "./20260227_113637_v1_initial";
|
||||
|
||||
export const migrations = [
|
||||
{
|
||||
up: migration_20260227_113637_v1_initial.up,
|
||||
down: migration_20260227_113637_v1_initial.down,
|
||||
name: "20260227_113637_v1_initial",
|
||||
},
|
||||
];
|
||||
2
next-env.d.ts
vendored
2
next-env.d.ts
vendored
@@ -1,6 +1,6 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
import "./.next/types/routes.d.ts";
|
||||
import "./.next/dev/types/routes.d.ts";
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { withPayload } from "@payloadcms/next/withPayload";
|
||||
import withMintelConfig from "@mintel/next-config";
|
||||
|
||||
/** @type {import('next').NextConfig} */
|
||||
@@ -17,12 +18,20 @@ const nextConfig = {
|
||||
source: "/stats/:path*",
|
||||
destination: `${umamiUrl}/:path*`,
|
||||
},
|
||||
{
|
||||
source: "/:locale(de)/stats/:path*",
|
||||
destination: `${umamiUrl}/:path*`,
|
||||
},
|
||||
{
|
||||
source: "/errors/:path*",
|
||||
destination: `${glitchtipUrl}/:path*`,
|
||||
},
|
||||
{
|
||||
source: "/:locale(de)/errors/:path*",
|
||||
destination: `${glitchtipUrl}/:path*`,
|
||||
},
|
||||
];
|
||||
},
|
||||
};
|
||||
|
||||
export default withMintelConfig(nextConfig);
|
||||
export default withPayload(withMintelConfig(nextConfig));
|
||||
|
||||
52
package.json
52
package.json
@@ -4,48 +4,58 @@
|
||||
"type": "module",
|
||||
"packageManager": "pnpm@10.18.3",
|
||||
"scripts": {
|
||||
"dev": "docker network create infra 2>/dev/null || true && echo '\\n🚀 Development Environment Starting...\\n\\n📱 App: http://mb-grid-solutions.localhost\\n🗄️ CMS: http://cms.mb-grid-solutions.localhost/admin\\n🚦 Traefik: http://localhost:8080\\n\\n(Press Ctrl+C to stop)\\n' && docker compose down --remove-orphans && docker compose up app directus directus-db",
|
||||
"dev": "docker network create infra 2>/dev/null || true && echo '\\n🚀 Development Environment Starting...\\n\\n📱 App: http://mb-grid-solutions.localhost\\n🗄️ CMS: http://mb-grid-solutions.localhost/admin\\n🚦 Traefik: http://localhost:8080\\n\\n(Press Ctrl+C to stop)\\n' && docker compose down --remove-orphans && docker compose up app mb-grid-db",
|
||||
"dev:next": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "eslint app components lib scripts",
|
||||
"test": "vitest",
|
||||
"prepare": "husky",
|
||||
"cms:bootstrap": "DIRECTUS_URL=http://localhost:8055 npx tsx --env-file=.env scripts/setup-directus.ts",
|
||||
"cms:push:staging": "./scripts/sync-directus.sh push staging",
|
||||
"cms:pull:staging": "./scripts/sync-directus.sh pull staging",
|
||||
"cms:push:testing": "./scripts/sync-directus.sh push testing",
|
||||
"cms:pull:testing": "./scripts/sync-directus.sh pull testing",
|
||||
"cms:push:prod": "./scripts/sync-directus.sh push production",
|
||||
"cms:pull:prod": "./scripts/sync-directus.sh pull production",
|
||||
"pagespeed:test": "mintel pagespeed test"
|
||||
"generate:types": "payload generate:types",
|
||||
"generate:importmap": "payload generate:importmap",
|
||||
"pagespeed:test": "mintel pagespeed test",
|
||||
"check:http": "tsx ./scripts/check-http.ts",
|
||||
"check:apis": "tsx ./scripts/check-apis.ts",
|
||||
"check:locale": "tsx ./scripts/check-locale.ts"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"description": "",
|
||||
"dependencies": {
|
||||
"@mintel/next-config": "^1.1.13",
|
||||
"@mintel/next-utils": "^1.1.13",
|
||||
"@aws-sdk/client-s3": "^3.999.0",
|
||||
"@mintel/mail": "^1.8.21",
|
||||
"@mintel/next-config": "^1.8.20",
|
||||
"@mintel/next-utils": "^1.8.20",
|
||||
"@payloadcms/db-postgres": "^3.77.0",
|
||||
"@payloadcms/email-nodemailer": "^3.77.0",
|
||||
"@payloadcms/next": "^3.77.0",
|
||||
"@payloadcms/richtext-lexical": "^3.77.0",
|
||||
"@payloadcms/storage-s3": "^3.77.0",
|
||||
"@payloadcms/ui": "^3.77.0",
|
||||
"@react-email/components": "^1.0.8",
|
||||
"@sentry/nextjs": "^10.38.0",
|
||||
"framer-motion": "^12.29.2",
|
||||
"graphql": "^16.13.0",
|
||||
"lucide-react": "^0.562.0",
|
||||
"next": "^16.1.6",
|
||||
"next-intl": "^4.8.2",
|
||||
"nodemailer": "^7.0.12",
|
||||
"payload": "^3.77.0",
|
||||
"pino": "^10.3.0",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
"zod": "^4.3.6"
|
||||
"react-email": "^5.2.8",
|
||||
"sharp": "^0.34.5",
|
||||
"zod": "^3.24.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@commitlint/cli": "^20.4.0",
|
||||
"@commitlint/config-conventional": "^20.4.0",
|
||||
"@directus/sdk": "^21.0.0",
|
||||
"@mintel/cli": "^1.1.13",
|
||||
"@mintel/eslint-config": "^1.1.13",
|
||||
"@mintel/husky-config": "^1.1.13",
|
||||
"@mintel/tsconfig": "^1.1.13",
|
||||
"@mintel/cli": "^1.8.20",
|
||||
"@mintel/eslint-config": "^1.8.20",
|
||||
"@mintel/husky-config": "^1.8.20",
|
||||
"@mintel/tsconfig": "^1.8.20",
|
||||
"@tailwindcss/postcss": "^4.1.18",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
@@ -55,8 +65,11 @@
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^5.1.2",
|
||||
"autoprefixer": "^10.4.23",
|
||||
"axios": "^1.13.5",
|
||||
"cheerio": "^1.2.0",
|
||||
"eslint": "^8.57.1",
|
||||
"eslint-config-next": "15.1.6",
|
||||
"happy-dom": "^20.6.1",
|
||||
"husky": "^9.1.7",
|
||||
"jsdom": "^27.4.0",
|
||||
"lint-staged": "^16.2.7",
|
||||
@@ -64,7 +77,12 @@
|
||||
"postcss": "^8.5.6",
|
||||
"prettier": "^3.5.0",
|
||||
"tailwindcss": "^4.1.18",
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "^5.9.3",
|
||||
"vitest": "^4.0.18"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "ssh://git@git.infra.mintel.me:2222/mmintel/mb-grid-solutions.com.git"
|
||||
}
|
||||
}
|
||||
|
||||
7248
pnpm-lock.yaml
generated
7248
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
Binary file not shown.
|
Before Width: | Height: | Size: 204 KiB After Width: | Height: | Size: 21 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 1.4 MiB After Width: | Height: | Size: 1.1 MiB |
Binary file not shown.
|
Before Width: | Height: | Size: 736 KiB After Width: | Height: | Size: 428 KiB |
136
scripts/check-apis.ts
Normal file
136
scripts/check-apis.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
import axios from "axios";
|
||||
import dns from "dns";
|
||||
import { promisify } from "util";
|
||||
import url from "url";
|
||||
|
||||
const resolve4 = promisify(dns.resolve4);
|
||||
|
||||
// This script verifies that external logging and analytics APIs are reachable
|
||||
// from the deployment environment (which could be behind corporate firewalls or VPNs).
|
||||
|
||||
const umamiEndpoint =
|
||||
process.env.UMAMI_API_ENDPOINT || "https://analytics.infra.mintel.me";
|
||||
const sentryDsn = process.env.SENTRY_DSN || "";
|
||||
|
||||
async function checkUmami() {
|
||||
console.log(`\n🔍 Checking Umami Analytics API Availability...`);
|
||||
console.log(` Endpoint: ${umamiEndpoint}`);
|
||||
|
||||
try {
|
||||
// Umami usually exposes a /api/heartbeat or /api/health if we know the route.
|
||||
// Trying root or /api/auth/verify (which will give 401 but proves routing works).
|
||||
// A simple GET to the configured endpoint should return a 200 or 401, not a 5xx/timeout.
|
||||
const response = await axios.get(
|
||||
`${umamiEndpoint.replace(/\/$/, "")}/api/health`,
|
||||
{
|
||||
timeout: 5000,
|
||||
validateStatus: () => true, // Accept any status, we just want to know it's reachable and not 5xx
|
||||
},
|
||||
);
|
||||
|
||||
// As long as it's not a 502/503/504 Bad Gateway/Timeout, the service is "up" from our perspective
|
||||
if (response.status >= 500) {
|
||||
throw new Error(
|
||||
`Umami API responded with server error HTTP ${response.status}`,
|
||||
);
|
||||
}
|
||||
|
||||
console.log(` ✅ Umami Analytics is reachable (HTTP ${response.status})`);
|
||||
return true;
|
||||
} catch (error) {
|
||||
const err = error as Error;
|
||||
// If /api/health fails completely, maybe try a DNS check as a fallback
|
||||
try {
|
||||
console.warn(
|
||||
` ⚠️ HTTP check failed, falling back to DNS resolution...`,
|
||||
);
|
||||
const umamiHost = new url.URL(umamiEndpoint).hostname;
|
||||
await resolve4(umamiHost);
|
||||
console.log(
|
||||
` ✅ Umami Analytics DNS resolved successfully (${umamiHost})`,
|
||||
);
|
||||
return true;
|
||||
} catch (error) {
|
||||
const dnsErr = error as Error;
|
||||
console.error(
|
||||
` ❌ CRITICAL: Umami Analytics is completely unreachable! ${err.message} | DNS: ${dnsErr.message}`,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function checkSentry() {
|
||||
console.log(`\n🔍 Checking Glitchtip/Sentry Error Tracking Availability...`);
|
||||
|
||||
if (!sentryDsn) {
|
||||
console.log(` ℹ️ No SENTRY_DSN provided in environment. Skipping.`);
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsedDsn = new url.URL(sentryDsn);
|
||||
const host = parsedDsn.hostname;
|
||||
console.log(` Host: ${host}`);
|
||||
|
||||
// We do a DNS lookup to ensure the runner can actually resolve the tracking server
|
||||
const addresses = await resolve4(host);
|
||||
|
||||
if (addresses && addresses.length > 0) {
|
||||
console.log(` ✅ Glitchtip/Sentry domain resolved: ${addresses[0]}`);
|
||||
|
||||
// Optional: Quick TCP/HTTP check to the host root (Glitchtip usually runs on 80/443 root)
|
||||
try {
|
||||
const proto = parsedDsn.protocol || "https:";
|
||||
await axios.get(`${proto}//${host}/api/0/`, {
|
||||
timeout: 5000,
|
||||
validateStatus: () => true,
|
||||
});
|
||||
console.log(` ✅ Glitchtip/Sentry API root responds to HTTP.`);
|
||||
} catch {
|
||||
console.log(
|
||||
` ⚠️ Glitchtip/Sentry HTTP ping failed or timed out, but DNS is valid. Proceeding.`,
|
||||
);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
throw new Error("No IP addresses found for DSN host");
|
||||
} catch (error) {
|
||||
const err = error as Error;
|
||||
console.error(
|
||||
` ❌ CRITICAL: Glitchtip/Sentry DSN is invalid or hostname is unresolvable! ${err.message}`,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log("🚀 Starting External API Connectivity Smoke Test...");
|
||||
|
||||
let hasErrors = false;
|
||||
|
||||
const umamiOk = await checkUmami();
|
||||
if (!umamiOk) hasErrors = true;
|
||||
|
||||
const sentryOk = await checkSentry();
|
||||
if (!sentryOk) hasErrors = true;
|
||||
|
||||
if (hasErrors) {
|
||||
console.error(
|
||||
`\n🚨 POST-DEPLOY CHECK FAILED: One or more critical external APIs are unreachable.`,
|
||||
);
|
||||
console.error(
|
||||
` This might mean the deployment environment lacks outbound internet access, `,
|
||||
);
|
||||
console.error(
|
||||
` DNS is misconfigured, or the upstream services are down.`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`\n🎉 SUCCESS: All required external APIs are reachable!`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
main();
|
||||
91
scripts/check-http.ts
Normal file
91
scripts/check-http.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import axios from "axios";
|
||||
import * as cheerio from "cheerio";
|
||||
|
||||
const targetUrl =
|
||||
process.argv[2] ||
|
||||
process.env.NEXT_PUBLIC_BASE_URL ||
|
||||
"http://localhost:3000";
|
||||
const gatekeeperPassword = process.env.GATEKEEPER_PASSWORD || "lassmichrein";
|
||||
|
||||
async function main() {
|
||||
console.log(`\n🚀 Starting HTTP Sitemap Validation for: ${targetUrl}\n`);
|
||||
|
||||
try {
|
||||
const sitemapUrl = `${targetUrl.replace(/\/$/, "")}/sitemap.xml`;
|
||||
console.log(`📥 Fetching sitemap from ${sitemapUrl}...`);
|
||||
|
||||
const response = await axios.get(sitemapUrl, {
|
||||
headers: { Cookie: `mintel_gatekeeper_session=${gatekeeperPassword}` },
|
||||
validateStatus: (status) => status < 400,
|
||||
});
|
||||
|
||||
const $ = cheerio.load(response.data, { xmlMode: true });
|
||||
let urls = $("url loc")
|
||||
.map((i, el) => $(el).text())
|
||||
.get();
|
||||
|
||||
const urlPattern = /https?:\/\/[^\/]+/;
|
||||
urls = [...new Set(urls)]
|
||||
.filter((u) => u.startsWith("http"))
|
||||
.map((u) => u.replace(urlPattern, targetUrl.replace(/\/$/, "")))
|
||||
.sort();
|
||||
|
||||
console.log(`✅ Found ${urls.length} target URLs in sitemap.`);
|
||||
|
||||
if (urls.length === 0) {
|
||||
console.error("❌ No URLs found in sitemap. Is the site up?");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`\n🔍 Verifying HTTP Status Codes (Limit: None)...`);
|
||||
let hasErrors = false;
|
||||
|
||||
// Run fetches sequentially to avoid overwhelming the server during CI
|
||||
for (let i = 0; i < urls.length; i++) {
|
||||
const u = urls[i];
|
||||
try {
|
||||
const res = await axios.get(u, {
|
||||
headers: {
|
||||
Cookie: `mintel_gatekeeper_session=${gatekeeperPassword}`,
|
||||
},
|
||||
validateStatus: null, // Don't throw on error status
|
||||
});
|
||||
|
||||
if (res.status >= 400) {
|
||||
console.error(`❌ ERROR ${res.status}: ${res.statusText} -> ${u}`);
|
||||
hasErrors = true;
|
||||
} else {
|
||||
console.log(`✅ OK ${res.status} -> ${u}`);
|
||||
}
|
||||
} catch (error) {
|
||||
const err = error as Error;
|
||||
console.error(`❌ NETWORK ERROR: ${err.message} -> ${u}`);
|
||||
hasErrors = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (hasErrors) {
|
||||
console.error(
|
||||
`\n❌ HTTP Sitemap Validation Failed. One or more pages returned an error.`,
|
||||
);
|
||||
process.exit(1);
|
||||
} else {
|
||||
console.log(
|
||||
`\n✨ Success: All ${urls.length} pages are healthy! (HTTP 200)`,
|
||||
);
|
||||
process.exit(0);
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
if (axios.isAxiosError(e) && e.response) {
|
||||
console.error(
|
||||
`\n❌ Critical Error during Sitemap Fetch: HTTP ${e.response.status} ${e.response.statusText}`,
|
||||
);
|
||||
} else {
|
||||
const errorMsg = e instanceof Error ? e.message : String(e);
|
||||
console.error(`\n❌ Critical Error during Sitemap Fetch: ${errorMsg}`);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
199
scripts/check-locale.ts
Normal file
199
scripts/check-locale.ts
Normal file
@@ -0,0 +1,199 @@
|
||||
import axios from "axios";
|
||||
import * as cheerio from "cheerio";
|
||||
|
||||
/**
|
||||
* Locale & Language Switcher Smoke Test
|
||||
*
|
||||
* For every URL in the sitemap:
|
||||
* 1. Fetches the page HTML
|
||||
* 2. Extracts <link rel="alternate" hreflang="..." href="..."> tags
|
||||
* 3. Verifies each alternate URL uses correctly translated slugs
|
||||
* 4. Verifies each alternate URL returns HTTP 200
|
||||
*/
|
||||
|
||||
const targetUrl =
|
||||
process.argv[2] ||
|
||||
process.env.NEXT_PUBLIC_BASE_URL ||
|
||||
"http://localhost:3000";
|
||||
const gatekeeperPassword = process.env.GATEKEEPER_PASSWORD || "lassmichrein";
|
||||
|
||||
// Expected slug translations: German key → English value
|
||||
const SLUG_MAP: Record<string, string> = {
|
||||
// Add translations if mb-grid translates URLs: e.g. produkte: 'products'
|
||||
};
|
||||
|
||||
// Reverse map: English → German
|
||||
const REVERSE_SLUG_MAP: Record<string, string> = Object.fromEntries(
|
||||
Object.entries(SLUG_MAP).map(([de, en]) => [en, de]),
|
||||
);
|
||||
|
||||
const headers = { Cookie: `mintel_gatekeeper_session=${gatekeeperPassword}` };
|
||||
|
||||
function getExpectedTranslation(
|
||||
sourcePath: string,
|
||||
sourceLocale: string,
|
||||
targetLocale: string,
|
||||
): string {
|
||||
const segments = sourcePath.split("/").filter(Boolean);
|
||||
// First segment is locale
|
||||
segments[0] = targetLocale;
|
||||
|
||||
const map = sourceLocale === "de" ? SLUG_MAP : REVERSE_SLUG_MAP;
|
||||
|
||||
return (
|
||||
"/" +
|
||||
segments
|
||||
.map((seg, i) => {
|
||||
if (i === 0) return seg; // locale
|
||||
return map[seg] || seg; // translate or keep (product names like n2x2y stay the same)
|
||||
})
|
||||
.join("/")
|
||||
);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log(`\n🌐 Starting Locale Smoke Test for: ${targetUrl}\n`);
|
||||
|
||||
// 1. Fetch sitemap
|
||||
const sitemapUrl = `${targetUrl.replace(/\/$/, "")}/sitemap.xml`;
|
||||
console.log(`📥 Fetching sitemap from ${sitemapUrl}...`);
|
||||
const sitemapRes = await axios.get(sitemapUrl, {
|
||||
headers,
|
||||
validateStatus: (s) => s < 400,
|
||||
});
|
||||
const $sitemap = cheerio.load(sitemapRes.data, { xmlMode: true });
|
||||
|
||||
let urls = $sitemap("url loc")
|
||||
.map((_i, el) => $sitemap(el).text())
|
||||
.get();
|
||||
|
||||
const urlPattern = /https?:\/\/[^/]+/;
|
||||
urls = [...new Set(urls)]
|
||||
.filter((u) => u.startsWith("http"))
|
||||
.map((u) => u.replace(urlPattern, targetUrl.replace(/\/$/, "")))
|
||||
.sort();
|
||||
|
||||
console.log(`✅ Found ${urls.length} URLs in sitemap.\n`);
|
||||
|
||||
let totalChecked = 0;
|
||||
let totalPassed = 0;
|
||||
let totalFailed = 0;
|
||||
const failures: string[] = [];
|
||||
|
||||
for (const url of urls) {
|
||||
const path = new URL(url).pathname;
|
||||
const locale = path.split("/")[1];
|
||||
if (!locale || !["de", "en"].includes(locale)) continue;
|
||||
|
||||
try {
|
||||
const res = await axios.get(url, { headers, validateStatus: null });
|
||||
if (res.status >= 400) continue; // Skip pages that are already broken (check-http catches those)
|
||||
|
||||
const $ = cheerio.load(res.data);
|
||||
|
||||
// Extract hreflang alternate links
|
||||
const alternates: { hreflang: string; href: string }[] = [];
|
||||
$('link[rel="alternate"][hreflang]').each((_i, el) => {
|
||||
const hreflang = $(el).attr("hreflang") || "";
|
||||
let href = $(el).attr("href") || "";
|
||||
if (href && hreflang && hreflang !== "x-default") {
|
||||
href = href.replace(urlPattern, targetUrl.replace(/\/$/, ""));
|
||||
alternates.push({ hreflang, href });
|
||||
}
|
||||
});
|
||||
|
||||
if (alternates.length === 0) {
|
||||
// Some pages may not have alternates, that's OK
|
||||
continue;
|
||||
}
|
||||
|
||||
totalChecked++;
|
||||
|
||||
// Validate each alternate
|
||||
let pageOk = true;
|
||||
|
||||
for (const alt of alternates) {
|
||||
if (alt.hreflang === locale) continue; // Same locale, skip
|
||||
|
||||
// 1. Check slug translation is correct
|
||||
const expectedPath = getExpectedTranslation(path, locale, alt.hreflang);
|
||||
const actualPath = new URL(alt.href).pathname;
|
||||
|
||||
if (actualPath !== expectedPath) {
|
||||
console.error(
|
||||
`❌ SLUG MISMATCH: ${path} → hreflang="${alt.hreflang}" expected ${expectedPath} but got ${actualPath}`,
|
||||
);
|
||||
failures.push(
|
||||
`Slug mismatch: ${path} → ${alt.hreflang}: expected ${expectedPath}, got ${actualPath}`,
|
||||
);
|
||||
pageOk = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
// 2. Check alternate URL returns 200
|
||||
try {
|
||||
const altRes = await axios.get(alt.href, {
|
||||
headers,
|
||||
validateStatus: null,
|
||||
maxRedirects: 5,
|
||||
});
|
||||
if (altRes.status >= 400) {
|
||||
console.error(
|
||||
`❌ BROKEN ALTERNATE: ${path} → ${alt.href} returned ${altRes.status}`,
|
||||
);
|
||||
failures.push(
|
||||
`Broken alternate: ${path} → ${alt.href} (${altRes.status})`,
|
||||
);
|
||||
pageOk = false;
|
||||
}
|
||||
} catch (error) {
|
||||
const err = error as Error;
|
||||
console.error(
|
||||
`❌ NETWORK ERROR: ${path} → ${alt.href}: ${err.message}`,
|
||||
);
|
||||
failures.push(`Network error: ${path} → ${alt.href}: ${err.message}`);
|
||||
pageOk = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (pageOk) {
|
||||
console.log(
|
||||
`✅ ${path} — alternates OK (${alternates
|
||||
.map((a) => a.hreflang)
|
||||
.filter((h) => h !== locale)
|
||||
.join(", ")})`,
|
||||
);
|
||||
totalPassed++;
|
||||
} else {
|
||||
totalFailed++;
|
||||
}
|
||||
} catch (error) {
|
||||
const err = error as Error;
|
||||
console.error(`❌ NETWORK ERROR fetching ${url}: ${err.message}`);
|
||||
totalFailed++;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\n${"─".repeat(60)}`);
|
||||
console.log(`📊 Locale Smoke Test Results:`);
|
||||
console.log(` Pages checked: ${totalChecked}`);
|
||||
console.log(` Passed: ${totalPassed}`);
|
||||
console.log(` Failed: ${totalFailed}`);
|
||||
|
||||
if (failures.length > 0) {
|
||||
console.log(`\n❌ Failures:`);
|
||||
failures.forEach((f) => console.log(` • ${f}`));
|
||||
console.log(`\n❌ Locale Smoke Test FAILED.`);
|
||||
process.exit(1);
|
||||
} else {
|
||||
console.log(
|
||||
`\n✨ All locale alternates are correctly translated and reachable!`,
|
||||
);
|
||||
process.exit(0);
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(`\n❌ Critical error:`, err.message);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -1,150 +0,0 @@
|
||||
import {
|
||||
createMintelDirectusClient,
|
||||
ensureDirectusAuthenticated,
|
||||
} from "@mintel/next-utils";
|
||||
import { createCollection, createField, updateSettings } from "@directus/sdk";
|
||||
|
||||
const client = createMintelDirectusClient();
|
||||
|
||||
async function setupBranding() {
|
||||
const prjName = process.env.PROJECT_NAME || "MB Grid Solutions";
|
||||
const prjColor = process.env.PROJECT_COLOR || "#82ed20";
|
||||
|
||||
console.log(`🎨 Refining Directus Branding for ${prjName}...`);
|
||||
await ensureDirectusAuthenticated(client);
|
||||
|
||||
const cssInjection = `
|
||||
<style>
|
||||
@import url('https://fonts.googleapis.com/css2?family=Outfit:wght@400;500;600;700&display=swap');
|
||||
|
||||
body, .v-app { font-family: 'Outfit', sans-serif !important; }
|
||||
|
||||
.public-view .v-card {
|
||||
backdrop-filter: blur(20px);
|
||||
background: rgba(255, 255, 255, 0.9) !important;
|
||||
border-radius: 32px !important;
|
||||
box-shadow: 0 50px 100px -20px rgba(0, 0, 0, 0.4) !important;
|
||||
border: 1px solid rgba(255, 255, 255, 0.3) !important;
|
||||
}
|
||||
|
||||
.v-navigation-drawer { background: #000c24 !important; }
|
||||
|
||||
.v-list-item--active {
|
||||
color: ${prjColor} !important;
|
||||
background: rgba(130, 237, 32, 0.1) !important;
|
||||
}
|
||||
</style>
|
||||
<div style="font-family: 'Outfit', sans-serif; text-align: center; margin-top: 24px;">
|
||||
<p style="color: rgba(255,255,255,0.6); font-size: 11px; letter-spacing: 2px; margin-bottom: 4px; font-weight: 600; text-transform: uppercase;">Mintel Infrastructure Engine</p>
|
||||
<h1 style="color: #ffffff; font-size: 20px; font-weight: 700; margin: 0; letter-spacing: -0.5px;">${prjName.toUpperCase()} <span style="color: ${prjColor};">SYNC.</span></h1>
|
||||
</div>
|
||||
`;
|
||||
|
||||
try {
|
||||
await client.request(
|
||||
updateSettings({
|
||||
project_name: prjName,
|
||||
project_color: prjColor,
|
||||
public_note: cssInjection,
|
||||
module_bar_background: "#00081a",
|
||||
theme_light_overrides: {
|
||||
primary: prjColor,
|
||||
borderRadius: "12px",
|
||||
navigationBackground: "#000c24",
|
||||
navigationForeground: "#ffffff",
|
||||
moduleBarBackground: "#00081a",
|
||||
},
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} as any),
|
||||
);
|
||||
console.log("✨ Branding applied!");
|
||||
|
||||
await createCollectionAndFields();
|
||||
console.log("🏗️ Schema alignment complete!");
|
||||
} catch (error) {
|
||||
console.error("❌ Error during bootstrap:", error);
|
||||
}
|
||||
}
|
||||
|
||||
async function createCollectionAndFields() {
|
||||
const collectionName = "contact_submissions";
|
||||
|
||||
try {
|
||||
await client.request(
|
||||
createCollection({
|
||||
collection: collectionName,
|
||||
schema: {},
|
||||
meta: {
|
||||
icon: "contact_mail",
|
||||
display_template: "{{name}} <{{email}}>",
|
||||
group: null,
|
||||
sort: null,
|
||||
collapse: "open",
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
// Add ID field
|
||||
await client.request(
|
||||
createField(collectionName, {
|
||||
field: "id",
|
||||
type: "integer",
|
||||
meta: { hidden: true },
|
||||
schema: { is_primary_key: true, has_auto_increment: true },
|
||||
}),
|
||||
);
|
||||
console.log(`✅ Collection ${collectionName} created.`);
|
||||
} catch {
|
||||
console.log(`ℹ️ Collection ${collectionName} exists.`);
|
||||
}
|
||||
|
||||
const safeAddField = async (
|
||||
field: string,
|
||||
type: string,
|
||||
meta: Record<string, unknown> = {},
|
||||
) => {
|
||||
try {
|
||||
await client.request(createField(collectionName, { field, type, meta }));
|
||||
console.log(`✅ Field ${field} added.`);
|
||||
} catch {
|
||||
// Ignore if exists
|
||||
}
|
||||
};
|
||||
|
||||
await safeAddField("name", "string", {
|
||||
interface: "input",
|
||||
display: "raw",
|
||||
width: "half",
|
||||
});
|
||||
await safeAddField("email", "string", {
|
||||
interface: "input",
|
||||
display: "raw",
|
||||
width: "half",
|
||||
});
|
||||
await safeAddField("company", "string", {
|
||||
interface: "input",
|
||||
display: "raw",
|
||||
width: "half",
|
||||
});
|
||||
await safeAddField("message", "text", {
|
||||
interface: "textarea",
|
||||
display: "raw",
|
||||
width: "full",
|
||||
});
|
||||
await safeAddField("date_created", "timestamp", {
|
||||
interface: "datetime",
|
||||
special: ["date-created"],
|
||||
display: "datetime",
|
||||
display_options: { relative: true },
|
||||
width: "half",
|
||||
});
|
||||
}
|
||||
|
||||
setupBranding()
|
||||
.then(() => {
|
||||
process.exit(0);
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error("🚨 Fatal bootstrap error:", err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -1,118 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Configuration
|
||||
REMOTE_HOST="${SSH_HOST:-root@alpha.mintel.me}"
|
||||
ACTION=$1
|
||||
ENV=$2
|
||||
|
||||
# Help
|
||||
if [ -z "$ACTION" ] || [ -z "$ENV" ]; then
|
||||
echo "Usage: ./scripts/sync-directus.sh [push|pull] [testing|staging|production]"
|
||||
echo ""
|
||||
echo "Commands:"
|
||||
echo " push Sync LOCAL data -> REMOTE"
|
||||
echo " pull Sync REMOTE data -> LOCAL"
|
||||
echo ""
|
||||
echo "Environments:"
|
||||
echo " testing, staging, production"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Project Configuration (extracted from package.json and aligned with deploy.yml)
|
||||
PRJ_ID=$(jq -r .name package.json | sed 's/@mintel\///' | sed 's/\.com$//')
|
||||
REMOTE_DIR="/home/deploy/sites/${PRJ_ID}.com"
|
||||
|
||||
case $ENV in
|
||||
testing) PROJECT_NAME="${PRJ_ID}-testing"; ENV_FILE=".env.testing" ;;
|
||||
staging) PROJECT_NAME="${PRJ_ID}-staging"; ENV_FILE=".env.staging" ;;
|
||||
production) PROJECT_NAME="${PRJ_ID}-prod"; ENV_FILE=".env.prod" ;;
|
||||
*) echo "❌ Invalid environment: $ENV"; exit 1 ;;
|
||||
esac
|
||||
|
||||
# DB Details (matching docker-compose defaults)
|
||||
DB_USER="directus"
|
||||
DB_NAME="directus"
|
||||
|
||||
echo "🔍 Detecting local database..."
|
||||
LOCAL_DB_CONTAINER=$(docker compose ps -q directus-db)
|
||||
if [ -z "$LOCAL_DB_CONTAINER" ]; then
|
||||
echo "❌ Local directus-db container not found. Is it running? (npm run dev)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ "$ACTION" == "push" ]; then
|
||||
echo "🚀 Pushing LOCAL -> $ENV ($PROJECT_NAME)..."
|
||||
|
||||
# 1. DB Dump
|
||||
echo "📦 Dumping local database..."
|
||||
docker exec "$LOCAL_DB_CONTAINER" pg_dump -U "$DB_USER" --clean --if-exists --no-owner --no-privileges "$DB_NAME" > dump.sql
|
||||
|
||||
# 2. Upload Dump
|
||||
echo "📤 Uploading dump to remote server..."
|
||||
scp dump.sql "$REMOTE_HOST:$REMOTE_DIR/dump.sql"
|
||||
|
||||
# 3. Restore on Remote
|
||||
echo "🔄 Restoring dump on $ENV..."
|
||||
REMOTE_DB_CONTAINER=$(ssh "$REMOTE_HOST" "cd $REMOTE_DIR && docker compose -p $PROJECT_NAME ps -q directus-db")
|
||||
|
||||
if [ -z "$REMOTE_DB_CONTAINER" ]; then
|
||||
echo "❌ Remote $ENV-db container not found!"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "🧹 Wiping remote database schema..."
|
||||
ssh "$REMOTE_HOST" "docker exec $REMOTE_DB_CONTAINER psql -U $DB_USER $DB_NAME -c 'DROP SCHEMA public CASCADE; CREATE SCHEMA public;'"
|
||||
|
||||
echo "⚡ Restoring database..."
|
||||
ssh "$REMOTE_HOST" "docker exec -i $REMOTE_DB_CONTAINER psql -U $DB_USER $DB_NAME < $REMOTE_DIR/dump.sql"
|
||||
|
||||
# 4. Sync Uploads
|
||||
echo "📁 Syncing uploads (Local -> $ENV)..."
|
||||
rsync -avz --progress ./directus/uploads/ "$REMOTE_HOST:$REMOTE_DIR/directus/uploads/"
|
||||
|
||||
# Clean up
|
||||
rm dump.sql
|
||||
ssh "$REMOTE_HOST" "rm $REMOTE_DIR/dump.sql"
|
||||
|
||||
# 5. Restart Directus to trigger migrations and refresh schema cache
|
||||
echo "🔄 Restarting remote Directus to apply migrations..."
|
||||
ssh "$REMOTE_HOST" "cd $REMOTE_DIR && docker compose -p $PROJECT_NAME restart directus"
|
||||
|
||||
echo "✨ Push to $ENV complete!"
|
||||
|
||||
elif [ "$ACTION" == "pull" ]; then
|
||||
echo "📥 Pulling $ENV Data -> LOCAL..."
|
||||
|
||||
# 1. DB Dump on Remote
|
||||
echo "📦 Dumping remote database ($ENV)..."
|
||||
REMOTE_DB_CONTAINER=$(ssh "$REMOTE_HOST" "cd $REMOTE_DIR && docker compose -p $PROJECT_NAME ps -q directus-db")
|
||||
|
||||
if [ -z "$REMOTE_DB_CONTAINER" ]; then
|
||||
echo "❌ Remote $ENV-db container not found!"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
ssh "$REMOTE_HOST" "docker exec $REMOTE_DB_CONTAINER pg_dump -U $DB_USER --clean --if-exists --no-owner --no-privileges $DB_NAME > $REMOTE_DIR/dump.sql"
|
||||
|
||||
# 2. Download Dump
|
||||
echo "📥 Downloading dump..."
|
||||
scp "$REMOTE_HOST:$REMOTE_DIR/dump.sql" dump.sql
|
||||
|
||||
# 3. Restore Locally
|
||||
echo "🧹 Wiping local database schema..."
|
||||
docker exec "$LOCAL_DB_CONTAINER" psql -U "$DB_USER" "$DB_NAME" -c 'DROP SCHEMA public CASCADE; CREATE SCHEMA public;'
|
||||
|
||||
echo "⚡ Restoring database locally..."
|
||||
docker exec -i "$LOCAL_DB_CONTAINER" psql -U "$DB_USER" "$DB_NAME" < dump.sql
|
||||
|
||||
# 4. Sync Uploads
|
||||
echo "📁 Syncing uploads ($ENV -> Local)..."
|
||||
rsync -avz --progress "$REMOTE_HOST:$REMOTE_DIR/directus/uploads/" ./directus/uploads/
|
||||
|
||||
# Clean up
|
||||
rm dump.sql
|
||||
ssh "$REMOTE_HOST" "rm $REMOTE_DIR/dump.sql"
|
||||
|
||||
echo "✨ Pull to Local complete!"
|
||||
fi
|
||||
|
||||
86
scripts/upload-s3.ts
Normal file
86
scripts/upload-s3.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3";
|
||||
import * as fs from "fs";
|
||||
import * as path from "path";
|
||||
|
||||
const S3_ENDPOINT = process.env.S3_ENDPOINT;
|
||||
const S3_REGION = process.env.S3_REGION || "fsn1";
|
||||
const S3_BUCKET = process.env.S3_BUCKET;
|
||||
const S3_PREFIX = process.env.S3_PREFIX;
|
||||
const S3_ACCESS_KEY = process.env.S3_ACCESS_KEY;
|
||||
const S3_SECRET_KEY = process.env.S3_SECRET_KEY;
|
||||
|
||||
if (!S3_ENDPOINT || !S3_BUCKET || !S3_ACCESS_KEY || !S3_SECRET_KEY) {
|
||||
console.error("Missing S3 credentials in environment");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const s3Client = new S3Client({
|
||||
region: S3_REGION,
|
||||
endpoint: S3_ENDPOINT,
|
||||
credentials: {
|
||||
accessKeyId: S3_ACCESS_KEY,
|
||||
secretAccessKey: S3_SECRET_KEY,
|
||||
},
|
||||
forcePathStyle: true,
|
||||
});
|
||||
|
||||
async function uploadDirectory(dirPath: string, prefix: string) {
|
||||
const files = fs.readdirSync(dirPath, { withFileTypes: true });
|
||||
|
||||
for (const file of files) {
|
||||
if (file.name === ".DS_Store" || file.name === ".gitkeep") continue;
|
||||
|
||||
const fullPath = path.join(dirPath, file.name);
|
||||
// Combine prefix with filename, ensuring no double slashes, e.g., mb-grid-solutions/media/filename.ext
|
||||
const s3Key = `${prefix}/${file.name}`.replace(/\/+/g, "/");
|
||||
|
||||
if (file.isDirectory()) {
|
||||
await uploadDirectory(fullPath, s3Key);
|
||||
} else {
|
||||
const fileContent = fs.readFileSync(fullPath);
|
||||
let contentType = "application/octet-stream";
|
||||
if (file.name.endsWith(".png")) contentType = "image/png";
|
||||
else if (file.name.endsWith(".jpg") || file.name.endsWith(".jpeg"))
|
||||
contentType = "image/jpeg";
|
||||
else if (file.name.endsWith(".svg")) contentType = "image/svg+xml";
|
||||
else if (file.name.endsWith(".webp")) contentType = "image/webp";
|
||||
else if (file.name.endsWith(".pdf")) contentType = "application/pdf";
|
||||
|
||||
try {
|
||||
await s3Client.send(
|
||||
new PutObjectCommand({
|
||||
Bucket: S3_BUCKET,
|
||||
Key: s3Key,
|
||||
Body: fileContent,
|
||||
ContentType: contentType,
|
||||
ACL: "public-read", // Hetzner requires public-read for public access usually
|
||||
}),
|
||||
);
|
||||
console.log(`✅ Uploaded ${file.name} to ${S3_BUCKET}/${s3Key}`);
|
||||
} catch (err) {
|
||||
console.error(`❌ Failed to upload ${file.name}:`, err);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const mediaDir = path.resolve(process.cwd(), "public/media");
|
||||
if (fs.existsSync(mediaDir)) {
|
||||
console.log("Uploading public/media...");
|
||||
// Media inside Payload CMS uses prefix/media usually, like mb-grid-solutions/media
|
||||
await uploadDirectory(mediaDir, `${S3_PREFIX}/media`);
|
||||
} else {
|
||||
console.log("No public/media directory found.");
|
||||
}
|
||||
|
||||
const assetsDir = path.resolve(process.cwd(), "public/assets");
|
||||
if (fs.existsSync(assetsDir)) {
|
||||
console.log("Uploading public/assets...");
|
||||
await uploadDirectory(assetsDir, `${S3_PREFIX}/assets`);
|
||||
} else {
|
||||
console.log("No public/assets directory found.");
|
||||
}
|
||||
}
|
||||
|
||||
main().catch(console.error);
|
||||
13
sentry.client.config.ts
Normal file
13
sentry.client.config.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import * as Sentry from "@sentry/nextjs";
|
||||
import { config } from "./lib/config";
|
||||
|
||||
if (config.errors.glitchtip.enabled) {
|
||||
Sentry.init({
|
||||
dsn: config.errors.glitchtip.dsn,
|
||||
tracesSampleRate: 0.1,
|
||||
debug: config.isDevelopment,
|
||||
environment: config.target || "production",
|
||||
// Use the proxy path defined in config
|
||||
tunnel: config.errors.glitchtip.proxyPath,
|
||||
});
|
||||
}
|
||||
16
sentry.edge.config.ts
Normal file
16
sentry.edge.config.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import * as Sentry from "@sentry/nextjs";
|
||||
import { config } from "./lib/config";
|
||||
|
||||
if (config.errors.glitchtip.enabled) {
|
||||
console.log("Initializing Sentry in Edge runtime...", {
|
||||
environment: config.target || "production",
|
||||
});
|
||||
Sentry.init({
|
||||
dsn: config.errors.glitchtip.dsn,
|
||||
tracesSampleRate: 1.0,
|
||||
debug: true, // Force debug for now to see why it's failing
|
||||
environment: config.target || "production",
|
||||
});
|
||||
} else {
|
||||
console.warn("Sentry is DISABLED in Edge runtime (missing DSN)");
|
||||
}
|
||||
11
sentry.server.config.ts
Normal file
11
sentry.server.config.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import * as Sentry from "@sentry/nextjs";
|
||||
import { config } from "./lib/config";
|
||||
|
||||
if (config.errors.glitchtip.enabled) {
|
||||
Sentry.init({
|
||||
dsn: config.errors.glitchtip.dsn,
|
||||
tracesSampleRate: 1.0,
|
||||
debug: config.isDevelopment,
|
||||
environment: config.target || "production",
|
||||
});
|
||||
}
|
||||
1278
src/migrations/20260227_113637_v1_initial.json
Normal file
1278
src/migrations/20260227_113637_v1_initial.json
Normal file
File diff suppressed because it is too large
Load Diff
183
src/migrations/20260227_113637_v1_initial.ts
Normal file
183
src/migrations/20260227_113637_v1_initial.ts
Normal file
@@ -0,0 +1,183 @@
|
||||
import { MigrateUpArgs, MigrateDownArgs, sql } from "@payloadcms/db-postgres";
|
||||
|
||||
export async function up({ db, payload, req }: MigrateUpArgs): Promise<void> {
|
||||
await db.execute(sql`
|
||||
CREATE TABLE "users_sessions" (
|
||||
"_order" integer NOT NULL,
|
||||
"_parent_id" integer NOT NULL,
|
||||
"id" varchar PRIMARY KEY NOT NULL,
|
||||
"created_at" timestamp(3) with time zone,
|
||||
"expires_at" timestamp(3) with time zone NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE "users" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
|
||||
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
|
||||
"email" varchar NOT NULL,
|
||||
"reset_password_token" varchar,
|
||||
"reset_password_expiration" timestamp(3) with time zone,
|
||||
"salt" varchar,
|
||||
"hash" varchar,
|
||||
"login_attempts" numeric DEFAULT 0,
|
||||
"lock_until" timestamp(3) with time zone
|
||||
);
|
||||
|
||||
CREATE TABLE "media" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"alt" varchar NOT NULL,
|
||||
"prefix" varchar DEFAULT 'mb-grid-solutions/media',
|
||||
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
|
||||
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
|
||||
"url" varchar,
|
||||
"thumbnail_u_r_l" varchar,
|
||||
"filename" varchar,
|
||||
"mime_type" varchar,
|
||||
"filesize" numeric,
|
||||
"width" numeric,
|
||||
"height" numeric,
|
||||
"focal_x" numeric,
|
||||
"focal_y" numeric,
|
||||
"sizes_thumbnail_url" varchar,
|
||||
"sizes_thumbnail_width" numeric,
|
||||
"sizes_thumbnail_height" numeric,
|
||||
"sizes_thumbnail_mime_type" varchar,
|
||||
"sizes_thumbnail_filesize" numeric,
|
||||
"sizes_thumbnail_filename" varchar,
|
||||
"sizes_card_url" varchar,
|
||||
"sizes_card_width" numeric,
|
||||
"sizes_card_height" numeric,
|
||||
"sizes_card_mime_type" varchar,
|
||||
"sizes_card_filesize" numeric,
|
||||
"sizes_card_filename" varchar
|
||||
);
|
||||
|
||||
CREATE TABLE "form_submissions" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"name" varchar NOT NULL,
|
||||
"email" varchar NOT NULL,
|
||||
"company" varchar,
|
||||
"message" varchar NOT NULL,
|
||||
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
|
||||
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE "pages" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"title" varchar NOT NULL,
|
||||
"slug" varchar NOT NULL,
|
||||
"content" jsonb NOT NULL,
|
||||
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
|
||||
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE "payload_kv" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"key" varchar NOT NULL,
|
||||
"data" jsonb NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE "payload_locked_documents" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"global_slug" varchar,
|
||||
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
|
||||
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE "payload_locked_documents_rels" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"order" integer,
|
||||
"parent_id" integer NOT NULL,
|
||||
"path" varchar NOT NULL,
|
||||
"users_id" integer,
|
||||
"media_id" integer,
|
||||
"form_submissions_id" integer,
|
||||
"pages_id" integer
|
||||
);
|
||||
|
||||
CREATE TABLE "payload_preferences" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"key" varchar,
|
||||
"value" jsonb,
|
||||
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
|
||||
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE "payload_preferences_rels" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"order" integer,
|
||||
"parent_id" integer NOT NULL,
|
||||
"path" varchar NOT NULL,
|
||||
"users_id" integer
|
||||
);
|
||||
|
||||
CREATE TABLE "payload_migrations" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"name" varchar,
|
||||
"batch" numeric,
|
||||
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
|
||||
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
|
||||
ALTER TABLE "users_sessions" ADD CONSTRAINT "users_sessions_parent_id_fk" FOREIGN KEY ("_parent_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;
|
||||
ALTER TABLE "payload_locked_documents_rels" ADD CONSTRAINT "payload_locked_documents_rels_parent_fk" FOREIGN KEY ("parent_id") REFERENCES "public"."payload_locked_documents"("id") ON DELETE cascade ON UPDATE no action;
|
||||
ALTER TABLE "payload_locked_documents_rels" ADD CONSTRAINT "payload_locked_documents_rels_users_fk" FOREIGN KEY ("users_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;
|
||||
ALTER TABLE "payload_locked_documents_rels" ADD CONSTRAINT "payload_locked_documents_rels_media_fk" FOREIGN KEY ("media_id") REFERENCES "public"."media"("id") ON DELETE cascade ON UPDATE no action;
|
||||
ALTER TABLE "payload_locked_documents_rels" ADD CONSTRAINT "payload_locked_documents_rels_form_submissions_fk" FOREIGN KEY ("form_submissions_id") REFERENCES "public"."form_submissions"("id") ON DELETE cascade ON UPDATE no action;
|
||||
ALTER TABLE "payload_locked_documents_rels" ADD CONSTRAINT "payload_locked_documents_rels_pages_fk" FOREIGN KEY ("pages_id") REFERENCES "public"."pages"("id") ON DELETE cascade ON UPDATE no action;
|
||||
ALTER TABLE "payload_preferences_rels" ADD CONSTRAINT "payload_preferences_rels_parent_fk" FOREIGN KEY ("parent_id") REFERENCES "public"."payload_preferences"("id") ON DELETE cascade ON UPDATE no action;
|
||||
ALTER TABLE "payload_preferences_rels" ADD CONSTRAINT "payload_preferences_rels_users_fk" FOREIGN KEY ("users_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;
|
||||
CREATE INDEX "users_sessions_order_idx" ON "users_sessions" USING btree ("_order");
|
||||
CREATE INDEX "users_sessions_parent_id_idx" ON "users_sessions" USING btree ("_parent_id");
|
||||
CREATE INDEX "users_updated_at_idx" ON "users" USING btree ("updated_at");
|
||||
CREATE INDEX "users_created_at_idx" ON "users" USING btree ("created_at");
|
||||
CREATE UNIQUE INDEX "users_email_idx" ON "users" USING btree ("email");
|
||||
CREATE INDEX "media_updated_at_idx" ON "media" USING btree ("updated_at");
|
||||
CREATE INDEX "media_created_at_idx" ON "media" USING btree ("created_at");
|
||||
CREATE UNIQUE INDEX "media_filename_idx" ON "media" USING btree ("filename");
|
||||
CREATE INDEX "media_sizes_thumbnail_sizes_thumbnail_filename_idx" ON "media" USING btree ("sizes_thumbnail_filename");
|
||||
CREATE INDEX "media_sizes_card_sizes_card_filename_idx" ON "media" USING btree ("sizes_card_filename");
|
||||
CREATE INDEX "form_submissions_updated_at_idx" ON "form_submissions" USING btree ("updated_at");
|
||||
CREATE INDEX "form_submissions_created_at_idx" ON "form_submissions" USING btree ("created_at");
|
||||
CREATE INDEX "pages_updated_at_idx" ON "pages" USING btree ("updated_at");
|
||||
CREATE INDEX "pages_created_at_idx" ON "pages" USING btree ("created_at");
|
||||
CREATE UNIQUE INDEX "payload_kv_key_idx" ON "payload_kv" USING btree ("key");
|
||||
CREATE INDEX "payload_locked_documents_global_slug_idx" ON "payload_locked_documents" USING btree ("global_slug");
|
||||
CREATE INDEX "payload_locked_documents_updated_at_idx" ON "payload_locked_documents" USING btree ("updated_at");
|
||||
CREATE INDEX "payload_locked_documents_created_at_idx" ON "payload_locked_documents" USING btree ("created_at");
|
||||
CREATE INDEX "payload_locked_documents_rels_order_idx" ON "payload_locked_documents_rels" USING btree ("order");
|
||||
CREATE INDEX "payload_locked_documents_rels_parent_idx" ON "payload_locked_documents_rels" USING btree ("parent_id");
|
||||
CREATE INDEX "payload_locked_documents_rels_path_idx" ON "payload_locked_documents_rels" USING btree ("path");
|
||||
CREATE INDEX "payload_locked_documents_rels_users_id_idx" ON "payload_locked_documents_rels" USING btree ("users_id");
|
||||
CREATE INDEX "payload_locked_documents_rels_media_id_idx" ON "payload_locked_documents_rels" USING btree ("media_id");
|
||||
CREATE INDEX "payload_locked_documents_rels_form_submissions_id_idx" ON "payload_locked_documents_rels" USING btree ("form_submissions_id");
|
||||
CREATE INDEX "payload_locked_documents_rels_pages_id_idx" ON "payload_locked_documents_rels" USING btree ("pages_id");
|
||||
CREATE INDEX "payload_preferences_key_idx" ON "payload_preferences" USING btree ("key");
|
||||
CREATE INDEX "payload_preferences_updated_at_idx" ON "payload_preferences" USING btree ("updated_at");
|
||||
CREATE INDEX "payload_preferences_created_at_idx" ON "payload_preferences" USING btree ("created_at");
|
||||
CREATE INDEX "payload_preferences_rels_order_idx" ON "payload_preferences_rels" USING btree ("order");
|
||||
CREATE INDEX "payload_preferences_rels_parent_idx" ON "payload_preferences_rels" USING btree ("parent_id");
|
||||
CREATE INDEX "payload_preferences_rels_path_idx" ON "payload_preferences_rels" USING btree ("path");
|
||||
CREATE INDEX "payload_preferences_rels_users_id_idx" ON "payload_preferences_rels" USING btree ("users_id");
|
||||
CREATE INDEX "payload_migrations_updated_at_idx" ON "payload_migrations" USING btree ("updated_at");
|
||||
CREATE INDEX "payload_migrations_created_at_idx" ON "payload_migrations" USING btree ("created_at");`);
|
||||
}
|
||||
|
||||
export async function down({
|
||||
db,
|
||||
payload,
|
||||
req,
|
||||
}: MigrateDownArgs): Promise<void> {
|
||||
await db.execute(sql`
|
||||
DROP TABLE "users_sessions" CASCADE;
|
||||
DROP TABLE "users" CASCADE;
|
||||
DROP TABLE "media" CASCADE;
|
||||
DROP TABLE "form_submissions" CASCADE;
|
||||
DROP TABLE "pages" CASCADE;
|
||||
DROP TABLE "payload_kv" CASCADE;
|
||||
DROP TABLE "payload_locked_documents" CASCADE;
|
||||
DROP TABLE "payload_locked_documents_rels" CASCADE;
|
||||
DROP TABLE "payload_preferences" CASCADE;
|
||||
DROP TABLE "payload_preferences_rels" CASCADE;
|
||||
DROP TABLE "payload_migrations" CASCADE;`);
|
||||
}
|
||||
9
src/migrations/index.ts
Normal file
9
src/migrations/index.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import * as migration_20260227_113637_v1_initial from "./20260227_113637_v1_initial";
|
||||
|
||||
export const migrations = [
|
||||
{
|
||||
up: migration_20260227_113637_v1_initial.up,
|
||||
down: migration_20260227_113637_v1_initial.down,
|
||||
name: "20260227_113637_v1_initial",
|
||||
},
|
||||
];
|
||||
4
src/payload/blocks/allBlocks.ts
Normal file
4
src/payload/blocks/allBlocks.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import { Block } from "payload";
|
||||
|
||||
// Define any custom blocks you want here. Leaving empty for now.
|
||||
export const payloadBlocks: Block[] = [];
|
||||
44
src/payload/collections/FormSubmissions.ts
Normal file
44
src/payload/collections/FormSubmissions.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import type { CollectionConfig } from "payload";
|
||||
|
||||
export const FormSubmissions: CollectionConfig = {
|
||||
slug: "form-submissions",
|
||||
admin: {
|
||||
useAsTitle: "name",
|
||||
defaultColumns: ["name", "email", "company", "createdAt"],
|
||||
description: "Captured leads from Contact Form.",
|
||||
},
|
||||
access: {
|
||||
read: ({ req: { user } }) =>
|
||||
Boolean(user) || process.env.NODE_ENV === "development",
|
||||
update: ({ req: { user } }) =>
|
||||
Boolean(user) || process.env.NODE_ENV === "development",
|
||||
delete: ({ req: { user } }) =>
|
||||
Boolean(user) || process.env.NODE_ENV === "development",
|
||||
create: () => false, // Only system creates submissions
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: "name",
|
||||
type: "text",
|
||||
required: true,
|
||||
admin: { readOnly: true },
|
||||
},
|
||||
{
|
||||
name: "email",
|
||||
type: "email",
|
||||
required: true,
|
||||
admin: { readOnly: true },
|
||||
},
|
||||
{
|
||||
name: "company",
|
||||
type: "text",
|
||||
admin: { readOnly: true },
|
||||
},
|
||||
{
|
||||
name: "message",
|
||||
type: "textarea",
|
||||
required: true,
|
||||
admin: { readOnly: true },
|
||||
},
|
||||
],
|
||||
};
|
||||
42
src/payload/collections/Media.ts
Normal file
42
src/payload/collections/Media.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import type { CollectionConfig } from "payload";
|
||||
import path from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
|
||||
const filename = fileURLToPath(import.meta.url);
|
||||
const dirname = path.dirname(filename);
|
||||
|
||||
export const Media: CollectionConfig = {
|
||||
slug: "media",
|
||||
admin: {
|
||||
useAsTitle: "filename",
|
||||
defaultColumns: ["filename", "alt", "updatedAt"],
|
||||
},
|
||||
access: {
|
||||
read: () => true, // Publicly readable
|
||||
},
|
||||
upload: {
|
||||
staticDir: path.resolve(dirname, "../../../public/media"),
|
||||
adminThumbnail: "thumbnail",
|
||||
imageSizes: [
|
||||
{
|
||||
name: "thumbnail",
|
||||
width: 400,
|
||||
height: 300,
|
||||
position: "centre",
|
||||
},
|
||||
{
|
||||
name: "card",
|
||||
width: 768,
|
||||
height: undefined,
|
||||
position: "centre",
|
||||
},
|
||||
],
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: "alt",
|
||||
type: "text",
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
};
|
||||
42
src/payload/collections/Pages.ts
Normal file
42
src/payload/collections/Pages.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { CollectionConfig } from "payload";
|
||||
import { lexicalEditor, BlocksFeature } from "@payloadcms/richtext-lexical";
|
||||
import { payloadBlocks } from "../blocks/allBlocks";
|
||||
|
||||
export const Pages: CollectionConfig = {
|
||||
slug: "pages",
|
||||
admin: {
|
||||
useAsTitle: "title",
|
||||
defaultColumns: ["title", "slug", "updatedAt"],
|
||||
},
|
||||
access: {
|
||||
read: () => true, // Publicly readable
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: "title",
|
||||
type: "text",
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: "slug",
|
||||
type: "text",
|
||||
required: true,
|
||||
admin: {
|
||||
position: "sidebar",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "content",
|
||||
type: "richText",
|
||||
editor: lexicalEditor({
|
||||
features: ({ defaultFeatures }) => [
|
||||
...defaultFeatures,
|
||||
BlocksFeature({
|
||||
blocks: payloadBlocks,
|
||||
}),
|
||||
],
|
||||
}),
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
};
|
||||
12
src/payload/collections/Users.ts
Normal file
12
src/payload/collections/Users.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import type { CollectionConfig } from "payload";
|
||||
|
||||
export const Users: CollectionConfig = {
|
||||
slug: "users",
|
||||
admin: {
|
||||
useAsTitle: "email",
|
||||
},
|
||||
auth: true,
|
||||
fields: [
|
||||
// Email added by default
|
||||
],
|
||||
};
|
||||
453
src/payload/payload-types.ts
Normal file
453
src/payload/payload-types.ts
Normal file
@@ -0,0 +1,453 @@
|
||||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
/**
|
||||
* This file was automatically generated by Payload.
|
||||
* DO NOT MODIFY IT BY HAND. Instead, modify your source Payload config,
|
||||
* and re-run `payload generate:types` to regenerate this file.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Supported timezones in IANA format.
|
||||
*
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "supportedTimezones".
|
||||
*/
|
||||
export type SupportedTimezones =
|
||||
| "Pacific/Midway"
|
||||
| "Pacific/Niue"
|
||||
| "Pacific/Honolulu"
|
||||
| "Pacific/Rarotonga"
|
||||
| "America/Anchorage"
|
||||
| "Pacific/Gambier"
|
||||
| "America/Los_Angeles"
|
||||
| "America/Tijuana"
|
||||
| "America/Denver"
|
||||
| "America/Phoenix"
|
||||
| "America/Chicago"
|
||||
| "America/Guatemala"
|
||||
| "America/New_York"
|
||||
| "America/Bogota"
|
||||
| "America/Caracas"
|
||||
| "America/Santiago"
|
||||
| "America/Buenos_Aires"
|
||||
| "America/Sao_Paulo"
|
||||
| "Atlantic/South_Georgia"
|
||||
| "Atlantic/Azores"
|
||||
| "Atlantic/Cape_Verde"
|
||||
| "Europe/London"
|
||||
| "Europe/Berlin"
|
||||
| "Africa/Lagos"
|
||||
| "Europe/Athens"
|
||||
| "Africa/Cairo"
|
||||
| "Europe/Moscow"
|
||||
| "Asia/Riyadh"
|
||||
| "Asia/Dubai"
|
||||
| "Asia/Baku"
|
||||
| "Asia/Karachi"
|
||||
| "Asia/Tashkent"
|
||||
| "Asia/Calcutta"
|
||||
| "Asia/Dhaka"
|
||||
| "Asia/Almaty"
|
||||
| "Asia/Jakarta"
|
||||
| "Asia/Bangkok"
|
||||
| "Asia/Shanghai"
|
||||
| "Asia/Singapore"
|
||||
| "Asia/Tokyo"
|
||||
| "Asia/Seoul"
|
||||
| "Australia/Brisbane"
|
||||
| "Australia/Sydney"
|
||||
| "Pacific/Guam"
|
||||
| "Pacific/Noumea"
|
||||
| "Pacific/Auckland"
|
||||
| "Pacific/Fiji";
|
||||
|
||||
export interface Config {
|
||||
auth: {
|
||||
users: UserAuthOperations;
|
||||
};
|
||||
blocks: {};
|
||||
collections: {
|
||||
users: User;
|
||||
media: Media;
|
||||
"form-submissions": FormSubmission;
|
||||
pages: Page;
|
||||
"payload-kv": PayloadKv;
|
||||
"payload-locked-documents": PayloadLockedDocument;
|
||||
"payload-preferences": PayloadPreference;
|
||||
"payload-migrations": PayloadMigration;
|
||||
};
|
||||
collectionsJoins: {};
|
||||
collectionsSelect: {
|
||||
users: UsersSelect<false> | UsersSelect<true>;
|
||||
media: MediaSelect<false> | MediaSelect<true>;
|
||||
"form-submissions":
|
||||
| FormSubmissionsSelect<false>
|
||||
| FormSubmissionsSelect<true>;
|
||||
pages: PagesSelect<false> | PagesSelect<true>;
|
||||
"payload-kv": PayloadKvSelect<false> | PayloadKvSelect<true>;
|
||||
"payload-locked-documents":
|
||||
| PayloadLockedDocumentsSelect<false>
|
||||
| PayloadLockedDocumentsSelect<true>;
|
||||
"payload-preferences":
|
||||
| PayloadPreferencesSelect<false>
|
||||
| PayloadPreferencesSelect<true>;
|
||||
"payload-migrations":
|
||||
| PayloadMigrationsSelect<false>
|
||||
| PayloadMigrationsSelect<true>;
|
||||
};
|
||||
db: {
|
||||
defaultIDType: number;
|
||||
};
|
||||
fallbackLocale: null;
|
||||
globals: {};
|
||||
globalsSelect: {};
|
||||
locale: null;
|
||||
user: User;
|
||||
jobs: {
|
||||
tasks: unknown;
|
||||
workflows: unknown;
|
||||
};
|
||||
}
|
||||
export interface UserAuthOperations {
|
||||
forgotPassword: {
|
||||
email: string;
|
||||
password: string;
|
||||
};
|
||||
login: {
|
||||
email: string;
|
||||
password: string;
|
||||
};
|
||||
registerFirstUser: {
|
||||
email: string;
|
||||
password: string;
|
||||
};
|
||||
unlock: {
|
||||
email: string;
|
||||
password: string;
|
||||
};
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "users".
|
||||
*/
|
||||
export interface User {
|
||||
id: number;
|
||||
updatedAt: string;
|
||||
createdAt: string;
|
||||
email: string;
|
||||
resetPasswordToken?: string | null;
|
||||
resetPasswordExpiration?: string | null;
|
||||
salt?: string | null;
|
||||
hash?: string | null;
|
||||
loginAttempts?: number | null;
|
||||
lockUntil?: string | null;
|
||||
sessions?:
|
||||
| {
|
||||
id: string;
|
||||
createdAt?: string | null;
|
||||
expiresAt: string;
|
||||
}[]
|
||||
| null;
|
||||
password?: string | null;
|
||||
collection: "users";
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "media".
|
||||
*/
|
||||
export interface Media {
|
||||
id: number;
|
||||
alt: string;
|
||||
prefix?: string | null;
|
||||
updatedAt: string;
|
||||
createdAt: string;
|
||||
url?: string | null;
|
||||
thumbnailURL?: string | null;
|
||||
filename?: string | null;
|
||||
mimeType?: string | null;
|
||||
filesize?: number | null;
|
||||
width?: number | null;
|
||||
height?: number | null;
|
||||
focalX?: number | null;
|
||||
focalY?: number | null;
|
||||
sizes?: {
|
||||
thumbnail?: {
|
||||
url?: string | null;
|
||||
width?: number | null;
|
||||
height?: number | null;
|
||||
mimeType?: string | null;
|
||||
filesize?: number | null;
|
||||
filename?: string | null;
|
||||
};
|
||||
card?: {
|
||||
url?: string | null;
|
||||
width?: number | null;
|
||||
height?: number | null;
|
||||
mimeType?: string | null;
|
||||
filesize?: number | null;
|
||||
filename?: string | null;
|
||||
};
|
||||
};
|
||||
}
|
||||
/**
|
||||
* Captured leads from Contact Form.
|
||||
*
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "form-submissions".
|
||||
*/
|
||||
export interface FormSubmission {
|
||||
id: number;
|
||||
name: string;
|
||||
email: string;
|
||||
company?: string | null;
|
||||
message: string;
|
||||
updatedAt: string;
|
||||
createdAt: string;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "pages".
|
||||
*/
|
||||
export interface Page {
|
||||
id: number;
|
||||
title: string;
|
||||
slug: string;
|
||||
content: {
|
||||
root: {
|
||||
type: string;
|
||||
children: {
|
||||
type: any;
|
||||
version: number;
|
||||
[k: string]: unknown;
|
||||
}[];
|
||||
direction: ("ltr" | "rtl") | null;
|
||||
format: "left" | "start" | "center" | "right" | "end" | "justify" | "";
|
||||
indent: number;
|
||||
version: number;
|
||||
};
|
||||
[k: string]: unknown;
|
||||
};
|
||||
updatedAt: string;
|
||||
createdAt: string;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "payload-kv".
|
||||
*/
|
||||
export interface PayloadKv {
|
||||
id: number;
|
||||
key: string;
|
||||
data:
|
||||
| {
|
||||
[k: string]: unknown;
|
||||
}
|
||||
| unknown[]
|
||||
| string
|
||||
| number
|
||||
| boolean
|
||||
| null;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "payload-locked-documents".
|
||||
*/
|
||||
export interface PayloadLockedDocument {
|
||||
id: number;
|
||||
document?:
|
||||
| ({
|
||||
relationTo: "users";
|
||||
value: number | User;
|
||||
} | null)
|
||||
| ({
|
||||
relationTo: "media";
|
||||
value: number | Media;
|
||||
} | null)
|
||||
| ({
|
||||
relationTo: "form-submissions";
|
||||
value: number | FormSubmission;
|
||||
} | null)
|
||||
| ({
|
||||
relationTo: "pages";
|
||||
value: number | Page;
|
||||
} | null);
|
||||
globalSlug?: string | null;
|
||||
user: {
|
||||
relationTo: "users";
|
||||
value: number | User;
|
||||
};
|
||||
updatedAt: string;
|
||||
createdAt: string;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "payload-preferences".
|
||||
*/
|
||||
export interface PayloadPreference {
|
||||
id: number;
|
||||
user: {
|
||||
relationTo: "users";
|
||||
value: number | User;
|
||||
};
|
||||
key?: string | null;
|
||||
value?:
|
||||
| {
|
||||
[k: string]: unknown;
|
||||
}
|
||||
| unknown[]
|
||||
| string
|
||||
| number
|
||||
| boolean
|
||||
| null;
|
||||
updatedAt: string;
|
||||
createdAt: string;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "payload-migrations".
|
||||
*/
|
||||
export interface PayloadMigration {
|
||||
id: number;
|
||||
name?: string | null;
|
||||
batch?: number | null;
|
||||
updatedAt: string;
|
||||
createdAt: string;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "users_select".
|
||||
*/
|
||||
export interface UsersSelect<T extends boolean = true> {
|
||||
updatedAt?: T;
|
||||
createdAt?: T;
|
||||
email?: T;
|
||||
resetPasswordToken?: T;
|
||||
resetPasswordExpiration?: T;
|
||||
salt?: T;
|
||||
hash?: T;
|
||||
loginAttempts?: T;
|
||||
lockUntil?: T;
|
||||
sessions?:
|
||||
| T
|
||||
| {
|
||||
id?: T;
|
||||
createdAt?: T;
|
||||
expiresAt?: T;
|
||||
};
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "media_select".
|
||||
*/
|
||||
export interface MediaSelect<T extends boolean = true> {
|
||||
alt?: T;
|
||||
prefix?: T;
|
||||
updatedAt?: T;
|
||||
createdAt?: T;
|
||||
url?: T;
|
||||
thumbnailURL?: T;
|
||||
filename?: T;
|
||||
mimeType?: T;
|
||||
filesize?: T;
|
||||
width?: T;
|
||||
height?: T;
|
||||
focalX?: T;
|
||||
focalY?: T;
|
||||
sizes?:
|
||||
| T
|
||||
| {
|
||||
thumbnail?:
|
||||
| T
|
||||
| {
|
||||
url?: T;
|
||||
width?: T;
|
||||
height?: T;
|
||||
mimeType?: T;
|
||||
filesize?: T;
|
||||
filename?: T;
|
||||
};
|
||||
card?:
|
||||
| T
|
||||
| {
|
||||
url?: T;
|
||||
width?: T;
|
||||
height?: T;
|
||||
mimeType?: T;
|
||||
filesize?: T;
|
||||
filename?: T;
|
||||
};
|
||||
};
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "form-submissions_select".
|
||||
*/
|
||||
export interface FormSubmissionsSelect<T extends boolean = true> {
|
||||
name?: T;
|
||||
email?: T;
|
||||
company?: T;
|
||||
message?: T;
|
||||
updatedAt?: T;
|
||||
createdAt?: T;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "pages_select".
|
||||
*/
|
||||
export interface PagesSelect<T extends boolean = true> {
|
||||
title?: T;
|
||||
slug?: T;
|
||||
content?: T;
|
||||
updatedAt?: T;
|
||||
createdAt?: T;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "payload-kv_select".
|
||||
*/
|
||||
export interface PayloadKvSelect<T extends boolean = true> {
|
||||
key?: T;
|
||||
data?: T;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "payload-locked-documents_select".
|
||||
*/
|
||||
export interface PayloadLockedDocumentsSelect<T extends boolean = true> {
|
||||
document?: T;
|
||||
globalSlug?: T;
|
||||
user?: T;
|
||||
updatedAt?: T;
|
||||
createdAt?: T;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "payload-preferences_select".
|
||||
*/
|
||||
export interface PayloadPreferencesSelect<T extends boolean = true> {
|
||||
user?: T;
|
||||
key?: T;
|
||||
value?: T;
|
||||
updatedAt?: T;
|
||||
createdAt?: T;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "payload-migrations_select".
|
||||
*/
|
||||
export interface PayloadMigrationsSelect<T extends boolean = true> {
|
||||
name?: T;
|
||||
batch?: T;
|
||||
updatedAt?: T;
|
||||
createdAt?: T;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "auth".
|
||||
*/
|
||||
export interface Auth {
|
||||
[k: string]: unknown;
|
||||
}
|
||||
|
||||
declare module "payload" {
|
||||
export interface GeneratedTypes extends Config {}
|
||||
}
|
||||
94
src/payload/payload.config.ts
Normal file
94
src/payload/payload.config.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import { buildConfig } from "payload";
|
||||
import { postgresAdapter } from "@payloadcms/db-postgres";
|
||||
import { lexicalEditor, BlocksFeature } from "@payloadcms/richtext-lexical";
|
||||
import { nodemailerAdapter } from "@payloadcms/email-nodemailer";
|
||||
import { s3Storage } from "@payloadcms/storage-s3";
|
||||
import sharp from "sharp";
|
||||
import path from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
import { payloadBlocks } from "./blocks/allBlocks";
|
||||
import { migrations } from "../../migrations/index";
|
||||
|
||||
import { Users } from "./collections/Users";
|
||||
import { Media } from "./collections/Media";
|
||||
import { FormSubmissions } from "./collections/FormSubmissions";
|
||||
import { Pages } from "./collections/Pages";
|
||||
|
||||
const filename = fileURLToPath(import.meta.url);
|
||||
const dirname = path.dirname(filename);
|
||||
|
||||
export default buildConfig({
|
||||
admin: {
|
||||
user: Users.slug,
|
||||
importMap: {
|
||||
baseDir: path.resolve(dirname),
|
||||
},
|
||||
meta: {
|
||||
titleSuffix: " – MB Grid Solutions",
|
||||
},
|
||||
},
|
||||
collections: [Users, Media, FormSubmissions, Pages],
|
||||
editor: lexicalEditor({
|
||||
features: ({ defaultFeatures }) => [
|
||||
...defaultFeatures,
|
||||
BlocksFeature({
|
||||
blocks: payloadBlocks,
|
||||
}),
|
||||
],
|
||||
}),
|
||||
secret: process.env.PAYLOAD_SECRET || "fallback-secret-for-dev",
|
||||
typescript: {
|
||||
outputFile: path.resolve(dirname, "payload-types.ts"),
|
||||
},
|
||||
db: postgresAdapter({
|
||||
migrations,
|
||||
pool: {
|
||||
connectionString:
|
||||
process.env.DATABASE_URI ||
|
||||
process.env.POSTGRES_URI ||
|
||||
`postgresql://${process.env.DIRECTUS_DB_USER || "directus"}:${process.env.DIRECTUS_DB_PASSWORD || "directus"}@127.0.0.1:5432/${process.env.DIRECTUS_DB_NAME || "directus"}`,
|
||||
},
|
||||
}),
|
||||
...(process.env.SMTP_HOST
|
||||
? {
|
||||
email: nodemailerAdapter({
|
||||
defaultFromAddress:
|
||||
process.env.SMTP_FROM || "info@mb-grid-solutions.com",
|
||||
defaultFromName: "MB Grid Solutions CMS",
|
||||
transportOptions: {
|
||||
host: process.env.SMTP_HOST,
|
||||
port: parseInt(process.env.SMTP_PORT || "587"),
|
||||
auth: {
|
||||
user: process.env.SMTP_USER,
|
||||
pass: process.env.SMTP_PASS,
|
||||
},
|
||||
secure: process.env.SMTP_SECURE === "true",
|
||||
},
|
||||
}),
|
||||
}
|
||||
: {}),
|
||||
sharp,
|
||||
plugins: [
|
||||
...(process.env.S3_ENDPOINT
|
||||
? [
|
||||
s3Storage({
|
||||
collections: {
|
||||
media: {
|
||||
prefix: `${process.env.S3_PREFIX || "mb-grid-solutions"}/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,
|
||||
},
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
],
|
||||
});
|
||||
1
tests/__mocks__/payload-config.ts
Normal file
1
tests/__mocks__/payload-config.ts
Normal file
@@ -0,0 +1 @@
|
||||
export default {};
|
||||
168
tests/api-contact.test.ts
Normal file
168
tests/api-contact.test.ts
Normal file
@@ -0,0 +1,168 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
// Mock Payload CMS
|
||||
const { mockCreate, mockSendEmail } = vi.hoisted(() => ({
|
||||
mockCreate: vi.fn(),
|
||||
mockSendEmail: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("payload", () => ({
|
||||
getPayload: vi.fn().mockResolvedValue({
|
||||
create: mockCreate,
|
||||
sendEmail: mockSendEmail,
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock Email Template renders
|
||||
vi.mock("@mintel/mail", () => ({
|
||||
render: vi.fn().mockResolvedValue("<html>Mocked Email HTML</html>"),
|
||||
ContactFormNotification: () => "ContactFormNotification",
|
||||
ConfirmationMessage: () => "ConfirmationMessage",
|
||||
}));
|
||||
|
||||
// Mock Notifications and Analytics
|
||||
const { mockNotify, mockTrack, mockCaptureException } = vi.hoisted(() => ({
|
||||
mockNotify: vi.fn(),
|
||||
mockTrack: vi.fn(),
|
||||
mockCaptureException: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/services/create-services.server", () => ({
|
||||
getServerAppServices: () => ({
|
||||
logger: {
|
||||
child: () => ({
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
}),
|
||||
},
|
||||
analytics: {
|
||||
setServerContext: vi.fn(),
|
||||
track: mockTrack,
|
||||
},
|
||||
notifications: {
|
||||
notify: mockNotify,
|
||||
},
|
||||
errors: {
|
||||
captureException: mockCaptureException,
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
// Import the route handler we want to test
|
||||
import { POST } from "../app/api/contact/route";
|
||||
import { NextResponse } from "next/server";
|
||||
import type { Mock } from "vitest";
|
||||
|
||||
describe("Contact API Integration", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
(NextResponse.json as Mock).mockImplementation((body: any, init?: any) => ({
|
||||
status: init?.status || 200,
|
||||
json: async () => body,
|
||||
}));
|
||||
});
|
||||
|
||||
it("should validate and decline empty or short messages", async () => {
|
||||
const req = new Request("http://localhost/api/contact", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
name: "Test User",
|
||||
email: "test@example.com",
|
||||
message: "too short",
|
||||
}),
|
||||
});
|
||||
|
||||
const response = await POST(req);
|
||||
expect(response.status).toBe(400);
|
||||
|
||||
const data = await response.json();
|
||||
expect(data.error).toBe("message_too_short");
|
||||
|
||||
// Ensure payload and email were NOT called
|
||||
expect(mockCreate).not.toHaveBeenCalled();
|
||||
expect(mockSendEmail).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should catch honeypot submissions", async () => {
|
||||
const req = new Request("http://localhost/api/contact", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
name: "Spam Bot",
|
||||
email: "spam@example.com",
|
||||
message: "This is a very long spam message that passes length checks.",
|
||||
website: "http://spam.com", // Honeypot filled
|
||||
}),
|
||||
});
|
||||
|
||||
const response = await POST(req);
|
||||
// Honeypot returns 200 OK so the bot thinks it succeeded
|
||||
expect(response.status).toBe(200);
|
||||
|
||||
// But it actually does NOTHING internally
|
||||
expect(mockCreate).not.toHaveBeenCalled();
|
||||
expect(mockSendEmail).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should successfully save to Payload and send emails", async () => {
|
||||
const req = new Request("http://localhost/api/contact", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"user-agent": "vitest",
|
||||
"x-forwarded-for": "127.0.0.1",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
name: "Jane Doe",
|
||||
email: "jane@example.com",
|
||||
company: "Jane Tech",
|
||||
message:
|
||||
"Hello, I am interested in exploring your high-voltage grid solutions.",
|
||||
}),
|
||||
});
|
||||
|
||||
const response = await POST(req);
|
||||
expect(response.status).toBe(200);
|
||||
|
||||
const data = await response.json();
|
||||
expect(data.message).toBe("Ok");
|
||||
|
||||
// 1. Verify Payload creation
|
||||
expect(mockCreate).toHaveBeenCalledTimes(1);
|
||||
expect(mockCreate).toHaveBeenCalledWith({
|
||||
collection: "form-submissions",
|
||||
data: {
|
||||
name: "Jane Doe",
|
||||
email: "jane@example.com",
|
||||
company: "Jane Tech",
|
||||
message:
|
||||
"Hello, I am interested in exploring your high-voltage grid solutions.",
|
||||
},
|
||||
});
|
||||
|
||||
// 2. Verify Email Sending
|
||||
// Note: sendEmail is called twice (Notification + User Confirmation)
|
||||
expect(mockSendEmail).toHaveBeenCalledTimes(2);
|
||||
|
||||
expect(mockSendEmail).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
expect.objectContaining({
|
||||
subject: "Kontaktanfrage von Jane Doe",
|
||||
replyTo: "jane@example.com",
|
||||
}),
|
||||
);
|
||||
|
||||
expect(mockSendEmail).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.objectContaining({
|
||||
to: "jane@example.com",
|
||||
subject: "Ihre Kontaktanfrage bei MB Grid Solutions",
|
||||
}),
|
||||
);
|
||||
|
||||
// 3. Verify notification and analytics
|
||||
expect(mockNotify).toHaveBeenCalledTimes(1);
|
||||
expect(mockTrack).toHaveBeenCalledWith("contact-form-success", {
|
||||
has_company: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
129
tests/contact.test.tsx
Normal file
129
tests/contact.test.tsx
Normal file
@@ -0,0 +1,129 @@
|
||||
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { NextIntlClientProvider } from "next-intl";
|
||||
import messages from "../messages/de.json";
|
||||
|
||||
// Mocks MUST be defined before component import to ensure they are picked up
|
||||
vi.mock("../components/Reveal", () => ({
|
||||
Reveal: ({ children }: any) => <>{children}</>,
|
||||
Stagger: ({ children }: any) => <>{children}</>,
|
||||
}));
|
||||
|
||||
// Better FormData mock for happy-dom
|
||||
global.FormData = class MockFormData {
|
||||
private data = new Map();
|
||||
constructor(form?: HTMLFormElement) {
|
||||
if (form) {
|
||||
const elements = form.elements as any;
|
||||
for (let i = 0; i < elements.length; i++) {
|
||||
const item = elements.item(i);
|
||||
if (item.name && item.value) {
|
||||
this.data.set(item.name, item.value);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
append(key: string, value: any) {
|
||||
this.data.set(key, value);
|
||||
}
|
||||
get(key: string) {
|
||||
return this.data.get(key);
|
||||
}
|
||||
entries() {
|
||||
return Array.from(this.data.entries())[Symbol.iterator]();
|
||||
}
|
||||
} as any;
|
||||
|
||||
// Mock alert
|
||||
const alertMock = vi.fn();
|
||||
global.alert = alertMock;
|
||||
|
||||
// Import component AFTER mocks
|
||||
import Contact from "../components/ContactContent";
|
||||
|
||||
// Mock fetch
|
||||
const fetchMock = vi.fn();
|
||||
global.fetch = fetchMock;
|
||||
|
||||
const renderContact = () => {
|
||||
return render(
|
||||
<NextIntlClientProvider locale="de" messages={messages}>
|
||||
<Contact />
|
||||
</NextIntlClientProvider>,
|
||||
);
|
||||
};
|
||||
|
||||
describe("Contact Page", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
fetchMock.mockReset();
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ success: true }),
|
||||
});
|
||||
});
|
||||
|
||||
it("renders the contact form correctly", () => {
|
||||
renderContact();
|
||||
|
||||
expect(screen.getByLabelText(/Name \*/i)).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/Firma/i)).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/E-Mail \*/i)).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/Nachricht \*/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("submits the form successfully", async () => {
|
||||
renderContact();
|
||||
|
||||
fireEvent.change(screen.getByLabelText(/Name \*/i), {
|
||||
target: { value: "John Doe" },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText(/E-Mail \*/i), {
|
||||
target: { value: "john@example.com" },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText(/Nachricht \*/i), {
|
||||
target: { value: "This is a test message that is long enough." },
|
||||
});
|
||||
|
||||
const form = screen.getByRole("form");
|
||||
fireEvent.submit(form);
|
||||
|
||||
await waitFor(
|
||||
() => {
|
||||
expect(fetchMock).toHaveBeenCalled();
|
||||
},
|
||||
{ timeout: 2000 },
|
||||
);
|
||||
|
||||
expect(
|
||||
(await screen.findAllByText(/Anfrage erfolgreich übermittelt/i)).length,
|
||||
).toBeGreaterThanOrEqual(1);
|
||||
expect(
|
||||
(await screen.findAllByText(/Ihr Anliegen wurde erfasst/i)).length,
|
||||
).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it("handles submission errors", async () => {
|
||||
fetchMock.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
json: async () => ({ error: "Server error" }),
|
||||
});
|
||||
|
||||
renderContact();
|
||||
|
||||
fireEvent.change(screen.getByLabelText(/Name \*/i), {
|
||||
target: { value: "John Doe" },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText(/E-Mail \*/i), {
|
||||
target: { value: "john@example.com" },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText(/Nachricht \*/i), {
|
||||
target: { value: "This is a test message." },
|
||||
});
|
||||
|
||||
const form = screen.getByRole("form");
|
||||
fireEvent.submit(form);
|
||||
|
||||
expect(await screen.findByText(/Server error/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
37
tests/home.test.tsx
Normal file
37
tests/home.test.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, it, expect } from "vitest";
|
||||
import Home from "../components/HomeContent";
|
||||
import { NextIntlClientProvider } from "next-intl";
|
||||
import messages from "../messages/de.json";
|
||||
|
||||
const renderHome = () => {
|
||||
return render(
|
||||
<NextIntlClientProvider locale="de" messages={messages}>
|
||||
<Home />
|
||||
</NextIntlClientProvider>,
|
||||
);
|
||||
};
|
||||
|
||||
describe("Home Page", () => {
|
||||
it("renders the hero section with correct title", () => {
|
||||
renderHome();
|
||||
expect(
|
||||
screen.getByRole("heading", { name: /Spezialisierter Partner/i }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("contains the CTA button", () => {
|
||||
renderHome();
|
||||
const ctaButton = screen.getByRole("link", { name: /Projekt anfragen/i });
|
||||
expect(ctaButton).toBeInTheDocument();
|
||||
expect(ctaButton).toHaveAttribute("href", "/kontakt");
|
||||
});
|
||||
|
||||
it("renders the portfolio section", async () => {
|
||||
renderHome();
|
||||
expect(await screen.findByText(/Unsere Leistungen/i)).toBeInTheDocument();
|
||||
// Use getAllByText because it appears in both hero description and card title
|
||||
const elements = await screen.findAllByText(/Technische Beratung/i);
|
||||
expect(elements.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
56
tests/setup.tsx
Normal file
56
tests/setup.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import React from "react";
|
||||
import { vi } from "vitest";
|
||||
|
||||
// Mock next/navigation
|
||||
vi.mock("next/navigation", () => ({
|
||||
usePathname: () => "/",
|
||||
useRouter: () => ({
|
||||
push: vi.fn(),
|
||||
replace: vi.fn(),
|
||||
prefetch: vi.fn(),
|
||||
back: vi.fn(),
|
||||
}),
|
||||
useSearchParams: () => new URLSearchParams(),
|
||||
}));
|
||||
|
||||
// Mock next-intl to avoid transitive next/server issues
|
||||
vi.mock("next-intl/middleware", () => ({
|
||||
default: vi.fn(() => (req: any) => req),
|
||||
}));
|
||||
|
||||
vi.mock("next-intl/server", () => ({
|
||||
getRequestConfig: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock next/server
|
||||
vi.mock("next/server", () => ({
|
||||
NextResponse: {
|
||||
json: vi.fn(),
|
||||
next: vi.fn(),
|
||||
redirect: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock next/dynamic to be synchronous in tests
|
||||
vi.mock("next/dynamic", () => ({
|
||||
default: vi.fn((loader) => {
|
||||
return (props: any) => {
|
||||
const [Component, setComponent] = React.useState<any>(null);
|
||||
React.useEffect(() => {
|
||||
loader().then((mod: any) => {
|
||||
setComponent(
|
||||
() =>
|
||||
mod.default ||
|
||||
mod.PortfolioSection ||
|
||||
mod.ExpertiseSection ||
|
||||
mod.TechnicalSpecsSection ||
|
||||
mod.CTASection ||
|
||||
mod,
|
||||
);
|
||||
});
|
||||
}, []);
|
||||
return Component ? <Component {...props} /> : null;
|
||||
};
|
||||
}),
|
||||
}));
|
||||
@@ -1,126 +0,0 @@
|
||||
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import Contact from "../app/kontakt/page";
|
||||
|
||||
// Mock fetch
|
||||
const fetchMock = vi.fn();
|
||||
global.fetch = fetchMock;
|
||||
|
||||
// Mock alert
|
||||
const alertMock = vi.fn();
|
||||
global.alert = alertMock;
|
||||
|
||||
describe("Contact Page", () => {
|
||||
beforeEach(() => {
|
||||
fetchMock.mockClear();
|
||||
alertMock.mockClear();
|
||||
});
|
||||
|
||||
it("renders the contact form correctly", () => {
|
||||
render(<Contact />);
|
||||
|
||||
expect(screen.getByLabelText(/Name \*/i)).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/Firma/i)).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/E-Mail \*/i)).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/Nachricht \*/i)).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("button", { name: /Nachricht senden/i }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("submits the form successfully", async () => {
|
||||
fetchMock.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ success: true }),
|
||||
});
|
||||
|
||||
render(<Contact />);
|
||||
|
||||
fireEvent.change(screen.getByLabelText(/Name \*/i), {
|
||||
target: { value: "John Doe" },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText(/Firma/i), {
|
||||
target: { value: "Acme Corp" },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText(/E-Mail \*/i), {
|
||||
target: { value: "john@example.com" },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText(/Nachricht \*/i), {
|
||||
target: { value: "This is a test message that is long enough." },
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /Nachricht senden/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"/api/contact",
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
name: "John Doe",
|
||||
company: "Acme Corp",
|
||||
email: "john@example.com",
|
||||
message: "This is a test message that is long enough.",
|
||||
website: "",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
expect(screen.getByText(/Nachricht gesendet/i)).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(/Vielen Dank für Ihre Anfrage/i),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("handles submission errors", async () => {
|
||||
fetchMock.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
json: async () => ({ error: "Server error" }),
|
||||
});
|
||||
|
||||
render(<Contact />);
|
||||
|
||||
fireEvent.change(screen.getByLabelText(/Name \*/i), {
|
||||
target: { value: "John Doe" },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText(/E-Mail \*/i), {
|
||||
target: { value: "john@example.com" },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText(/Nachricht \*/i), {
|
||||
target: { value: "This is a test message that is long enough." },
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /Nachricht senden/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(alertMock).toHaveBeenCalledWith("Fehler: Server error");
|
||||
});
|
||||
});
|
||||
|
||||
it("handles network errors", async () => {
|
||||
fetchMock.mockRejectedValueOnce(new Error("Network error"));
|
||||
|
||||
render(<Contact />);
|
||||
|
||||
fireEvent.change(screen.getByLabelText(/Name \*/i), {
|
||||
target: { value: "John Doe" },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText(/E-Mail \*/i), {
|
||||
target: { value: "john@example.com" },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText(/Nachricht \*/i), {
|
||||
target: { value: "This is a test message that is long enough." },
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /Nachricht senden/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(alertMock).toHaveBeenCalledWith(
|
||||
"Es gab einen Fehler beim Senden Ihrer Nachricht.",
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,27 +0,0 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, it, expect } from "vitest";
|
||||
import Home from "../app/page";
|
||||
|
||||
describe("Home Page", () => {
|
||||
it("renders the hero section with correct title", () => {
|
||||
render(<Home />);
|
||||
expect(
|
||||
screen.getByText(/Spezialisierter Partner für Energiekabelprojekte/i),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("contains the CTA button", () => {
|
||||
render(<Home />);
|
||||
const ctaButton = screen.getByRole("link", { name: /Projekt anfragen/i });
|
||||
expect(ctaButton).toBeInTheDocument();
|
||||
expect(ctaButton).toHaveAttribute("href", "/kontakt");
|
||||
});
|
||||
|
||||
it("renders the portfolio section", () => {
|
||||
render(<Home />);
|
||||
expect(screen.getByText(/Unsere Leistungen/i)).toBeInTheDocument();
|
||||
// Use getAllByText because it appears in both hero description and card title
|
||||
const elements = screen.getAllByText(/Technische Beratung/i);
|
||||
expect(elements.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
@@ -1,12 +0,0 @@
|
||||
import "@testing-library/jest-dom";
|
||||
import { vi } from "vitest";
|
||||
|
||||
// Mock next/navigation
|
||||
vi.mock("next/navigation", () => ({
|
||||
usePathname: () => "/",
|
||||
useRouter: () => ({
|
||||
push: vi.fn(),
|
||||
replace: vi.fn(),
|
||||
prefetch: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
@@ -3,7 +3,8 @@
|
||||
"compilerOptions": {
|
||||
"strict": true,
|
||||
"paths": {
|
||||
"@/*": ["./*"]
|
||||
"@/*": ["./*"],
|
||||
"@payload-config": ["./src/payload/payload.config.ts"]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
|
||||
5
types/mintel-mail.d.ts
vendored
Normal file
5
types/mintel-mail.d.ts
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
declare module "@mintel/mail" {
|
||||
export const render: any;
|
||||
export const ContactFormNotification: any;
|
||||
export const ConfirmationMessage: any;
|
||||
}
|
||||
31
vitest.config.mts
Normal file
31
vitest.config.mts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
import react from '@vitejs/plugin-react';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
test: {
|
||||
environment: 'happy-dom',
|
||||
environmentOptions: {
|
||||
happyDOM: {
|
||||
settings: {
|
||||
disableIframePageLoading: true,
|
||||
disableJavaScriptFileLoading: true,
|
||||
disableCSSFileLoading: true,
|
||||
}
|
||||
}
|
||||
},
|
||||
globals: true,
|
||||
setupFiles: ['./tests/setup.tsx'],
|
||||
alias: {
|
||||
'next/server': 'next/server.js',
|
||||
'@payload-config': new URL('./tests/__mocks__/payload-config.ts', import.meta.url).pathname,
|
||||
'@': new URL('./', import.meta.url).pathname,
|
||||
},
|
||||
exclude: ['**/node_modules/**', '**/.next/**'],
|
||||
server: {
|
||||
deps: {
|
||||
inline: ['next-intl', '@mintel/next-utils'],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -1,17 +0,0 @@
|
||||
import { defineConfig } from 'vitest/config'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import path from 'path'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
test: {
|
||||
environment: 'jsdom',
|
||||
globals: true,
|
||||
setupFiles: './tests/setup.ts',
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, './'),
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user