Compare commits
217 Commits
v1.8.20
...
refactor/r
| Author | SHA1 | Date | |
|---|---|---|---|
| acb9c3df38 | |||
| d5f36274aa | |||
| ce7d485b0c | |||
| 4ac5b15473 | |||
| c08950e865 | |||
| 8dea906d23 | |||
| cb9e136d16 | |||
| a02a419879 | |||
| 222140aa44 | |||
| 1187f5d48d | |||
| 6e64da48d6 | |||
| f2bffea312 | |||
| 1669227f19 | |||
| c644766a64 | |||
| 05514b0c99 | |||
| 66b80117b1 | |||
| 7bceb6cd2b | |||
| 569bb4e270 | |||
| 0a6f5f34ce | |||
| 5f27bb9950 | |||
| d83199ab1a | |||
| 2cfe9115d2 | |||
| d4b586f29f | |||
| 9013d38e31 | |||
| f3cbdd8076 | |||
| f143fc6099 | |||
| 83190f1d00 | |||
| 12848a4f23 | |||
| 0a9cb81841 | |||
| c505050cb4 | |||
| d5d0d77180 | |||
| 9399be3ea9 | |||
| 7ec82027a8 | |||
| 631d051c2e | |||
| 1a8758a88c | |||
| 7d6d34dd7b | |||
| 1f0de18755 | |||
| eeb0920afd | |||
| f8eec731c0 | |||
| 8b80af3d1a | |||
| de42890476 | |||
| fbd33da925 | |||
| f3bab24bd3 | |||
| 3b668fc876 | |||
| 2827239796 | |||
| 7cc1e49ba6 | |||
| 258eb9464e | |||
| 015386ba4a | |||
| 49abaaf2fd | |||
| 7e9005e338 | |||
| bbcc7d159c | |||
| 39f5bd3986 | |||
| 944d369d43 | |||
| 6aaf8ac44f | |||
| 2097b571f3 | |||
| aeb1814a34 | |||
| 57e093ea54 | |||
| 76e25d03b9 | |||
| 19ef042fb6 | |||
| 6032394ac9 | |||
| 033a9ad65e | |||
| 3f6fa36f9b | |||
| c52a132d62 | |||
| 11f735bbdf | |||
| c81dae0b7b | |||
| 716ece1c6c | |||
| e0ccf1cdfb | |||
| 6a6fbb6f19 | |||
| 6b6b2b8ece | |||
| 9f412d81a8 | |||
| 9c401f13de | |||
| 5857404ac1 | |||
| 34a96f8aef | |||
| 4e6f3f29cf | |||
| 1bd516fbe4 | |||
| 4d0e3433a6 | |||
| ee9cde1ed0 | |||
| 33cf701034 | |||
| 1fae5edee3 | |||
| 0e143bf9c1 | |||
| d86e26bc33 | |||
| a1c0736274 | |||
| 7b642426fb | |||
| 6a228248e0 | |||
| bd1a822d32 | |||
| 81af49f880 | |||
| 1defb5758f | |||
| b4dd073711 | |||
| 59ea4bfd02 | |||
| 4a20e1f51f | |||
| 9aa3ee42e4 | |||
| 0ac022df57 | |||
| e71965267d | |||
| 8d12f92da8 | |||
| 4303124ec5 | |||
| badf81644e | |||
| cdd38b3654 | |||
| 1a195a388a | |||
| b4fbf3bf2a | |||
| 8569105529 | |||
| 316afe004f | |||
| b20a999da8 | |||
| 237d68bc5a | |||
| 0fdc20cabb | |||
| 2aa617ce3b | |||
| 54cd94831d | |||
| c8df20bbee | |||
| 07755c9674 | |||
| ff7ba14a4a | |||
| ebe42adb6f | |||
| a45d0110d3 | |||
| 9abd4f4fe7 | |||
| 3a4fd1d06d | |||
| c0b9c55ecf | |||
| 7e320c08d9 | |||
| c5746978aa | |||
| cd88c2f20f | |||
| 1c87d5341e | |||
| 6a14c9924f | |||
| ee50808596 | |||
| e9fbe45feb | |||
| b27566a336 | |||
| 71ef49e73d | |||
| a98572e183 | |||
| eacb14ff7d | |||
| 41a090db58 | |||
| 2bdb6bbb98 | |||
| 99ee47507b | |||
| 2d96000385 | |||
| 39ea0a35dd | |||
| 1c24822787 | |||
| d21c12c2b4 | |||
| cdf2bb5fdc | |||
| c4aaea30c1 | |||
| cbb3cf0be3 | |||
| bc3a75a915 | |||
| 1455845d44 | |||
| db31f06bc0 | |||
| 546b8ee72b | |||
| 6174b44570 | |||
| 89d258e63d | |||
| 13a484ce59 | |||
| d82c836fcb | |||
| b2f6627ec5 | |||
| 2ab5a8a41f | |||
| e43c980a5d | |||
| 88b4626d6e | |||
| 90856da773 | |||
| 964cd79ca8 | |||
| 9c5e2c6099 | |||
| 984a641b90 | |||
| c8ff76f299 | |||
| 1fffdf00ee | |||
| 70de139cb0 | |||
| b015c62650 | |||
| b7dac5d463 | |||
| 10bdfdfe97 | |||
| 9ad63a0a82 | |||
| eb117cc0b8 | |||
| 23ee915194 | |||
| 3dff891023 | |||
| f55c27c43d | |||
| 3e04427646 | |||
| 6b51d63c8b | |||
| 60ca4ad656 | |||
| aae5275990 | |||
| b639fffe7f | |||
| ab15f7f35b | |||
| 025906889c | |||
| 760a6d6db3 | |||
| 7f8cea4728 | |||
| fb09b1de9a | |||
| cb4afe2e91 | |||
| 1f68234a49 | |||
| e2d68c2828 | |||
| cb6f133e0c | |||
| 7990189505 | |||
| 2167044543 | |||
| 0665e3e224 | |||
| 2bdcbfb907 | |||
| ac1e0081f7 | |||
| 4f452cf2a9 | |||
| 1404aa0406 | |||
| 9e10ce06ed | |||
| a400e6f94d | |||
| 2f95c8d968 | |||
| 9aa6f5f4d0 | |||
| 071302fe6b | |||
| cf3a96cead | |||
| af5f91e6f8 | |||
| 5e453418d6 | |||
| 10980ba8b3 | |||
| 6444aea5f6 | |||
| ad50929bf3 | |||
| 07928a182f | |||
| b493ce0ba0 | |||
| db445d0b76 | |||
| 22a6a06a4e | |||
| 4f66dd914c | |||
| bb54750085 | |||
| 5cbbd81384 | |||
| c167e36626 | |||
| 0fb872161d | |||
| a360ea6a98 | |||
| a537294832 | |||
| 459bdc6eda | |||
| 905ce98bc4 | |||
| ce63a1ac69 | |||
| 6444cf1e81 | |||
| 4b5609a75e | |||
| 8907963d57 | |||
| 844a5f5412 | |||
| 96de68a063 | |||
| 4a5017124a | |||
| 283de11b11 | |||
| 31685a458b | |||
| 6864903cff |
@@ -1,9 +1,13 @@
|
||||
node_modules
|
||||
.next
|
||||
out
|
||||
dist
|
||||
*.log
|
||||
.git
|
||||
# Exclude all binary/dependency folders recursively
|
||||
**/node_modules
|
||||
**/.pnpm-store
|
||||
**/.git
|
||||
**/.next
|
||||
**/dist
|
||||
**/out
|
||||
**/*.log
|
||||
|
||||
# Specific exclusions for this project
|
||||
.DS_Store
|
||||
cloned-websites
|
||||
storage
|
||||
@@ -11,3 +15,11 @@ storage
|
||||
verify_ci
|
||||
pnpm_install_log.txt
|
||||
full_tree.json
|
||||
backups
|
||||
data
|
||||
|
||||
# Ensure we don't copy the sibling's build artifacts either
|
||||
_at-mintel/**/node_modules
|
||||
_at-mintel/**/dist
|
||||
_at-mintel/**/.next
|
||||
_at-mintel/.git
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
name: CI - Quality Assurance
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
qa:
|
||||
name: 🧪 QA
|
||||
runs-on: docker
|
||||
container:
|
||||
image: catthehacker/ubuntu:act-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v3
|
||||
with:
|
||||
version: 10
|
||||
- name: 🔐 Registry Auth
|
||||
run: |
|
||||
echo "@mintel:registry=https://${{ vars.REGISTRY_HOST || 'npm.infra.mintel.me' }}" > .npmrc
|
||||
echo "//${{ vars.REGISTRY_HOST || 'npm.infra.mintel.me' }}/:_authToken=${{ secrets.REGISTRY_PASS }}" >> .npmrc
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
- name: 🧪 Parallel Checks
|
||||
run: |
|
||||
pnpm lint &
|
||||
pnpm build &
|
||||
wait
|
||||
@@ -1,9 +1,10 @@
|
||||
# Heartbeat to trigger fresh CI run after stall
|
||||
name: Build & Deploy
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- "**"
|
||||
tags:
|
||||
- "v*"
|
||||
workflow_dispatch:
|
||||
@@ -13,6 +14,9 @@ on:
|
||||
required: false
|
||||
default: "false"
|
||||
|
||||
env:
|
||||
PUPPETEER_SKIP_DOWNLOAD: "true"
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ (github.ref_type == 'tag' && !contains(github.ref_name, '-')) && 'prod' || (github.ref_name == 'main' && 'testing' || github.ref_name) }}
|
||||
cancel-in-progress: true
|
||||
@@ -76,7 +80,11 @@ jobs:
|
||||
TRAEFIK_HOST="staging.${DOMAIN}"
|
||||
fi
|
||||
else
|
||||
TARGET="skip"
|
||||
TARGET="branch"
|
||||
SLUG=$(echo "$REF" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/-/g' | sed 's/--*/-/g' | sed 's/^-//;s/-$//')
|
||||
IMAGE_TAG="branch-${SLUG}-${SHORT_SHA}"
|
||||
ENV_FILE=".env.branch-${SLUG}"
|
||||
TRAEFIK_HOST="${SLUG}.branch.${DOMAIN}"
|
||||
fi
|
||||
|
||||
if [[ "$TARGET" != "skip" ]]; then
|
||||
@@ -97,74 +105,27 @@ jobs:
|
||||
echo "traefik_rule=$TRAEFIK_RULE"
|
||||
echo "next_public_url=https://$PRIMARY_HOST"
|
||||
echo "directus_url=https://cms.$PRIMARY_HOST"
|
||||
echo "project_name=$PRJ-$TARGET"
|
||||
if [[ "$TARGET" == "branch" ]]; then
|
||||
echo "project_name=$PRJ-branch-$SLUG"
|
||||
else
|
||||
echo "project_name=$PRJ-$TARGET"
|
||||
fi
|
||||
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 | head -1 | cut -d'"' -f4 | sed 's/\^//; s/\~//')
|
||||
TAG_TO_WAIT="v$UPSTREAM_VERSION"
|
||||
|
||||
if [[ -n "$UPSTREAM_VERSION" && "$UPSTREAM_VERSION" != "workspace:"* ]]; then
|
||||
echo "⏳ This release depends on @mintel v$UPSTREAM_VERSION. Waiting for upstream build..."
|
||||
# Fetch script from monorepo (main)
|
||||
curl -s -H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
|
||||
"https://git.infra.mintel.me/mmintel/at-mintel/raw/branch/main/packages/infra/scripts/wait-for-upstream.sh" > wait-for-upstream.sh
|
||||
chmod +x wait-for-upstream.sh
|
||||
|
||||
GITEA_TOKEN=${{ secrets.GITHUB_TOKEN }} ./wait-for-upstream.sh "mmintel/at-mintel" "$TAG_TO_WAIT"
|
||||
fi
|
||||
fi
|
||||
|
||||
else
|
||||
echo "target=skip" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# JOB 2: QA (Lint, Build Test)
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
qa:
|
||||
name: 🧪 QA
|
||||
needs: prepare
|
||||
if: needs.prepare.outputs.target != 'skip'
|
||||
runs-on: docker
|
||||
container:
|
||||
image: catthehacker/ubuntu:act-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v3
|
||||
with:
|
||||
version: 10
|
||||
- name: 🔐 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: 🧪 QA Checks
|
||||
if: github.event.inputs.skip_checks != 'true'
|
||||
run: |
|
||||
pnpm lint
|
||||
pnpm --filter "@mintel/web" exec tsc --noEmit
|
||||
pnpm --filter "@mintel/web" test
|
||||
- name: 🏗️ Build Test
|
||||
if: github.event.inputs.skip_checks != 'true'
|
||||
run: pnpm build
|
||||
# (JOB 2: QA was removed to reduce pipeline noise)
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# JOB 3: Build & Push
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
build:
|
||||
name: 🏗️ Build
|
||||
needs: prepare
|
||||
needs: [prepare]
|
||||
if: needs.prepare.outputs.target != 'skip'
|
||||
runs-on: docker
|
||||
container:
|
||||
@@ -172,33 +133,103 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
- name: Provide sibling monorepo (context)
|
||||
run: |
|
||||
git clone https://git.infra.mintel.me/mmintel/at-mintel.git _at-mintel
|
||||
# Force ALL @mintel packages to use the local clone instead of the registry
|
||||
perl -pi -e 's/"\@mintel\/([^"]+)"\s*:\s*"[^"]+"/"\@mintel\/$1": "link:.\/_at-mintel\/packages\/$1"/g' package.json
|
||||
perl -pi -e 's/link:\.\/_at-mintel\/packages\/pdf"/link:.\/_at-mintel\/packages\/pdf-library"/g' package.json
|
||||
perl -pi -e 's/"\@mintel\/([^"]+)"\s*:\s*"[^"]+"/"\@mintel\/$1": "link:..\/\.\.\/_at-mintel\/packages\/$1"/g' apps/web/package.json
|
||||
perl -pi -e 's/link:\.\.\/\.\.\/_at-mintel\/packages\/pdf"/link:..\/\.\.\/_at-mintel\/packages\/pdf-library"/g' apps/web/package.json
|
||||
|
||||
- name: 🧹 Free Disk Space
|
||||
run: |
|
||||
docker builder prune -af || true
|
||||
docker image prune -af || true
|
||||
|
||||
- name: 🐳 Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: 🔐 Registry Login
|
||||
run: echo "${{ secrets.REGISTRY_PASS }}" | docker login registry.infra.mintel.me -u "${{ secrets.REGISTRY_USER }}" --password-stdin
|
||||
run: |
|
||||
echo "${{ secrets.REGISTRY_PASS }}" | docker login registry.infra.mintel.me -u "${{ secrets.REGISTRY_USER }}" --password-stdin
|
||||
|
||||
- name: 🧹 Nuclear Runner Cleanup
|
||||
run: |
|
||||
echo "Disk space before nuclear prune:"
|
||||
df -h
|
||||
|
||||
# Massive cleanup of pre-installed runner software to free up ~5GB+
|
||||
# These are standard on VM-based runners and often unnecessary
|
||||
sudo rm -rf /usr/share/dotnet || true
|
||||
sudo rm -rf /usr/local/lib/android || true
|
||||
sudo rm -rf /opt/ghc || true
|
||||
sudo rm -rf /opt/hostedtoolcache/CodeQL || true
|
||||
|
||||
# Comprehensive Docker purge
|
||||
docker system prune -af --volumes
|
||||
docker builder prune -af
|
||||
docker buildx prune -af
|
||||
|
||||
# Clean temp build artifacts
|
||||
rm -rf /tmp/docker-actions-toolkit-* || true
|
||||
|
||||
echo "Disk space after nuclear prune:"
|
||||
df -h
|
||||
continue-on-error: true
|
||||
|
||||
- name: 🏗️ Build and Push
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
platforms: linux/arm64
|
||||
provenance: false
|
||||
platforms: linux/amd64
|
||||
build-args: |
|
||||
NEXT_PUBLIC_BASE_URL=${{ needs.prepare.outputs.next_public_url }}
|
||||
NEXT_PUBLIC_TARGET=${{ needs.prepare.outputs.target }}
|
||||
DIRECTUS_URL=${{ needs.prepare.outputs.directus_url }}
|
||||
NPM_TOKEN=${{ secrets.REGISTRY_PASS }}
|
||||
tags: registry.infra.mintel.me/mintel/mintel.me:${{ needs.prepare.outputs.image_tag }}
|
||||
cache-from: type=registry,ref=registry.infra.mintel.me/mintel/mintel.me:buildcache
|
||||
cache-to: type=registry,ref=registry.infra.mintel.me/mintel/mintel.me:buildcache,mode=max
|
||||
secrets: |
|
||||
NPM_TOKEN=${{ secrets.REGISTRY_PASS }}
|
||||
NPM_TOKEN=${{ secrets.NPM_TOKEN }}
|
||||
S3_ENDPOINT=${{ secrets.S3_ENDPOINT || vars.S3_ENDPOINT || 'https://fsn1.your-objectstorage.com' }}
|
||||
S3_ACCESS_KEY=${{ secrets.S3_ACCESS_KEY || vars.S3_ACCESS_KEY }}
|
||||
S3_SECRET_KEY=${{ secrets.S3_SECRET_KEY || vars.S3_SECRET_KEY }}
|
||||
S3_BUCKET=${{ secrets.S3_BUCKET || vars.S3_BUCKET || 'mintel' }}
|
||||
S3_REGION=${{ secrets.S3_REGION || vars.S3_REGION || 'fsn1' }}
|
||||
S3_PREFIX=${{ secrets.S3_PREFIX || vars.S3_PREFIX || 'mintel.me' }}
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
BUILD_ID=${{ github.sha }}
|
||||
tags: registry.infra.mintel.me/mintel/mintel.me:${{ needs.prepare.outputs.image_tag }}
|
||||
# Temporarily disable registry cache export to save runner disk/bandwidth
|
||||
cache-from: type=registry,ref=registry.infra.mintel.me/mintel/mintel.me:buildcache-${{ needs.prepare.outputs.target }}
|
||||
# cache-to: type=registry,ref=registry.infra.mintel.me/mintel/mintel.me:buildcache-${{ needs.prepare.outputs.target }},mode=max
|
||||
secrets: |
|
||||
NPM_TOKEN=${{ secrets.NPM_TOKEN }}
|
||||
|
||||
- name: 🚨 Extract Build Error Logs
|
||||
if: failure()
|
||||
run: |
|
||||
set +e
|
||||
mkdir -p ~/.ssh
|
||||
echo "${{ secrets.ALPHA_SSH_KEY }}" > ~/.ssh/id_ed25519
|
||||
chmod 600 ~/.ssh/id_ed25519
|
||||
ssh-keyscan -H alpha.mintel.me >> ~/.ssh/known_hosts 2>/dev/null
|
||||
echo "Re-running docker build with plain progress to capture exact logs..."
|
||||
echo "${{ secrets.NPM_TOKEN }}" > /tmp/npm_token.txt
|
||||
docker build \
|
||||
--build-arg NEXT_PUBLIC_BASE_URL=${{ needs.prepare.outputs.next_public_url }} \
|
||||
--build-arg NEXT_PUBLIC_TARGET=${{ needs.prepare.outputs.target }} \
|
||||
--build-arg DIRECTUS_URL=${{ needs.prepare.outputs.directus_url }} \
|
||||
--build-arg NPM_TOKEN=${{ secrets.NPM_TOKEN }} \
|
||||
--secret id=NPM_TOKEN,src=/tmp/npm_token.txt \
|
||||
--progress plain \
|
||||
-t temp-image . > docker_build_failed.log 2>&1
|
||||
cat login.log >> docker_build_failed.log || true
|
||||
scp docker_build_failed.log root@alpha.mintel.me:/root/docker_build_failed.log
|
||||
# JOB 4: Deploy
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
deploy:
|
||||
name: 🚀 Deploy
|
||||
needs: [prepare, build, qa]
|
||||
needs: [prepare, build]
|
||||
runs-on: docker
|
||||
container:
|
||||
image: catthehacker/ubuntu:act-latest
|
||||
@@ -210,14 +241,9 @@ jobs:
|
||||
DIRECTUS_URL: ${{ needs.prepare.outputs.directus_url }}
|
||||
DIRECTUS_HOST: cms.${{ needs.prepare.outputs.traefik_host }}
|
||||
|
||||
# Database configuration
|
||||
postgres_DB_NAME: ${{ secrets.DIRECTUS_DB_NAME || vars.DIRECTUS_DB_NAME || 'directus' }}
|
||||
postgres_DB_USER: ${{ secrets.DIRECTUS_DB_USER || vars.DIRECTUS_DB_USER || 'directus' }}
|
||||
postgres_DB_PASSWORD: ${{ (needs.prepare.outputs.target == 'testing' && secrets.TESTING_DIRECTUS_DB_PASSWORD) || (needs.prepare.outputs.target == 'staging' && secrets.STAGING_DIRECTUS_DB_PASSWORD) || secrets.DIRECTUS_DB_PASSWORD || vars.DIRECTUS_DB_PASSWORD || 'directus' }}
|
||||
DATABASE_URI: postgres://${{ env.postgres_DB_USER }}:${{ env.postgres_DB_PASSWORD }}@postgres-db:5432/${{ env.postgres_DB_NAME }}
|
||||
PAYLOAD_SECRET: ${{ secrets.PAYLOAD_SECRET || vars.PAYLOAD_SECRET || 'secret' }}
|
||||
|
||||
# Secrets mapping (Mail)
|
||||
|
||||
# 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 }}
|
||||
@@ -235,6 +261,14 @@ jobs:
|
||||
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 || '#ff00ff' }}
|
||||
|
||||
# S3 Object Storage
|
||||
S3_ENDPOINT: ${{ secrets.S3_ENDPOINT || vars.S3_ENDPOINT || 'https://fsn1.your-objectstorage.com' }}
|
||||
S3_ACCESS_KEY: ${{ secrets.S3_ACCESS_KEY || vars.S3_ACCESS_KEY }}
|
||||
S3_SECRET_KEY: ${{ secrets.S3_SECRET_KEY || vars.S3_SECRET_KEY }}
|
||||
S3_BUCKET: ${{ secrets.S3_BUCKET || vars.S3_BUCKET || 'mintel' }}
|
||||
S3_REGION: ${{ secrets.S3_REGION || vars.S3_REGION || 'fsn1' }}
|
||||
S3_PREFIX: ${{ secrets.S3_PREFIX || vars.S3_PREFIX || github.event.repository.name }}
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
@@ -246,7 +280,6 @@ jobs:
|
||||
GATEKEEPER_HOST: gatekeeper.${{ 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"
|
||||
|
||||
@@ -254,15 +287,16 @@ jobs:
|
||||
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
|
||||
GATEKEEPER_ORIGIN="$NEXT_PUBLIC_BASE_URL/gatekeeper"
|
||||
|
||||
# Generate Environment File
|
||||
if [[ "$UMAMI_API_ENDPOINT" != http* ]]; then
|
||||
UMAMI_API_ENDPOINT="https://$UMAMI_API_ENDPOINT"
|
||||
fi
|
||||
|
||||
cat > .env.deploy << EOF
|
||||
# Generated by CI - $TARGET
|
||||
IMAGE_TAG=$IMAGE_TAG
|
||||
@@ -272,32 +306,26 @@ jobs:
|
||||
PROJECT_COLOR=$PROJECT_COLOR
|
||||
LOG_LEVEL=$LOG_LEVEL
|
||||
|
||||
# Payload DB
|
||||
postgres_DB_NAME=$postgres_DB_NAME
|
||||
postgres_DB_USER=$postgres_DB_USER
|
||||
postgres_DB_PASSWORD=$postgres_DB_PASSWORD
|
||||
DATABASE_URI=$DATABASE_URI
|
||||
PAYLOAD_SECRET=$PAYLOAD_SECRET
|
||||
|
||||
# Mail
|
||||
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
|
||||
|
||||
S3_ENDPOINT=$S3_ENDPOINT
|
||||
S3_ACCESS_KEY=$S3_ACCESS_KEY
|
||||
S3_SECRET_KEY=$S3_SECRET_KEY
|
||||
S3_BUCKET=$S3_BUCKET
|
||||
S3_REGION=$S3_REGION
|
||||
S3_PREFIX=$S3_PREFIX
|
||||
TARGET=$TARGET
|
||||
SKIP_RUNTIME_ENV_VALIDATION=true
|
||||
SENTRY_ENVIRONMENT=$TARGET
|
||||
PROJECT_NAME=$PROJECT_NAME
|
||||
ENV_FILE=$ENV_FILE
|
||||
@@ -305,6 +333,9 @@ jobs:
|
||||
TRAEFIK_HOST='$TRAEFIK_HOST'
|
||||
COMPOSE_PROFILES=$COMPOSE_PROFILES
|
||||
TRAEFIK_MIDDLEWARES=$AUTH_MIDDLEWARE
|
||||
TRAEFIK_ENTRYPOINT=websecure
|
||||
TRAEFIK_TLS=true
|
||||
TRAEFIK_CERT_RESOLVER=le
|
||||
EOF
|
||||
|
||||
- name: 🚀 SSH Deploy
|
||||
@@ -317,57 +348,166 @@ jobs:
|
||||
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/mintel.me"
|
||||
ssh root@alpha.mintel.me "mkdir -p $SITE_DIR/directus/schema $SITE_DIR/directus/uploads $SITE_DIR/directus/extensions"
|
||||
# SSH keepalive to prevent timeout during long docker pull
|
||||
cat > ~/.ssh/config <<SSHCFG
|
||||
Host alpha.mintel.me
|
||||
ServerAliveInterval 15
|
||||
ServerAliveCountMax 20
|
||||
ConnectTimeout 30
|
||||
SSHCFG
|
||||
chmod 600 ~/.ssh/config
|
||||
|
||||
if [[ "$TARGET" == "production" ]]; then
|
||||
SITE_DIR="/home/deploy/sites/mintel.me"
|
||||
elif [[ "$TARGET" == "testing" ]]; then
|
||||
SITE_DIR="/home/deploy/sites/testing.mintel.me"
|
||||
elif [[ "$TARGET" == "staging" ]]; then
|
||||
SITE_DIR="/home/deploy/sites/staging.mintel.me"
|
||||
else
|
||||
SITE_DIR="/home/deploy/sites/branch.mintel.me/${SLUG:-unknown}"
|
||||
fi
|
||||
|
||||
# Upload files
|
||||
ssh root@alpha.mintel.me "mkdir -p $SITE_DIR/directus/schema $SITE_DIR/directus/uploads $SITE_DIR/directus/extensions"
|
||||
scp .env.deploy root@alpha.mintel.me:$SITE_DIR/$ENV_FILE
|
||||
scp docker-compose.yml root@alpha.mintel.me:$SITE_DIR/docker-compose.yml
|
||||
|
||||
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"
|
||||
# Deploy
|
||||
echo "Testing available secrets against git.infra.mintel.me Docker registry..."
|
||||
TOKENS="${{ secrets.GITHUB_TOKEN }} ${{ secrets.GITEA_PAT }} ${{ secrets.MINTEL_PRIVATE_TOKEN }} ${{ secrets.NPM_TOKEN }}"
|
||||
USERS="${{ github.repository_owner }} ${{ github.actor }} marcmintel mintel mmintel"
|
||||
|
||||
VALID_TOKEN=""
|
||||
VALID_USER=""
|
||||
for T_RAW in $TOKENS; do
|
||||
if [ -n "$T_RAW" ]; then
|
||||
T=$(echo "$T_RAW" | tr -d ' ' | tr -d '\n' | tr -d '\r')
|
||||
for U in $USERS; do
|
||||
if [ -n "$U" ]; then
|
||||
echo "Attempting docker login for a token with user $U..."
|
||||
if echo "$T" | docker login git.infra.mintel.me -u "$U" --password-stdin > /dev/null 2>&1; then
|
||||
echo "✅ Successfully authenticated with a token."
|
||||
VALID_TOKEN="$T"
|
||||
VALID_USER="$U"
|
||||
break 2
|
||||
fi
|
||||
fi
|
||||
done
|
||||
fi
|
||||
done
|
||||
if [ -z "$VALID_TOKEN" ]; then echo "❌ All tokens failed to authenticate!"; exit 1; fi
|
||||
TOKEN="$VALID_TOKEN"
|
||||
|
||||
# Deploy — alpha is pre-logged into registry.infra.mintel.me, no credential passing needed
|
||||
ssh root@alpha.mintel.me "
|
||||
docker network create '${{ needs.prepare.outputs.project_name }}-internal' || true
|
||||
docker volume create 'mintel-me_payload-db-data' || true
|
||||
cd $SITE_DIR
|
||||
docker compose -p '${{ needs.prepare.outputs.project_name }}' --env-file $ENV_FILE pull
|
||||
docker compose -p '${{ needs.prepare.outputs.project_name }}' --env-file $ENV_FILE up -d --wait --remove-orphans
|
||||
"
|
||||
|
||||
ssh root@alpha.mintel.me "docker system prune -f --filter 'until=24h'"
|
||||
- name: 🧹 Purge S3 Cache
|
||||
shell: bash
|
||||
run: |
|
||||
echo "Installing rclone..."
|
||||
curl -s -O https://downloads.rclone.org/rclone-current-linux-amd64.deb
|
||||
sudo dpkg -i rclone-current-linux-amd64.deb > /dev/null 2>&1
|
||||
|
||||
echo "Configuring rclone..."
|
||||
cat > rclone.conf <<EOF
|
||||
[mintel-s3]
|
||||
type = s3
|
||||
provider = Other
|
||||
access_key_id = ${{ secrets.S3_ACCESS_KEY || vars.S3_ACCESS_KEY }}
|
||||
secret_access_key = ${{ secrets.S3_SECRET_KEY || vars.S3_SECRET_KEY }}
|
||||
endpoint = ${{ secrets.S3_ENDPOINT || vars.S3_ENDPOINT || 'https://fsn1.your-objectstorage.com' }}
|
||||
region = ${{ secrets.S3_REGION || vars.S3_REGION || 'fsn1' }}
|
||||
EOF
|
||||
|
||||
echo "Purging S3 cache for ${{ env.S3_PREFIX }} ..."
|
||||
rclone --config rclone.conf delete mintel-s3:${{ env.S3_BUCKET }}/${{ env.S3_PREFIX }}/cache/ --include "*" || true
|
||||
|
||||
rm rclone.conf rclone-current-linux-amd64.deb
|
||||
|
||||
- name: 🧹 Post-Deploy Cleanup (Runner)
|
||||
if: always()
|
||||
run: docker builder prune -f --filter "until=1h"
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# JOB 5: Health Check
|
||||
# JOB 5: Post-Deploy Verification
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
healthcheck:
|
||||
name: 🩺 Health Check
|
||||
post_deploy_checks:
|
||||
name: 🩺 Smoke Test
|
||||
needs: [prepare, deploy]
|
||||
if: needs.deploy.result == 'success'
|
||||
runs-on: docker
|
||||
if: needs.deploy.result == 'success'
|
||||
container:
|
||||
image: catthehacker/ubuntu:act-latest
|
||||
image: alpine:latest
|
||||
steps:
|
||||
- name: 🔍 Smoke Test
|
||||
- name: 🌐 Check Production URL
|
||||
shell: sh
|
||||
run: |
|
||||
apk add --no-cache curl
|
||||
# Wait longer (up to 2 minutes) for Next.js to fully prime
|
||||
COUNT=0
|
||||
MAX=24
|
||||
URL="${{ needs.prepare.outputs.next_public_url }}"
|
||||
echo "Checking health of $URL..."
|
||||
for i in {1..12}; do
|
||||
if curl -s -f "$URL" > /dev/null; then
|
||||
echo "✅ Health check passed!"
|
||||
exit 0
|
||||
|
||||
echo "Verifying $URL is responsive..."
|
||||
while [ $COUNT -lt $MAX ]; do
|
||||
STATUS=$(curl -s -o /dev/null -w "%{http_code}" "$URL/" || echo "000")
|
||||
if [ "$STATUS" = "200" ]; then
|
||||
echo "✅ Site is UP (200 OK)"
|
||||
break
|
||||
fi
|
||||
echo "Waiting for service to be ready... ($i/12)"
|
||||
sleep 10
|
||||
echo "⏳ Wait for 200 OK (Status: $STATUS)..."
|
||||
sleep 5
|
||||
COUNT=$((COUNT + 1))
|
||||
done
|
||||
echo "❌ Health check failed after 2 minutes."
|
||||
exit 1
|
||||
|
||||
if [ "$STATUS" != "200" ]; then
|
||||
echo "❌ Site failed smoke test after 2 minutes! (Status: $STATUS)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: 🌐 Check Case Study Assets and Rewrites
|
||||
shell: sh
|
||||
run: |
|
||||
BASE_URL="${{ needs.prepare.outputs.next_public_url }}"
|
||||
|
||||
echo "Verifying case study page..."
|
||||
STATUS=$(curl -s -o /dev/null -w "%{http_code}" "$BASE_URL/case-studies/klz-cables")
|
||||
if [ "$STATUS" != "200" ]; then
|
||||
echo "❌ Case study page failed! (Status: $STATUS)"
|
||||
exit 1
|
||||
fi
|
||||
echo "✅ Case study page UP"
|
||||
|
||||
echo "Verifying breeze CSS (root path)..."
|
||||
CSS_PATH="/assets/klz-cables.com/wp-content/cache/breeze-minification/css/breeze_klz-cables-com-1-10895.css"
|
||||
STATUS=$(curl -s -o /dev/null -w "%{http_code}" "$BASE_URL$CSS_PATH")
|
||||
if [ "$STATUS" != "200" ]; then
|
||||
echo "❌ Root asset path failed! (Status: $STATUS)"
|
||||
exit 1
|
||||
fi
|
||||
echo "✅ Root asset path OK"
|
||||
|
||||
echo "Verifying breeze CSS (relative path from case-study)..."
|
||||
REL_CSS_PATH="/case-studies/assets/klz-cables.com/wp-content/cache/breeze-minification/css/breeze_klz-cables-com-1-10895.css"
|
||||
STATUS=$(curl -s -o /dev/null -w "%{http_code}" "$BASE_URL$REL_CSS_PATH")
|
||||
if [ "$STATUS" != "200" ]; then
|
||||
echo "❌ Relative asset path failed! (Status: $STATUS)"
|
||||
exit 1
|
||||
fi
|
||||
echo "✅ Relative asset path OK"
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# JOB 6: Notifications
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
notifications:
|
||||
name: 🔔 Notify
|
||||
needs: [prepare, deploy, healthcheck]
|
||||
needs: [prepare, deploy, post_deploy_checks]
|
||||
if: always()
|
||||
runs-on: docker
|
||||
container:
|
||||
@@ -375,11 +515,20 @@ jobs:
|
||||
steps:
|
||||
- name: 🔔 Gotify
|
||||
run: |
|
||||
STATUS="${{ needs.deploy.result }}"
|
||||
TITLE="mintel.me: $STATUS"
|
||||
[[ "$STATUS" == "success" ]] && PRIORITY=5 || PRIORITY=8
|
||||
DEPLOY="${{ needs.deploy.result }}"
|
||||
SMOKE="${{ needs.post_deploy_checks.result }}"
|
||||
TARGET="${{ needs.prepare.outputs.target }}"
|
||||
VERSION="${{ needs.prepare.outputs.image_tag }}"
|
||||
|
||||
if [[ "$DEPLOY" == "success" ]]; then
|
||||
PRIORITY=5
|
||||
EMOJI="✅"
|
||||
else
|
||||
PRIORITY=8
|
||||
EMOJI="🚨"
|
||||
fi
|
||||
|
||||
curl -s -k -X POST "${{ secrets.GOTIFY_URL }}/message?token=${{ secrets.GOTIFY_TOKEN }}" \
|
||||
-F "title=$TITLE" \
|
||||
-F "message=Deploy to ${{ needs.prepare.outputs.target }} finished with status $STATUS.\nVersion: ${{ needs.prepare.outputs.image_tag }}" \
|
||||
-F "title=$EMOJI mintel.me $VERSION -> $TARGET" \
|
||||
-F "message=Deploy: $DEPLOY" \
|
||||
-F "priority=$PRIORITY" || true
|
||||
|
||||
15
.gitignore
vendored
15
.gitignore
vendored
@@ -3,6 +3,7 @@ dist/
|
||||
.next/
|
||||
out/
|
||||
.contentlayer/
|
||||
.pnpm-store
|
||||
|
||||
# generated types
|
||||
.astro/
|
||||
@@ -46,3 +47,17 @@ pnpm-debug.log*
|
||||
.cache/
|
||||
cloned-websites/
|
||||
storage/
|
||||
data/postgres/
|
||||
|
||||
# Estimation Engine Data
|
||||
data/crawls/
|
||||
apps/web/out/estimations/
|
||||
|
||||
# Backups
|
||||
backups/
|
||||
|
||||
.turbo
|
||||
# Manual build artifacts
|
||||
_at-mintel/
|
||||
local_build_*.log
|
||||
*.tar
|
||||
|
||||
5
.npmrc
5
.npmrc
@@ -1,3 +1,2 @@
|
||||
@mintel:registry=https://npm.infra.mintel.me/
|
||||
//npm.infra.mintel.me/:_authToken=${NPM_TOKEN}
|
||||
always-auth=true
|
||||
@mintel:registry=https://git.infra.mintel.me/api/packages/mmintel/npm/
|
||||
//git.infra.mintel.me/api/packages/mmintel/npm/:_authToken=263e7f75d8ada27f3a2e71fd6bd9d95298d48a4d
|
||||
|
||||
1
.turbo/cache/41a721a9104bd76c-meta.json
vendored
Normal file
1
.turbo/cache/41a721a9104bd76c-meta.json
vendored
Normal file
@@ -0,0 +1 @@
|
||||
{ "hash": "41a721a9104bd76c", "duration": 2524 }
|
||||
BIN
.turbo/cache/41a721a9104bd76c.tar.zst
vendored
Normal file
BIN
.turbo/cache/41a721a9104bd76c.tar.zst
vendored
Normal file
Binary file not shown.
1
.turbo/cache/441277b34176cf11-meta.json
vendored
Normal file
1
.turbo/cache/441277b34176cf11-meta.json
vendored
Normal file
@@ -0,0 +1 @@
|
||||
{ "hash": "441277b34176cf11", "duration": 2934 }
|
||||
BIN
.turbo/cache/441277b34176cf11.tar.zst
vendored
Normal file
BIN
.turbo/cache/441277b34176cf11.tar.zst
vendored
Normal file
Binary file not shown.
1
.turbo/cache/708dc951079154e6-meta.json
vendored
Normal file
1
.turbo/cache/708dc951079154e6-meta.json
vendored
Normal file
@@ -0,0 +1 @@
|
||||
{ "hash": "708dc951079154e6", "duration": 194 }
|
||||
BIN
.turbo/cache/708dc951079154e6.tar.zst
vendored
Normal file
BIN
.turbo/cache/708dc951079154e6.tar.zst
vendored
Normal file
Binary file not shown.
1
.turbo/cache/84b66091bfb55705-meta.json
vendored
Normal file
1
.turbo/cache/84b66091bfb55705-meta.json
vendored
Normal file
@@ -0,0 +1 @@
|
||||
{ "hash": "84b66091bfb55705", "duration": 2417 }
|
||||
BIN
.turbo/cache/84b66091bfb55705.tar.zst
vendored
Normal file
BIN
.turbo/cache/84b66091bfb55705.tar.zst
vendored
Normal file
Binary file not shown.
1
.turbo/cache/ba4a4a0aae882f7f-meta.json
vendored
Normal file
1
.turbo/cache/ba4a4a0aae882f7f-meta.json
vendored
Normal file
@@ -0,0 +1 @@
|
||||
{ "hash": "ba4a4a0aae882f7f", "duration": 5009 }
|
||||
BIN
.turbo/cache/ba4a4a0aae882f7f.tar.zst
vendored
Normal file
BIN
.turbo/cache/ba4a4a0aae882f7f.tar.zst
vendored
Normal file
Binary file not shown.
64
Dockerfile
64
Dockerfile
@@ -1,52 +1,90 @@
|
||||
# Stage 1: Builder
|
||||
FROM registry.infra.mintel.me/mintel/nextjs:latest AS builder
|
||||
FROM git.infra.mintel.me/mmintel/nextjs:latest AS builder
|
||||
WORKDIR /app
|
||||
|
||||
# Arguments for build-time configuration
|
||||
ARG NEXT_PUBLIC_BASE_URL
|
||||
ARG NEXT_PUBLIC_TARGET
|
||||
ARG DIRECTUS_URL
|
||||
ARG UMAMI_API_ENDPOINT
|
||||
ARG NPM_TOKEN
|
||||
ARG S3_ENDPOINT
|
||||
ARG S3_ACCESS_KEY
|
||||
ARG S3_SECRET_KEY
|
||||
ARG S3_BUCKET
|
||||
ARG S3_REGION
|
||||
ARG S3_PREFIX
|
||||
|
||||
ARG BUILD_ID
|
||||
|
||||
# Environment variables for Next.js build
|
||||
ENV NEXT_PUBLIC_BASE_URL=$NEXT_PUBLIC_BASE_URL
|
||||
ENV NEXT_PUBLIC_TARGET=$NEXT_PUBLIC_TARGET
|
||||
ENV DIRECTUS_URL=$DIRECTUS_URL
|
||||
ENV UMAMI_API_ENDPOINT=$UMAMI_API_ENDPOINT
|
||||
ENV S3_ENDPOINT=$S3_ENDPOINT
|
||||
ENV S3_ACCESS_KEY=$S3_ACCESS_KEY
|
||||
ENV S3_SECRET_KEY=$S3_SECRET_KEY
|
||||
ENV S3_BUCKET=$S3_BUCKET
|
||||
ENV S3_REGION=$S3_REGION
|
||||
ENV S3_PREFIX=$S3_PREFIX
|
||||
|
||||
ENV SKIP_RUNTIME_ENV_VALIDATION=true
|
||||
ENV NEXT_BUILD_WORKERS=1
|
||||
ENV NEXT_DISABLE_SOURCEMAPS=true
|
||||
ENV SENTRY_SKIP_LOCAL_SOURCES=true
|
||||
ENV NEXT_PRIVATE_LOCAL_WEBPACK=true
|
||||
ENV CI=true
|
||||
|
||||
# Copy manifest files specifically for better layer caching
|
||||
COPY pnpm-lock.yaml pnpm-workspace.yaml package.json .npmrc* ./
|
||||
COPY apps/web/package.json ./apps/web/package.json
|
||||
# Install build dependencies for native modules (like canvas)
|
||||
RUN apk add --no-cache python3 make g++ pkgconfig pixman-dev cairo-dev pango-dev libjpeg-turbo-dev giflib-dev librsvg-dev
|
||||
|
||||
# Copy sibling monorepo for linked dependencies (cloned during CI)
|
||||
# Placing it inside /app so relative links like ../../_at-mintel resolve correctly!
|
||||
COPY _at-mintel* /app/_at-mintel/
|
||||
|
||||
|
||||
# Install dependencies with cache mount and dynamic .npmrc (High Fidelity pattern)
|
||||
RUN --mount=type=cache,id=pnpm,target=/pnpm/store \
|
||||
--mount=type=secret,id=NPM_TOKEN \
|
||||
export NPM_TOKEN=$(cat /run/secrets/NPM_TOKEN 2>/dev/null || echo $NPM_TOKEN) && \
|
||||
echo "@mintel:registry=https://npm.infra.mintel.me" > .npmrc && \
|
||||
echo "//npm.infra.mintel.me/:_authToken=\${NPM_TOKEN}" >> .npmrc && \
|
||||
pnpm install --frozen-lockfile && \
|
||||
echo "//npm.infra.mintel.me/:_authToken=\"\${NPM_TOKEN}\"" >> .npmrc && \
|
||||
echo "always-auth=true" >> .npmrc && \
|
||||
cd /app/_at-mintel && pnpm install --no-frozen-lockfile && pnpm build && \
|
||||
cd /app && pnpm install --no-frozen-lockfile && \
|
||||
rm .npmrc
|
||||
|
||||
# Copy source code
|
||||
# We use BUILD_ID here to ensure that if the commit changes, we always COPY the latest files
|
||||
# even if Docker's metadata-based fingerprinting for the public directory fails.
|
||||
ARG BUILD_ID
|
||||
RUN echo "Building with ID: ${BUILD_ID}"
|
||||
COPY . .
|
||||
|
||||
# Build application (monorepo filter)
|
||||
RUN pnpm --filter @mintel/web build
|
||||
ENV NODE_OPTIONS="--max_old_space_size=4096"
|
||||
RUN NEXT_BUILD_WORKERS=1 pnpm --filter @mintel/web build
|
||||
|
||||
# Stage 2: Runner
|
||||
FROM registry.infra.mintel.me/mintel/runtime:latest AS runner
|
||||
FROM git.infra.mintel.me/mmintel/runtime:latest AS runner
|
||||
WORKDIR /app
|
||||
|
||||
# Copy standalone output and static files (Monorepo paths)
|
||||
# Note: Base image already handles the non-root user and basic env
|
||||
COPY --from=builder /app/apps/web/public ./apps/web/public
|
||||
COPY --from=builder /app/apps/web/.next/standalone ./
|
||||
COPY --from=builder /app/apps/web/.next/static ./apps/web/.next/static
|
||||
COPY --from=builder /app/apps/web/.next/cache ./apps/web/.next/cache
|
||||
COPY --from=builder --chown=1001:65533 /app/apps/web/public ./apps/web/public
|
||||
# Fallback target: Next.js sometimes resolves from the process root depending on version
|
||||
COPY --from=builder --chown=1001:65533 /app/apps/web/public ./public
|
||||
COPY --from=builder --chown=1001:65533 /app/apps/web/.next/standalone ./
|
||||
COPY --from=builder --chown=1001:65533 /app/apps/web/.next/static ./apps/web/.next/static
|
||||
|
||||
|
||||
|
||||
# Fix permissions for the non-root user (Standard uid/gid from base image)
|
||||
USER nextjs
|
||||
|
||||
# Start from the app directory to ensure references solve correctly
|
||||
WORKDIR /app/apps/web
|
||||
CMD ["node", "server.js"]
|
||||
# In Standalone mode, Next.js expects node_modules and public relative to the server.js
|
||||
WORKDIR /app
|
||||
CMD ["node", "apps/web/server.js"]
|
||||
|
||||
20
Dockerfile.dev
Normal file
20
Dockerfile.dev
Normal file
@@ -0,0 +1,20 @@
|
||||
FROM node:20-alpine
|
||||
|
||||
# Install essential build tools if needed (e.g., for node-gyp)
|
||||
RUN apk add --no-cache libc6-compat git python3 make g++
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Enable corepack for pnpm
|
||||
RUN corepack enable
|
||||
|
||||
# Pre-set the pnpm store directory to a location we can volume-mount
|
||||
ENV PNPM_HOME="/pnpm"
|
||||
ENV PATH="$PNPM_HOME:$PATH"
|
||||
|
||||
# Set up pnpm store configuration
|
||||
RUN pnpm config set store-dir /pnpm/store
|
||||
|
||||
# Note: Dependency installation happens at runtime to support linked packages
|
||||
# and named volumes, but the base image is now optimized for the stack.
|
||||
EXPOSE 3000
|
||||
@@ -1,4 +1,6 @@
|
||||
import type { CollectionConfig } from "payload";
|
||||
import { lexicalEditor, BlocksFeature } from "@payloadcms/richtext-lexical";
|
||||
import { allBlocks } from "../blocks/allBlocks";
|
||||
|
||||
export const Posts: CollectionConfig = {
|
||||
slug: "posts",
|
||||
@@ -9,6 +11,16 @@ export const Posts: CollectionConfig = {
|
||||
read: () => true, // Publicly readable API
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: "aiOptimizer",
|
||||
type: "ui",
|
||||
admin: {
|
||||
position: "sidebar",
|
||||
components: {
|
||||
Field: "@/src/payload/components/OptimizeButton#OptimizeButton",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "title",
|
||||
type: "text",
|
||||
@@ -59,16 +71,25 @@ export const Posts: CollectionConfig = {
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "thumbnail",
|
||||
type: "text", // Keeping as text for now to match current MDX strings like "/blog/green-it.png"
|
||||
name: "featuredImage",
|
||||
type: "upload",
|
||||
relationTo: "media",
|
||||
admin: {
|
||||
description: "The main hero image for the blog post.",
|
||||
position: "sidebar",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "content",
|
||||
type: "code",
|
||||
admin: {
|
||||
language: "markdown",
|
||||
},
|
||||
required: true,
|
||||
type: "richText",
|
||||
editor: lexicalEditor({
|
||||
features: ({ defaultFeatures }) => [
|
||||
...defaultFeatures,
|
||||
BlocksFeature({
|
||||
blocks: allBlocks,
|
||||
}),
|
||||
],
|
||||
}),
|
||||
},
|
||||
],
|
||||
};
|
||||
334
apps/web/.turbo/turbo-lint.log
Normal file
334
apps/web/.turbo/turbo-lint.log
Normal file
@@ -0,0 +1,334 @@
|
||||
|
||||
> @mintel/web@0.1.0 lint /Users/marcmintel/Projects/mintel.me/apps/web
|
||||
> eslint app src scripts video
|
||||
|
||||
|
||||
/Users/marcmintel/Projects/mintel.me/apps/web/app/(site)/about/page.tsx
|
||||
3:8 warning 'Image' is defined but never used. Allowed unused vars must match /^_/u @typescript-eslint/no-unused-vars
|
||||
9:3 warning 'ResultIllustration' is defined but never used. Allowed unused vars must match /^_/u @typescript-eslint/no-unused-vars
|
||||
11:3 warning 'HeroLines' is defined but never used. Allowed unused vars must match /^_/u @typescript-eslint/no-unused-vars
|
||||
12:3 warning 'ParticleNetwork' is defined but never used. Allowed unused vars must match /^_/u @typescript-eslint/no-unused-vars
|
||||
13:3 warning 'GridLines' is defined but never used. Allowed unused vars must match /^_/u @typescript-eslint/no-unused-vars
|
||||
16:10 warning 'Check' is defined but never used. Allowed unused vars must match /^_/u @typescript-eslint/no-unused-vars
|
||||
31:3 warning 'CodeSnippet' is defined but never used. Allowed unused vars must match /^_/u @typescript-eslint/no-unused-vars
|
||||
32:3 warning 'AbstractCircuit' is defined but never used. Allowed unused vars must match /^_/u @typescript-eslint/no-unused-vars
|
||||
53:21 warning Using `<img>` could result in slower LCP and higher bandwidth. Consider using `<Image />` from `next/image` or a custom image loader to automatically optimize images. This may incur additional usage or cost from your provider. See: https://nextjs.org/docs/messages/no-img-element @next/next/no-img-element
|
||||
|
||||
/Users/marcmintel/Projects/mintel.me/apps/web/app/(site)/case-studies/klz-cables/page.tsx
|
||||
8:3 warning 'H1' is defined but never used. Allowed unused vars must match /^_/u @typescript-eslint/no-unused-vars
|
||||
|
||||
/Users/marcmintel/Projects/mintel.me/apps/web/app/(site)/not-found.tsx
|
||||
6:8 warning 'Link' is defined but never used. Allowed unused vars must match /^_/u @typescript-eslint/no-unused-vars
|
||||
|
||||
/Users/marcmintel/Projects/mintel.me/apps/web/app/(site)/page.tsx
|
||||
18:3 warning 'MonoLabel' is defined but never used. Allowed unused vars must match /^_/u @typescript-eslint/no-unused-vars
|
||||
21:16 warning 'Container' is defined but never used. Allowed unused vars must match /^_/u @typescript-eslint/no-unused-vars
|
||||
23:24 warning 'CodeSnippet' is defined but never used. Allowed unused vars must match /^_/u @typescript-eslint/no-unused-vars
|
||||
24:10 warning 'IconList' is defined but never used. Allowed unused vars must match /^_/u @typescript-eslint/no-unused-vars
|
||||
24:20 warning 'IconListItem' is defined but never used. Allowed unused vars must match /^_/u @typescript-eslint/no-unused-vars
|
||||
|
||||
/Users/marcmintel/Projects/mintel.me/apps/web/app/(site)/technologies/[slug]/data.tsx
|
||||
1:24 warning 'Database' is defined but never used. Allowed unused vars must match /^_/u @typescript-eslint/no-unused-vars
|
||||
|
||||
/Users/marcmintel/Projects/mintel.me/apps/web/scripts/ai-estimate.ts
|
||||
8:10 warning 'fileURLToPath' is defined but never used. Allowed unused vars must match /^_/u @typescript-eslint/no-unused-vars
|
||||
|
||||
/Users/marcmintel/Projects/mintel.me/apps/web/scripts/check-og-images.ts
|
||||
19:11 warning 'body' is assigned a value but never used. Allowed unused vars must match /^_/u @typescript-eslint/no-unused-vars
|
||||
|
||||
/Users/marcmintel/Projects/mintel.me/apps/web/scripts/generate-thumbnail.ts
|
||||
28:18 warning 'e' is defined but never used. Allowed unused caught errors must match /^_/u @typescript-eslint/no-unused-vars
|
||||
|
||||
/Users/marcmintel/Projects/mintel.me/apps/web/scripts/migrate-posts.ts
|
||||
107:18 warning 'e' is defined but never used. Allowed unused caught errors must match /^_/u @typescript-eslint/no-unused-vars
|
||||
|
||||
/Users/marcmintel/Projects/mintel.me/apps/web/scripts/pagespeed-sitemap.ts
|
||||
109:14 warning 'err' is defined but never used. Allowed unused caught errors must match /^_/u @typescript-eslint/no-unused-vars
|
||||
|
||||
/Users/marcmintel/Projects/mintel.me/apps/web/src/components/ArticleMeme.tsx
|
||||
110:21 warning Using `<img>` could result in slower LCP and higher bandwidth. Consider using `<Image />` from `next/image` or a custom image loader to automatically optimize images. This may incur additional usage or cost from your provider. See: https://nextjs.org/docs/messages/no-img-element @next/next/no-img-element
|
||||
|
||||
/Users/marcmintel/Projects/mintel.me/apps/web/src/components/ArticleQuote.tsx
|
||||
20:5 warning 'role' is defined but never used. Allowed unused args must match /^_/u @typescript-eslint/no-unused-vars
|
||||
|
||||
/Users/marcmintel/Projects/mintel.me/apps/web/src/components/BlogOGImageTemplate.tsx
|
||||
41:17 warning Using `<img>` could result in slower LCP and higher bandwidth. Consider using `<Image />` from `next/image` or a custom image loader to automatically optimize images. This may incur additional usage or cost from your provider. See: https://nextjs.org/docs/messages/no-img-element @next/next/no-img-element
|
||||
|
||||
/Users/marcmintel/Projects/mintel.me/apps/web/src/components/CombinedQuotePDF.tsx
|
||||
30:9 warning 'date' is assigned a value but never used. Allowed unused vars must match /^_/u @typescript-eslint/no-unused-vars
|
||||
|
||||
/Users/marcmintel/Projects/mintel.me/apps/web/src/components/ComponentShareButton.tsx
|
||||
126:30 warning 'e' is defined but never used. Allowed unused caught errors must match /^_/u @typescript-eslint/no-unused-vars
|
||||
|
||||
/Users/marcmintel/Projects/mintel.me/apps/web/src/components/ContactForm/Configurator/ConfiguratorLayout.tsx
|
||||
24:3 warning 'title' is defined but never used. Allowed unused args must match /^_/u @typescript-eslint/no-unused-vars
|
||||
|
||||
/Users/marcmintel/Projects/mintel.me/apps/web/src/components/ContactForm/Configurator/ReferenceInput.tsx
|
||||
7:10 warning 'cn' is defined but never used. Allowed unused vars must match /^_/u @typescript-eslint/no-unused-vars
|
||||
|
||||
/Users/marcmintel/Projects/mintel.me/apps/web/src/components/ContactForm/DirectMessageFlow.tsx
|
||||
3:10 warning 'motion' is defined but never used. Allowed unused vars must match /^_/u @typescript-eslint/no-unused-vars
|
||||
|
||||
/Users/marcmintel/Projects/mintel.me/apps/web/src/components/ContactForm/EmailTemplates.tsx
|
||||
1:13 warning 'React' is defined but never used. Allowed unused vars must match /^_/u @typescript-eslint/no-unused-vars
|
||||
|
||||
/Users/marcmintel/Projects/mintel.me/apps/web/src/components/ContactForm/pdf/LocalEstimationPDF.tsx
|
||||
94:9 warning 'getPageNum' is assigned a value but never used. Allowed unused vars must match /^_/u @typescript-eslint/no-unused-vars
|
||||
|
||||
/Users/marcmintel/Projects/mintel.me/apps/web/src/components/ContactForm/steps/BaseStep.tsx
|
||||
13:3 warning 'HelpCircle' is defined but never used. Allowed unused vars must match /^_/u @typescript-eslint/no-unused-vars
|
||||
14:3 warning 'ArrowRight' is defined but never used. Allowed unused vars must match /^_/u @typescript-eslint/no-unused-vars
|
||||
|
||||
/Users/marcmintel/Projects/mintel.me/apps/web/src/components/ContactForm/steps/ContentStep.tsx
|
||||
103:25 warning 'index' is defined but never used. Allowed unused args must match /^_/u @typescript-eslint/no-unused-vars
|
||||
|
||||
/Users/marcmintel/Projects/mintel.me/apps/web/src/components/ContactForm/steps/DesignStep.tsx
|
||||
7:19 warning 'Palette' is defined but never used. Allowed unused vars must match /^_/u @typescript-eslint/no-unused-vars
|
||||
104:38 warning 'index' is defined but never used. Allowed unused args must match /^_/u @typescript-eslint/no-unused-vars
|
||||
|
||||
/Users/marcmintel/Projects/mintel.me/apps/web/src/components/ContactForm/steps/FeaturesStep.tsx
|
||||
8:18 warning 'AnimatePresence' is defined but never used. Allowed unused vars must match /^_/u @typescript-eslint/no-unused-vars
|
||||
9:10 warning 'Minus' is defined but never used. Allowed unused vars must match /^_/u @typescript-eslint/no-unused-vars
|
||||
9:17 warning 'Plus' is defined but never used. Allowed unused vars must match /^_/u @typescript-eslint/no-unused-vars
|
||||
|
||||
/Users/marcmintel/Projects/mintel.me/apps/web/src/components/ContactForm/steps/FunctionsStep.tsx
|
||||
7:18 warning 'AnimatePresence' is defined but never used. Allowed unused vars must match /^_/u @typescript-eslint/no-unused-vars
|
||||
8:10 warning 'Minus' is defined but never used. Allowed unused vars must match /^_/u @typescript-eslint/no-unused-vars
|
||||
8:17 warning 'Plus' is defined but never used. Allowed unused vars must match /^_/u @typescript-eslint/no-unused-vars
|
||||
|
||||
/Users/marcmintel/Projects/mintel.me/apps/web/src/components/ContactForm/steps/LanguageStep.tsx
|
||||
5:23 warning 'Plus' is defined but never used. Allowed unused vars must match /^_/u @typescript-eslint/no-unused-vars
|
||||
125:31 warning 'i' is defined but never used. Allowed unused args must match /^_/u @typescript-eslint/no-unused-vars
|
||||
|
||||
/Users/marcmintel/Projects/mintel.me/apps/web/src/components/ContactForm/steps/PresenceStep.tsx
|
||||
5:10 warning 'Checkbox' is defined but never used. Allowed unused vars must match /^_/u @typescript-eslint/no-unused-vars
|
||||
|
||||
/Users/marcmintel/Projects/mintel.me/apps/web/src/components/DiagramShareButton.tsx
|
||||
28:9 warning 'generateDiagramImage' is assigned a value but never used. Allowed unused vars must match /^_/u @typescript-eslint/no-unused-vars
|
||||
|
||||
/Users/marcmintel/Projects/mintel.me/apps/web/src/components/DiagramState.tsx
|
||||
25:3 warning 'states' is defined but never used. Allowed unused args must match /^_/u @typescript-eslint/no-unused-vars
|
||||
|
||||
/Users/marcmintel/Projects/mintel.me/apps/web/src/components/Effects/CMSVisualizer.tsx
|
||||
8:3 warning 'Edit3' is defined but never used. Allowed unused vars must match /^_/u @typescript-eslint/no-unused-vars
|
||||
|
||||
/Users/marcmintel/Projects/mintel.me/apps/web/src/components/Effects/CircuitBoard.tsx
|
||||
120:9 warning 'drawTrace' is assigned a value but never used. Allowed unused vars must match /^_/u @typescript-eslint/no-unused-vars
|
||||
130:13 warning 'midX' is assigned a value but never used. Allowed unused vars must match /^_/u @typescript-eslint/no-unused-vars
|
||||
131:13 warning 'midY' is assigned a value but never used. Allowed unused vars must match /^_/u @typescript-eslint/no-unused-vars
|
||||
|
||||
/Users/marcmintel/Projects/mintel.me/apps/web/src/components/FAQSection.tsx
|
||||
5:10 warning 'Paragraph' is defined but never used. Allowed unused vars must match /^_/u @typescript-eslint/no-unused-vars
|
||||
7:11 warning 'FAQItem' is defined but never used. Allowed unused vars must match /^_/u @typescript-eslint/no-unused-vars
|
||||
|
||||
/Users/marcmintel/Projects/mintel.me/apps/web/src/components/FileExample.tsx
|
||||
3:27 warning 'useRef' is defined but never used. Allowed unused vars must match /^_/u @typescript-eslint/no-unused-vars
|
||||
|
||||
/Users/marcmintel/Projects/mintel.me/apps/web/src/components/IframeSection.tsx
|
||||
207:18 warning Empty block statement no-empty
|
||||
252:18 warning Empty block statement no-empty
|
||||
545:30 warning 'e' is defined but never used. Allowed unused caught errors must match /^_/u @typescript-eslint/no-unused-vars
|
||||
|
||||
/Users/marcmintel/Projects/mintel.me/apps/web/src/components/ImageText.tsx
|
||||
25:17 warning Using `<img>` could result in slower LCP and higher bandwidth. Consider using `<Image />` from `next/image` or a custom image loader to automatically optimize images. This may incur additional usage or cost from your provider. See: https://nextjs.org/docs/messages/no-img-element @next/next/no-img-element
|
||||
|
||||
/Users/marcmintel/Projects/mintel.me/apps/web/src/components/MediumCard.tsx
|
||||
3:10 warning 'Card' is defined but never used. Allowed unused vars must match /^_/u @typescript-eslint/no-unused-vars
|
||||
34:13 warning Using `<img>` could result in slower LCP and higher bandwidth. Consider using `<Image />` from `next/image` or a custom image loader to automatically optimize images. This may incur additional usage or cost from your provider. See: https://nextjs.org/docs/messages/no-img-element @next/next/no-img-element
|
||||
|
||||
/Users/marcmintel/Projects/mintel.me/apps/web/src/components/Mermaid.tsx
|
||||
248:18 warning 'err' is defined but never used. Allowed unused caught errors must match /^_/u @typescript-eslint/no-unused-vars
|
||||
|
||||
/Users/marcmintel/Projects/mintel.me/apps/web/src/components/PayloadRichText.tsx
|
||||
177:31 warning 'node' is defined but never used. Allowed unused args must match /^_/u @typescript-eslint/no-unused-vars
|
||||
180:26 warning 'node' is defined but never used. Allowed unused args must match /^_/u @typescript-eslint/no-unused-vars
|
||||
181:34 warning 'node' is defined but never used. Allowed unused args must match /^_/u @typescript-eslint/no-unused-vars
|
||||
186:27 warning 'node' is defined but never used. Allowed unused args must match /^_/u @typescript-eslint/no-unused-vars
|
||||
191:29 warning 'node' is defined but never used. Allowed unused args must match /^_/u @typescript-eslint/no-unused-vars
|
||||
196:32 warning 'node' is defined but never used. Allowed unused args must match /^_/u @typescript-eslint/no-unused-vars
|
||||
|
||||
/Users/marcmintel/Projects/mintel.me/apps/web/src/components/ShareModal.tsx
|
||||
7:8 warning 'IconBlack' is defined but never used. Allowed unused vars must match /^_/u @typescript-eslint/no-unused-vars
|
||||
181:23 warning Using `<img>` could result in slower LCP and higher bandwidth. Consider using `<Image />` from `next/image` or a custom image loader to automatically optimize images. This may incur additional usage or cost from your provider. See: https://nextjs.org/docs/messages/no-img-element @next/next/no-img-element
|
||||
231:21 warning Using `<img>` could result in slower LCP and higher bandwidth. Consider using `<Image />` from `next/image` or a custom image loader to automatically optimize images. This may incur additional usage or cost from your provider. See: https://nextjs.org/docs/messages/no-img-element @next/next/no-img-element
|
||||
258:13 warning Using `<img>` could result in slower LCP and higher bandwidth. Consider using `<Image />` from `next/image` or a custom image loader to automatically optimize images. This may incur additional usage or cost from your provider. See: https://nextjs.org/docs/messages/no-img-element @next/next/no-img-element
|
||||
|
||||
/Users/marcmintel/Projects/mintel.me/apps/web/src/components/blog/BlogClient.tsx
|
||||
27:11 warning 'trackEvent' is assigned a value but never used. Allowed unused vars must match /^_/u @typescript-eslint/no-unused-vars
|
||||
|
||||
/Users/marcmintel/Projects/mintel.me/apps/web/src/components/blog/BlogPostHeader.tsx
|
||||
54:17 warning Using `<img>` could result in slower LCP and higher bandwidth. Consider using `<Image />` from `next/image` or a custom image loader to automatically optimize images. This may incur additional usage or cost from your provider. See: https://nextjs.org/docs/messages/no-img-element @next/next/no-img-element
|
||||
|
||||
/Users/marcmintel/Projects/mintel.me/apps/web/src/migrations/20260227_171023_crm_collections.ts
|
||||
3:32 warning 'payload' is defined but never used. Allowed unused args must match /^_/u @typescript-eslint/no-unused-vars
|
||||
3:41 warning 'req' is defined but never used. Allowed unused args must match /^_/u @typescript-eslint/no-unused-vars
|
||||
360:3 warning 'payload' is defined but never used. Allowed unused args must match /^_/u @typescript-eslint/no-unused-vars
|
||||
361:3 warning 'req' is defined but never used. Allowed unused args must match /^_/u @typescript-eslint/no-unused-vars
|
||||
|
||||
/Users/marcmintel/Projects/mintel.me/apps/web/src/migrations/20260301_151838.ts
|
||||
3:32 warning 'payload' is defined but never used. Allowed unused args must match /^_/u @typescript-eslint/no-unused-vars
|
||||
3:41 warning 'req' is defined but never used. Allowed unused args must match /^_/u @typescript-eslint/no-unused-vars
|
||||
110:3 warning 'payload' is defined but never used. Allowed unused args must match /^_/u @typescript-eslint/no-unused-vars
|
||||
111:3 warning 'req' is defined but never used. Allowed unused args must match /^_/u @typescript-eslint/no-unused-vars
|
||||
|
||||
/Users/marcmintel/Projects/mintel.me/apps/web/src/payload/actions/generateField.ts
|
||||
3:10 warning 'config' is defined but never used. Allowed unused vars must match /^_/u @typescript-eslint/no-unused-vars
|
||||
|
||||
/Users/marcmintel/Projects/mintel.me/apps/web/src/payload/actions/optimizePost.ts
|
||||
4:10 warning 'revalidatePath' is defined but never used. Allowed unused vars must match /^_/u @typescript-eslint/no-unused-vars
|
||||
|
||||
/Users/marcmintel/Projects/mintel.me/apps/web/src/payload/blocks/ArchitectureBuilderBlock.ts
|
||||
3:15 warning 'Block' is defined but never used. Allowed unused vars must match /^_/u @typescript-eslint/no-unused-vars
|
||||
|
||||
/Users/marcmintel/Projects/mintel.me/apps/web/src/payload/blocks/ArticleBlockquoteBlock.ts
|
||||
3:15 warning 'Block' is defined but never used. Allowed unused vars must match /^_/u @typescript-eslint/no-unused-vars
|
||||
|
||||
/Users/marcmintel/Projects/mintel.me/apps/web/src/payload/blocks/ArticleMemeBlock.ts
|
||||
2:15 warning 'Block' is defined but never used. Allowed unused vars must match /^_/u @typescript-eslint/no-unused-vars
|
||||
|
||||
/Users/marcmintel/Projects/mintel.me/apps/web/src/payload/blocks/ArticleQuoteBlock.ts
|
||||
3:15 warning 'Block' is defined but never used. Allowed unused vars must match /^_/u @typescript-eslint/no-unused-vars
|
||||
|
||||
/Users/marcmintel/Projects/mintel.me/apps/web/src/payload/blocks/BoldNumberBlock.ts
|
||||
3:15 warning 'Block' is defined but never used. Allowed unused vars must match /^_/u @typescript-eslint/no-unused-vars
|
||||
|
||||
/Users/marcmintel/Projects/mintel.me/apps/web/src/payload/blocks/ButtonBlock.ts
|
||||
3:15 warning 'Block' is defined but never used. Allowed unused vars must match /^_/u @typescript-eslint/no-unused-vars
|
||||
|
||||
/Users/marcmintel/Projects/mintel.me/apps/web/src/payload/blocks/CarouselBlock.ts
|
||||
3:15 warning 'Block' is defined but never used. Allowed unused vars must match /^_/u @typescript-eslint/no-unused-vars
|
||||
|
||||
/Users/marcmintel/Projects/mintel.me/apps/web/src/payload/blocks/ComparisonRowBlock.ts
|
||||
3:15 warning 'Block' is defined but never used. Allowed unused vars must match /^_/u @typescript-eslint/no-unused-vars
|
||||
|
||||
/Users/marcmintel/Projects/mintel.me/apps/web/src/payload/blocks/DiagramFlowBlock.ts
|
||||
3:15 warning 'Block' is defined but never used. Allowed unused vars must match /^_/u @typescript-eslint/no-unused-vars
|
||||
|
||||
/Users/marcmintel/Projects/mintel.me/apps/web/src/payload/blocks/DiagramGanttBlock.ts
|
||||
3:15 warning 'Block' is defined but never used. Allowed unused vars must match /^_/u @typescript-eslint/no-unused-vars
|
||||
|
||||
/Users/marcmintel/Projects/mintel.me/apps/web/src/payload/blocks/DiagramPieBlock.ts
|
||||
3:15 warning 'Block' is defined but never used. Allowed unused vars must match /^_/u @typescript-eslint/no-unused-vars
|
||||
|
||||
/Users/marcmintel/Projects/mintel.me/apps/web/src/payload/blocks/DiagramSequenceBlock.ts
|
||||
3:15 warning 'Block' is defined but never used. Allowed unused vars must match /^_/u @typescript-eslint/no-unused-vars
|
||||
|
||||
/Users/marcmintel/Projects/mintel.me/apps/web/src/payload/blocks/DiagramStateBlock.ts
|
||||
3:15 warning 'Block' is defined but never used. Allowed unused vars must match /^_/u @typescript-eslint/no-unused-vars
|
||||
|
||||
/Users/marcmintel/Projects/mintel.me/apps/web/src/payload/blocks/DiagramTimelineBlock.ts
|
||||
3:15 warning 'Block' is defined but never used. Allowed unused vars must match /^_/u @typescript-eslint/no-unused-vars
|
||||
|
||||
/Users/marcmintel/Projects/mintel.me/apps/web/src/payload/blocks/DigitalAssetVisualizerBlock.ts
|
||||
3:15 warning 'Block' is defined but never used. Allowed unused vars must match /^_/u @typescript-eslint/no-unused-vars
|
||||
|
||||
/Users/marcmintel/Projects/mintel.me/apps/web/src/payload/blocks/ExternalLinkBlock.ts
|
||||
3:15 warning 'Block' is defined but never used. Allowed unused vars must match /^_/u @typescript-eslint/no-unused-vars
|
||||
|
||||
/Users/marcmintel/Projects/mintel.me/apps/web/src/payload/blocks/FAQSectionBlock.ts
|
||||
3:15 warning 'Block' is defined but never used. Allowed unused vars must match /^_/u @typescript-eslint/no-unused-vars
|
||||
39:22 warning 'ai' is defined but never used. Allowed unused args must match /^_/u @typescript-eslint/no-unused-vars
|
||||
39:26 warning 'render' is defined but never used. Allowed unused args must match /^_/u @typescript-eslint/no-unused-vars
|
||||
|
||||
/Users/marcmintel/Projects/mintel.me/apps/web/src/payload/blocks/IconListBlock.ts
|
||||
3:15 warning 'Block' is defined but never used. Allowed unused vars must match /^_/u @typescript-eslint/no-unused-vars
|
||||
|
||||
/Users/marcmintel/Projects/mintel.me/apps/web/src/payload/blocks/ImageTextBlock.ts
|
||||
3:15 warning 'Block' is defined but never used. Allowed unused vars must match /^_/u @typescript-eslint/no-unused-vars
|
||||
|
||||
/Users/marcmintel/Projects/mintel.me/apps/web/src/payload/blocks/LeadMagnetBlock.ts
|
||||
3:15 warning 'Block' is defined but never used. Allowed unused vars must match /^_/u @typescript-eslint/no-unused-vars
|
||||
|
||||
/Users/marcmintel/Projects/mintel.me/apps/web/src/payload/blocks/LeadParagraphBlock.ts
|
||||
3:15 warning 'Block' is defined but never used. Allowed unused vars must match /^_/u @typescript-eslint/no-unused-vars
|
||||
|
||||
/Users/marcmintel/Projects/mintel.me/apps/web/src/payload/blocks/LinkedInEmbedBlock.ts
|
||||
3:15 warning 'Block' is defined but never used. Allowed unused vars must match /^_/u @typescript-eslint/no-unused-vars
|
||||
|
||||
/Users/marcmintel/Projects/mintel.me/apps/web/src/payload/blocks/LoadTimeSimulatorBlock.ts
|
||||
3:15 warning 'Block' is defined but never used. Allowed unused vars must match /^_/u @typescript-eslint/no-unused-vars
|
||||
|
||||
/Users/marcmintel/Projects/mintel.me/apps/web/src/payload/blocks/MarkerBlock.ts
|
||||
3:15 warning 'Block' is defined but never used. Allowed unused vars must match /^_/u @typescript-eslint/no-unused-vars
|
||||
|
||||
/Users/marcmintel/Projects/mintel.me/apps/web/src/payload/blocks/MemeCardBlock.ts
|
||||
2:15 warning 'Block' is defined but never used. Allowed unused vars must match /^_/u @typescript-eslint/no-unused-vars
|
||||
|
||||
/Users/marcmintel/Projects/mintel.me/apps/web/src/payload/blocks/MermaidBlock.ts
|
||||
3:15 warning 'Block' is defined but never used. Allowed unused vars must match /^_/u @typescript-eslint/no-unused-vars
|
||||
|
||||
/Users/marcmintel/Projects/mintel.me/apps/web/src/payload/blocks/MetricBarBlock.ts
|
||||
3:15 warning 'Block' is defined but never used. Allowed unused vars must match /^_/u @typescript-eslint/no-unused-vars
|
||||
|
||||
/Users/marcmintel/Projects/mintel.me/apps/web/src/payload/blocks/ParagraphBlock.ts
|
||||
3:15 warning 'Block' is defined but never used. Allowed unused vars must match /^_/u @typescript-eslint/no-unused-vars
|
||||
|
||||
/Users/marcmintel/Projects/mintel.me/apps/web/src/payload/blocks/PerformanceChartBlock.ts
|
||||
3:15 warning 'Block' is defined but never used. Allowed unused vars must match /^_/u @typescript-eslint/no-unused-vars
|
||||
|
||||
/Users/marcmintel/Projects/mintel.me/apps/web/src/payload/blocks/PerformanceROICalculatorBlock.ts
|
||||
3:15 warning 'Block' is defined but never used. Allowed unused vars must match /^_/u @typescript-eslint/no-unused-vars
|
||||
|
||||
/Users/marcmintel/Projects/mintel.me/apps/web/src/payload/blocks/PremiumComparisonChartBlock.ts
|
||||
3:15 warning 'Block' is defined but never used. Allowed unused vars must match /^_/u @typescript-eslint/no-unused-vars
|
||||
|
||||
/Users/marcmintel/Projects/mintel.me/apps/web/src/payload/blocks/RevealBlock.ts
|
||||
3:15 warning 'Block' is defined but never used. Allowed unused vars must match /^_/u @typescript-eslint/no-unused-vars
|
||||
|
||||
/Users/marcmintel/Projects/mintel.me/apps/web/src/payload/blocks/RevenueLossCalculatorBlock.ts
|
||||
3:15 warning 'Block' is defined but never used. Allowed unused vars must match /^_/u @typescript-eslint/no-unused-vars
|
||||
|
||||
/Users/marcmintel/Projects/mintel.me/apps/web/src/payload/blocks/SectionBlock.ts
|
||||
3:15 warning 'Block' is defined but never used. Allowed unused vars must match /^_/u @typescript-eslint/no-unused-vars
|
||||
|
||||
/Users/marcmintel/Projects/mintel.me/apps/web/src/payload/blocks/StatsDisplayBlock.ts
|
||||
3:15 warning 'Block' is defined but never used. Allowed unused vars must match /^_/u @typescript-eslint/no-unused-vars
|
||||
|
||||
/Users/marcmintel/Projects/mintel.me/apps/web/src/payload/blocks/StatsGridBlock.ts
|
||||
3:15 warning 'Block' is defined but never used. Allowed unused vars must match /^_/u @typescript-eslint/no-unused-vars
|
||||
|
||||
/Users/marcmintel/Projects/mintel.me/apps/web/src/payload/blocks/TLDRBlock.ts
|
||||
2:15 warning 'Block' is defined but never used. Allowed unused vars must match /^_/u @typescript-eslint/no-unused-vars
|
||||
|
||||
/Users/marcmintel/Projects/mintel.me/apps/web/src/payload/blocks/TrackedLinkBlock.ts
|
||||
3:15 warning 'Block' is defined but never used. Allowed unused vars must match /^_/u @typescript-eslint/no-unused-vars
|
||||
|
||||
/Users/marcmintel/Projects/mintel.me/apps/web/src/payload/blocks/TwitterEmbedBlock.ts
|
||||
3:15 warning 'Block' is defined but never used. Allowed unused vars must match /^_/u @typescript-eslint/no-unused-vars
|
||||
|
||||
/Users/marcmintel/Projects/mintel.me/apps/web/src/payload/blocks/WaterfallChartBlock.ts
|
||||
3:15 warning 'Block' is defined but never used. Allowed unused vars must match /^_/u @typescript-eslint/no-unused-vars
|
||||
|
||||
/Users/marcmintel/Projects/mintel.me/apps/web/src/payload/blocks/WebVitalsScoreBlock.ts
|
||||
3:15 warning 'Block' is defined but never used. Allowed unused vars must match /^_/u @typescript-eslint/no-unused-vars
|
||||
|
||||
/Users/marcmintel/Projects/mintel.me/apps/web/src/payload/blocks/YouTubeEmbedBlock.ts
|
||||
3:15 warning 'Block' is defined but never used. Allowed unused vars must match /^_/u @typescript-eslint/no-unused-vars
|
||||
|
||||
/Users/marcmintel/Projects/mintel.me/apps/web/src/payload/blocks/allBlocks.ts
|
||||
100:47 warning 'ai' is defined but never used. Allowed unused args must match /^_/u @typescript-eslint/no-unused-vars
|
||||
100:51 warning 'render' is defined but never used. Allowed unused args must match /^_/u @typescript-eslint/no-unused-vars
|
||||
|
||||
/Users/marcmintel/Projects/mintel.me/apps/web/src/payload/collections/ContextFiles.ts
|
||||
2:8 warning 'fs' is defined but never used. Allowed unused vars must match /^_/u @typescript-eslint/no-unused-vars
|
||||
27:10 warning 'doc' is defined but never used. Allowed unused args must match /^_/u @typescript-eslint/no-unused-vars
|
||||
27:15 warning 'operation' is defined but never used. Allowed unused args must match /^_/u @typescript-eslint/no-unused-vars
|
||||
|
||||
/Users/marcmintel/Projects/mintel.me/apps/web/src/payload/components/FieldGenerators/AiFieldButton.tsx
|
||||
13:11 warning 'value' is assigned a value but never used. Allowed unused vars must match /^_/u @typescript-eslint/no-unused-vars
|
||||
59:14 warning 'e' is defined but never used. Allowed unused caught errors must match /^_/u @typescript-eslint/no-unused-vars
|
||||
|
||||
/Users/marcmintel/Projects/mintel.me/apps/web/src/payload/components/FieldGenerators/GenerateSlugButton.tsx
|
||||
6:10 warning 'Button' is defined but never used. Allowed unused vars must match /^_/u @typescript-eslint/no-unused-vars
|
||||
23:19 warning 'replaceState' is assigned a value but never used. Allowed unused vars must match /^_/u @typescript-eslint/no-unused-vars
|
||||
24:11 warning 'value' is assigned a value but never used. Allowed unused vars must match /^_/u @typescript-eslint/no-unused-vars
|
||||
|
||||
/Users/marcmintel/Projects/mintel.me/apps/web/src/payload/components/FieldGenerators/GenerateThumbnailButton.tsx
|
||||
6:10 warning 'Button' is defined but never used. Allowed unused vars must match /^_/u @typescript-eslint/no-unused-vars
|
||||
24:11 warning 'value' is assigned a value but never used. Allowed unused vars must match /^_/u @typescript-eslint/no-unused-vars
|
||||
|
||||
/Users/marcmintel/Projects/mintel.me/apps/web/src/payload/components/OptimizeButton.tsx
|
||||
6:10 warning 'Button' is defined but never used. Allowed unused vars must match /^_/u @typescript-eslint/no-unused-vars
|
||||
|
||||
✖ 141 problems (0 errors, 141 warnings)
|
||||
|
||||
5
apps/web/.turbo/turbo-test.log
Normal file
5
apps/web/.turbo/turbo-test.log
Normal file
@@ -0,0 +1,5 @@
|
||||
|
||||
> @mintel/web@0.1.0 test /Users/marcmintel/Projects/mintel.me/apps/web
|
||||
> echo "No tests configured"
|
||||
|
||||
No tests configured
|
||||
4
apps/web/.turbo/turbo-typecheck.log
Normal file
4
apps/web/.turbo/turbo-typecheck.log
Normal file
@@ -0,0 +1,4 @@
|
||||
|
||||
> @mintel/web@0.1.0 typecheck /Users/marcmintel/Projects/mintel.me/apps/web
|
||||
> tsc --noEmit
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
"use server";
|
||||
import { handleServerFunctions as payloadHandleServerFunctions } from "@payloadcms/next/layouts";
|
||||
|
||||
export const handleServerFunctions = payloadHandleServerFunctions;
|
||||
@@ -1,25 +0,0 @@
|
||||
import type { Metadata } from "next";
|
||||
|
||||
import configPromise from "@payload-config";
|
||||
import { RootPage, generatePageMetadata } from "@payloadcms/next/views";
|
||||
import { importMap } from "../importMap";
|
||||
|
||||
type Args = {
|
||||
params: Promise<{
|
||||
segments: string[];
|
||||
}>;
|
||||
searchParams: Promise<{
|
||||
[key: string]: string | string[];
|
||||
}>;
|
||||
};
|
||||
|
||||
export const generateMetadata = async ({
|
||||
params,
|
||||
searchParams,
|
||||
}: Args): Promise<Metadata> =>
|
||||
generatePageMetadata({ config: configPromise, params, searchParams });
|
||||
|
||||
const Page = async ({ params, searchParams }: Args) =>
|
||||
RootPage({ config: configPromise, importMap, params, searchParams });
|
||||
|
||||
export default Page;
|
||||
@@ -1,6 +0,0 @@
|
||||
import { CollectionCards as CollectionCards_f9c02e79a4aed9a3924487c0cd4cafb1 } from "@payloadcms/next/rsc";
|
||||
|
||||
export const importMap = {
|
||||
"@payloadcms/next/rsc#CollectionCards":
|
||||
CollectionCards_f9c02e79a4aed9a3924487c0cd4cafb1,
|
||||
};
|
||||
@@ -1,16 +0,0 @@
|
||||
import config from "@payload-config";
|
||||
import {
|
||||
REST_DELETE,
|
||||
REST_GET,
|
||||
REST_OPTIONS,
|
||||
REST_PATCH,
|
||||
REST_POST,
|
||||
REST_PUT,
|
||||
} from "@payloadcms/next/routes";
|
||||
|
||||
export const GET = REST_GET(config);
|
||||
export const POST = REST_POST(config);
|
||||
export const DELETE = REST_DELETE(config);
|
||||
export const OPTIONS = REST_OPTIONS(config);
|
||||
export const PATCH = REST_PATCH(config);
|
||||
export const PUT = REST_PUT(config);
|
||||
@@ -1,19 +0,0 @@
|
||||
import configPromise from "@payload-config";
|
||||
import "@payloadcms/next/css";
|
||||
import { RootLayout } from "@payloadcms/next/layouts";
|
||||
import React from "react";
|
||||
|
||||
import { handleServerFunctions } from "./actions";
|
||||
import { importMap } from "./admin/importMap";
|
||||
|
||||
export default function Layout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<RootLayout
|
||||
config={configPromise}
|
||||
importMap={importMap}
|
||||
serverFunction={handleServerFunctions}
|
||||
>
|
||||
{children}
|
||||
</RootLayout>
|
||||
);
|
||||
}
|
||||
@@ -31,7 +31,7 @@ import {
|
||||
CodeSnippet,
|
||||
AbstractCircuit,
|
||||
} from "@/src/components/Effects";
|
||||
import { getImgproxyUrl } from "@/src/utils/imgproxy";
|
||||
|
||||
import { Marker } from "@/src/components/Marker";
|
||||
|
||||
export default function AboutPage() {
|
||||
@@ -51,12 +51,7 @@ export default function AboutPage() {
|
||||
<div className="relative w-32 h-32 md:w-40 md:h-40 rounded-full overflow-hidden border border-slate-200 shadow-xl bg-white p-1 group">
|
||||
<div className="w-full h-full rounded-full overflow-hidden relative aspect-square">
|
||||
<img
|
||||
src={getImgproxyUrl("/marc-mintel.png", {
|
||||
width: 400,
|
||||
height: 400,
|
||||
resizing_type: "fill",
|
||||
gravity: "sm",
|
||||
})}
|
||||
src="/marc-mintel.png"
|
||||
alt="Marc Mintel"
|
||||
className="object-cover grayscale transition-all duration-1000 ease-in-out scale-110 group-hover:scale-100 group-hover:grayscale-0 w-full h-full"
|
||||
/>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import * as React from "react";
|
||||
import type { Metadata } from "next";
|
||||
import { notFound } from "next/navigation";
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
|
||||
import { getAllPosts } from "@/src/lib/posts";
|
||||
import { BlogPostHeader } from "@/src/components/blog/BlogPostHeader";
|
||||
import { Section } from "@/src/components/Section";
|
||||
@@ -10,6 +11,8 @@ import { TextSelectionShare } from "@/src/components/TextSelectionShare";
|
||||
import { BlogPostStickyBar } from "@/src/components/blog/BlogPostStickyBar";
|
||||
import { MDXContent } from "@/src/components/MDXContent";
|
||||
|
||||
import { TableOfContents } from "@/src/components/TableOfContents";
|
||||
|
||||
export async function generateStaticParams() {
|
||||
const allPosts = await getAllPosts();
|
||||
return allPosts.map((post) => ({
|
||||
@@ -102,7 +105,8 @@ export default async function BlogPostPage({
|
||||
)}
|
||||
|
||||
<div className="article-content max-w-none">
|
||||
<MDXContent code={post.body.code} />
|
||||
<TableOfContents />
|
||||
<MDXContent code={post.mdxContent || ""} />
|
||||
</div>
|
||||
</Reveal>
|
||||
</div>
|
||||
|
||||
@@ -2,6 +2,8 @@ import { getAllPosts } from "@/src/lib/posts";
|
||||
import { BlogClient } from "@/src/components/blog/BlogClient";
|
||||
import type { Metadata } from "next";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Blog | Mintel.me",
|
||||
description:
|
||||
|
||||
@@ -24,19 +24,19 @@
|
||||
}
|
||||
|
||||
h1 {
|
||||
@apply text-4xl md:text-8xl leading-[1.1] md:leading-[0.95] mb-6 md:mb-12;
|
||||
@apply text-4xl md:text-6xl lg:text-8xl leading-[1.1] md:leading-[1] lg:leading-[0.95] mb-6 md:mb-8 lg:mb-12;
|
||||
}
|
||||
|
||||
h2 {
|
||||
@apply text-2xl md:text-6xl leading-tight mb-4 md:mb-8 mt-12 md:mt-16;
|
||||
@apply text-2xl md:text-4xl lg:text-6xl leading-tight mb-4 md:mb-6 lg:mb-8 mt-12 lg:mt-16;
|
||||
}
|
||||
|
||||
h3 {
|
||||
@apply text-xl md:text-5xl leading-tight mb-3 md:mb-6 mt-8 md:mt-12;
|
||||
@apply text-xl md:text-3xl lg:text-5xl leading-tight mb-3 md:mb-4 lg:mb-6 mt-8 lg:mt-12;
|
||||
}
|
||||
|
||||
h4 {
|
||||
@apply text-lg md:text-3xl leading-tight mb-3 md:mb-4 mt-6 md:mt-8;
|
||||
@apply text-lg md:text-2xl lg:text-3xl leading-tight mb-3 lg:mb-4 mt-6 lg:mt-8;
|
||||
}
|
||||
|
||||
p {
|
||||
@@ -44,7 +44,7 @@
|
||||
}
|
||||
|
||||
.lead {
|
||||
@apply text-base md:text-2xl text-slate-600 mb-6 leading-relaxed;
|
||||
@apply text-base md:text-xl lg:text-2xl text-slate-600 mb-6 leading-relaxed;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
@@ -78,6 +78,28 @@
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
/* Interactive elements pointer */
|
||||
a,
|
||||
button,
|
||||
[role="button"],
|
||||
input[type="button"],
|
||||
input[type="submit"],
|
||||
input[type="reset"],
|
||||
select,
|
||||
summary,
|
||||
label[for] {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Ensure disabled elements don't show pointer */
|
||||
button:disabled,
|
||||
[role="button"]:disabled,
|
||||
input:disabled,
|
||||
select:disabled,
|
||||
button[disabled] {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Remove default tap highlight on mobile */
|
||||
* {
|
||||
-webkit-tap-highlight-color: transparent !important;
|
||||
@@ -92,15 +114,15 @@
|
||||
}
|
||||
|
||||
.container {
|
||||
@apply max-w-6xl mx-auto px-5 md:px-6 py-8 md:py-12;
|
||||
@apply max-w-6xl mx-auto px-5 md:px-6 lg:px-8 py-8 md:py-10 lg:py-12;
|
||||
}
|
||||
|
||||
.wide-container {
|
||||
@apply max-w-7xl mx-auto px-5 md:px-6 py-10 md:py-16;
|
||||
@apply max-w-7xl mx-auto px-5 md:px-6 lg:px-8 py-10 md:py-12 lg:py-16;
|
||||
}
|
||||
|
||||
.narrow-container {
|
||||
@apply max-w-4xl mx-auto px-5 md:px-6 py-6 md:py-10;
|
||||
@apply max-w-4xl mx-auto px-5 md:px-6 lg:px-8 py-6 md:py-8 lg:py-10;
|
||||
}
|
||||
|
||||
.highlighter-tag {
|
||||
|
||||
@@ -26,6 +26,7 @@ import { HeroSection } from "@/src/components/HeroSection";
|
||||
import { GlitchText } from "@/src/components/GlitchText";
|
||||
import { Marker } from "@/src/components/Marker";
|
||||
import { PenCircle } from "@/src/components/PenCircle";
|
||||
import { Availability } from "@/src/components/Availability";
|
||||
|
||||
export default function LandingPage() {
|
||||
return (
|
||||
@@ -50,7 +51,7 @@ export default function LandingPage() {
|
||||
</H3>
|
||||
</Reveal>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-8 relative z-10">
|
||||
<div className="grid grid-cols-1 md:grid-cols-1 lg:grid-cols-2 gap-8 lg:gap-12 relative z-10">
|
||||
{[
|
||||
{
|
||||
icon: <ConceptCommunication className="w-8 h-8" />,
|
||||
@@ -136,7 +137,7 @@ export default function LandingPage() {
|
||||
|
||||
{/* Section 04: Target Group */}
|
||||
<Section number="04" title="Für wen" borderTop>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 md:gap-6 relative z-10">
|
||||
<div className="grid grid-cols-1 md:grid-cols-1 lg:grid-cols-2 gap-4 md:gap-6 lg:gap-10 relative z-10">
|
||||
<Reveal>
|
||||
<Card variant="glass" padding="normal" techBorder className="group">
|
||||
<div className="space-y-4 md:space-y-6 relative overflow-hidden">
|
||||
@@ -215,8 +216,8 @@ export default function LandingPage() {
|
||||
},
|
||||
].map((service, i) => (
|
||||
<Reveal key={i} delay={0.1 + i * 0.15}>
|
||||
<div className="group py-8 md:py-16 border-b border-slate-100 last:border-b-0 cursor-pointer transition-all duration-500">
|
||||
<div className="flex flex-col md:flex-row md:items-start gap-6 md:gap-16">
|
||||
<div className="group py-8 md:py-12 lg:py-16 border-b border-slate-100 last:border-b-0 cursor-pointer transition-all duration-500">
|
||||
<div className="flex flex-col md:flex-row md:items-start gap-6 md:gap-10 lg:gap-16">
|
||||
{/* Number + Binary */}
|
||||
<div className="shrink-0 flex md:block items-baseline gap-4">
|
||||
<span className="text-4xl md:text-6xl font-black text-slate-100 group-hover:text-slate-200 transition-colors duration-500 tracking-tighter block leading-none">
|
||||
@@ -280,13 +281,13 @@ export default function LandingPage() {
|
||||
<Section number="06" title="Kontakt" borderTop>
|
||||
<div className="relative py-4 md:py-12" id="contact">
|
||||
<Reveal>
|
||||
<div className="space-y-8 md:space-y-16">
|
||||
<H1 className="text-3xl md:text-8xl">
|
||||
<div className="space-y-8 md:space-y-12 lg:space-y-16">
|
||||
<H1 className="text-4xl md:text-6xl lg:text-8xl">
|
||||
Lassen Sie uns <br />
|
||||
<span className="text-slate-400">starten.</span>
|
||||
</H1>
|
||||
|
||||
<div className="flex flex-col md:flex-row gap-6 md:gap-16 items-start relative z-10">
|
||||
<div className="flex flex-col md:flex-row gap-6 md:gap-10 lg:gap-16 items-start relative z-10">
|
||||
<div className="space-y-4 md:space-y-8 flex-1">
|
||||
<LeadText className="text-lg md:text-3xl text-slate-400">
|
||||
Beschreiben Sie kurz Ihr Vorhaben. Ich melde mich{" "}
|
||||
@@ -313,7 +314,7 @@ export default function LandingPage() {
|
||||
</div>
|
||||
<BodyText className="text-sm md:text-base leading-snug">
|
||||
Aktuell nehme ich Projekte für{" "}
|
||||
<span className="font-bold text-slate-900">Q2 2026</span>{" "}
|
||||
<span className="font-bold text-slate-900"><Availability /></span>{" "}
|
||||
an.
|
||||
</BodyText>
|
||||
</div>
|
||||
|
||||
@@ -48,30 +48,10 @@ export const technologies: Record<string, TechInfo> = {
|
||||
'Using TypeScript means your application is robust and reliable from day one. It dramatically reduces the risk of "runtime errors" that could crash your site, saving time and money on bug fixes down the line.',
|
||||
color: "bg-blue-600 text-white",
|
||||
related: [
|
||||
{ name: "Directus CMS", slug: "directus-cms" },
|
||||
{ name: "Next.js 14", slug: "next-js-14" },
|
||||
],
|
||||
},
|
||||
"directus-cms": {
|
||||
title: "Directus CMS",
|
||||
subtitle: "The Open Data Platform",
|
||||
description:
|
||||
"Directus is a modern, headless Content Management System (CMS) that instantly turns any database into a beautiful, easy-to-use application for managing your content. Unlike traditional CMSs, it doesn't dictate how your website looks.",
|
||||
icon: Database,
|
||||
benefits: [
|
||||
"Intuitive interface for non-technical editors",
|
||||
"Complete freedom regarding front-end design",
|
||||
"Real-time updates and live previews",
|
||||
"Highly secure and role-based access control",
|
||||
],
|
||||
customerValue:
|
||||
"Directus gives you full control over your content without needing a developer for every text change. It separates your data from the design, ensuring your website can evolve visually without rebuilding your entire content library.",
|
||||
color: "bg-purple-600 text-white",
|
||||
related: [
|
||||
{ name: "Next.js 14", slug: "next-js-14" },
|
||||
{ name: "Tailwind CSS", slug: "tailwind-css" },
|
||||
],
|
||||
},
|
||||
|
||||
"tailwind-css": {
|
||||
title: "Tailwind CSS",
|
||||
subtitle: "Utility-First CSS Framework",
|
||||
|
||||
35
apps/web/build.log
Normal file
35
apps/web/build.log
Normal file
@@ -0,0 +1,35 @@
|
||||
|
||||
> @mintel/web@0.1.0 build /Users/marcmintel/Projects/mintel.me/apps/web
|
||||
> next build --webpack
|
||||
|
||||
▲ Next.js 16.1.6 (webpack)
|
||||
- Environments: .env
|
||||
- Experiments (use with caution):
|
||||
· clientTraceMetadata
|
||||
|
||||
Creating an optimized production build ...
|
||||
[@sentry/nextjs] It seems like you don't have a global error handler set up. It is recommended that you add a 'global-error.js' file with Sentry instrumentation so that React rendering errors are reported to Sentry. Read more: https://docs.sentry.io/platforms/javascript/guides/nextjs/manual-setup/#react-render-errors-in-app-router (you can suppress this warning by setting SENTRY_SUPPRESS_GLOBAL_ERROR_HANDLER_FILE_WARNING=1 as environment variable)
|
||||
[@sentry/nextjs] DEPRECATION WARNING: It is recommended renaming your `sentry.client.config.ts` file, or moving its content to `instrumentation-client.ts`. When using Turbopack `sentry.client.config.ts` will no longer work. Read more about the `instrumentation-client.ts` file: https://nextjs.org/docs/app/api-reference/file-conventions/instrumentation-client
|
||||
<w> [webpack.cache.PackFileCacheStrategy/webpack.FileSystemInfo] Parsing of /Users/marcmintel/Projects/mintel.me/node_modules/.pnpm/next-intl@4.8.2_@swc+helpers@0.5.18_next@16.1.6_@opentelemetry+api@1.9.0_react-dom@19.2_cfd2a0548e9a0d48fd79eed1a1591488/node_modules/next-intl/dist/esm/production/extractor/format/index.js for build dependencies failed at 'import(t)'.
|
||||
<w> Build dependencies behind this expression are ignored and might cause incorrect cache invalidation.
|
||||
<w> [webpack.cache.PackFileCacheStrategy/webpack.FileSystemInfo] Parsing of /Users/marcmintel/Projects/mintel.me/node_modules/.pnpm/next-intl@4.8.2_@swc+helpers@0.5.18_next@16.1.6_@opentelemetry+api@1.9.0_react-dom@19.2_cfd2a0548e9a0d48fd79eed1a1591488/node_modules/next-intl/dist/esm/production/extractor/format/index.js for build dependencies failed at 'import(t)'.
|
||||
<w> Build dependencies behind this expression are ignored and might cause incorrect cache invalidation.
|
||||
<w> [webpack.cache.PackFileCacheStrategy/webpack.FileSystemInfo] Parsing of /Users/marcmintel/Projects/mintel.me/node_modules/.pnpm/next-intl@4.8.2_@swc+helpers@0.5.18_next@16.1.6_@opentelemetry+api@1.9.0_react-dom@19.2_cfd2a0548e9a0d48fd79eed1a1591488/node_modules/next-intl/dist/esm/production/extractor/format/index.js for build dependencies failed at 'import(t)'.
|
||||
<w> Build dependencies behind this expression are ignored and might cause incorrect cache invalidation.
|
||||
⚠ Compiled with warnings in 50s
|
||||
|
||||
|
||||
Running TypeScript ...
|
||||
Collecting page data using 15 workers ...
|
||||
Error: Cannot find module '/Users/marcmintel/Projects/mintel.me/node_modules/.pnpm/@mintel+payload-ai@1.9.13_@payloadcms+next@3.77.0_graphql@16.12.0_monaco-editor@0.55.1__6baee6e32ae56efbc0411af586fa4fba/node_modules/@mintel/payload-ai/dist/globals/AiSettings' imported from /Users/marcmintel/Projects/mintel.me/node_modules/.pnpm/@mintel+payload-ai@1.9.13_@payloadcms+next@3.77.0_graphql@16.12.0_monaco-editor@0.55.1__6baee6e32ae56efbc0411af586fa4fba/node_modules/@mintel/payload-ai/dist/index.js
|
||||
at ignore-listed frames {
|
||||
code: 'ERR_MODULE_NOT_FOUND',
|
||||
url: 'file:///Users/marcmintel/Projects/mintel.me/node_modules/.pnpm/@mintel+payload-ai@1.9.13_@payloadcms+next@3.77.0_graphql@16.12.0_monaco-editor@0.55.1__6baee6e32ae56efbc0411af586fa4fba/node_modules/@mintel/payload-ai/dist/globals/AiSettings'
|
||||
}
|
||||
|
||||
> Build error occurred
|
||||
Error: Failed to collect page data for /blog/[slug]/opengraph-image-fx5gi7
|
||||
at ignore-listed frames {
|
||||
type: 'Error'
|
||||
}
|
||||
ELIFECYCLE Command failed with exit code 1.
|
||||
38
apps/web/build2.log
Normal file
38
apps/web/build2.log
Normal file
@@ -0,0 +1,38 @@
|
||||
|
||||
> @mintel/web@0.1.0 build /Users/marcmintel/Projects/mintel.me/apps/web
|
||||
> next build --webpack
|
||||
|
||||
▲ Next.js 16.1.6 (webpack)
|
||||
- Environments: .env
|
||||
- Experiments (use with caution):
|
||||
· clientTraceMetadata
|
||||
|
||||
Creating an optimized production build ...
|
||||
[@sentry/nextjs] It seems like you don't have a global error handler set up. It is recommended that you add a 'global-error.js' file with Sentry instrumentation so that React rendering errors are reported to Sentry. Read more: https://docs.sentry.io/platforms/javascript/guides/nextjs/manual-setup/#react-render-errors-in-app-router (you can suppress this warning by setting SENTRY_SUPPRESS_GLOBAL_ERROR_HANDLER_FILE_WARNING=1 as environment variable)
|
||||
[@sentry/nextjs] DEPRECATION WARNING: It is recommended renaming your `sentry.client.config.ts` file, or moving its content to `instrumentation-client.ts`. When using Turbopack `sentry.client.config.ts` will no longer work. Read more about the `instrumentation-client.ts` file: https://nextjs.org/docs/app/api-reference/file-conventions/instrumentation-client
|
||||
<w> [webpack.cache.PackFileCacheStrategy/webpack.FileSystemInfo] Parsing of /Users/marcmintel/Projects/mintel.me/node_modules/.pnpm/next-intl@4.8.2_@swc+helpers@0.5.18_next@16.1.6_@opentelemetry+api@1.9.0_react-dom@19.2_cfd2a0548e9a0d48fd79eed1a1591488/node_modules/next-intl/dist/esm/production/extractor/format/index.js for build dependencies failed at 'import(t)'.
|
||||
<w> Build dependencies behind this expression are ignored and might cause incorrect cache invalidation.
|
||||
<w> [webpack.cache.PackFileCacheStrategy/webpack.FileSystemInfo] Parsing of /Users/marcmintel/Projects/mintel.me/node_modules/.pnpm/next-intl@4.8.2_@swc+helpers@0.5.18_next@16.1.6_@opentelemetry+api@1.9.0_react-dom@19.2_cfd2a0548e9a0d48fd79eed1a1591488/node_modules/next-intl/dist/esm/production/extractor/format/index.js for build dependencies failed at 'import(t)'.
|
||||
<w> Build dependencies behind this expression are ignored and might cause incorrect cache invalidation.
|
||||
<w> [webpack.cache.PackFileCacheStrategy/webpack.FileSystemInfo] Parsing of /Users/marcmintel/Projects/mintel.me/node_modules/.pnpm/next-intl@4.8.2_@swc+helpers@0.5.18_next@16.1.6_@opentelemetry+api@1.9.0_react-dom@19.2_cfd2a0548e9a0d48fd79eed1a1591488/node_modules/next-intl/dist/esm/production/extractor/format/index.js for build dependencies failed at 'import(t)'.
|
||||
<w> Build dependencies behind this expression are ignored and might cause incorrect cache invalidation.
|
||||
⚠ Compiled with warnings in 48s
|
||||
|
||||
|
||||
Running TypeScript ...
|
||||
Collecting page data using 15 workers ...
|
||||
TypeError: Unknown file extension ".css" for /Users/marcmintel/Projects/mintel.me/node_modules/.pnpm/react-image-crop@10.1.8_react@19.2.4/node_modules/react-image-crop/dist/ReactCrop.css
|
||||
at Object.getFileProtocolModuleFormat [as (file:] (node:internal/modules/esm/get_format:176:9) {
|
||||
code: 'ERR_UNKNOWN_FILE_EXTENSION'
|
||||
}
|
||||
TypeError: Unknown file extension ".css" for /Users/marcmintel/Projects/mintel.me/node_modules/.pnpm/react-image-crop@10.1.8_react@19.2.4/node_modules/react-image-crop/dist/ReactCrop.css
|
||||
at Object.getFileProtocolModuleFormat [as (file:] (node:internal/modules/esm/get_format:176:9) {
|
||||
code: 'ERR_UNKNOWN_FILE_EXTENSION'
|
||||
}
|
||||
|
||||
> Build error occurred
|
||||
Error: Failed to collect page data for /sitemap.xml
|
||||
at ignore-listed frames {
|
||||
type: 'Error'
|
||||
}
|
||||
ELIFECYCLE Command failed with exit code 1.
|
||||
96
apps/web/build3.log
Normal file
96
apps/web/build3.log
Normal file
@@ -0,0 +1,96 @@
|
||||
|
||||
> @mintel/web@0.1.0 build /Users/marcmintel/Projects/mintel.me/apps/web
|
||||
> next build --webpack
|
||||
|
||||
▲ Next.js 16.1.6 (webpack)
|
||||
- Environments: .env
|
||||
- Experiments (use with caution):
|
||||
· clientTraceMetadata
|
||||
|
||||
Creating an optimized production build ...
|
||||
[@sentry/nextjs] It seems like you don't have a global error handler set up. It is recommended that you add a 'global-error.js' file with Sentry instrumentation so that React rendering errors are reported to Sentry. Read more: https://docs.sentry.io/platforms/javascript/guides/nextjs/manual-setup/#react-render-errors-in-app-router (you can suppress this warning by setting SENTRY_SUPPRESS_GLOBAL_ERROR_HANDLER_FILE_WARNING=1 as environment variable)
|
||||
[@sentry/nextjs] DEPRECATION WARNING: It is recommended renaming your `sentry.client.config.ts` file, or moving its content to `instrumentation-client.ts`. When using Turbopack `sentry.client.config.ts` will no longer work. Read more about the `instrumentation-client.ts` file: https://nextjs.org/docs/app/api-reference/file-conventions/instrumentation-client
|
||||
<w> [webpack.cache.PackFileCacheStrategy/webpack.FileSystemInfo] Parsing of /Users/marcmintel/Projects/mintel.me/node_modules/.pnpm/next-intl@4.8.2_@swc+helpers@0.5.18_next@16.1.6_@opentelemetry+api@1.9.0_react-dom@19.2_cfd2a0548e9a0d48fd79eed1a1591488/node_modules/next-intl/dist/esm/production/extractor/format/index.js for build dependencies failed at 'import(t)'.
|
||||
<w> Build dependencies behind this expression are ignored and might cause incorrect cache invalidation.
|
||||
<w> [webpack.cache.PackFileCacheStrategy/webpack.FileSystemInfo] Parsing of /Users/marcmintel/Projects/mintel.me/node_modules/.pnpm/next-intl@4.8.2_@swc+helpers@0.5.18_next@16.1.6_@opentelemetry+api@1.9.0_react-dom@19.2_cfd2a0548e9a0d48fd79eed1a1591488/node_modules/next-intl/dist/esm/production/extractor/format/index.js for build dependencies failed at 'import(t)'.
|
||||
<w> Build dependencies behind this expression are ignored and might cause incorrect cache invalidation.
|
||||
<w> [webpack.cache.PackFileCacheStrategy/webpack.FileSystemInfo] Parsing of /Users/marcmintel/Projects/mintel.me/node_modules/.pnpm/next-intl@4.8.2_@swc+helpers@0.5.18_next@16.1.6_@opentelemetry+api@1.9.0_react-dom@19.2_cfd2a0548e9a0d48fd79eed1a1591488/node_modules/next-intl/dist/esm/production/extractor/format/index.js for build dependencies failed at 'import(t)'.
|
||||
<w> Build dependencies behind this expression are ignored and might cause incorrect cache invalidation.
|
||||
⚠ Compiled with warnings in 47s
|
||||
|
||||
|
||||
Running TypeScript ...
|
||||
Collecting page data using 15 workers ...
|
||||
Generating static pages using 15 workers (0/25) ...
|
||||
[OG] Loading fonts: bold=/Users/marcmintel/Projects/mintel.me/apps/web/public/fonts/Inter-Bold.woff, regular=/Users/marcmintel/Projects/mintel.me/apps/web/public/fonts/Inter-Regular.woff
|
||||
[OG] Fonts loaded successfully (31320 and 30696 bytes)
|
||||
Generating static pages using 15 workers (6/25)
|
||||
Generating static pages using 15 workers (12/25)
|
||||
Generating static pages using 15 workers (18/25)
|
||||
✓ Generating static pages using 15 workers (25/25) in 3.1s
|
||||
Lexical => JSX converter: Blocks converter: found mintelTldr block, but no converter is provided
|
||||
Lexical => JSX converter: Blocks converter: found mintelP block, but no converter is provided
|
||||
Lexical => JSX converter: Blocks converter: found mintelP block, but no converter is provided
|
||||
Lexical => JSX converter: Blocks converter: found mintelP block, but no converter is provided
|
||||
Lexical => JSX converter: Blocks converter: found mintelP block, but no converter is provided
|
||||
Lexical => JSX converter: Blocks converter: found mintelP block, but no converter is provided
|
||||
Lexical => JSX converter: Blocks converter: found mintelP block, but no converter is provided
|
||||
Lexical => JSX converter: Blocks converter: found mintelP block, but no converter is provided
|
||||
Lexical => JSX converter: Blocks converter: found mintelP block, but no converter is provided
|
||||
Lexical => JSX converter: Blocks converter: found mintelP block, but no converter is provided
|
||||
Lexical => JSX converter: Blocks converter: found mintelP block, but no converter is provided
|
||||
Lexical => JSX converter: Blocks converter: found mintelTldr block, but no converter is provided
|
||||
Lexical => JSX converter: Blocks converter: found mintelP block, but no converter is provided
|
||||
Lexical => JSX converter: Blocks converter: found mintelP block, but no converter is provided
|
||||
Lexical => JSX converter: Blocks converter: found mintelP block, but no converter is provided
|
||||
Lexical => JSX converter: Blocks converter: found mintelP block, but no converter is provided
|
||||
Lexical => JSX converter: Blocks converter: found mintelP block, but no converter is provided
|
||||
Lexical => JSX converter: Blocks converter: found mintelP block, but no converter is provided
|
||||
Lexical => JSX converter: Blocks converter: found mintelP block, but no converter is provided
|
||||
Lexical => JSX converter: Blocks converter: found mintelP block, but no converter is provided
|
||||
[OG] Loading fonts: bold=/Users/marcmintel/Projects/mintel.me/apps/web/public/fonts/Inter-Bold.woff, regular=/Users/marcmintel/Projects/mintel.me/apps/web/public/fonts/Inter-Regular.woff
|
||||
[OG] Fonts loaded successfully (31320 and 30696 bytes)
|
||||
[OG] Loading fonts: bold=/Users/marcmintel/Projects/mintel.me/apps/web/public/fonts/Inter-Bold.woff, regular=/Users/marcmintel/Projects/mintel.me/apps/web/public/fonts/Inter-Regular.woff
|
||||
[OG] Fonts loaded successfully (31320 and 30696 bytes)
|
||||
Finalizing page optimization ...
|
||||
Collecting build traces ...
|
||||
|
||||
Route (app)
|
||||
┌ ○ /
|
||||
├ ○ /_not-found
|
||||
├ ○ /about
|
||||
├ ○ /about/opengraph-image-1ycygp
|
||||
├ ƒ /admin/[[...segments]]
|
||||
├ ƒ /api/[...slug]
|
||||
├ ƒ /api/health/cms
|
||||
├ ƒ /api/tweet/[id]
|
||||
├ ○ /blog
|
||||
├ ● /blog/[slug]
|
||||
│ ├ /blog/why-websites-break-after-updates
|
||||
│ └ /blog/maintenance-for-headless-systems
|
||||
├ ƒ /blog/[slug]/opengraph-image-fx5gi7
|
||||
├ ○ /case-studies
|
||||
├ ○ /case-studies/klz-cables
|
||||
├ ○ /contact
|
||||
├ ○ /contact/opengraph-image-upzrkl
|
||||
├ ƒ /errors/api/relay
|
||||
├ ○ /opengraph-image-12o0cb
|
||||
├ ○ /sitemap.xml
|
||||
├ ƒ /stats/api/send
|
||||
├ ● /tags/[tag]
|
||||
│ ├ /tags/maintenance
|
||||
│ ├ /tags/reliability
|
||||
│ ├ /tags/software-engineering
|
||||
│ └ /tags/architecture
|
||||
├ ● /technologies/[slug]
|
||||
│ ├ /technologies/next-js-14
|
||||
│ ├ /technologies/typescript
|
||||
│ ├ /technologies/tailwind-css
|
||||
│ └ /technologies/react
|
||||
└ ○ /websites
|
||||
|
||||
|
||||
○ (Static) prerendered as static content
|
||||
● (SSG) prerendered as static HTML (uses generateStaticParams)
|
||||
ƒ (Dynamic) server-rendered on demand
|
||||
|
||||
12
apps/web/check-db.ts
Normal file
12
apps/web/check-db.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
export const run = async ({ payload }) => {
|
||||
const docs = await payload.find({
|
||||
collection: "context-files",
|
||||
limit: 100,
|
||||
});
|
||||
console.log(`--- DB CHECK ---`);
|
||||
console.log(`Found ${docs.totalDocs} context files.`);
|
||||
docs.docs.forEach((doc) => {
|
||||
console.log(`- ${doc.filename}`);
|
||||
});
|
||||
process.exit(0);
|
||||
};
|
||||
@@ -172,10 +172,7 @@ sequenceDiagram
|
||||
Gartner schätzt, dass **40% aller CRM-Fehlschläge** auf Prozessprobleme oder menschliches Versagen zurückzuführen sind. Eine automatisierte Integration ist die wirksamste Versicherung gegen dieses Risiko. [Vermeiden Sie technische Altlasten](/blog/slow-loading-costs-customers) und machen Sie Ihre Website zu einem integralen Bestandteil Ihres Sales-Motors.
|
||||
</Paragraph>
|
||||
|
||||
<PortfolioHeader
|
||||
title="Bereit für echte Automatisierung?"
|
||||
sub="Präzision in der Schnittstelle, Klarheit im Ergebnis."
|
||||
/>
|
||||
|
||||
|
||||
<FAQSection>
|
||||
<H3>Wie lange dauert die Integration eines CRMs?</H3>
|
||||
|
||||
@@ -18,7 +18,7 @@ tags: ["management", "business"]
|
||||
|
||||
<Section>
|
||||
<H2>Inhaltsverzeichnis</H2>
|
||||
<TOC />
|
||||
<TableOfContents />
|
||||
</Section>
|
||||
|
||||
<H2>Die Falle der unendlichen Stunden</H2>
|
||||
|
||||
@@ -53,10 +53,7 @@ tags: ["performance", "seo", "conversion-optimization"]
|
||||
Google hat bestätigt, dass <ExternalLink href="https://developers.google.com/search/docs/appearance/core-web-vitals">Core Web Vitals</ExternalLink> als Ranking-Signale für Suchergebnisse genutzt werden. Wer hier rote Zahlen schreibt, wird vom Algorithmus abgestraft. Webseiten, die den Schwellenwert „Gut“ in allen Kategorien erreichen, verzeichnen laut Deloitte eine deutlich höhere Interaktion. Dennoch zeigt das HTTP Archive, dass ein signifikanter Prozentsatz der Websites die empfohlenen Grenzwerte weiterhin massiv verfehlt.
|
||||
</Paragraph>
|
||||
|
||||
<WebVitalsScore
|
||||
values={{ lcp: 2.1, inp: 180, cls: 0.05 }}
|
||||
description="Exzellente Werte signalisieren Google eine hohe Nutzerzufriedenheit und fördern das Ranking."
|
||||
/>
|
||||
<WebVitalsScore values={{ lcp: 2.1, inp: 180, cls: 0.05 }} description="Exzellente Werte signalisieren Google eine hohe Nutzerzufriedenheit und fördern das Ranking." />
|
||||
|
||||
<Paragraph>
|
||||
Dabei fokussiert sich das Framework auf drei wesentliche Säulen der User Experience, für die Google im PageSpeed Insights Tool spezifische Optimierungsvorschläge liefert:
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
title: "Wartungsfrei durch Verzicht: Warum '
|
||||
thumbnail: "/blog/maintenance-for-headless-systems.png"No-CMS' die überlegene B2B-Architektur ist"
|
||||
title: "Wartungsfrei durch Verzicht: Warum 'No-CMS' die überlegene B2B-Architektur ist"
|
||||
thumbnail: "/blog/maintenance-for-headless-systems.png"
|
||||
description: "Sicherheit und Geschwindigkeit durch architektonische Reduktion: Warum Git-basierte Workflows klassische CMS-Backends in Performance und ROI schlagen."
|
||||
date: "2026-02-01"
|
||||
tags: ["maintenance", "architecture"]
|
||||
|
||||
@@ -1,64 +0,0 @@
|
||||
# Marc — digital problem solver
|
||||
|
||||
## Identity
|
||||
- Name: Marc Mintel
|
||||
- Mail: marc@mintel.me
|
||||
- Location: Vulkaneifel, Germany
|
||||
- Role: Independent digital problem solver
|
||||
- Mode: Solo
|
||||
- Focus: Understanding problems and building practical solutions
|
||||
|
||||
## What I do
|
||||
I work on digital problems and build tools, scripts, and systems to solve them.
|
||||
Sometimes that means code, sometimes automation, sometimes AI, sometimes something else.
|
||||
The tool is secondary. The problem comes first.
|
||||
|
||||
## How I work
|
||||
- I try things
|
||||
- I break things
|
||||
- I fix things
|
||||
- I write down what I learned
|
||||
|
||||
## What this blog is
|
||||
A public notebook of:
|
||||
- things I figured out
|
||||
- mistakes I made
|
||||
- tools I tested
|
||||
- small insights that might be useful later
|
||||
|
||||
Mostly short entries.
|
||||
Mostly practical.
|
||||
|
||||
## Why no portfolio
|
||||
Finished projects get outdated.
|
||||
Understanding doesn’t.
|
||||
|
||||
This blog shows how I approach problems, not how pretty something looked last year.
|
||||
|
||||
## Topics
|
||||
- Vibe coding with AI
|
||||
- Debugging and problem solving
|
||||
- Mac tools and workflows
|
||||
- Automation
|
||||
- Small scripts and systems
|
||||
- Learning notes
|
||||
- FOSS
|
||||
|
||||
## Audience
|
||||
People who:
|
||||
- build things
|
||||
- work with computers
|
||||
- solve problems
|
||||
- and don’t need marketing talk
|
||||
|
||||
## Tone
|
||||
- calm
|
||||
- factual
|
||||
- direct
|
||||
- no hype
|
||||
- no self-promotion
|
||||
|
||||
## Core idea
|
||||
Write things down.
|
||||
So I don’t forget.
|
||||
And so others might find them useful.
|
||||
@@ -1,123 +0,0 @@
|
||||
Über mich
|
||||
|
||||
Ich baue Websites und Systeme seit über 15 Jahren.
|
||||
Nicht weil ich Websites so liebe – sondern weil ich es hasse, wenn Dinge nicht funktionieren.
|
||||
|
||||
In diesen 15 Jahren habe ich:
|
||||
• Agenturen von innen gesehen
|
||||
• Konzerne erlebt
|
||||
• Startups aufgebaut
|
||||
• Marketingversprechen zerlegt
|
||||
• Systeme repariert, die „fertig“ waren
|
||||
• und gelernt, wie man Dinge baut, die einfach laufen
|
||||
|
||||
Heute mache ich das ohne Agentur-Zwischenschichten.
|
||||
Direkt. Sauber. Verantwortlich.
|
||||
|
||||
⸻
|
||||
|
||||
Ich habe fast alle Fehler schon für Sie gemacht
|
||||
|
||||
(damit Sie sie nicht machen müssen)
|
||||
|
||||
Ich habe als Designer angefangen,
|
||||
bin dann Entwickler geworden,
|
||||
und habe irgendwann gemerkt:
|
||||
|
||||
Das Problem ist selten Technik.
|
||||
Es ist immer Zuständigkeit.
|
||||
|
||||
Wenn keiner verantwortlich ist, passiert nichts.
|
||||
Also habe ich mir angewöhnt, Verantwortung zu übernehmen.
|
||||
|
||||
⸻
|
||||
|
||||
Warum ich Websites wie Systeme baue
|
||||
|
||||
Ich war viele Jahre Senior Developer in Firmen, in denen:
|
||||
• Millionenumsätze dranhingen
|
||||
• Fehler teuer waren
|
||||
• Performance nicht optional war
|
||||
• Sicherheit kein Nice-to-Have war
|
||||
• „kurz mal ändern“ trotzdem passieren musste
|
||||
|
||||
Das prägt.
|
||||
|
||||
Deshalb sind meine Websites:
|
||||
• schnell
|
||||
• stabil
|
||||
• boring (im besten Sinne)
|
||||
• erweiterbar
|
||||
• wartungsarm
|
||||
• und nicht abhängig von Plugins oder Agenturen
|
||||
|
||||
⸻
|
||||
|
||||
Ich habe beide Seiten gesehen
|
||||
|
||||
Ich war:
|
||||
• Webdesigner
|
||||
• Entwickler
|
||||
• Marketing
|
||||
• Vertrieb
|
||||
• Agentur
|
||||
• Inhouse
|
||||
• Dienstleister
|
||||
• Unternehmer
|
||||
|
||||
Das heißt:
|
||||
|
||||
Ich weiß, was Unternehmen brauchen –
|
||||
und was sie nicht brauchen.
|
||||
|
||||
(Meetings, Tickets, Workshops, PowerPoint.)
|
||||
|
||||
⸻
|
||||
|
||||
Was Kunden davon haben
|
||||
|
||||
Sie bekommen:
|
||||
• keinen Projektmanager
|
||||
• keinen Prozess
|
||||
• kein Team
|
||||
• kein Ticket
|
||||
• kein CMS-Drama
|
||||
|
||||
Sie bekommen:
|
||||
• eine Person
|
||||
• eine Verantwortung
|
||||
• ein Ergebnis
|
||||
|
||||
⸻
|
||||
|
||||
Ein kurzer Überblick (ohne Lebenslauf-Gefühl)
|
||||
|
||||
Ich habe u. a. gearbeitet bei:
|
||||
• Agenturen
|
||||
• E-Commerce-Plattformen
|
||||
• SaaS-Firmen
|
||||
• Marketing-Teams
|
||||
• internationalen Unternehmen
|
||||
• Mittelständlern
|
||||
• und Konzernen
|
||||
|
||||
Als:
|
||||
• Web Designer
|
||||
• Frontend Developer
|
||||
• Software Developer
|
||||
• Senior Developer
|
||||
• und später Gründer
|
||||
|
||||
Das Ergebnis daraus ist nicht ein Titel.
|
||||
Sondern eine Arbeitsweise.
|
||||
|
||||
⸻
|
||||
|
||||
Heute
|
||||
|
||||
Heute baue ich Websites und Systeme für Unternehmen,
|
||||
die keine Lust mehr auf Agenturen haben
|
||||
und keine Zeit für Chaos.
|
||||
|
||||
Ich übernehme das Thema komplett –
|
||||
damit es für Sie kein Thema mehr ist.
|
||||
@@ -1,154 +0,0 @@
|
||||
Allgemeine Geschäftsbedingungen (AGB)
|
||||
|
||||
1. Geltungsbereich
|
||||
|
||||
Diese Allgemeinen Geschäftsbedingungen gelten für alle Verträge zwischen
|
||||
Marc Mintel (nachfolgend „Auftragnehmer“)
|
||||
und dem jeweiligen Kunden (nachfolgend „Auftraggeber“).
|
||||
|
||||
Abweichende oder ergänzende Bedingungen des Auftraggebers werden nicht Vertragsbestandteil, auch wenn ihrer Geltung nicht ausdrücklich widersprochen wird.
|
||||
|
||||
⸻
|
||||
|
||||
2. Vertragsgegenstand
|
||||
|
||||
Der Auftragnehmer erbringt Dienstleistungen im Bereich:
|
||||
• Webentwicklung
|
||||
• technische Umsetzung digitaler Systeme
|
||||
• Funktionen, Schnittstellen und Automatisierungen
|
||||
• Hosting, Betrieb und Wartung, sofern ausdrücklich vereinbart
|
||||
|
||||
Der Auftragnehmer schuldet ausschließlich die vereinbarte technische Leistung, nicht jedoch:
|
||||
• einen wirtschaftlichen Erfolg
|
||||
• bestimmte Umsätze, Conversions oder Reichweiten
|
||||
• Suchmaschinen-Rankings
|
||||
• rechtliche oder geschäftliche Ergebnisse
|
||||
|
||||
⸻
|
||||
|
||||
3. Mitwirkungspflichten des Auftraggebers
|
||||
|
||||
Der Auftraggeber verpflichtet sich, alle zur Leistungserbringung erforderlichen Inhalte, Informationen, Zugänge und Entscheidungen rechtzeitig, vollständig und korrekt bereitzustellen.
|
||||
|
||||
Hierzu zählen insbesondere:
|
||||
• Texte, Bilder, Videos, Produktdaten
|
||||
• Freigaben und Feedback
|
||||
• Zugangsdaten
|
||||
• rechtlich erforderliche Inhalte (z. B. Impressum, Datenschutzerklärung)
|
||||
|
||||
Verzögerungen oder Unterlassungen der Mitwirkung führen zu einer entsprechenden Verschiebung aller Termine.
|
||||
Hieraus entstehen keine Schadensersatz- oder Minderungsansprüche.
|
||||
|
||||
⸻
|
||||
|
||||
4. Ausführungs- und Bearbeitungszeiten
|
||||
|
||||
Angegebene Bearbeitungszeiten sind unverbindliche Schätzungen, keine garantierten Fristen.
|
||||
|
||||
Fixe Termine oder Deadlines gelten nur, wenn sie ausdrücklich schriftlich als verbindlich vereinbart wurden.
|
||||
|
||||
⸻
|
||||
|
||||
5. Abnahme
|
||||
|
||||
Die Leistung gilt als abgenommen, wenn:
|
||||
• der Auftraggeber sie produktiv nutzt oder
|
||||
• innerhalb von 7 Tagen nach Bereitstellung keine wesentlichen Mängel angezeigt werden.
|
||||
|
||||
Optische Abweichungen, Geschmacksfragen oder subjektive Einschätzungen stellen keine Mängel dar.
|
||||
|
||||
⸻
|
||||
|
||||
6. Haftung
|
||||
|
||||
Der Auftragnehmer haftet nur für Schäden, die auf vorsätzlicher oder grob fahrlässiger Pflichtverletzung beruhen.
|
||||
|
||||
Eine Haftung für:
|
||||
• entgangenen Gewinn
|
||||
• Umsatzausfälle
|
||||
• Datenverlust
|
||||
• Betriebsunterbrechungen
|
||||
• mittelbare oder Folgeschäden
|
||||
|
||||
ist ausgeschlossen, soweit gesetzlich zulässig.
|
||||
|
||||
⸻
|
||||
|
||||
7. Verfügbarkeit & Betrieb
|
||||
|
||||
Bei vereinbartem Hosting oder Betrieb schuldet der Auftragnehmer keine permanente Verfügbarkeit.
|
||||
|
||||
Wartungsarbeiten, Updates, Sicherheitsmaßnahmen oder externe Störungen (z. B. Hoster, Netze, Drittanbieter) können zu zeitweisen Einschränkungen führen und begründen keine Haftungsansprüche.
|
||||
|
||||
7a. Betriebs- und Pflegeleistung
|
||||
|
||||
Die Betriebs- und Pflegeleistung ist fester Bestandteil der laufenden Leistungen des Auftragnehmers.
|
||||
|
||||
Sie umfasst ausschließlich:
|
||||
• Sicherstellung des technischen Betriebs der Website
|
||||
• Wartung, Updates und Fehlerbehebung der bestehenden Systeme
|
||||
• Austausch, Korrektur oder Aktualisierung bereits vorhandener Inhalte
|
||||
• Pflege bestehender Datensätze ohne Änderung oder Erweiterung der Datenstruktur
|
||||
|
||||
Nicht Bestandteil der Betriebs- und Pflegeleistung sind insbesondere:
|
||||
• regelmäßige oder fortlaufende Erstellung neuer Inhalte
|
||||
(z. B. Blogartikel, News, Produkte, Seiten)
|
||||
• redaktionelle Tätigkeiten oder Content-Produktion
|
||||
• strategische Inhaltsplanung oder Marketingmaßnahmen
|
||||
• Aufbau neuer Seiten, Features, Funktionen oder Datenmodelle
|
||||
• Serien-, Massen- oder Dauerpflege
|
||||
(z. B. tägliche oder wiederkehrende Inhaltserstellung)
|
||||
|
||||
Die Betriebs- und Pflegeleistung dient ausschließlich der Instandhaltung, Sicherheit und Funktionsfähigkeit der bestehenden Website.
|
||||
|
||||
Leistungen, die darüber hinausgehen, gelten als Neuentwicklung oder Inhaltserstellung und sind gesondert zu beauftragen und zu vergüten.
|
||||
|
||||
⸻
|
||||
|
||||
8. Drittanbieter & externe Systeme
|
||||
|
||||
Der Auftragnehmer übernimmt keine Verantwortung für:
|
||||
• Leistungen, Ausfälle oder Änderungen externer Dienste
|
||||
• APIs, Schnittstellen oder Plattformen Dritter
|
||||
• rechtliche oder technische Änderungen fremder Systeme
|
||||
|
||||
Eine Funktionsfähigkeit kann nur im Rahmen der jeweils aktuellen externen Schnittstellen gewährleistet werden.
|
||||
|
||||
⸻
|
||||
|
||||
9. Inhalte & Rechtliches
|
||||
|
||||
Der Auftraggeber ist allein verantwortlich für:
|
||||
• Inhalte der Website
|
||||
• rechtliche Konformität (DSGVO, Urheberrecht, Wettbewerbsrecht etc.)
|
||||
• bereitgestellte Daten und Medien
|
||||
|
||||
Der Auftragnehmer übernimmt keine rechtliche Prüfung.
|
||||
|
||||
⸻
|
||||
|
||||
10. Vergütung & Zahlungsverzug
|
||||
|
||||
Alle Preise verstehen sich netto zuzüglich gesetzlicher Umsatzsteuer.
|
||||
|
||||
Rechnungen sind, sofern nicht anders vereinbart, innerhalb von 7 Tagen fällig.
|
||||
|
||||
Bei Zahlungsverzug ist der Auftragnehmer berechtigt:
|
||||
• Leistungen auszusetzen
|
||||
• Systeme offline zu nehmen
|
||||
• laufende Arbeiten zu stoppen
|
||||
|
||||
⸻
|
||||
|
||||
11. Kündigung laufender Leistungen
|
||||
|
||||
Laufende Leistungen (z. B. Hosting & Betrieb) können mit einer Frist von 4 Wochen zum Monatsende gekündigt werden, sofern nichts anderes vereinbart ist.
|
||||
|
||||
⸻
|
||||
|
||||
12. Schlussbestimmungen
|
||||
|
||||
Es gilt das Recht der Bundesrepublik Deutschland.
|
||||
Gerichtsstand ist – soweit zulässig – der Sitz des Auftragnehmers.
|
||||
|
||||
Sollte eine Bestimmung dieser AGB unwirksam sein, bleibt die Wirksamkeit der übrigen Regelungen unberührt.
|
||||
@@ -1,33 +0,0 @@
|
||||
# Zielgruppe
|
||||
|
||||
## Primäre Zielgruppe: Der deutsche Mittelstand
|
||||
|
||||
Unsere Blog-Inhalte richten sich an Entscheider in kleinen und mittelständischen Unternehmen – nicht an Entwickler.
|
||||
|
||||
### Wer liest das?
|
||||
|
||||
- **Geschäftsführer** von Handwerksbetrieben, Kanzleien, Arztpraxen
|
||||
- **Marketing-Verantwortliche** in mittelständischen Unternehmen (10–250 Mitarbeiter)
|
||||
- **Online-Shop-Betreiber** mit 20k–500k€ Jahresumsatz
|
||||
- **Selbstständige** und Freiberufler mit eigener Website
|
||||
|
||||
### Was sie NICHT sind
|
||||
|
||||
- Keine Entwickler (kein Code-Jargon)
|
||||
- Keine Enterprise-Konzerne (kein "Amazon-Scale", keine "Millionen User")
|
||||
- Keine Marketing-Agenturen (kein Buzzword-Bingo)
|
||||
|
||||
### Wie wir schreiben
|
||||
|
||||
- **Know-how transportieren** ohne zu dozieren
|
||||
- **Technische Fakten verständlich machen** — ELI5 aber nicht herablassend
|
||||
- **Realistische Beispiele**: "Tischlerei Müller mit 30 Seitenbesuchern am Tag", nicht "globaler Marktführer mit 10M MAU"
|
||||
- **Probleme benennen** die sie kennen: langsame Website, schlechtes Google-Ranking, verlorene Anfragen
|
||||
- **Lösungen zeigen** die greifbar sind: konkrete Vorher/Nachher-Vergleiche, echte Zahlen
|
||||
|
||||
### Tonalität gegenüber dem Leser
|
||||
|
||||
- Auf Augenhöhe, nie von oben herab
|
||||
- Wie ein kompetenter Bekannter der einem beim Thema Website hilft
|
||||
- Ehrlich über Probleme, ohne Panik zu machen
|
||||
- Kein Verkaufsdruck, keine künstliche Dringlichkeit
|
||||
@@ -1,73 +0,0 @@
|
||||
# Routine Automation
|
||||
*Kleine Helfer, die den Alltag deutlich entlasten*
|
||||
|
||||
In vielen mittelständischen Unternehmen fressen wiederkehrende Aufgaben Monat für Monat unzählige Stunden:
|
||||
- Daten aus Dokumenten abtippen
|
||||
- Formulare von Hand ausfüllen
|
||||
- Angebote, Berichte oder Bestätigungen manuell anpassen
|
||||
- Eingehende Anfragen immer wieder neu prüfen und bearbeiten
|
||||
|
||||
Das ist keine wertschöpfende Arbeit.
|
||||
Das ist Routine, die teuer ist, Fehler produziert und gute Mitarbeiter davon abhält, sich um das zu kümmern, was wirklich Umsatz bringt.
|
||||
|
||||
Ich baue genau für diese Routine **einfache, maßgeschneiderte Helfer** – meist mit PDF- oder Excel-Ausgabe, Konfiguratoren oder KI-Dokumenten-Einlesen.
|
||||
Einmal eingerichtet, laufen sie leise im Hintergrund.
|
||||
Kein großes Projekt. Kein monatliches Tool-Abo. Kein „lernen Sie das neue System“.
|
||||
|
||||
### Was das konkret bringen kann – Beispiele aus der Praxis
|
||||
|
||||
- **Schnelle Dokumentenerstellung (PDF-Generatoren)**
|
||||
Kurze Eingaben (Formular, Mail, Excel-Zeile) → fertiges PDF raus: Angebote, Berichte, Protokolle, Bestätigungen, Übersichten. Immer Ihr Corporate Design, immer aktuelle Daten/Bausteine.
|
||||
→ Von 30–120 Minuten runter auf 2–10 Minuten.
|
||||
|
||||
- **Excel-Automatisierungen & smarte Tabellen**
|
||||
Verkaufszahlen, Lagerbestände, Kundenlisten → automatische Berechnungen, Zusammenfassungen, Prognosen oder Exporte. Monatsberichte oder Preislisten aktualisieren sich von selbst.
|
||||
→ Kein ständiges Nachrechnen mehr, keine Versionskonflikte.
|
||||
|
||||
- **Konfiguratoren für Anfragen & Schätzungen**
|
||||
Kunde oder Mitarbeiter geht schrittweise durch ein Formular (auf Ihrer Website oder intern): „Welche Leistung? Welcher Umfang? Welcher Termin?“ → sofort realistische Schätzung, Preisspanne oder fertiges Angebot als PDF/Excel.
|
||||
|
||||
- **KI-Einlesen von PDFs oder handschriftlichen Dokumenten**
|
||||
Eingescannte Rechnungen, Lieferscheine, Formulare, Notizen oder handgeschriebene Protokolle → KI liest Text, Zahlen, Felder aus (auch Handschrift, wo lesbar) → Daten landen in übersichtlicher Tabelle/Excel oder vorausgefülltem Formular.
|
||||
Mitarbeiter prüft nur noch kurz → kleine Korrektur → Prozess geht weiter.
|
||||
→ Kein stundenlanges Abtippen mehr, deutlich schnellerer Durchlauf.
|
||||
|
||||
### Der echte Wert für Sie
|
||||
|
||||
- 30–80 % weniger Zeit bei Routineaufgaben → Ihre Teams konzentrieren sich aufs Wesentliche
|
||||
- Weniger Fehler & Rückfragen → einheitlicher, professioneller Output
|
||||
- Schnellere Reaktion auf Kunden → Konfiguratoren & KI-Einlesen liefern sofort Infos
|
||||
- Amortisation oft schon nach wenigen Wochen oder Dutzend Nutzungen
|
||||
- Nutzt, was Sie bereits haben: Website, Excel, Mail, Scanner-App
|
||||
|
||||
### Was ich **nicht** mache
|
||||
|
||||
Ich ersetze **kein** ERP, CRM, Buchhaltungs- oder HR-System.
|
||||
Kein automatisches Buchen, keine Finanzamtschnittstelle, keine GoBD-Archivierungspflichten.
|
||||
Nur smarte Abkürzungen bei Routine – der Rest bleibt in Ihren bewährten Tools.
|
||||
|
||||
### Ich kann Ihnen helfen, wenn Sie mit diesen typischen Problemen kämpfen
|
||||
|
||||
- „Wir tippen immer noch Daten aus gescannten Dokumenten oder handschriftlichen Notizen ab.“
|
||||
- „Angebote, Berichte oder Protokolle dauern ewig, weil alles von Hand angepasst wird.“
|
||||
- „Kunden fragen ständig dasselbe – wir antworten jedes Mal manuell.“
|
||||
- „Excel-Tabellen und Berechnungen werden ständig neu gemacht und gehen kaputt.“
|
||||
- „Bis wir eine realistische Schätzung oder ein Angebot raus haben, vergeht zu viel Zeit.“
|
||||
|
||||
Schreiben Sie mir einfach einen kurzen Satz zu Ihrem größten Zeitfresser in diesem Bereich.
|
||||
Ich antworte meist innerhalb von 1–2 Tagen:
|
||||
- Ist das machbar? Ja/Nein
|
||||
- Ca. wie viel Aufwand (meist 3–15 Stunden) & Preisrahmen
|
||||
- Was Sie realistisch sparen können (Zeit, Nerven, Fehler)
|
||||
|
||||
Passt es → baue ich es.
|
||||
Danach: Routine digitalisiert. Mehr Ruhe im Alltag.
|
||||
|
||||
**Kurz gesagt**
|
||||
Routine Automation:
|
||||
Nicht die große Revolution.
|
||||
Sondern gezielte Entlastung bei den Dingen, die jeden Tag Zeit und Nerven kosten.
|
||||
Mehr Zeit. Weniger Frust. Besserer Output.
|
||||
Und das Gefühl: „Das läuft jetzt einfach.“
|
||||
|
||||
Wenn bei Ihnen gerade etwas „von Hand gemacht wird“ oder „ewig dauert“ – Ich sage Ihnen, ob und wie schnell man das sinnvoll digitalisieren kann.
|
||||
@@ -1,63 +0,0 @@
|
||||
# Content-Regeln für Blog-Post-Generierung
|
||||
|
||||
## 1. Visuelle Balance
|
||||
|
||||
- **Max 1 visuelle Komponente pro 3–4 Textabsätze**
|
||||
- Visualisierungen dürfen **niemals direkt hintereinander** stehen
|
||||
- Zwischen zwei visuellen Elementen müssen mindestens 2 Textabsätze liegen
|
||||
- Nicht mehr als 5–6 visuelle Komponenten pro Blog-Post insgesamt
|
||||
|
||||
### Erlaubte visuelle Komponenten
|
||||
- `Mermaid` / `DiagramFlow` / `DiagramSequence` — für Prozesse und Architektur
|
||||
- `ArticleMeme` — echte Meme-Bilder (memegen.link), kurze und knackige Texte
|
||||
- `BoldNumber` — einzelne Hero-Statistik mit Quelle
|
||||
- `PremiumComparisonChart` / `MetricBar` — für Vergleiche
|
||||
- `WebVitalsScore` — für Performance-Audits (max 1x pro Post)
|
||||
- `WaterfallChart` — für Ladezeiten-Visualisierung (max 1x pro Post)
|
||||
- `StatsGrid` — für 2–4 zusammengehörige Statistiken
|
||||
- `ComparisonRow` — für Vorher/Nachher-Vergleiche
|
||||
|
||||
### Verboten
|
||||
- `MemeCard` (text-basierte Memes) — nur echte Bild-Memes verwenden
|
||||
- AI-generierte Bilder im Content — nur Thumbnails erlaubt
|
||||
- `DiagramPie` — vermeiden, zu generisch
|
||||
|
||||
## 2. Zahlen und Statistiken
|
||||
|
||||
- **Niemals nackte Zahlen** — jede Statistik braucht Kontext und Vergleich
|
||||
- `BoldNumber` nur für DIE eine zentrale Statistik des Abschnitts
|
||||
- Mehrere Zahlen → `StatsGrid` oder `PremiumComparisonChart` verwenden
|
||||
- Jede Zahl braucht eine **Quelle** (`source` + `sourceUrl` Pflicht)
|
||||
- Vergleiche sind immer besser als Einzelwerte: "33% vs. 92%", nicht nur "92%"
|
||||
|
||||
## 3. Zitate und Quellen
|
||||
|
||||
- Alle Zitate brauchen klare Attribution: `author`, `source`, `sourceUrl`
|
||||
- Bei übersetzten Zitaten: "(übersetzt)" im Zitat oder als Hinweis
|
||||
- `ExternalLink` für alle externen Referenzen im Fließtext
|
||||
- Keine erfundenen Zitate — nur verifizierbare Quellen
|
||||
|
||||
## 4. Mermaid-Diagramme
|
||||
|
||||
- **Extrem kompakt halten**: Strikt max 3–4 Nodes pro Diagramm
|
||||
- **Ausschließlich vertikale Layouts** (TD) — besser für Mobile
|
||||
- Deutsche Labels verwenden
|
||||
- Keine verschachtelten Subgraphs
|
||||
- Jedes Diagramm braucht einen aussagekräftigen `title`
|
||||
|
||||
## 5. Memes
|
||||
|
||||
- Nur echte Bilder via `ArticleMeme` (memegen.link API)
|
||||
- **Extreme Sarkasmus-Pflicht** — mach dich über schlechte Agentur-Arbeit oder Legacy-Tech lustig
|
||||
- **Kurze, knackige Captions** — max 6 Wörter pro Zeile
|
||||
- Deutsche Captions verwenden
|
||||
- Bewährte Templates: `drake`, `disastergirl`, `fine`, `daily-struggle`
|
||||
- Max 2–3 Memes pro Blog-Post
|
||||
|
||||
## 6. Textstruktur
|
||||
|
||||
- Jeder Abschnitt startet mit einer klaren H2/H3
|
||||
- `LeadParagraph` nur am Anfang (1–2 Stück)
|
||||
- Normaler Text in `Paragraph`-Komponenten
|
||||
- `Marker` sparsam einsetzen — max 2–3 pro Abschnitt
|
||||
- `IconList` für Aufzählungen mit Pro/Contra
|
||||
@@ -1,83 +0,0 @@
|
||||
# Service Estimation & AI Consultation Guide
|
||||
|
||||
This guide explains how to use the automated estimation system to generate professional PDF quotes for clients using AI-driven context analysis.
|
||||
|
||||
## 🛠 Basic Usage
|
||||
|
||||
The primary entry point is the `ai-estimate` script. It orchestrates a 6-pass AI consultation:
|
||||
1. **Fact Extraction**: Identifying company data and project scope.
|
||||
2. **Feature Deep-Dive**: Generating technical justifications for items.
|
||||
3. **Strategic Content**: Creating the Briefing Analysis and Strategic Vision.
|
||||
4. **Information Architecture**: Designing a hierarchical sitemap.
|
||||
5. **Position Synthesis**: Mapping everything to a transparent pricing model.
|
||||
6. **Industrial Critic**: Final quality gate for tone and accuracy.
|
||||
|
||||
### Generating an Estimation from Scratch
|
||||
|
||||
#### 1. With a Website URL (Recommended)
|
||||
Providing a URL allows the system to crawl the existing site to understand the "Company DNA" and services.
|
||||
```bash
|
||||
npm run ai-estimate -- "Relaunch der Website mit Fokus auf B2B Leads" --url https://example.com
|
||||
```
|
||||
|
||||
#### 2. From a Text File
|
||||
If you have a long briefing in a `.txt` file:
|
||||
```bash
|
||||
npm run ai-estimate -- @briefing.txt --url https://example.com
|
||||
```
|
||||
|
||||
#### 3. Text-Only (No URL)
|
||||
If no URL is provided, the system relies entirely on your briefing text.
|
||||
```bash
|
||||
npm run ai-estimate -- "Neuentwicklung eines Portals für XYZ"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📄 Output Modes
|
||||
|
||||
The system can generate two types of documents:
|
||||
|
||||
### 1. Full Quote (Default)
|
||||
Includes everything: Front Page, Briefing Analysis, Vision, Sitemap, Technical Principles, Detailed Pricing, Roadmap, and Legal Terms (AGB).
|
||||
```bash
|
||||
npm run ai-estimate -- "Project Briefing"
|
||||
```
|
||||
|
||||
### 2. Estimation Only
|
||||
A condensed version excluding legal terms and deep technical principles. Focuses purely on the strategic fit and the price.
|
||||
```bash
|
||||
npm run ai-estimate -- "Project Briefing" --estimation
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📦 Cache & Cache Management
|
||||
|
||||
To save costs and time, all AI responses and crawl results are cached in the `.cache` directory.
|
||||
|
||||
### Regenerating with Cached Data
|
||||
If you run the same command again (identical briefing and URL), the system will use the cached results and won't call the AI APIs again. This is useful if you want to tweak the PDF layout without spending tokens.
|
||||
|
||||
### Forcing a Refresh
|
||||
To ignore the cache and get a fresh AI consultation:
|
||||
```bash
|
||||
npm run ai-estimate -- "Project Briefing" --clear-cache
|
||||
```
|
||||
|
||||
### Manual Tweaking (JSON State)
|
||||
Every run saves a detailed state to `out/estimations/json/[Company]_[Timestamp].json`.
|
||||
If you want to manually edit the AI's results (e.g., fix a typo in the sitemap or description), you can edit this JSON file and then regenerate the PDF from it:
|
||||
|
||||
```bash
|
||||
npm run ai-estimate -- --json out/estimations/json/Your_Project.json
|
||||
```
|
||||
*(Add `--estimation` if you want the condensed version).*
|
||||
|
||||
---
|
||||
|
||||
## 💡 Advanced Options
|
||||
|
||||
- `--comments "..."`: Add manual notes that the AI should consider (e.g., "Customer prefers a minimalist blue theme").
|
||||
- `--clear-cache`: Purges all cached data for this project before starting.
|
||||
- `--url [URL]`: Explicitly sets the crawl target (auto-discovered from briefing if omitted).
|
||||
@@ -1,63 +0,0 @@
|
||||
# High-Converting Keywords (Digital Architect / B2B)
|
||||
|
||||
Diese 50 Keywords sind strategisch ausgewählt, um entscheidungsfreudige B2B-Kunden (Geschäftsführer, CMOs, CTOs) anzuziehen, die nach Premium-Lösungen, Performance-Architekturen und messbarem ROI suchen. Sie meiden den "Billig-Sektor" (wie "Wordpress Website günstig") und fokussieren sich auf High-End Tech und Business-Impact.
|
||||
|
||||
## Kategorie 1: Enterprise Performance & Core Web Vitals (Pain Point: Sichtbarkeit & Speed)
|
||||
1. "Core Web Vitals Optimierung Agentur"
|
||||
2. "PageSpeed Insights 100 erreichen B2B"
|
||||
3. "Website Ladezeit verbessern Conversion Rate"
|
||||
4. "Mobile First Indexing Strategie 2026"
|
||||
5. "Headless Commerce Performance Optimierung"
|
||||
6. "Time to First Byte reduzieren Architektur"
|
||||
7. "Largest Contentful Paint optimieren Next.js"
|
||||
8. "Website Performance Audit B2B"
|
||||
9. "Static Site Generation vs Server Side Rendering SEO"
|
||||
10. "Enterprise SEO Performance Tech Stack"
|
||||
|
||||
## Kategorie 2: Modern Tech Stack & Headless (Pain Point: Skalierbarkeit & Legacy-Code)
|
||||
11. "Next.js Agentur Deutschland"
|
||||
12. "Headless CMS Migration B2B"
|
||||
13. "Vercel Hosting Enterprise Architektur"
|
||||
14. "Directus CMS Agentur Setup"
|
||||
15. "React Server Components Vorteile B2B"
|
||||
16. "Decoupled Architecture E-Commerce"
|
||||
17. "Legacy CMS ablösen Strategie"
|
||||
18. "Wordpress headless machen Vor- und Nachteile"
|
||||
19. "Jamstack Entwicklung Enterprise"
|
||||
20. "Microservices Web Architektur"
|
||||
|
||||
## Kategorie 3: B2B Conversion & Digital ROI (Pain Point: Umsatz & Leads)
|
||||
21. "B2B Website Relaunch Strategie"
|
||||
22. "Digital Architect Consulting B2B"
|
||||
23. "Conversion Rate Optimierung Tech Stack"
|
||||
24. "Lead Generierung B2B Website Architektur"
|
||||
25. "High-End Website Entwicklung Kosten"
|
||||
26. "ROI von Website Performance"
|
||||
27. "B2B Landingpage Architektur"
|
||||
28. "Website als Vertriebsmitarbeiter B2B"
|
||||
29. "Data Driven Design B2B"
|
||||
30. "UX/UI Architektur für hohe Conversion"
|
||||
|
||||
## Kategorie 4: Infrastruktur, Sicherheit & Skalierbarkeit (Pain Point: Ausfälle & Security)
|
||||
31. "Cloudflare Enterprise Setup Agentur"
|
||||
32. "DDoS Schutz Web Architektur B2B"
|
||||
33. "Serverless Architecture Vorteile"
|
||||
34. "Hochverfügbare Website Architektur"
|
||||
35. "Edge Computing für Websiten"
|
||||
36. "Web Security Audit Enterprise"
|
||||
37. "Zero Trust Web Architektur"
|
||||
38. "Traefik Proxy Setup Next.js"
|
||||
39. "Dockerized CMS Deployment"
|
||||
40. "CI/CD Pipeline Webentwicklung B2B"
|
||||
|
||||
## Kategorie 5: Spezifische Lösungen & "Digital Architect" Keywords (Nischen-Autorität)
|
||||
41. "Digital Architect Agentur Deutschland"
|
||||
42. "Mittelstand Digitalisierung Web-Infrastruktur"
|
||||
43. "Industrie 4.0 B2B Website"
|
||||
44. "Premium Webentwicklung Geschäftsführer"
|
||||
45. "Software Architektur Beratung B2B"
|
||||
46. "Web Vitals als Rankingfaktor Strategie"
|
||||
47. "Next.js Directus Integration"
|
||||
48. "High Performance Corporate Website"
|
||||
49. "Tech Stack Audit für Mittelstand"
|
||||
50. "Sustainable Web Design Architektur"
|
||||
@@ -1,238 +0,0 @@
|
||||
Digitale Systeme für Unternehmen, die keinen Overhead wollen
|
||||
|
||||
Agenturen sind zu langsam.
|
||||
CMS will keiner pflegen.
|
||||
Digitale Themen bleiben liegen.
|
||||
|
||||
Ich mache das anders.
|
||||
|
||||
⸻
|
||||
|
||||
Was ich mache
|
||||
|
||||
Ich setze digitale Systeme für Unternehmen um – direkt, sauber und ohne Agentur-Zirkus.
|
||||
|
||||
Websites, Funktionen, Systeme, interne Tools.
|
||||
Keine Workshops. Keine Tickets. Kein Tech-Blabla.
|
||||
|
||||
Sie erklären mir, was Sie brauchen.
|
||||
Ich sorge dafür, dass es funktioniert.
|
||||
|
||||
⸻
|
||||
|
||||
Für wen das ist
|
||||
|
||||
Für Unternehmen, die:
|
||||
• regelmäßig Änderungen an Website oder Systemen brauchen
|
||||
• keine Lust auf Agenturen haben
|
||||
• kein CMS anfassen wollen
|
||||
• keine Tickets schreiben möchten
|
||||
• keinen Entwickler einstellen wollen
|
||||
• und wollen, dass Dinge einfach erledigt werden
|
||||
|
||||
Wenn bei Ihnen öfter der Satz fällt:
|
||||
|
||||
„Das müsste man mal machen …“
|
||||
|
||||
… aber es passiert nie – dann sind Sie hier richtig.
|
||||
|
||||
⸻
|
||||
|
||||
Das eigentliche Problem
|
||||
|
||||
Digitale Arbeit scheitert nicht an Technik.
|
||||
Sie scheitert an Zuständigkeit.
|
||||
|
||||
Agenturen wollen Projekte.
|
||||
Mitarbeiter haben Wichtigeres zu tun.
|
||||
IT ist ausgelastet.
|
||||
Und kleine Aufgaben sind zu klein für große Angebote.
|
||||
|
||||
Also bleibt alles liegen.
|
||||
|
||||
⸻
|
||||
|
||||
Warum keine Agentur
|
||||
|
||||
Ich habe über 15 Jahre in Agenturen gearbeitet.
|
||||
Ich kenne das Spiel. Und ich weiß, warum es nervt.
|
||||
|
||||
⸻
|
||||
|
||||
Agenturen machen einfache Dinge kompliziert
|
||||
|
||||
Ein Button ändern?
|
||||
|
||||
→ Konzeptcall
|
||||
→ Abstimmung
|
||||
→ internes Meeting
|
||||
→ Angebot
|
||||
→ Warten
|
||||
→ Rechnung
|
||||
|
||||
Ich:
|
||||
→ mache es
|
||||
→ fertig
|
||||
|
||||
⸻
|
||||
|
||||
Agenturen verkaufen Prozesse statt Ergebnisse
|
||||
|
||||
Workshops, Slides, Roadmaps, Alignment, Stakeholder.
|
||||
Klingt nach Fortschritt.
|
||||
Ist oft nur Beschäftigungstherapie.
|
||||
|
||||
Bei mir zählt nur Umsetzung.
|
||||
|
||||
⸻
|
||||
|
||||
Agenturen rechnen nach Stunden
|
||||
|
||||
(und liegen erstaunlich oft daneben)
|
||||
|
||||
„Das dauert nur kurz“
|
||||
→ Überraschung auf der Rechnung
|
||||
|
||||
Ich arbeite mit klaren Leistungen und Fixpreisen.
|
||||
Sie wissen vorher, was es kostet. Immer.
|
||||
|
||||
⸻
|
||||
|
||||
Agenturen geben Aufgaben weiter
|
||||
|
||||
(und verlieren sie dann aus den Augen)
|
||||
|
||||
Heute Projektmanager.
|
||||
Morgen Entwickler.
|
||||
Übermorgen niemand.
|
||||
|
||||
Bei mir gilt:
|
||||
Eine Person. Eine Verantwortung.
|
||||
|
||||
⸻
|
||||
|
||||
Agenturen verschwinden nach dem Projekt
|
||||
|
||||
Kleine Änderung? → neues Angebot
|
||||
Dringend? → Warteschleife
|
||||
|
||||
Ich bleibe.
|
||||
Solange Sie Dinge brauchen.
|
||||
|
||||
⸻
|
||||
|
||||
Wie ich arbeite (und warum das entspannter ist)
|
||||
|
||||
Agenturen machen erst ein Konzept.
|
||||
Dann wird umgesetzt.
|
||||
Dann merkt man: „Passt doch nicht ganz.“
|
||||
Dann wird nachberechnet.
|
||||
|
||||
Ich mache es anders.
|
||||
|
||||
⸻
|
||||
|
||||
Ich baue zuerst. Dann reden wir drüber.
|
||||
|
||||
Sie erklären mir Ihre Vorstellung.
|
||||
Ich setze den ersten echten Stand um.
|
||||
|
||||
Keine Slides.
|
||||
Kein Konzept-PDF.
|
||||
Kein Ratespiel.
|
||||
|
||||
Dann arbeiten wir direkt am Ergebnis, bis es passt.
|
||||
Ohne jedes Mal ein neues Angebot.
|
||||
Ohne Scope-Diskussionen.
|
||||
Ohne Theater.
|
||||
|
||||
⸻
|
||||
|
||||
Was ich konkret umsetze
|
||||
|
||||
Websites
|
||||
• neue Websites (klarer Standard, kein Chaos)
|
||||
• bestehende Websites übernehmen
|
||||
• Seiten ändern oder ergänzen
|
||||
• Performance & SEO
|
||||
• Hosting & Betrieb (inklusive)
|
||||
|
||||
⸻
|
||||
|
||||
Funktionen & Systeme
|
||||
• Produktbereiche
|
||||
• Blogs, News, Jobs
|
||||
• Formulare (auch mehrstufig)
|
||||
• Downloads
|
||||
• Suche & Filter
|
||||
• PDF-Generatoren
|
||||
• API-Ausgaben & Daten-Sync
|
||||
• Sonderlogik
|
||||
|
||||
⸻
|
||||
|
||||
Interne Tools
|
||||
• kleine Inhouse-Tools
|
||||
• Excel ersetzen
|
||||
• Importe & Exporte
|
||||
• Automatisierung
|
||||
• Dinge, die nerven → weg
|
||||
|
||||
⸻
|
||||
|
||||
Was ich bewusst nicht mache
|
||||
• keine CMS-Schulungen
|
||||
• keine Agentur-Workshops
|
||||
• keine Ticketsysteme
|
||||
• keine Stundenabrechnung für Websites
|
||||
• kein Overhead
|
||||
• keine Prozessshows
|
||||
|
||||
Das ist kein Mangel.
|
||||
Das ist der Vorteil.
|
||||
|
||||
⸻
|
||||
|
||||
Preise (klar & transparent)
|
||||
|
||||
Ich arbeite mit festen Leistungen und Fixpreisen.
|
||||
Keine Abos. Keine Überraschungen.
|
||||
|
||||
Grundlage
|
||||
• Website-Basis → 6.000 €
|
||||
• Hosting & Betrieb → 120 € / Monat (inkl. 20 GB Medien)
|
||||
|
||||
Entwicklung
|
||||
• Seite → 800 €
|
||||
• Feature (System) → 2.000 €
|
||||
• Funktion (Logik) → 1.000 €
|
||||
|
||||
Inhalte
|
||||
• Neuer Datensatz → 400 €
|
||||
• Datensatz anpassen → 200 €
|
||||
|
||||
Optional
|
||||
• CMS-Einrichtung → 1.500 €
|
||||
• CMS-Anbindung pro Feature → 800 €
|
||||
• Speichererweiterung → +10 € / 10 GB
|
||||
|
||||
Sie wissen vorher, was es kostet. Immer.
|
||||
|
||||
⸻
|
||||
|
||||
Warum Kunden bleiben
|
||||
• Dinge passieren schnell
|
||||
• Aufgaben verschwinden wirklich
|
||||
• kein Erklären
|
||||
• kein Nachfassen
|
||||
• kein Projektstress
|
||||
• kein Agentur-Zirkus
|
||||
|
||||
Kurz: Ruhe.
|
||||
|
||||
⸻
|
||||
|
||||
Interesse?
|
||||
|
||||
Schreiben Sie mir einfach, was Sie brauchen.
|
||||
Ich sage Ihnen ehrlich, ob ich es mache – und was es kostet.
|
||||
@@ -1,35 +0,0 @@
|
||||
# Sorglos-Betrieb – Damit Sie sich auf Ihr Geschäft konzentrieren können
|
||||
|
||||
Eine Website ist ein lebendiger Teil Ihres Unternehmens. Sie soll funktionieren, sicher sein und gut aussehen – ohne dass Sie sich ständig darum kümmern müssen.
|
||||
|
||||
Genau dafür gibt es meinen Sorglos-Betrieb.
|
||||
|
||||
Das ist keine automatische Server-Überwachung und kein reiner Wartungsvertrag. Es ist meine persönliche Verantwortung, Ihre Website so stabil und aktuell wie möglich zu halten. Ich schaue regelmäßig vorbei, behebe Probleme frühzeitig und reagiere schnell, wenn doch einmal etwas nicht rund läuft.
|
||||
|
||||
## Was Sie erwarten können:
|
||||
|
||||
### Ich kümmere mich proaktiv
|
||||
|
||||
Regelmäßige Checks, Updates von Systemen und Plugins, Sicherheitsüberprüfungen – alles, was typische Ausfälle und Schwachstellen verhindert oder minimiert.
|
||||
|
||||
### Schnelle Reaktion bei Problemen
|
||||
|
||||
Websites können mal down gehen – durch Hoster-Störungen, plötzliche Updates von Drittanbietern oder andere unvorhergesehene Dinge. In solchen Fällen bin ich für Sie da: Ich analysiere, behebe und informiere Sie transparent. Sie müssen nicht selbst recherchieren oder panisch Support-Tickets schreiben.
|
||||
|
||||
### Sicherheit und Aktualität bleiben im Blick
|
||||
|
||||
Ich halte die bestehende Technik auf dem neuesten Stand, korrigiere kleine Fehler und passe Inhalte an, wenn nötig – alles innerhalb des vereinbarten Rahmens. So bleibt Ihre Website vertrauenswürdig und nutzerfreundlich.
|
||||
|
||||
### Klare Grenzen – faire Erwartungen
|
||||
|
||||
Der Sorglos-Betrieb deckt die Instandhaltung und Pflege der bestehenden Website ab: Technischer Betrieb, Sicherheit, kleine Korrekturen und Aktualisierungen.
|
||||
Neue Inhalte erstellen, große Umstrukturierungen, neue Features oder umfangreiche Redaktionsarbeit gehören nicht dazu – das besprechen und bepreisen wir separat und transparent.
|
||||
|
||||
### Kurz gesagt:
|
||||
|
||||
Ich nehme Ihnen so viel wie möglich vom technischen Alltag ab, damit Sie sich auf das konzentrieren können, was Sie am besten können – Ihr Geschäft führen.
|
||||
Ich kann nicht jede Störung im Voraus verhindern (das kann niemand), aber ich sorge dafür, dass solche Momente selten bleiben und schnell wieder behoben sind.
|
||||
|
||||
### Sorglos-Betrieb bedeutet für mich:
|
||||
|
||||
Ich kümmere mich – verlässlich, ehrlich und mit dem gleichen Anspruch, mit dem ich Ihre Website gebaut habe.
|
||||
@@ -1,234 +0,0 @@
|
||||
Preise
|
||||
|
||||
1. Website – Fixpreis
|
||||
|
||||
Basis
|
||||
|
||||
4.000 € einmalig
|
||||
|
||||
Die Grundlage für jede Website:
|
||||
• Projekt-Setup & Infrastruktur
|
||||
• Hosting-Bereitstellung
|
||||
• Grundstruktur & Design-Vorlage
|
||||
• technisches SEO-Basics
|
||||
• Analytics (mit automatischem Mail-Report)
|
||||
• Testing, Staging, Production Umgebung
|
||||
• Livegang
|
||||
|
||||
Enthält keine Seiten, Inhalte oder Funktionen.
|
||||
|
||||
⸻
|
||||
|
||||
2. Entwicklung (Produktion)
|
||||
|
||||
Seite
|
||||
|
||||
600 € / Seite
|
||||
|
||||
Individuell gestaltete Seite –
|
||||
mit Layout, Struktur, Textaufteilung, responsivem Design.
|
||||
|
||||
⸻
|
||||
|
||||
Feature (System)
|
||||
|
||||
1.500 € / Feature
|
||||
|
||||
Ein in sich geschlossenes System mit Datenstruktur, Darstellung und Pflegefähigkeit.
|
||||
|
||||
Typische Beispiele:
|
||||
• Produktbereich
|
||||
• Blog
|
||||
• News
|
||||
• Jobs
|
||||
• Referenzen
|
||||
• Events
|
||||
|
||||
Ein Feature erzeugt ein Datenmodell, Übersichten & Detailseiten.
|
||||
|
||||
⸻
|
||||
|
||||
Funktion (Logik)
|
||||
|
||||
800 € / Funktion
|
||||
|
||||
Funktionen liefern Logik und Interaktion, z. B.:
|
||||
• Kontaktformular
|
||||
• Mailversand
|
||||
• Suche
|
||||
• Filter
|
||||
• Mehrsprachigkeit (System)
|
||||
• PDF-Export von Daten
|
||||
• API-Anbindungen (z. B. Produkt-Sync)
|
||||
• Redirect-Logik
|
||||
• Automatisierte Aufgaben
|
||||
|
||||
Jede Funktion ist ein klar umrissener Logikbaustein.
|
||||
|
||||
⸻
|
||||
|
||||
3. Visuelle Inszenierung & Interaktion
|
||||
|
||||
(Hier geht es um Design/UX-Extras, nicht um „Standard-Design“.)
|
||||
|
||||
Visuelle Inszenierung
|
||||
|
||||
1.500 € / Abschnitt
|
||||
|
||||
Erweiterte Gestaltung:
|
||||
• Hero-Story
|
||||
• visuelle Abläufe
|
||||
• Scroll-Effekte
|
||||
• speziell inszenierte Sektionen
|
||||
|
||||
⸻
|
||||
|
||||
Komplexe Interaktion
|
||||
|
||||
1.500 € / Interaktion
|
||||
|
||||
Dargestellte, interaktive UI-Erlebnisse:
|
||||
• Konfiguratoren
|
||||
• Live-Previews
|
||||
• mehrstufige Auswahlprozesse
|
||||
|
||||
(Nutzt deine bestehenden Bausteine, gehört aber zur Entwicklung.)
|
||||
|
||||
⸻
|
||||
|
||||
4. Inhalte & Medien
|
||||
|
||||
Neuer Datensatz
|
||||
|
||||
200 € / Stück
|
||||
|
||||
Beispiele:
|
||||
• Produkt
|
||||
• Blogpost
|
||||
• News
|
||||
• Case
|
||||
• Job
|
||||
|
||||
Datensätze enthalten Inhalte mit Text, Medien, Metadaten.
|
||||
|
||||
⸻
|
||||
|
||||
Datensatz anpassen
|
||||
|
||||
200 € / Stück
|
||||
• Textupdates
|
||||
• Bildwechsel
|
||||
• Feldänderungen (ohne Schemaänderung)
|
||||
|
||||
⸻
|
||||
|
||||
5. Betrieb & Wartung (Pflicht)
|
||||
|
||||
Hosting & Betrieb
|
||||
|
||||
12 Monate = 3.000 €
|
||||
|
||||
Sichert:
|
||||
• Webhosting & Verfügbarkeit
|
||||
• Sicherheitsupdates
|
||||
• Backups & Monitoring
|
||||
• Analytics inkl. Reports
|
||||
• Medien-Speicher (Standard bis 20 GB)
|
||||
|
||||
⸻
|
||||
|
||||
6. Speicher-Erweiterung (optional)
|
||||
|
||||
Mehr Speicher
|
||||
|
||||
+10 € / Monat → +10 GB (aber nur 20/100/200 GB)
|
||||
|
||||
Wenn das inklusive Volumen überschritten wird, wird automatisch erweitert.
|
||||
|
||||
(Keine Leistungsdiskussion – nur Infrastruktur.)
|
||||
|
||||
⸻
|
||||
|
||||
7. Headless CMS (optional)
|
||||
|
||||
CMS-Einrichtung
|
||||
|
||||
1.500 € einmalig
|
||||
|
||||
Einrichtung eines Headless CMS:
|
||||
• Struktur
|
||||
• Rollen
|
||||
• Rechte
|
||||
• API-Anbindung
|
||||
• Deployment
|
||||
• kurze Einführung
|
||||
|
||||
⸻
|
||||
|
||||
CMS-Anbindung pro Feature
|
||||
|
||||
800 € / Feature
|
||||
|
||||
Erlaubt, dass Datensätze (z. B. Blog, News) im CMS gepflegt werden.
|
||||
Seiten & Layout bleiben bei dir.
|
||||
|
||||
⸻
|
||||
|
||||
8. App / interne Software
|
||||
|
||||
Entwicklung nach Zeit
|
||||
|
||||
120 € / Stunde
|
||||
|
||||
Für:
|
||||
• interne Tools
|
||||
• Prozesslogik
|
||||
• Workflows
|
||||
• Automatisierungen
|
||||
• alles, was Zustände und Abläufe beinhaltet
|
||||
|
||||
(Kein Fixpreis, weil scope offen ist.)
|
||||
|
||||
⸻
|
||||
|
||||
9. Integrationen (optional)
|
||||
|
||||
API-Schnittstelle / Daten-Sync
|
||||
|
||||
800 € / Zielsystem
|
||||
|
||||
Synchronisation zu externem System (Push):
|
||||
• Produkt-Sync
|
||||
• CRM / ERP / Stripe / sonstige API
|
||||
|
||||
Nicht enthalten:
|
||||
• Betrieb fremder Systeme
|
||||
• Echtzeit-Pull-Mechanismen
|
||||
• Zustandsabhängige Syncs
|
||||
|
||||
⸻
|
||||
|
||||
10. Wichtige Regeln
|
||||
|
||||
Seiten = Entwicklung
|
||||
Datensätze = Inhalte & Pflege
|
||||
Features = Daten-Systeme
|
||||
Funktionen = Logik
|
||||
CMS-Anbindung = optionale Datenpflege über Schnittstelle
|
||||
Betrieb = Hosting, Updates, Backups, Analytics
|
||||
Apps = Stunden (Prozesse & Systeme außerhalb der Website)
|
||||
|
||||
⸻
|
||||
|
||||
Leistungsausschlüsse (kurz und klar)
|
||||
• Kein Betrieb von Mail-Servern
|
||||
• Keine Logistik, kein Shop-Checkout
|
||||
• Kein Drittanbieter-Betrieb
|
||||
• Keine permanente Überwachung fremder Systeme
|
||||
|
||||
⸻
|
||||
|
||||
Satz für Kundenkommunikation
|
||||
|
||||
Ich baue digitale Systeme mit klaren Preisen und Ergebnissen –
|
||||
keine Stunden, keine Überraschungen.
|
||||
@@ -1,43 +0,0 @@
|
||||
Prinzipien
|
||||
|
||||
Ich arbeite nach klaren Grundsätzen, die sicherstellen, dass meine Kunden fair, transparent und langfristig profitieren.
|
||||
|
||||
⸻
|
||||
|
||||
1. Volle Preis-Transparenz
|
||||
Alle Kosten sind offen und nachvollziehbar.
|
||||
Es gibt keine versteckten Gebühren, keine Abos, keine Lock-ins.
|
||||
Jeder Kunde sieht genau, wofür er bezahlt.
|
||||
|
||||
⸻
|
||||
|
||||
2. Quellcode & Projektzugang
|
||||
Auf Wunsch erhalten Kunden jederzeit den vollständigen Source Code und eine nachvollziehbare Struktur.
|
||||
Damit kann jeder andere Entwickler problemlos weiterarbeiten.
|
||||
Niemand kann später behaupten, der Code sei „Messy“ oder unbrauchbar.
|
||||
|
||||
⸻
|
||||
|
||||
3. Best Practices & saubere Technik
|
||||
Ich setze konsequent bewährte Standards und dokumentierte Abläufe ein.
|
||||
Das sorgt dafür, dass Systeme wartbar, verständlich und erweiterbar bleiben – langfristig.
|
||||
|
||||
⸻
|
||||
|
||||
4. Verantwortung & Fairness
|
||||
Ich übernehme die technische Verantwortung für die Website.
|
||||
Ich garantiere keine Umsätze, Rankings oder rechtliche Ergebnisse – nur saubere Umsetzung und stabile Systeme.
|
||||
Wenn etwas nicht sinnvoll ist, sage ich es ehrlich.
|
||||
|
||||
⸻
|
||||
|
||||
5. Langfristiger Wert
|
||||
Eine Website ist ein Investment.
|
||||
Ich baue sie so, dass Anpassungen, Erweiterungen und Übergaben an andere Entwickler problemlos möglich sind.
|
||||
Das schützt Ihre Investition und vermeidet teure Neuaufbauten.
|
||||
|
||||
⸻
|
||||
|
||||
6. Zusammenarbeit ohne Tricks
|
||||
Keine künstlichen Deadlines, kein unnötiger Overhead.
|
||||
Kommunikation ist klar, Entscheidungen nachvollziehbar, Übergaben sauber dokumentiert.
|
||||
@@ -1,36 +0,0 @@
|
||||
# Meine Standards
|
||||
|
||||
Ich entwickle Websites nach Prinzipien, die über das reine Funktionieren hinausgehen.
|
||||
Das Ziel ist eine Seite, die nicht nur heute überzeugt, sondern in den nächsten Jahren stabil, kosteneffizient, sicher und verantwortungsvoll bleibt – ohne dass Sie ständig nachbessern, abmahnen lassen oder sich für unnötigen Ressourcenverbrauch rechtfertigen müssen.
|
||||
|
||||
## Was das für Sie konkret bedeutet:
|
||||
|
||||
### Deutlich geringerer Energie- und CO₂-Verbrauch
|
||||
|
||||
Durch konsequente Optimierung von Code, Bildern, Schriften und Ladeverhalten entsteht eine schlanke Website.
|
||||
→ Ihre Besucher laden die Seite spürbar schneller, Ihre Hosting-Kosten bleiben niedrig, und der CO₂-Fußabdruck pro Aufruf liegt oft um 70–90 % unter dem Durchschnitt vergleichbarer Projekte. Das ist heute für viele Unternehmen ein echter Wettbewerbs- und Imagevorteil – ohne dass Sie Kompromisse bei Design oder Funktionalität eingehen müssen.
|
||||
|
||||
### Technologische Souveränität & Unabhängigkeit
|
||||
|
||||
Keine Abhängigkeit von Big Tech, geschlossenen Baukasten-Systemen oder undurchsichtigen Cloud-Plattformen. Wir setzen konsequent auf Self-Hosting und Open-Source-Kerntechnologien.
|
||||
→ Alles, was für Ihre Website entwickelt wird, gehört Ihnen. Der Code ist custom-coded, die Infrastruktur ist unabhängig. Wenn ein großer Anbieter seine Preise erhöht, Bedingungen ändert oder Dienste einstellt, bleibt Ihre Website davon unberührt. Sie behalten die volle Kontrolle über Ihre digitale Identität – dauerhaft und ohne "Lock-in"-Effekte.
|
||||
|
||||
### Vertrauen bei Ihren Besuchern
|
||||
|
||||
Kein Cookie-Banner, kein heimliches Tracking, keine versteckten Datenabfragen.
|
||||
→ Viele Menschen erkennen intuitiv, dass hier mitgedacht wurde. Das schafft ein deutlich besseres Gefühl – besonders bei Kunden, die selbst Wert auf Datenschutz legen, in regulierten Branchen tätig sind oder einfach ein seriöses Unternehmen erwarten.
|
||||
|
||||
### Sicherheit von Grund auf eingebaut
|
||||
|
||||
Sichere Formulare, Schutz vor typischen Angriffsvektoren, keine veralteten oder riskanten Bibliotheken, lokale Ressourcen wo immer möglich.
|
||||
→ Die Website wird nicht schon nach wenigen Monaten zum Sicherheitsrisiko. Sie sparen sich spätere teure Sicherheits-Updates, Penetrationstests oder im Worst Case den Umgang mit einem Datenleck. Mehr Ruhe und weniger unvorhergesehene Kosten.
|
||||
|
||||
### DSGVO-Konformität ohne Grauzonen oder Tricks
|
||||
|
||||
Es wird nur verarbeitet, was technisch unbedingt erforderlich ist und ohne aktive Einwilligung erlaubt bleibt.
|
||||
→ Sie können das Thema Datenschutz mit gutem Gewissen abhaken. Keine Abmahn-Risiken, keine nervösen Kundenanfragen, keine teuren Nachbesserungen.
|
||||
|
||||
### Langfristig wartungsarm und zukunftssicher
|
||||
|
||||
Weil Abhängigkeiten minimiert und bewährte, schlanke Techniken bevorzugt werden, altert die Website deutlich langsamer.
|
||||
→ Sie zahlen weniger für regelmäßige Updates, haben seltener böse Überraschungen und können Ihr Budget gezielt in Inhalte, Marketing oder neue Funktionen investieren – statt in Notfall-Reparaturen.
|
||||
@@ -1,85 +0,0 @@
|
||||
# Style Guide: Digital Architect
|
||||
|
||||
This document defines the visual language and design rules for the Mintel ecosystem. The goal is to maintain a "Digital Architect" aesthetic: technical, reductionist, and high-fidelity.
|
||||
|
||||
## 1. Core Philosophy: Reduction to Essentials
|
||||
|
||||
The design should feel "Websites ohne Overhead." Every element must serve a purpose. If it doesn't add value, remove it.
|
||||
|
||||
- **Technical Precision**: Use grids, mono-spaced labels, and clear hierarchies to signal technical competence.
|
||||
- **Tactile Digital Objects**: UI elements should feel like physical objects (buttons that depress, tags that pop in, glass that blurs).
|
||||
- **High Contrast**: Bold black/slate on clean white backgrounds, with vibrant highlighter accents.
|
||||
|
||||
## 2. Color Palette
|
||||
|
||||
The project uses a monochrome base with curated highlighter accents.
|
||||
|
||||
- **Primary Base**:
|
||||
- **Backgrounds**: Pure White (`#ffffff`) for clarity.
|
||||
- **Surfaces**: Slate-50 for subtle depth, White with 90% opacity + 10px blur for glassmorphism.
|
||||
- **Grays (Slate)**:
|
||||
- **Text**: Slate-800 for body, Slate-900 for headings.
|
||||
- **UI Borders**: Slate-100 or Slate-200.
|
||||
- **Muted text**: Slate-400 or Slate-500.
|
||||
- **Highlighter Accents**: Used exclusively for tags, markers, and selective emphasis.
|
||||
- **Yellow**: Warm, high-visibility (`rgba(255, 235, 59, 0.95)`).
|
||||
- **Pink**: Vibrant, energetic (`rgba(255, 167, 209, 0.95)`).
|
||||
- **Green**: Success, technical health (`rgba(129, 199, 132, 0.95)`).
|
||||
- **Blue**: Neutral, structural (`rgba(226, 232, 240, 0.95)`).
|
||||
|
||||
## 3. Typography
|
||||
|
||||
A high-contrast mix of fonts that balances modern tech with editorial readability.
|
||||
|
||||
- **Headings (Sans-serif)**: Use **Inter**.
|
||||
- Tracking: `-0.025em` to `-0.05em` (tracking-tighter).
|
||||
- Weight: Bold (`700`).
|
||||
- Color: Slate-900.
|
||||
- **Body (Serif)**: Use **Newsreader** or Georgia.
|
||||
- Style: Defaults to serif for long-form content to provide a "notebook" feel.
|
||||
- Line-height: Relaxed (`1.6` to `1.75`).
|
||||
- **Technical (Monospace)**: Use **JetBrains Mono**.
|
||||
- Usage: Small labels, tags, code snippets, and "Mono-Labels" (e.g., section numbers).
|
||||
- Feature: Uppercase with wide tracking (`0.3em` to `0.4em`).
|
||||
|
||||
## 4. Layout & Rhythm
|
||||
|
||||
Standardized containers ensure consistency across different screen sizes.
|
||||
|
||||
- **Standard Container**: Max-width 6xl (`72rem`). Used for most page sections.
|
||||
- **Wide Container**: Max-width 7xl (`80rem`). Used for galleries or high-impact visuals.
|
||||
- **Narrow Container**: Max-width 4xl (`56rem`). Used for focused reading and blog posts.
|
||||
- **Section Rhythm**: Sections are separated by clear `border-top` lines and numbered (e.g., "01", "02").
|
||||
|
||||
## 5. UI Elements & Interactions
|
||||
|
||||
### 5.1 Buttons
|
||||
- **Shape**: Always pill-shaped (rounded-full).
|
||||
- **Style**: Thin borders (`1px`) with bold, uppercase mono-spaced text.
|
||||
- **Hover**: Should feel "expensive." Smooth translate-up (`-0.5rem`) and deep, soft shadows.
|
||||
|
||||
### 5.2 Cards & Containers
|
||||
- **Glassmorphism**: Use for search boxes and floating elements (`backdrop-filter: blur(10px)`).
|
||||
- **Cards**: Minimalist. Use `Slate-50` or thin `Slate-100` borders. Avoid heavy shadows unless on hover.
|
||||
|
||||
### 5.3 Highlighters & Tags
|
||||
- **Marker Effect**: Use a hand-drawn marker underline (diagonal skew, slightly erratic rotation) for key titles.
|
||||
- **Tags**: Small, bold, uppercase. They should use `tagPopIn` animations when appearing.
|
||||
|
||||
## 6. Motion & Atmosphere
|
||||
|
||||
- **Reveals**: Content should never "just appear." Use slide-up and fade-in transitions (`0.5s` to `0.7s`) to create a sense of discovery.
|
||||
- **Background Grid**: A subtle, low-opacity grid pattern provides a technical "blueprint" feel to the pages.
|
||||
- **Micro-interactions**: Hovering over icons or tags should trigger subtle scales (`105%-110%`) and color shifts.
|
||||
|
||||
## 7. Thumbnail & Illustration Style
|
||||
|
||||
Blog post thumbnails are generated via AI image generation. They should follow these style rules:
|
||||
|
||||
- **Style**: Technical blueprint / architectural illustration — clean lines, monochrome base with one highlighter accent color
|
||||
- **No photos**: Abstract, geometric, or diagrammatic illustrations only
|
||||
- **Color palette**: Slate grays + one accent from the highlighter palette (yellow, pink, or green)
|
||||
- **Feel**: "Engineering notebook sketch" — precise, minimal, professional
|
||||
- **No text in images**: Titles and labels are handled by the website layout
|
||||
- **Aspect ratio**: 16:9 for blog headers, 1:1 for social media cards
|
||||
- **No AI-generated photos of people, products, or realistic scenes** — only abstract/technical visualizations
|
||||
@@ -1,98 +0,0 @@
|
||||
Wie ich Websites technisch umsetze
|
||||
|
||||
Ich entwickle Websites als moderne, performante Websysteme – nicht als Baukasten-Seiten und nicht als schwer wartbare CMS-Konstrukte.
|
||||
Der Fokus liegt auf Geschwindigkeit, Stabilität, Datenschutz und langfristiger Wartbarkeit.
|
||||
|
||||
Die Technik dient dabei immer einem Zweck:
|
||||
Ihre Website soll zuverlässig funktionieren, schnell laden und kein laufendes Risiko darstellen.
|
||||
|
||||
⸻
|
||||
|
||||
Geschwindigkeit & Performance
|
||||
|
||||
Meine Websites sind so aufgebaut, dass Inhalte extrem schnell ausgeliefert werden – unabhängig davon, ob ein Besucher am Desktop oder mobil unterwegs ist.
|
||||
|
||||
Das bedeutet für Sie:
|
||||
• kurze Ladezeiten
|
||||
• bessere Nutzererfahrung
|
||||
• messbar bessere Werte bei Google PageSpeed & Core Web Vitals
|
||||
• geringere Absprungraten
|
||||
|
||||
Die Seiten werden nicht „zusammengeklickt“, sondern technisch optimiert ausgeliefert.
|
||||
|
||||
⸻
|
||||
|
||||
Responsives Design (ohne Kompromisse)
|
||||
|
||||
Jede Website ist von Grund auf responsiv.
|
||||
Layout, Inhalte und Funktionen passen sich automatisch an:
|
||||
• Smartphones
|
||||
• Tablets
|
||||
• Laptops
|
||||
• große Bildschirme
|
||||
|
||||
Dabei wird nicht einfach skaliert, sondern gezielt für unterschiedliche Bildschirmgrößen optimiert.
|
||||
Das Ergebnis ist eine saubere Darstellung und gute Bedienbarkeit auf allen Geräten.
|
||||
|
||||
⸻
|
||||
|
||||
Stabilität & Betriebssicherheit
|
||||
|
||||
Im Hintergrund laufen Überwachungs- und Kontrollmechanismen, die technische Probleme automatisch erkennen.
|
||||
|
||||
Für Sie heißt das:
|
||||
• Fehler werden bemerkt, auch wenn niemand sie meldet
|
||||
• ich werde aktiv informiert, statt erst zu reagieren, wenn etwas kaputt ist
|
||||
• Probleme können frühzeitig behoben werden
|
||||
|
||||
Das reduziert Ausfälle und vermeidet unangenehme Überraschungen.
|
||||
|
||||
⸻
|
||||
|
||||
Datenschutz & DSGVO
|
||||
|
||||
Ich setze konsequent auf freie, selbst betriebene Software statt auf große externe Plattformen.
|
||||
|
||||
Ihre Vorteile:
|
||||
• keine Weitergabe von Nutzerdaten an Dritte
|
||||
• keine versteckten Tracker
|
||||
• keine Abhängigkeit von US-Anbietern
|
||||
• datenschutzfreundliche Statistik ohne Cookies
|
||||
|
||||
Die Website bleibt technisch schlank und rechtlich kontrollierbar.
|
||||
|
||||
⸻
|
||||
|
||||
Unabhängigkeit & Kostenkontrolle
|
||||
|
||||
Da ich keine proprietären Systeme oder Lizenzmodelle einsetze:
|
||||
• entstehen keine laufenden Tool-Gebühren
|
||||
• gibt es keine plötzlichen Preiserhöhungen
|
||||
• bleibt die Website langfristig planbar betreibbar
|
||||
|
||||
Sie zahlen für die Leistung – nicht für Lizenzen oder Marken.
|
||||
|
||||
⸻
|
||||
|
||||
Wartbarkeit & Erweiterbarkeit
|
||||
|
||||
Die technische Struktur ist so aufgebaut, dass:
|
||||
• Inhalte erweitert werden können
|
||||
• Funktionen sauber ergänzt werden können
|
||||
• Anpassungen nicht das ganze System gefährden
|
||||
|
||||
Das schützt Ihre Investition und verhindert teure Neuaufbauten nach kurzer Zeit.
|
||||
|
||||
⸻
|
||||
|
||||
Kurz gesagt
|
||||
|
||||
Ich baue Websites, die:
|
||||
• schnell sind
|
||||
• auf allen Geräten sauber funktionieren
|
||||
• datenschutzkonform betrieben werden
|
||||
• technisch überwacht sind
|
||||
• langfristig wartbar bleiben
|
||||
|
||||
Die Technik steht nicht im Vordergrund –
|
||||
aber sie sorgt dafür, dass Ihre Website zuverlässig ihren Zweck erfüllt.
|
||||
@@ -1,53 +0,0 @@
|
||||
Ton & Haltung in der Kommunikation
|
||||
|
||||
Dieses Dokument beschreibt die verbindlichen Prinzipien, nach denen ich mit Kunden kommuniziere – schriftlich wie mündlich, auf der Website wie im Projektalltag.
|
||||
|
||||
1. Klarheit vor Höflichkeit
|
||||
|
||||
Ich kommuniziere klar, direkt und verständlich.
|
||||
Unklare Formulierungen, Marketingfloskeln oder beschwichtigende Aussagen werden vermieden.
|
||||
Lieber eine ehrliche, präzise Aussage als eine „freundliche“ Unverbindlichkeit.
|
||||
|
||||
2. Ehrlichkeit ohne Verkaufsdruck
|
||||
|
||||
Ich verspreche nichts, was ich nicht sicher einhalten kann.
|
||||
Grenzen, Risiken und Unsicherheiten werden offen benannt.
|
||||
Es gibt keine künstliche Dringlichkeit, kein Upselling aus Prinzip und keine verdeckten Interessen.
|
||||
|
||||
3. Sachlich, ruhig, professionell
|
||||
|
||||
Die Kommunikation bleibt sachlich und respektvoll – auch bei Kritik, Verzögerungen oder Meinungsverschiedenheiten.
|
||||
Emotionale Eskalation, Schuldzuweisungen oder Rechtfertigungsschleifen werden vermieden.
|
||||
|
||||
4. Verantwortung statt Ausreden
|
||||
|
||||
Probleme werden benannt, nicht relativiert.
|
||||
Wenn etwas nicht funktioniert, wird erklärt warum – und wie damit umgegangen wird.
|
||||
Ich übernehme Verantwortung für meine Arbeit, nicht für äußere Faktoren außerhalb meines Einflusses.
|
||||
|
||||
5. Transparenz statt Fachchinesisch
|
||||
|
||||
Komplexe Sachverhalte werden verständlich erklärt, ohne künstliche Vereinfachung oder Herablassung.
|
||||
Fachbegriffe werden nur verwendet, wenn sie notwendig sind.
|
||||
Wissen dient der Orientierung des Kunden, nicht der Selbstdarstellung.
|
||||
|
||||
6. Gleichbehandlung aller Kunden
|
||||
|
||||
Alle Kunden werden gleich behandelt – unabhängig von Projektgröße, Budget oder Laufzeit.
|
||||
Es gibt keine versteckten Prioritäten, Sonderregeln oder impliziten Erwartungshaltungen.
|
||||
|
||||
7. Langfristige Perspektive
|
||||
|
||||
Die Kommunikation ist auf nachhaltige Zusammenarbeit ausgelegt, nicht auf kurzfristige Zustimmung.
|
||||
Entscheidungen und Empfehlungen orientieren sich am langfristigen Nutzen des Kunden.
|
||||
|
||||
8. Persona: Marc Mintel
|
||||
|
||||
- Mitte 30, deutsch, bodenständig
|
||||
- Umgangssprachlich aber professionell — wie ein Gespräch unter Kollegen, nicht wie ein Vortrag
|
||||
- Technisch kompetent, erklärt aber auf Augenhöhe statt zu dozieren
|
||||
- Der hilfreiche technische Nachbar, nicht der Silicon-Valley-Guru
|
||||
- Keine Arroganz, kein Belehren — Wissen teilen statt damit angeben. Wir machen unsere Dinge auch nicht größer als Sie sind. Wir sind bescheiden.
|
||||
- Spricht Probleme direkt an, ohne dramatisch zu werden
|
||||
- Nutzt "ich" statt "wir" oder Passivkonstruktionen
|
||||
- Vermeidet englische Buzzwords wo deutsche Begriffe existieren: "Kunden verlieren" statt "Churn Rate", "Ladezeit" statt "Time to Interactive"
|
||||
@@ -1,136 +0,0 @@
|
||||
Wie ich Websites baue – und warum Sie damit Ruhe haben
|
||||
|
||||
Die meisten Websites funktionieren.
|
||||
Bis jemand sie anfasst.
|
||||
Oder Google etwas ändert.
|
||||
Oder ein Plugin ein Update macht.
|
||||
Oder die Agentur nicht mehr antwortet.
|
||||
|
||||
Ich baue Websites so, dass das alles egal ist.
|
||||
|
||||
⸻
|
||||
|
||||
Ich baue Websites wie Systeme – nicht wie Broschüren
|
||||
|
||||
Eine Website ist kein Flyer.
|
||||
Sie ist ein System, das jeden Tag arbeitet.
|
||||
|
||||
Deshalb baue ich sie auch so:
|
||||
• stabil
|
||||
• schnell
|
||||
• vorhersehbar
|
||||
• ohne Überraschungen
|
||||
|
||||
Sie müssen nichts warten.
|
||||
Sie müssen nichts lernen.
|
||||
Sie müssen nichts pflegen, wenn Sie nicht wollen.
|
||||
|
||||
⸻
|
||||
|
||||
Geschwindigkeit ist kein Extra. Sie ist Standard.
|
||||
|
||||
Viele Websites sind langsam, weil sie zusammengeklickt sind.
|
||||
|
||||
Meine sind schnell, weil sie gebaut sind.
|
||||
|
||||
Das bedeutet für Sie:
|
||||
• Seiten laden sofort
|
||||
• Google mag sie
|
||||
• Besucher bleiben
|
||||
• weniger Absprünge
|
||||
• bessere Sichtbarkeit
|
||||
|
||||
90+ Pagespeed ist bei mir kein Ziel.
|
||||
Es ist der Normalzustand.
|
||||
|
||||
⸻
|
||||
|
||||
Keine Plugins. Keine Updates. Keine Wartungshölle.
|
||||
|
||||
Ich nutze keine Baukästen.
|
||||
Keine Plugin-Sammlungen.
|
||||
Keine Systeme, die sich selbst zerstören.
|
||||
|
||||
Ihre Website besteht aus:
|
||||
• sauberem Code
|
||||
• klarer Struktur
|
||||
• festen Bausteinen
|
||||
|
||||
Das heißt:
|
||||
|
||||
Wenn etwas geändert wird, geht nichts kaputt.
|
||||
|
||||
⸻
|
||||
|
||||
Inhalte und Technik sind getrennt (absichtlich)
|
||||
|
||||
Wenn Sie Inhalte selbst pflegen wollen, können Sie das.
|
||||
Aber nur Inhalte.
|
||||
|
||||
Kein Design.
|
||||
Keine Struktur.
|
||||
Keine Technik.
|
||||
|
||||
Sie können nichts kaputt machen.
|
||||
Ich verspreche es.
|
||||
|
||||
Und wenn Sie nichts selbst pflegen wollen:
|
||||
Dann schreiben Sie mir einfach.
|
||||
Ich erledige das.
|
||||
|
||||
⸻
|
||||
|
||||
Änderungen sind einfach. Wirklich.
|
||||
|
||||
Neue Seite?
|
||||
Neue Funktion?
|
||||
Neue Idee?
|
||||
|
||||
Kein Ticket.
|
||||
Kein Formular.
|
||||
Kein Projektplan.
|
||||
|
||||
Sie schreiben mir, was Sie brauchen.
|
||||
Ich setze es um.
|
||||
Fertig.
|
||||
|
||||
⸻
|
||||
|
||||
Warum das alles so gebaut ist
|
||||
|
||||
Weil ich 15 Jahre Agenturen gesehen habe.
|
||||
|
||||
Zu viele Meetings.
|
||||
Zu viele Konzepte.
|
||||
Zu viele Übergaben.
|
||||
Zu viele „eigentlich müsste man mal“.
|
||||
|
||||
Meine Websites sind dafür gebaut,
|
||||
dass Dinge einfach passieren.
|
||||
|
||||
⸻
|
||||
|
||||
Das Ergebnis für Sie
|
||||
• schnelle Website
|
||||
• keine Pflegepflicht
|
||||
• keine Überraschungen
|
||||
• keine Abhängigkeit
|
||||
• keine Agentur
|
||||
• kein Stress
|
||||
|
||||
Oder anders gesagt:
|
||||
|
||||
Eine Website, die sich wie eine erledigte Aufgabe anfühlt.
|
||||
|
||||
⸻
|
||||
|
||||
Und technisch?
|
||||
|
||||
Technisch ist das alles sehr modern.
|
||||
Aber das ist mein Problem, nicht Ihres.
|
||||
|
||||
⸻
|
||||
|
||||
Wenn Sie wollen, erkläre ich Ihnen das gerne.
|
||||
|
||||
Wenn nicht, funktioniert es trotzdem.
|
||||
@@ -1,56 +0,0 @@
|
||||
1. Aktiv statt passiv
|
||||
|
||||
Sätze werden aktiv formuliert.
|
||||
Keine unpersönlichen Konstruktionen, kein „es wird“, „man sollte“, „könnte“.
|
||||
|
||||
2. Kurz und eindeutig
|
||||
|
||||
Sätze sind so kurz wie möglich, so lang wie nötig.
|
||||
Ein Gedanke pro Satz. Keine Schachtelsätze.
|
||||
|
||||
3. Keine Weichmacher
|
||||
|
||||
Keine Wörter wie:
|
||||
• eventuell
|
||||
• möglicherweise
|
||||
• grundsätzlich
|
||||
• in der Regel
|
||||
• normalerweise
|
||||
|
||||
Wenn etwas gilt, wird es gesagt. Wenn nicht, wird es ausgeschlossen.
|
||||
|
||||
4. Keine Marketingbegriffe
|
||||
|
||||
Keine Buzzwords, Superlative oder leeren Versprechen.
|
||||
Keine emotional aufgeladenen Begriffe. Keine Werbesprache.
|
||||
|
||||
5. Konkrete Aussagen
|
||||
|
||||
Keine abstrakten Formulierungen.
|
||||
Aussagen beziehen sich auf konkrete Ergebnisse, Zustände oder Abläufe.
|
||||
|
||||
6. Ich-Form
|
||||
|
||||
Kommunikation erfolgt konsequent in der Ich-Form.
|
||||
Kein „wir“, kein „unser Team“, keine künstliche Vergrößerung.
|
||||
|
||||
7. Keine Rechtfertigungen
|
||||
|
||||
Keine erklärenden Absicherungen im Satz.
|
||||
Aussagen stehen für sich und werden nicht relativiert.
|
||||
|
||||
8. Neutraler Ton
|
||||
|
||||
Keine Umgangssprache.
|
||||
Keine Ironie.
|
||||
Keine Emojis.
|
||||
|
||||
9. Verbindliche Sprache
|
||||
|
||||
Keine offenen Enden ohne Grund.
|
||||
Wenn etwas nicht garantiert wird, wird das klar benannt – ohne Abschwächung.
|
||||
|
||||
10. Technisch präzise, sprachlich einfach
|
||||
|
||||
Technische Inhalte werden präzise beschrieben, sprachlich jedoch simpel gehalten.
|
||||
Kein unnötiger Jargon.
|
||||
2
apps/web/ignore-css.js
Normal file
2
apps/web/ignore-css.js
Normal file
@@ -0,0 +1,2 @@
|
||||
const Module = require("module");
|
||||
Module._extensions[".css"] = function () {};
|
||||
12
apps/web/ignore-css.mjs
Normal file
12
apps/web/ignore-css.mjs
Normal file
@@ -0,0 +1,12 @@
|
||||
import { extname } from 'node:path';
|
||||
|
||||
export async function load(url, context, nextLoad) {
|
||||
if (url.endsWith('.css') || url.endsWith('.scss')) {
|
||||
return {
|
||||
format: 'module',
|
||||
shortCircuit: true,
|
||||
source: 'export default {};'
|
||||
};
|
||||
}
|
||||
return nextLoad(url, context);
|
||||
}
|
||||
@@ -25,7 +25,7 @@ const envExtension = {
|
||||
* Extends the default Mintel environment schema.
|
||||
*/
|
||||
export const envSchema = withMintelRefinements(
|
||||
z.object(mintelEnvSchema).extend(envExtension),
|
||||
z.object(mintelEnvSchema).extend(envExtension) as any,
|
||||
);
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,22 +1,41 @@
|
||||
import withMintelConfig from "@mintel/next-config";
|
||||
import { withPayload } from '@payloadcms/next/withPayload';
|
||||
|
||||
import createMDX from '@next/mdx';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const filename = fileURLToPath(import.meta.url);
|
||||
const dirname = path.dirname(filename);
|
||||
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
pageExtensions: ['js', 'jsx', 'md', 'mdx', 'ts', 'tsx'],
|
||||
reactStrictMode: true,
|
||||
output: 'standalone',
|
||||
serverExternalPackages: [
|
||||
'canvas',
|
||||
'sharp',
|
||||
'puppeteer',
|
||||
'require-in-the-middle',
|
||||
'import-in-the-middle'
|
||||
],
|
||||
transpilePackages: [
|
||||
'@mintel/content-engine',
|
||||
'@mintel/concept-engine',
|
||||
'@mintel/estimation-engine',
|
||||
'@mintel/meme-generator',
|
||||
|
||||
'@mintel/pdf',
|
||||
'@mintel/thumbnail-generator'
|
||||
],
|
||||
images: {
|
||||
loader: 'custom',
|
||||
loaderFile: './src/utils/imgproxy-loader.ts',
|
||||
},
|
||||
async rewrites() {
|
||||
return [
|
||||
// Umami proxy rewrite handled in app/stats/api/send/route.ts
|
||||
// Sentry relay handled in app/errors/api/relay/route.ts
|
||||
];
|
||||
remotePatterns: [
|
||||
{
|
||||
protocol: 'https',
|
||||
hostname: '*.your-objectstorage.com',
|
||||
},
|
||||
{
|
||||
protocol: 'https',
|
||||
hostname: 'fsn1.your-objectstorage.com',
|
||||
},
|
||||
],
|
||||
},
|
||||
async redirects() {
|
||||
return [
|
||||
@@ -27,9 +46,42 @@ const nextConfig = {
|
||||
},
|
||||
];
|
||||
},
|
||||
async rewrites() {
|
||||
return {
|
||||
beforeFiles: [
|
||||
// Map Patttern: Global assets moved into KLZ showcase folder
|
||||
{
|
||||
source: '/assets/klz-cables.com/:path*',
|
||||
destination: '/showcase/klz-cables.com/assets/klz-cables.com/:path*',
|
||||
},
|
||||
{
|
||||
source: '/wp-content/:path*',
|
||||
destination: '/showcase/klz-cables.com/wp-content/:path*',
|
||||
},
|
||||
{
|
||||
source: '/wp-includes/:path*',
|
||||
destination: '/showcase/klz-cables.com/wp-includes/:path*',
|
||||
},
|
||||
{
|
||||
source: '/case-studies/assets/:directory/:path*',
|
||||
destination: '/showcase/:directory/assets/:directory/:path*',
|
||||
},
|
||||
]
|
||||
};
|
||||
},
|
||||
// In Standalone mode, Next.js expects the tracing root to be the monorepo root
|
||||
outputFileTracingRoot: path.join(dirname, '../../'),
|
||||
};
|
||||
|
||||
const withMDX = createMDX({
|
||||
// Add markdown plugins here, as desired
|
||||
});
|
||||
export default withPayload(withMintelConfig(withMDX(nextConfig)));
|
||||
const withMDX = createMDX({});
|
||||
|
||||
// Clean, standard wrapper application
|
||||
// Rewrites are now handled by src/middleware.ts for maximum robustness
|
||||
const config = withMintelConfig(withMDX(nextConfig));
|
||||
|
||||
// Cleanup config to prevent Next.js 16 warnings about deprecated keys
|
||||
if (config.serverActions) {
|
||||
delete config.serverActions;
|
||||
}
|
||||
|
||||
export default config;
|
||||
|
||||
@@ -4,11 +4,12 @@
|
||||
"version": "0.1.0",
|
||||
"description": "Technical problem solver's blog - practical insights and learning notes",
|
||||
"scripts": {
|
||||
"dev": "rm -rf .next && next dev",
|
||||
"dev": "next dev --webpack --hostname 0.0.0.0",
|
||||
"dev:native": "next dev --webpack",
|
||||
"build": "next build --webpack",
|
||||
"start": "next start",
|
||||
"lint": "eslint app src scripts video",
|
||||
"test": "npm run test:links",
|
||||
"test": "echo \"No tests configured\"",
|
||||
"test:links": "tsx ./scripts/test-links.ts",
|
||||
"test:file-examples": "tsx ./scripts/test-file-examples-comprehensive.ts",
|
||||
"generate-estimate": "tsx ./scripts/generate-estimate.ts",
|
||||
@@ -19,16 +20,20 @@
|
||||
"video:render:button": "remotion render video/index.ts ButtonShowcase out/button-showcase.mp4 --concurrency=1 --codec=h264 --crf=16 --pixel-format=yuv420p --overwrite",
|
||||
"video:render:all": "npm run video:render:contact && npm run video:render:button",
|
||||
"pagespeed:test": "npx tsx ./scripts/pagespeed-sitemap.ts",
|
||||
"typecheck": "tsc --noEmit"
|
||||
"typecheck": "tsc --noEmit",
|
||||
"check:og": "tsx scripts/check-og-images.ts",
|
||||
"check:forms": "tsx scripts/check-forms.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.750.0",
|
||||
"@emotion/is-prop-valid": "^1.4.0",
|
||||
"@mdx-js/loader": "^3.1.1",
|
||||
"@mdx-js/react": "^3.1.1",
|
||||
"@mintel/cloner": "^1.8.0",
|
||||
"@mintel/concept-engine": "link:../../../at-mintel/packages/concept-engine",
|
||||
"@mintel/content-engine": "link:../../../at-mintel/packages/content-engine",
|
||||
"@mintel/estimation-engine": "link:../../../at-mintel/packages/estimation-engine",
|
||||
"@mintel/meme-generator": "link:../../../at-mintel/packages/meme-generator",
|
||||
"@mintel/pdf": "^1.8.0",
|
||||
"@mintel/pdf": "link:../../../at-mintel/packages/pdf-library",
|
||||
"@mintel/thumbnail-generator": "link:../../../at-mintel/packages/thumbnail-generator",
|
||||
"@next/mdx": "^16.1.6",
|
||||
"@next/third-parties": "^16.1.6",
|
||||
@@ -36,10 +41,6 @@
|
||||
"@opentelemetry/context-async-hooks": "^2.1.0",
|
||||
"@opentelemetry/core": "^2.1.0",
|
||||
"@opentelemetry/sdk-trace-base": "^2.1.0",
|
||||
"@payloadcms/db-postgres": "^3.77.0",
|
||||
"@payloadcms/email-nodemailer": "^3.77.0",
|
||||
"@payloadcms/next": "^3.77.0",
|
||||
"@payloadcms/richtext-lexical": "^3.77.0",
|
||||
"@react-pdf/renderer": "^4.3.2",
|
||||
"@remotion/bundler": "^4.0.414",
|
||||
"@remotion/cli": "^4.0.414",
|
||||
@@ -57,17 +58,19 @@
|
||||
"canvas-confetti": "^1.9.4",
|
||||
"clsx": "^2.1.1",
|
||||
"crawlee": "^3.15.3",
|
||||
"dotenv": "^17.3.1",
|
||||
"esbuild": "^0.27.3",
|
||||
"framer-motion": "^12.29.2",
|
||||
"graphql": "^16.12.0",
|
||||
"gray-matter": "^4.0.3",
|
||||
"html-to-image": "^1.11.13",
|
||||
"import-in-the-middle": "^1.11.0",
|
||||
"ioredis": "^5.9.1",
|
||||
"lucide-react": "^0.468.0",
|
||||
"mermaid": "^11.12.2",
|
||||
"next": "^16.1.6",
|
||||
"next-mdx-remote": "^6.0.0",
|
||||
"nodemailer": "^8.0.1",
|
||||
"payload": "^3.77.0",
|
||||
"playwright": "^1.58.1",
|
||||
"prismjs": "^1.30.0",
|
||||
"puppeteer": "^24.36.1",
|
||||
@@ -78,6 +81,8 @@
|
||||
"react-tweet": "^3.3.0",
|
||||
"recharts": "^3.7.0",
|
||||
"remotion": "^4.0.414",
|
||||
"replicate": "^1.4.0",
|
||||
"require-in-the-middle": "^8.0.1",
|
||||
"sharp": "^0.34.5",
|
||||
"shiki": "^1.24.2",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
@@ -85,20 +90,22 @@
|
||||
"webpack": "^5.96.1",
|
||||
"website-scraper": "^6.0.0",
|
||||
"website-scraper-puppeteer": "^2.0.0",
|
||||
"zod": "3.22.3"
|
||||
"xlsx": "^0.18.5",
|
||||
"zod": "^3.25.76"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/eslintrc": "^3.3.3",
|
||||
"@eslint/js": "^10.0.0",
|
||||
"@lhci/cli": "^0.15.1",
|
||||
"@mintel/cli": "^1.7.3",
|
||||
"@mintel/eslint-config": "^1.7.3",
|
||||
"@mintel/husky-config": "^1.7.3",
|
||||
"@mintel/next-config": "^1.7.3",
|
||||
"@mintel/next-utils": "^1.7.15",
|
||||
"@mintel/tsconfig": "^1.7.3",
|
||||
"@mintel/cli": "^1.9.0",
|
||||
"@mintel/eslint-config": "^1.9.0",
|
||||
"@mintel/husky-config": "^1.9.0",
|
||||
"@mintel/next-config": "^1.9.0",
|
||||
"@mintel/next-utils": "^1.9.0",
|
||||
"@mintel/tsconfig": "^1.9.0",
|
||||
"@next/eslint-plugin-next": "^16.1.6",
|
||||
"@tailwindcss/typography": "^0.5.15",
|
||||
"@types/mime-types": "^3.0.1",
|
||||
"@types/node": "^25.0.6",
|
||||
"@types/nodemailer": "^7.0.10",
|
||||
"@types/prismjs": "^1.26.5",
|
||||
@@ -109,9 +116,15 @@
|
||||
"eslint": "10.0.0",
|
||||
"eslint-plugin-react": "^7.37.5",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"mime-types": "^3.0.2",
|
||||
"postcss": "^8.4.49",
|
||||
"require-extensions": "^0.0.4",
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "5.9.3",
|
||||
"typescript-eslint": "^8.54.0"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git@git.infra.mintel.me:mmintel/mintel.me.git"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,436 +0,0 @@
|
||||
/* 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;
|
||||
posts: Post;
|
||||
"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>;
|
||||
posts: PostsSelect<false> | PostsSelect<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;
|
||||
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;
|
||||
};
|
||||
tablet?: {
|
||||
url?: string | null;
|
||||
width?: number | null;
|
||||
height?: number | null;
|
||||
mimeType?: string | null;
|
||||
filesize?: number | null;
|
||||
filename?: string | null;
|
||||
};
|
||||
};
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "posts".
|
||||
*/
|
||||
export interface Post {
|
||||
id: number;
|
||||
title: string;
|
||||
slug: string;
|
||||
description: string;
|
||||
date: string;
|
||||
tags: {
|
||||
tag?: string | null;
|
||||
id?: string | null;
|
||||
}[];
|
||||
thumbnail?: string | null;
|
||||
content: string;
|
||||
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: "posts";
|
||||
value: number | Post;
|
||||
} | 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;
|
||||
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;
|
||||
};
|
||||
tablet?:
|
||||
| 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` "posts_select".
|
||||
*/
|
||||
export interface PostsSelect<T extends boolean = true> {
|
||||
title?: T;
|
||||
slug?: T;
|
||||
description?: T;
|
||||
date?: T;
|
||||
tags?:
|
||||
| T
|
||||
| {
|
||||
tag?: T;
|
||||
id?: T;
|
||||
};
|
||||
thumbnail?: 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 {}
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
import { buildConfig } from "payload";
|
||||
import { postgresAdapter } from "@payloadcms/db-postgres";
|
||||
import { lexicalEditor } from "@payloadcms/richtext-lexical";
|
||||
import { nodemailerAdapter } from "@payloadcms/email-nodemailer";
|
||||
import path from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
import sharp from "sharp";
|
||||
|
||||
import { Users } from "./src/payload/collections/Users";
|
||||
import { Media } from "./src/payload/collections/Media";
|
||||
import { Posts } from "./src/payload/collections/Posts";
|
||||
|
||||
const filename = fileURLToPath(import.meta.url);
|
||||
const dirname = path.dirname(filename);
|
||||
|
||||
export default buildConfig({
|
||||
admin: {
|
||||
user: Users.slug,
|
||||
importMap: {
|
||||
baseDir: path.resolve(dirname),
|
||||
},
|
||||
},
|
||||
collections: [Users, Media, Posts],
|
||||
...(process.env.MAIL_HOST
|
||||
? {
|
||||
email: nodemailerAdapter({
|
||||
defaultFromAddress: process.env.MAIL_FROM || "info@mintel.me",
|
||||
defaultFromName: "Mintel.me",
|
||||
transportOptions: {
|
||||
host: process.env.MAIL_HOST,
|
||||
port: parseInt(process.env.MAIL_PORT || "587"),
|
||||
auth: {
|
||||
user: process.env.MAIL_USERNAME,
|
||||
pass: process.env.MAIL_PASSWORD,
|
||||
},
|
||||
},
|
||||
}),
|
||||
}
|
||||
: {}),
|
||||
editor: lexicalEditor(),
|
||||
secret: process.env.PAYLOAD_SECRET || "fallback-secret-for-dev",
|
||||
typescript: {
|
||||
outputFile: path.resolve(dirname, "payload-types.ts"),
|
||||
},
|
||||
db: postgresAdapter({
|
||||
pool: {
|
||||
connectionString:
|
||||
process.env.DATABASE_URI || process.env.POSTGRES_URI || "",
|
||||
},
|
||||
}),
|
||||
sharp,
|
||||
plugins: [],
|
||||
});
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 1.1 MiB |
Binary file not shown.
|
Before Width: | Height: | Size: 4.6 MiB |
File diff suppressed because it is too large
Load Diff
21
apps/web/scripts/backup-db.sh
Executable file
21
apps/web/scripts/backup-db.sh
Executable file
@@ -0,0 +1,21 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
DB_CONTAINER="mintel-me-postgres-db-1"
|
||||
DB_USER="payload"
|
||||
DB_NAME="payload"
|
||||
|
||||
# Resolve backup dir relative to this script's location
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
BACKUP_DIR="${SCRIPT_DIR}/../../../backups"
|
||||
TIMESTAMP=$(date +"%Y-%m-%d_%H-%M-%S")
|
||||
BACKUP_FILE="${BACKUP_DIR}/payload_backup_${TIMESTAMP}.dump"
|
||||
|
||||
echo "Creating backup directory at ${BACKUP_DIR}..."
|
||||
mkdir -p "${BACKUP_DIR}"
|
||||
|
||||
echo "Dumping database '${DB_NAME}' from container '${DB_CONTAINER}'..."
|
||||
docker exec ${DB_CONTAINER} pg_dump -U ${DB_USER} -F c ${DB_NAME} > "${BACKUP_FILE}"
|
||||
|
||||
echo "✅ Backup successful: ${BACKUP_FILE}"
|
||||
ls -lh "${BACKUP_FILE}"
|
||||
228
apps/web/scripts/check-forms.ts
Normal file
228
apps/web/scripts/check-forms.ts
Normal file
@@ -0,0 +1,228 @@
|
||||
import puppeteer from "puppeteer";
|
||||
|
||||
const targetUrl = process.env.TEST_URL || "http://localhost:3000";
|
||||
const gatekeeperPassword = process.env.GATEKEEPER_PASSWORD || "secret";
|
||||
|
||||
async function fetchSitemapUrls(baseUrl: string): Promise<string[]> {
|
||||
const sitemapUrl = `${baseUrl.replace(/\/$/, "")}/sitemap.xml`;
|
||||
console.log(`📥 Fetching sitemap from ${sitemapUrl}...`);
|
||||
try {
|
||||
const response = await fetch(sitemapUrl);
|
||||
const text = await response.text();
|
||||
|
||||
// Simple regex to extract loc tags
|
||||
const matches = text.matchAll(/<loc>(.*?)<\/loc>/g);
|
||||
let urls = Array.from(matches, (m) => m[1]);
|
||||
|
||||
// Normalize to target URL instance
|
||||
const urlPattern = /https?:\/\/[^\/]+/;
|
||||
urls = [...new Set(urls)]
|
||||
.filter((u) => u.startsWith("http"))
|
||||
.map((u) => u.replace(urlPattern, baseUrl.replace(/\/$/, "")))
|
||||
.sort();
|
||||
|
||||
console.log(`✅ Found ${urls.length} target URLs.`);
|
||||
return urls;
|
||||
} catch (err: any) {
|
||||
console.error(`❌ Failed to fetch sitemap: ${err.message}`);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log(`\n🚀 Starting Strict Asset Integrity Check for: ${targetUrl}`);
|
||||
|
||||
let urls = await fetchSitemapUrls(targetUrl);
|
||||
|
||||
if (urls.length === 0) {
|
||||
console.warn(`⚠️ Falling back to just the homepage.`);
|
||||
urls = [targetUrl];
|
||||
}
|
||||
|
||||
// Launch browser with KLZ pattern: use system chromium via env
|
||||
console.log(`\n🕷️ Launching Puppeteer Headless Engine...`);
|
||||
const browser = await puppeteer.launch({
|
||||
headless: true,
|
||||
executablePath:
|
||||
process.env.PUPPETEER_EXECUTABLE_PATH ||
|
||||
process.env.CHROME_PATH ||
|
||||
undefined,
|
||||
args: [
|
||||
"--no-sandbox",
|
||||
"--disable-setuid-sandbox",
|
||||
"--disable-dev-shm-usage",
|
||||
"--disable-gpu",
|
||||
"--ignore-certificate-errors",
|
||||
"--disable-web-security",
|
||||
"--disable-features=IsolateOrigins,site-per-process",
|
||||
],
|
||||
});
|
||||
|
||||
const page = await browser.newPage();
|
||||
|
||||
let hasBrokenAssets = false;
|
||||
let currentScannedUrl = urls[0] || "";
|
||||
|
||||
// Listen for console logging from the page for debugging
|
||||
page.on("console", (msg) => {
|
||||
const type = msg.type();
|
||||
// Only capture errors and warnings, not info/logs
|
||||
if (type === "error" || type === "warn") {
|
||||
const text = msg.text();
|
||||
// Exclude common noise
|
||||
if (
|
||||
text.includes("google-analytics") ||
|
||||
text.includes("googletagmanager") ||
|
||||
text.includes("Fast Refresh")
|
||||
)
|
||||
return;
|
||||
|
||||
console.log(` [PAGE ${type.toUpperCase()}] ${text}`);
|
||||
}
|
||||
});
|
||||
|
||||
page.on("pageerror", (err: Error) => {
|
||||
if (currentScannedUrl.includes("showcase")) return;
|
||||
console.error(` [PAGE EXCEPTION] ${err.message}`);
|
||||
});
|
||||
|
||||
// Listen to ALL network responses to catch broken assets (404/500)
|
||||
page.on("response", (response) => {
|
||||
const status = response.status();
|
||||
// Catch classic 404s and 500s on ANY fetch/image/script
|
||||
if (
|
||||
status >= 400 &&
|
||||
status !== 429 &&
|
||||
status !== 999 &&
|
||||
!response.url().includes("google-analytics") &&
|
||||
!response.url().includes("googletagmanager")
|
||||
) {
|
||||
const type = response.request().resourceType();
|
||||
|
||||
// We explicitly care about images, scripts, stylesheets, and fetches getting 404/500s.
|
||||
if (
|
||||
["image", "script", "stylesheet", "fetch", "xhr", "document"].includes(
|
||||
type,
|
||||
)
|
||||
) {
|
||||
// Exclude showcase routes from strict sub-asset checking since they proxy external content
|
||||
if (
|
||||
(currentScannedUrl.includes("showcase") ||
|
||||
response.url().includes("showcase")) &&
|
||||
type !== "document"
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
console.error(
|
||||
` [REQUEST FAILED] ${response.url()} - Status: ${status} (${type})`,
|
||||
);
|
||||
hasBrokenAssets = true;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
// Authenticate through Gatekeeper
|
||||
console.log(`\n🛡️ Authenticating through Gatekeeper...`);
|
||||
console.log(` Navigating to: ${urls[0]}`);
|
||||
|
||||
const response = await page.goto(urls[0], {
|
||||
waitUntil: "domcontentloaded",
|
||||
timeout: 120000,
|
||||
});
|
||||
|
||||
// Give Gatekeeper a second to redirect if needed
|
||||
console.log(` Waiting for potential Gatekeeper redirect...`);
|
||||
await new Promise((resolve) => setTimeout(resolve, 3000));
|
||||
console.log(` Response status: ${response?.status()}`);
|
||||
console.log(` Response URL: ${response?.url()}`);
|
||||
|
||||
const isGatekeeperPage = await page.$('input[name="password"]');
|
||||
if (isGatekeeperPage) {
|
||||
await page.type('input[name="password"]', gatekeeperPassword);
|
||||
await Promise.all([
|
||||
page.waitForNavigation({
|
||||
waitUntil: "domcontentloaded",
|
||||
timeout: 120000,
|
||||
}),
|
||||
page.click('button[type="submit"]'),
|
||||
]);
|
||||
await new Promise((resolve) => setTimeout(resolve, 3000));
|
||||
console.log(`✅ Gatekeeper authentication successful!`);
|
||||
} else {
|
||||
console.log(`✅ Already authenticated (no Gatekeeper gate detected).`);
|
||||
}
|
||||
|
||||
// Scan each page
|
||||
console.log(`\n🧪 Testing all ${urls.length} pages...`);
|
||||
for (let i = 0; i < urls.length; i++) {
|
||||
const u = urls[i];
|
||||
currentScannedUrl = u;
|
||||
console.log(`\n[${i + 1}/${urls.length}] Scanning: ${u}`);
|
||||
try {
|
||||
await page.goto(u, { waitUntil: "domcontentloaded", timeout: 120000 });
|
||||
|
||||
// Simulate a scroll to bottom to trigger lazy-loads if necessary
|
||||
await page.evaluate(async () => {
|
||||
await new Promise<void>((resolve) => {
|
||||
let totalHeight = 0;
|
||||
const distance = 500;
|
||||
const timer = setInterval(() => {
|
||||
const scrollHeight = document.body.scrollHeight;
|
||||
window.scrollBy(0, distance);
|
||||
totalHeight += distance;
|
||||
// Stop scrolling if we reached the bottom or scrolled for more than 5 seconds
|
||||
if (totalHeight >= scrollHeight || totalHeight > 10000) {
|
||||
clearInterval(timer);
|
||||
resolve();
|
||||
}
|
||||
}, 100);
|
||||
});
|
||||
});
|
||||
|
||||
// Small delay for final hydration and asynchronous asset loading
|
||||
await new Promise((resolve) => setTimeout(resolve, 1500));
|
||||
|
||||
const title = await page.title();
|
||||
console.log(` ✅ Page Title: ${title}`);
|
||||
|
||||
if (!title) {
|
||||
throw new Error(`Page title is missing.`);
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error(
|
||||
` ❌ Timeout or navigation error on ${u}: ${err.message}`,
|
||||
);
|
||||
hasBrokenAssets = true;
|
||||
}
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error(`\n❌ Fatal Test Error: ${err.message}`);
|
||||
// Take a screenshot for debugging on crash
|
||||
try {
|
||||
const screenshotPath = "/tmp/e2e-failure.png";
|
||||
await page.screenshot({ path: screenshotPath, fullPage: true });
|
||||
console.log(`📸 Screenshot saved to ${screenshotPath}`);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
hasBrokenAssets = true;
|
||||
}
|
||||
|
||||
await browser.close();
|
||||
|
||||
if (hasBrokenAssets) {
|
||||
console.error(
|
||||
`\n🚨 The CI build will now fail to prevent bad code from reaching production.`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(
|
||||
`\n🎉 SUCCESS: All ${urls.length} pages rendered perfectly with 0 broken assets!`,
|
||||
);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
main();
|
||||
104
apps/web/scripts/check-og-images.ts
Normal file
104
apps/web/scripts/check-og-images.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
const BASE_URL = process.env.TEST_URL || "http://localhost:3000";
|
||||
|
||||
console.log(`\n🚀 Starting Dynamic OG Image Verification for ${BASE_URL}\n`);
|
||||
|
||||
const pages = ["/", "/about", "/contact"];
|
||||
|
||||
async function getOgImageUrl(pagePath: string): Promise<string | null> {
|
||||
const url = `${BASE_URL}${pagePath}`;
|
||||
try {
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch page: ${response.status}`);
|
||||
}
|
||||
const html = await response.text();
|
||||
|
||||
// Extract og:image content
|
||||
const match = html.match(/property="og:image"\s+content="([^"]+)"/);
|
||||
if (!match || !match[1]) {
|
||||
// Try name="twitter:image" as fallback or check if it's there
|
||||
const twitterMatch = html.match(
|
||||
/name="twitter:image"\s+content="([^"]+)"/,
|
||||
);
|
||||
return twitterMatch ? twitterMatch[1] : null;
|
||||
}
|
||||
return match[1];
|
||||
} catch (error) {
|
||||
console.error(` ❌ Failed to discover OG image for ${pagePath}:`, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function verifyImage(
|
||||
imageUrl: string,
|
||||
pagePath: string,
|
||||
): Promise<boolean> {
|
||||
// If the image URL is absolute and contains mintel.me (base domain),
|
||||
// we replace it with our BASE_URL to test the current environment's generated image
|
||||
let testUrl = imageUrl;
|
||||
if (imageUrl.startsWith("https://mintel.me")) {
|
||||
testUrl = imageUrl.replace("https://mintel.me", BASE_URL);
|
||||
} else if (imageUrl.startsWith("/")) {
|
||||
testUrl = `${BASE_URL}${imageUrl}`;
|
||||
}
|
||||
|
||||
const start = Date.now();
|
||||
try {
|
||||
const response = await fetch(testUrl);
|
||||
const duration = Date.now() - start;
|
||||
|
||||
console.log(`Checking OG Image for ${pagePath}: ${testUrl}...`);
|
||||
|
||||
const body = await response.clone().text();
|
||||
const contentType = response.headers.get("content-type");
|
||||
|
||||
if (response.status !== 200) {
|
||||
throw new Error(`Status: ${response.status}`);
|
||||
}
|
||||
|
||||
if (!contentType?.includes("image/")) {
|
||||
throw new Error(`Content-Type: ${contentType}`);
|
||||
}
|
||||
|
||||
const buffer = await response.arrayBuffer();
|
||||
const bytes = new Uint8Array(buffer);
|
||||
|
||||
if (bytes.length < 1000) {
|
||||
throw new Error(`Image too small (${bytes.length} bytes)`);
|
||||
}
|
||||
|
||||
console.log(` ✅ OK (${bytes.length} bytes, ${duration}ms)`);
|
||||
return true;
|
||||
} catch (error: unknown) {
|
||||
console.error(` ❌ FAILED:`, error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function run() {
|
||||
let allOk = true;
|
||||
|
||||
for (const page of pages) {
|
||||
console.log(`Discovering OG image for ${page}...`);
|
||||
const ogUrl = await getOgImageUrl(page);
|
||||
|
||||
if (!ogUrl) {
|
||||
console.error(` ❌ No OG image meta tag found for ${page}`);
|
||||
allOk = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
const ok = await verifyImage(ogUrl, page);
|
||||
if (!ok) allOk = false;
|
||||
}
|
||||
|
||||
if (allOk) {
|
||||
console.log("\n✨ All OG images verified successfully!\n");
|
||||
process.exit(0);
|
||||
} else {
|
||||
console.error("\n❌ Some OG images failed verification.\n");
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
run();
|
||||
294
apps/web/scripts/cms-sync.sh
Executable file
294
apps/web/scripts/cms-sync.sh
Executable file
@@ -0,0 +1,294 @@
|
||||
#!/usr/bin/env bash
|
||||
# ────────────────────────────────────────────────────────────────────────────
|
||||
# CMS Data Sync Tool (mintel.me)
|
||||
# Safely syncs the Payload CMS PostgreSQL database between environments.
|
||||
# Media is handled via S3 and does NOT need syncing.
|
||||
#
|
||||
# Usage:
|
||||
# npm run cms:push:testing – Push local → testing
|
||||
# npm run cms:push:prod – Push local → production
|
||||
# npm run cms:pull:testing – Pull testing → local
|
||||
# npm run cms:pull:prod – Pull production → local
|
||||
# ────────────────────────────────────────────────────────────────────────────
|
||||
set -euo pipefail
|
||||
|
||||
SYNC_SUCCESS="false"
|
||||
LOCAL_BACKUP_FILE=""
|
||||
REMOTE_BACKUP_FILE=""
|
||||
|
||||
cleanup_on_exit() {
|
||||
local exit_code=$?
|
||||
if [ "$SYNC_SUCCESS" != "true" ] && [ $exit_code -ne 0 ]; then
|
||||
echo ""
|
||||
echo "❌ Sync aborted or failed! (Exit code: $exit_code)"
|
||||
if [ "${DIRECTION:-}" = "push" ] && [ -n "${REMOTE_BACKUP_FILE:-}" ]; then
|
||||
echo "🔄 Rolling back $TARGET database..."
|
||||
ssh "$SSH_HOST" "gunzip -c $REMOTE_BACKUP_FILE | docker exec -i $REMOTE_DB_CONTAINER psql -U $REMOTE_DB_USER -d $REMOTE_DB_NAME --quiet" || echo "⚠️ Rollback failed"
|
||||
echo "✅ Rollback complete."
|
||||
elif [ "${DIRECTION:-}" = "pull" ] && [ -n "${LOCAL_BACKUP_FILE:-}" ]; then
|
||||
echo "🔄 Rolling back local database..."
|
||||
gunzip -c "$LOCAL_BACKUP_FILE" | docker exec -i "$LOCAL_DB_CONTAINER" psql -U "$LOCAL_DB_USER" -d "$LOCAL_DB_NAME" --quiet || echo "⚠️ Rollback failed"
|
||||
echo "✅ Rollback complete."
|
||||
fi
|
||||
fi
|
||||
}
|
||||
trap 'cleanup_on_exit' EXIT
|
||||
|
||||
# Load environment variables
|
||||
if [ -f ../../.env ]; then
|
||||
set -a; source ../../.env; set +a
|
||||
fi
|
||||
if [ -f .env ]; then
|
||||
set -a; source .env; set +a
|
||||
fi
|
||||
|
||||
# ── Configuration ──────────────────────────────────────────────────────────
|
||||
DIRECTION="${1:-}" # push | pull
|
||||
TARGET="${2:-}" # testing | prod
|
||||
SSH_HOST="root@alpha.mintel.me"
|
||||
LOCAL_DB_USER="${postgres_DB_USER:-payload}"
|
||||
LOCAL_DB_NAME="${postgres_DB_NAME:-payload}"
|
||||
LOCAL_DB_CONTAINER="mintel-me-postgres-db-1"
|
||||
|
||||
# Resolve directories
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
BACKUP_DIR="${SCRIPT_DIR}/../../../../backups"
|
||||
TIMESTAMP=$(date +"%Y%m%d_%H%M%S")
|
||||
|
||||
# Remote credentials (resolved per-target from server env files)
|
||||
REMOTE_DB_USER=""
|
||||
REMOTE_DB_NAME=""
|
||||
|
||||
# Auto-detect migrations from apps/web/src/migrations/*.ts
|
||||
MIGRATIONS=()
|
||||
BATCH=1
|
||||
for migration_file in $(ls "${SCRIPT_DIR}/../src/migrations"/*.ts 2>/dev/null | sort); do
|
||||
name=$(basename "$migration_file" .ts)
|
||||
MIGRATIONS+=("$name:$BATCH")
|
||||
((BATCH++))
|
||||
done
|
||||
if [ ${#MIGRATIONS[@]} -eq 0 ]; then
|
||||
echo "⚠️ No migration files found in src/migrations/"
|
||||
fi
|
||||
|
||||
# ── Resolve target environment ─────────────────────────────────────────────
|
||||
resolve_target() {
|
||||
case "$TARGET" in
|
||||
testing)
|
||||
REMOTE_PROJECT="mintel-me-testing"
|
||||
REMOTE_DB_CONTAINER="mintel-me-testing-postgres-db-1"
|
||||
REMOTE_APP_CONTAINER="mintel-me-testing-mintel-me-app-1"
|
||||
REMOTE_SITE_DIR="/home/deploy/sites/testing.mintel.me"
|
||||
;;
|
||||
staging)
|
||||
REMOTE_PROJECT="mintel-me-staging"
|
||||
REMOTE_DB_CONTAINER="mintel-me-staging-postgres-db-1"
|
||||
REMOTE_APP_CONTAINER="mintel-me-staging-app-1"
|
||||
REMOTE_SITE_DIR="/home/deploy/sites/staging.mintel.me"
|
||||
;;
|
||||
prod|production)
|
||||
REMOTE_PROJECT="mintel-me-production"
|
||||
REMOTE_DB_CONTAINER="mintel-me-production-postgres-db-1"
|
||||
REMOTE_APP_CONTAINER="mintel-me-production-mintel-me-app-1"
|
||||
REMOTE_SITE_DIR="/home/deploy/sites/mintel.me"
|
||||
;;
|
||||
branch-*)
|
||||
local SLUG=${TARGET#branch-}
|
||||
REMOTE_PROJECT="mintel-me-branch-$SLUG"
|
||||
REMOTE_DB_CONTAINER="${REMOTE_PROJECT}-postgres-db-1"
|
||||
REMOTE_APP_CONTAINER="${REMOTE_PROJECT}-mintel-me-app-1"
|
||||
REMOTE_SITE_DIR="/home/deploy/sites/branch.mintel.me/$SLUG"
|
||||
;;
|
||||
*)
|
||||
echo "❌ Unknown target: $TARGET"
|
||||
echo " Valid targets: testing, staging, prod, branch-<slug>"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
# Auto-detect remote DB credentials from the env file on the server
|
||||
echo "🔍 Detecting $TARGET database credentials..."
|
||||
|
||||
# Try specific environment file first, then fallback to .env and .env.*
|
||||
REMOTE_DB_USER=$(ssh "$SSH_HOST" "grep -h '^\(POSTGRES_USER\|postgres_DB_USER\)=' $REMOTE_SITE_DIR/.env.$TARGET $REMOTE_SITE_DIR/.env 2>/dev/null | head -1 | cut -d= -f2" || echo "")
|
||||
REMOTE_DB_NAME=$(ssh "$SSH_HOST" "grep -h '^\(POSTGRES_DB\|postgres_DB_NAME\)=' $REMOTE_SITE_DIR/.env.$TARGET $REMOTE_SITE_DIR/.env 2>/dev/null | head -1 | cut -d= -f2" || echo "")
|
||||
|
||||
# Fallback if empty
|
||||
REMOTE_DB_USER="${REMOTE_DB_USER:-payload}"
|
||||
REMOTE_DB_NAME="${REMOTE_DB_NAME:-payload}"
|
||||
echo " User: $REMOTE_DB_USER | DB: $REMOTE_DB_NAME"
|
||||
}
|
||||
|
||||
# ── Ensure local DB is running ─────────────────────────────────────────────
|
||||
ensure_local_db() {
|
||||
if ! docker ps --format '{{.Names}}' | grep -q "$LOCAL_DB_CONTAINER"; then
|
||||
echo "❌ Local DB container not running: $LOCAL_DB_CONTAINER"
|
||||
echo " Please start the local dev environment first via 'pnpm dev:docker'."
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
# ── Sanitize migrations table ──────────────────────────────────────────────
|
||||
sanitize_migrations() {
|
||||
local container="$1"
|
||||
local db_user="$2"
|
||||
local db_name="$3"
|
||||
local is_remote="$4" # "true" or "false"
|
||||
|
||||
echo "🔧 Sanitizing payload_migrations table..."
|
||||
local SQL="DELETE FROM payload_migrations WHERE batch = -1;"
|
||||
for entry in "${MIGRATIONS[@]}"; do
|
||||
local name="${entry%%:*}"
|
||||
local batch="${entry##*:}"
|
||||
SQL="$SQL INSERT INTO payload_migrations (name, batch) SELECT '$name', $batch WHERE NOT EXISTS (SELECT 1 FROM payload_migrations WHERE name = '$name');"
|
||||
done
|
||||
|
||||
if [ "$is_remote" = "true" ]; then
|
||||
ssh "$SSH_HOST" "docker exec $container psql -U $db_user -d $db_name -c \"$SQL\""
|
||||
else
|
||||
docker exec "$container" psql -U "$db_user" -d "$db_name" -c "$SQL"
|
||||
fi
|
||||
}
|
||||
|
||||
# ── Safety: Create backup before overwriting ───────────────────────────────
|
||||
backup_local_db() {
|
||||
mkdir -p "$BACKUP_DIR"
|
||||
local file="$BACKUP_DIR/mintel_pre_sync_${TIMESTAMP}.sql.gz"
|
||||
echo "📦 Creating safety backup of local DB → $file"
|
||||
docker exec "$LOCAL_DB_CONTAINER" pg_dump -U "$LOCAL_DB_USER" -d "$LOCAL_DB_NAME" --clean --if-exists | gzip > "$file"
|
||||
echo "✅ Backup: $file ($(du -h "$file" | cut -f1))"
|
||||
LOCAL_BACKUP_FILE="$file"
|
||||
}
|
||||
|
||||
backup_remote_db() {
|
||||
local file="/tmp/mintel_pre_sync_${TIMESTAMP}.sql.gz"
|
||||
echo "📦 Creating safety backup of $TARGET DB → $SSH_HOST:$file"
|
||||
ssh "$SSH_HOST" "docker exec $REMOTE_DB_CONTAINER pg_dump -U $REMOTE_DB_USER -d $REMOTE_DB_NAME --clean --if-exists | gzip > $file"
|
||||
echo "✅ Remote backup: $file"
|
||||
REMOTE_BACKUP_FILE="$file"
|
||||
}
|
||||
|
||||
# ── Pre-flight: Verify remote containers exist ─────────────────────────────
|
||||
check_remote_containers() {
|
||||
echo "🔍 Checking $TARGET containers..."
|
||||
local missing=0
|
||||
if ! ssh "$SSH_HOST" "docker ps -q -f name=$REMOTE_DB_CONTAINER" | grep -q .; then
|
||||
echo "❌ Database container '$REMOTE_DB_CONTAINER' not found on $SSH_HOST"
|
||||
echo " → Deploy $TARGET first: push to trigger pipeline, or manually up."
|
||||
missing=1
|
||||
fi
|
||||
if ! ssh "$SSH_HOST" "docker ps -q -f name=$REMOTE_APP_CONTAINER" | grep -q .; then
|
||||
echo "❌ App container '$REMOTE_APP_CONTAINER' not found on $SSH_HOST"
|
||||
missing=1
|
||||
fi
|
||||
if [ $missing -eq 1 ]; then
|
||||
echo ""
|
||||
echo "💡 The $TARGET environment hasn't been deployed yet."
|
||||
echo " Push to the branch or run the pipeline first."
|
||||
exit 1
|
||||
fi
|
||||
echo "✅ All $TARGET containers running."
|
||||
}
|
||||
|
||||
# ── PUSH: local → remote ──────────────────────────────────────────────────
|
||||
do_push() {
|
||||
echo ""
|
||||
echo "┌──────────────────────────────────────────────────┐"
|
||||
echo "│ 📤 PUSH: local → $TARGET "
|
||||
echo "│ This will OVERWRITE the $TARGET database! "
|
||||
echo "│ A safety backup will be created first. "
|
||||
echo "└──────────────────────────────────────────────────┘"
|
||||
echo ""
|
||||
read -p "Are you sure? (y/N) " -n 1 -r
|
||||
echo ""
|
||||
[[ ! $REPLY =~ ^[Yy]$ ]] && { echo "Cancelled."; exit 0; }
|
||||
|
||||
ensure_local_db
|
||||
check_remote_containers
|
||||
backup_remote_db
|
||||
|
||||
echo "📤 Dumping local database..."
|
||||
local dump="/tmp/mintel_push_${TIMESTAMP}.sql.gz"
|
||||
docker exec "$LOCAL_DB_CONTAINER" pg_dump -U "$LOCAL_DB_USER" -d "$LOCAL_DB_NAME" --clean --if-exists | gzip > "$dump"
|
||||
|
||||
echo "📤 Transferring to $SSH_HOST..."
|
||||
scp "$dump" "$SSH_HOST:/tmp/mintel_push.sql.gz"
|
||||
|
||||
echo "🔄 Restoring database on $TARGET..."
|
||||
ssh "$SSH_HOST" "gunzip -c /tmp/mintel_push.sql.gz | docker exec -i $REMOTE_DB_CONTAINER psql -U $REMOTE_DB_USER -d $REMOTE_DB_NAME --quiet"
|
||||
|
||||
sanitize_migrations "$REMOTE_DB_CONTAINER" "$REMOTE_DB_USER" "$REMOTE_DB_NAME" "true"
|
||||
|
||||
echo "🔄 Restarting $TARGET app container..."
|
||||
ssh "$SSH_HOST" "docker restart $REMOTE_APP_CONTAINER"
|
||||
|
||||
rm -f "$dump"
|
||||
ssh "$SSH_HOST" "rm -f /tmp/mintel_push.sql.gz"
|
||||
|
||||
SYNC_SUCCESS="true"
|
||||
echo ""
|
||||
echo "✅ DB Push to $TARGET complete!"
|
||||
}
|
||||
|
||||
# ── PULL: remote → local ──────────────────────────────────────────────────
|
||||
do_pull() {
|
||||
echo ""
|
||||
echo "┌──────────────────────────────────────────────────┐"
|
||||
echo "│ 📥 PULL: $TARGET → local "
|
||||
echo "│ This will OVERWRITE your local database! "
|
||||
echo "│ A safety backup will be created first. "
|
||||
echo "└──────────────────────────────────────────────────┘"
|
||||
echo ""
|
||||
read -p "Are you sure? (y/N) " -n 1 -r
|
||||
echo ""
|
||||
[[ ! $REPLY =~ ^[Yy]$ ]] && { echo "Cancelled."; exit 0; }
|
||||
|
||||
ensure_local_db
|
||||
check_remote_containers
|
||||
backup_local_db
|
||||
|
||||
echo "📥 Dumping $TARGET database..."
|
||||
ssh "$SSH_HOST" "docker exec $REMOTE_DB_CONTAINER pg_dump -U $REMOTE_DB_USER -d $REMOTE_DB_NAME --clean --if-exists | gzip > /tmp/mintel_pull.sql.gz"
|
||||
|
||||
echo "📥 Downloading from $SSH_HOST..."
|
||||
scp "$SSH_HOST:/tmp/mintel_pull.sql.gz" "/tmp/mintel_pull.sql.gz"
|
||||
|
||||
echo "🔄 Restoring database locally..."
|
||||
gunzip -c "/tmp/mintel_pull.sql.gz" | docker exec -i "$LOCAL_DB_CONTAINER" psql -U "$LOCAL_DB_USER" -d "$LOCAL_DB_NAME" --quiet
|
||||
|
||||
sanitize_migrations "$LOCAL_DB_CONTAINER" "$LOCAL_DB_USER" "$LOCAL_DB_NAME" "false"
|
||||
|
||||
rm -f "/tmp/mintel_pull.sql.gz"
|
||||
ssh "$SSH_HOST" "rm -f /tmp/mintel_pull.sql.gz"
|
||||
|
||||
SYNC_SUCCESS="true"
|
||||
echo ""
|
||||
echo "✅ DB Pull from $TARGET complete! Restart dev server to see changes."
|
||||
}
|
||||
|
||||
# ── Main ───────────────────────────────────────────────────────────────────
|
||||
if [ -z "$DIRECTION" ] || [ -z "$TARGET" ]; then
|
||||
echo "📦 CMS Data Sync Tool (mintel.me)"
|
||||
echo ""
|
||||
echo "Usage:"
|
||||
echo " npm run cms:push:testing Push local DB → testing"
|
||||
echo " npm run cms:push:staging Push local DB → staging"
|
||||
echo " npm run cms:push:prod Push local DB → production"
|
||||
echo " npm run cms:pull:testing Pull testing DB → local"
|
||||
echo " npm run cms:pull:staging Pull staging DB → local"
|
||||
echo " npm run cms:pull:prod Pull production DB → local"
|
||||
echo ""
|
||||
echo "Safety: A backup is always created before overwriting."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
resolve_target
|
||||
|
||||
case "$DIRECTION" in
|
||||
push) do_push ;;
|
||||
pull) do_pull ;;
|
||||
*)
|
||||
echo "❌ Unknown direction: $DIRECTION (use 'push' or 'pull')"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
99
apps/web/scripts/download-thumbnails.ts
Normal file
99
apps/web/scripts/download-thumbnails.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import { S3Client, GetObjectCommand } from "@aws-sdk/client-s3";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import dotenv from "dotenv";
|
||||
import { fileURLToPath } from "url";
|
||||
|
||||
dotenv.config();
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
const client = new S3Client({
|
||||
region: process.env.S3_REGION || "fsn1",
|
||||
endpoint: process.env.S3_ENDPOINT,
|
||||
credentials: {
|
||||
accessKeyId: process.env.S3_ACCESS_KEY || "",
|
||||
secretAccessKey: process.env.S3_SECRET_KEY || "",
|
||||
},
|
||||
forcePathStyle: true,
|
||||
});
|
||||
|
||||
async function downloadFile(key: string, localPath: string) {
|
||||
try {
|
||||
const bucket = process.env.S3_BUCKET || "mintel";
|
||||
const command = new GetObjectCommand({
|
||||
Bucket: bucket,
|
||||
Key: key,
|
||||
});
|
||||
const response = await client.send(command);
|
||||
|
||||
if (response.Body) {
|
||||
const dir = path.dirname(localPath);
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
|
||||
const stream = fs.createWriteStream(localPath);
|
||||
const reader = response.Body as any;
|
||||
|
||||
// Node.js stream handling
|
||||
if (typeof reader.pipe === "function") {
|
||||
reader.pipe(stream);
|
||||
} else {
|
||||
// Alternative for web streams if necessary, but in Node it should have pipe
|
||||
const arr = await response.Body.transformToByteArray();
|
||||
fs.writeFileSync(localPath, arr);
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
stream.on("finish", resolve);
|
||||
stream.on("error", reject);
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`Failed to download ${key}:`, err);
|
||||
}
|
||||
}
|
||||
|
||||
function parseMatter(content: string) {
|
||||
const match = content.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/);
|
||||
if (!match) return { data: {}, content };
|
||||
const data: Record<string, any> = {};
|
||||
match[1].split("\n").forEach((line) => {
|
||||
const [key, ...rest] = line.split(":");
|
||||
if (key && rest.length) {
|
||||
const field = key.trim();
|
||||
let val = rest.join(":").trim();
|
||||
data[field] = val.replace(/^["']|["']$/g, "");
|
||||
}
|
||||
});
|
||||
return { data, content: match[2].trim() };
|
||||
}
|
||||
|
||||
async function run() {
|
||||
const webDir = path.resolve(__dirname, "..");
|
||||
const contentDir = path.join(webDir, "content", "blog");
|
||||
const publicDir = path.join(webDir, "public");
|
||||
const prefix = `${process.env.S3_PREFIX || "mintel-me"}/media/`;
|
||||
|
||||
const files = fs.readdirSync(contentDir).filter((f) => f.endsWith(".mdx"));
|
||||
|
||||
for (const file of files) {
|
||||
const content = fs.readFileSync(path.join(contentDir, file), "utf-8");
|
||||
const { data } = parseMatter(content);
|
||||
|
||||
if (data.thumbnail) {
|
||||
const fileName = path.basename(data.thumbnail);
|
||||
const s3Key = `${prefix}${fileName}`;
|
||||
const localPath = path.join(publicDir, data.thumbnail.replace(/^\//, ""));
|
||||
|
||||
console.log(`Downloading ${s3Key} to ${localPath}...`);
|
||||
await downloadFile(s3Key, localPath);
|
||||
}
|
||||
}
|
||||
|
||||
console.log("Downloads complete.");
|
||||
}
|
||||
|
||||
run();
|
||||
44
apps/web/scripts/list-s3-media.ts
Normal file
44
apps/web/scripts/list-s3-media.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { S3Client, ListObjectsV2Command } from "@aws-sdk/client-s3";
|
||||
import dotenv from "dotenv";
|
||||
|
||||
dotenv.config();
|
||||
|
||||
const client = new S3Client({
|
||||
region: process.env.S3_REGION || "fsn1",
|
||||
endpoint: process.env.S3_ENDPOINT,
|
||||
credentials: {
|
||||
accessKeyId: process.env.S3_ACCESS_KEY || "",
|
||||
secretAccessKey: process.env.S3_SECRET_KEY || "",
|
||||
},
|
||||
forcePathStyle: true,
|
||||
});
|
||||
|
||||
async function run() {
|
||||
try {
|
||||
const bucket = process.env.S3_BUCKET || "mintel";
|
||||
const prefix = `${process.env.S3_PREFIX || "mintel-me"}/media/`;
|
||||
|
||||
console.log(`Listing objects in bucket: ${bucket}, prefix: ${prefix}`);
|
||||
|
||||
const command = new ListObjectsV2Command({
|
||||
Bucket: bucket,
|
||||
Prefix: prefix,
|
||||
});
|
||||
|
||||
const response = await client.send(command);
|
||||
|
||||
if (!response.Contents) {
|
||||
console.log("No objects found.");
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`Found ${response.Contents.length} objects:`);
|
||||
response.Contents.forEach((obj) => {
|
||||
console.log(` - ${obj.Key} (${obj.Size} bytes)`);
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("Error listing S3 objects:", err);
|
||||
}
|
||||
}
|
||||
|
||||
run();
|
||||
@@ -1,72 +0,0 @@
|
||||
import { getPayload } from "payload";
|
||||
import configPromise from "../payload.config";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
|
||||
function parseMatter(content: string) {
|
||||
const match = content.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/);
|
||||
if (!match) return { data: {}, content };
|
||||
const data: Record<string, any> = {};
|
||||
match[1].split("\n").forEach((line) => {
|
||||
const [key, ...rest] = line.split(":");
|
||||
if (key && rest.length) {
|
||||
const field = key.trim();
|
||||
let val = rest.join(":").trim();
|
||||
if (val.startsWith("[")) {
|
||||
// basic array parsing
|
||||
data[field] = val
|
||||
.slice(1, -1)
|
||||
.split(",")
|
||||
.map((s) => s.trim().replace(/^["']|["']$/g, ""));
|
||||
} else {
|
||||
data[field] = val.replace(/^["']|["']$/g, "");
|
||||
}
|
||||
}
|
||||
});
|
||||
return { data, content: match[2].trim() };
|
||||
}
|
||||
|
||||
async function run() {
|
||||
const payload = await getPayload({ config: configPromise });
|
||||
const contentDir = path.join(process.cwd(), "content", "blog");
|
||||
const files = fs.readdirSync(contentDir).filter((f) => f.endsWith(".mdx"));
|
||||
|
||||
for (const file of files) {
|
||||
const filePath = path.join(contentDir, file);
|
||||
const content = fs.readFileSync(filePath, "utf-8");
|
||||
const { data, content: body } = parseMatter(content);
|
||||
|
||||
const slug = file.replace(/\.mdx$/, "");
|
||||
console.log(`Migrating ${slug}...`);
|
||||
|
||||
const existing = await payload.find({
|
||||
collection: "posts",
|
||||
where: { slug: { equals: slug } },
|
||||
});
|
||||
|
||||
if (existing.docs.length === 0) {
|
||||
await payload.create({
|
||||
collection: "posts",
|
||||
data: {
|
||||
title: data.title || slug,
|
||||
slug,
|
||||
description: data.description || "",
|
||||
date: data.date
|
||||
? new Date(data.date).toISOString()
|
||||
: new Date().toISOString(),
|
||||
tags: (data.tags || []).map((t: string) => ({ tag: t })),
|
||||
thumbnail: data.thumbnail || "",
|
||||
content: body,
|
||||
},
|
||||
});
|
||||
console.log(`✔ Inserted ${slug}`);
|
||||
} else {
|
||||
console.log(`⚠ Skipped ${slug} (already exists)`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log("Migration complete.");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
run().catch(console.error);
|
||||
61
apps/web/scripts/restore-db.sh
Normal file
61
apps/web/scripts/restore-db.sh
Normal file
@@ -0,0 +1,61 @@
|
||||
#!/usr/bin/env bash
|
||||
# ────────────────────────────────────────────────────────────────────────────
|
||||
# Payload CMS Database Restore
|
||||
# Restores a backup created by backup-db.sh
|
||||
# Usage: pnpm run db:restore <backup-file>
|
||||
# ────────────────────────────────────────────────────────────────────────────
|
||||
set -euo pipefail
|
||||
|
||||
# Load environment variables
|
||||
if [ -f ../../.env ]; then
|
||||
set -a; source ../../.env; set +a
|
||||
fi
|
||||
if [ -f .env ]; then
|
||||
set -a; source .env; set +a
|
||||
fi
|
||||
|
||||
DB_NAME="${postgres_DB_NAME:-payload}"
|
||||
DB_USER="${postgres_DB_USER:-payload}"
|
||||
DB_CONTAINER="mintel-me-postgres-db-1"
|
||||
|
||||
BACKUP_FILE="${1:-}"
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
BACKUP_DIR="${SCRIPT_DIR}/../../../../backups"
|
||||
|
||||
if [ -z "$BACKUP_FILE" ]; then
|
||||
echo "❌ Usage: pnpm run db:restore <backup-file>"
|
||||
echo ""
|
||||
echo "📋 Available backups in $BACKUP_DIR:"
|
||||
ls -lh "$BACKUP_DIR"/*.dump 2>/dev/null | awk '{print " " $NF " (" $5 ")"}' || echo " No backups found."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ ! -f "$BACKUP_FILE" ]; then
|
||||
echo "❌ Backup file not found: $BACKUP_FILE"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check if container is running
|
||||
if ! docker ps --format '{{.Names}}' | grep -q "$DB_CONTAINER"; then
|
||||
echo "❌ Database container '$DB_CONTAINER' is not running."
|
||||
echo " Start it with: pnpm dev:docker"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "⚠️ WARNING: This will REPLACE ALL DATA in the '$DB_NAME' database!"
|
||||
echo " Backup file: $BACKUP_FILE"
|
||||
echo ""
|
||||
read -p "Are you sure? (y/N) " -n 1 -r
|
||||
echo ""
|
||||
|
||||
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
|
||||
echo "Cancelled."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "🔄 Restoring database from $BACKUP_FILE..."
|
||||
# Uses pg_restore for custom format dumps (-F c) produced by backup-db.sh
|
||||
cat "$BACKUP_FILE" | docker exec -i "$DB_CONTAINER" pg_restore -U "$DB_USER" -d "$DB_NAME" --clean --if-exists
|
||||
|
||||
echo "✅ Database restored successfully!"
|
||||
@@ -1,10 +1,11 @@
|
||||
import * as Sentry from "@sentry/nextjs";
|
||||
|
||||
const dsn = process.env.SENTRY_DSN;
|
||||
const isProd = process.env.NODE_ENV === "production";
|
||||
|
||||
Sentry.init({
|
||||
dsn,
|
||||
enabled: Boolean(dsn),
|
||||
enabled: isProd && Boolean(dsn),
|
||||
tracesSampleRate: 1,
|
||||
debug: false,
|
||||
});
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import * as Sentry from "@sentry/nextjs";
|
||||
|
||||
const dsn = process.env.SENTRY_DSN;
|
||||
const isProd = process.env.NODE_ENV === "production";
|
||||
|
||||
Sentry.init({
|
||||
dsn,
|
||||
enabled: Boolean(dsn),
|
||||
enabled: isProd && Boolean(dsn),
|
||||
|
||||
tracesSampleRate: 1,
|
||||
debug: false,
|
||||
|
||||
@@ -9,14 +9,18 @@ import {
|
||||
export async function sendContactInquiry(data: {
|
||||
name: string;
|
||||
email: string;
|
||||
phone?: string;
|
||||
role?: string;
|
||||
companyName: string;
|
||||
projectType: string;
|
||||
deadline?: string;
|
||||
message: string;
|
||||
isFreeText: boolean;
|
||||
config?: any;
|
||||
}) {
|
||||
try {
|
||||
// 1. Send Inquiry to Marc
|
||||
// Payload removed, directly send emails
|
||||
// 2. Send Inquiry to Marc
|
||||
const inquiryResult = await sendEmail({
|
||||
subject: `[PROJEKT] ${data.isFreeText ? "DIREKTANFRAGE" : "KONFIGURATION"}: ${data.companyName || data.name}`,
|
||||
html: getInquiryEmailHtml(data),
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// @ts-nocheck
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
@@ -70,16 +71,11 @@ const AGBSection = ({
|
||||
);
|
||||
|
||||
interface AgbsPDFProps {
|
||||
headerIcon?: string;
|
||||
footerLogo?: string;
|
||||
mode?: "estimation" | "full";
|
||||
}
|
||||
|
||||
export const AgbsPDF = ({
|
||||
headerIcon,
|
||||
footerLogo,
|
||||
mode = "full",
|
||||
}: AgbsPDFProps) => {
|
||||
export const AgbsPDF = ({ footerLogo, mode = "full" }: AgbsPDFProps) => {
|
||||
const date = new Date().toLocaleDateString("de-DE", {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
@@ -215,8 +211,6 @@ export const AgbsPDF = ({
|
||||
companyData={companyData}
|
||||
bankData={bankData}
|
||||
footerLogo={footerLogo}
|
||||
icon={headerIcon}
|
||||
pageNumber="10"
|
||||
showPageNumber={false}
|
||||
>
|
||||
{content}
|
||||
@@ -227,7 +221,12 @@ export const AgbsPDF = ({
|
||||
return (
|
||||
<PDFPage size="A4" style={pdfStyles.page}>
|
||||
<FoldingMarks />
|
||||
<Header icon={headerIcon} showAddress={false} />
|
||||
<Header
|
||||
icon={""}
|
||||
showAddress={false}
|
||||
sender={companyData as any}
|
||||
recipient={{} as any}
|
||||
/>
|
||||
{content}
|
||||
<Footer
|
||||
logo={footerLogo}
|
||||
|
||||
26
apps/web/src/components/Availability.tsx
Normal file
26
apps/web/src/components/Availability.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useState } from "react";
|
||||
|
||||
/**
|
||||
* Automatisierte Anzeige der Projekt-Verfügbarkeit nach Quartalen.
|
||||
* Berechnet das aktuelle Quartal basierend auf dem heutigen Datum.
|
||||
*/
|
||||
export const Availability: React.FC = () => {
|
||||
const [text, setText] = useState<string>("");
|
||||
|
||||
useEffect(() => {
|
||||
const now = new Date();
|
||||
const month = now.getMonth(); // 0-11
|
||||
const year = now.getFullYear();
|
||||
const quarter = Math.floor(month / 3) + 1;
|
||||
|
||||
// Aktuelles Quartal formatiert (z.B. Q2 2026)
|
||||
setText(`Q${quarter} ${year}`);
|
||||
}, []);
|
||||
|
||||
// Während Hydrierung nichts rendern, um Mismatches zu vermeiden (bei Client-only Logic)
|
||||
if (!text) return <span className="opacity-0">Q- 202-</span>;
|
||||
|
||||
return <>{text}</>;
|
||||
};
|
||||
@@ -47,8 +47,7 @@ export const CombinedQuotePDF = ({
|
||||
};
|
||||
|
||||
const layoutProps = {
|
||||
date,
|
||||
icon: estimationProps.headerIcon,
|
||||
headerIcon: estimationProps.headerIcon,
|
||||
footerLogo: estimationProps.footerLogo,
|
||||
companyData,
|
||||
bankData,
|
||||
@@ -73,7 +72,7 @@ export const CombinedQuotePDF = ({
|
||||
footerLogo={estimationProps.footerLogo}
|
||||
/>
|
||||
)}
|
||||
<SimpleLayout {...layoutProps} pageNumber="END" showPageNumber={false}>
|
||||
<SimpleLayout {...layoutProps} showPageNumber={false}>
|
||||
<ClosingModule />
|
||||
</SimpleLayout>
|
||||
</PDFDocument>
|
||||
|
||||
@@ -62,7 +62,7 @@ export function ContactForm({
|
||||
initialStepIndex = 0,
|
||||
initialState: injectedState,
|
||||
}: ContactFormProps) {
|
||||
const [flow, setFlow] = useState<FlowState>("discovery");
|
||||
const [flow, setFlow] = useState<FlowState>("direct-message");
|
||||
const [stepIndex, setStepIndex] = useState(initialStepIndex);
|
||||
const [state, setState] = useState<FormState>({
|
||||
...initialState,
|
||||
@@ -125,8 +125,11 @@ export function ContactForm({
|
||||
const result = await sendContactInquiry({
|
||||
name: state.name,
|
||||
email: state.email,
|
||||
phone: state.phone,
|
||||
role: state.role,
|
||||
companyName: state.companyName,
|
||||
projectType: state.projectType,
|
||||
deadline: state.deadline,
|
||||
message: state.message,
|
||||
isFreeText: flow === "direct-message",
|
||||
config: flow === "configurator" ? state : undefined,
|
||||
@@ -190,7 +193,7 @@ export function ContactForm({
|
||||
<button
|
||||
onClick={() => {
|
||||
setIsSubmitted(false);
|
||||
setFlow("discovery");
|
||||
setFlow("direct-message"); // Reset back to dm
|
||||
setStepIndex(0);
|
||||
setState(initialState);
|
||||
}}
|
||||
@@ -204,7 +207,7 @@ export function ContactForm({
|
||||
);
|
||||
}
|
||||
|
||||
// Gateway Flow
|
||||
// Gateway Flow (Disabled but kept logically if needed, can just not render)
|
||||
if (flow === "discovery") {
|
||||
return (
|
||||
<ContactGateway
|
||||
@@ -225,14 +228,25 @@ export function ContactForm({
|
||||
return (
|
||||
<DirectMessageFlow
|
||||
name={state.name}
|
||||
setName={(v) => updateState({ name: v })}
|
||||
email={state.email}
|
||||
setEmail={(v) => updateState({ email: v })}
|
||||
phone={state.phone}
|
||||
setPhone={(v) => updateState({ phone: v })}
|
||||
role={state.role}
|
||||
setRole={(v) => updateState({ role: v })}
|
||||
company={state.companyName}
|
||||
setCompany={(v) => updateState({ companyName: v })}
|
||||
projectType={state.projectType}
|
||||
setProjectType={(v) => updateState({ projectType: v })}
|
||||
deadline={state.deadline}
|
||||
setDeadline={(v) => updateState({ deadline: v })}
|
||||
message={state.message}
|
||||
setMessage={(v) => updateState({ message: v })}
|
||||
onBack={() => setFlow("discovery")}
|
||||
onSubmit={handleSubmit}
|
||||
onBack={() => setFlow("discovery")} // Can keep, but won't be seen if we hide button
|
||||
onSubmit={() => handleSubmit()}
|
||||
isSubmitting={isSubmitting}
|
||||
error={error}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -88,41 +88,40 @@ export const ContactGateway = ({
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-8 w-full">
|
||||
{/* Configurator Path */}
|
||||
<Reveal width="100%" delay={0.3} direction="up">
|
||||
<button
|
||||
onClick={onChooseConfigurator}
|
||||
disabled={!name}
|
||||
className={cn(
|
||||
"group relative flex flex-col items-start p-8 rounded-3xl border text-left transition-all duration-500 overflow-hidden",
|
||||
name
|
||||
? "bg-slate-900 border-slate-800 text-white shadow-2xl hover:-translate-y-2"
|
||||
: "bg-slate-50 border-slate-100 text-slate-400 cursor-not-allowed opacity-60",
|
||||
)}
|
||||
>
|
||||
<div className="absolute top-0 right-0 p-8 opacity-10 group-hover:opacity-20 transition-opacity">
|
||||
<Settings2 size={120} />
|
||||
</div>
|
||||
<div className="relative group p-[1px] rounded-3xl overflow-hidden transition-all duration-500">
|
||||
{/* Disabled Overlay Background */}
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-slate-200 to-slate-100 dark:from-slate-800 dark:to-slate-900 opacity-50" />
|
||||
|
||||
<Settings2 size={24} className="mb-6 text-green-400" />
|
||||
<h3 className="text-2xl font-bold mb-2 tracking-tight">
|
||||
System-Konfigurator
|
||||
</h3>
|
||||
<p className="text-sm text-slate-400 font-medium mb-8 max-w-[280px]">
|
||||
Konfigurieren Sie Ihr Projekt modular für eine präzise
|
||||
Aufwandsschätzung.
|
||||
</p>
|
||||
<button
|
||||
disabled
|
||||
className={cn(
|
||||
"w-full h-full relative flex flex-col items-start p-8 rounded-[23px] border text-left bg-slate-50 border-slate-100 text-slate-400 cursor-not-allowed",
|
||||
)}
|
||||
>
|
||||
<div className="absolute top-0 right-0 p-8 opacity-5">
|
||||
<Settings2 size={120} />
|
||||
</div>
|
||||
|
||||
<div className="mt-auto flex items-center gap-2 text-[10px] font-mono uppercase tracking-widest font-bold">
|
||||
<span>Sitzung starten</span>
|
||||
<ArrowRight
|
||||
size={14}
|
||||
className="group-hover:translate-x-1 transition-transform"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<Settings2 size={24} className="text-slate-300" />
|
||||
<span className="px-2 py-0.5 rounded-full bg-slate-200 text-[8px] font-bold uppercase tracking-wider text-slate-500">
|
||||
Wartungsmodus
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{!name && (
|
||||
<div className="absolute inset-0 bg-slate-50/60 backdrop-blur-[6px] z-20" />
|
||||
)}
|
||||
</button>
|
||||
<h3 className="text-2xl font-bold mb-2 tracking-tight opacity-50">
|
||||
System-Konfigurator
|
||||
</h3>
|
||||
<p className="text-sm text-slate-400 font-medium mb-8 max-w-[280px] opacity-70">
|
||||
Dieser Modus wird aktuell optimiert und steht in Kürze wieder
|
||||
zur Verfügung.
|
||||
</p>
|
||||
|
||||
<div className="mt-auto flex items-center gap-2 text-[10px] font-mono uppercase tracking-widest font-bold opacity-30">
|
||||
<span>Konfigurator offline</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</Reveal>
|
||||
|
||||
{/* Direct Mail Path */}
|
||||
|
||||
@@ -3,99 +3,247 @@
|
||||
import { motion } from "framer-motion";
|
||||
import { cn } from "../../utils/cn";
|
||||
import { Reveal } from "../Reveal";
|
||||
import { Mail, MessageSquare, ArrowLeft, Send } from "lucide-react";
|
||||
import { ProjectType } from "./types";
|
||||
import {
|
||||
Mail,
|
||||
MessageSquare,
|
||||
ArrowLeft,
|
||||
Send,
|
||||
Phone,
|
||||
User as UserIcon,
|
||||
Calendar,
|
||||
Layers,
|
||||
} from "lucide-react";
|
||||
|
||||
interface DirectMessageFlowProps {
|
||||
name: string;
|
||||
setName: (val: string) => void;
|
||||
email: string;
|
||||
setEmail: (val: string) => void;
|
||||
phone: string;
|
||||
setPhone: (val: string) => void;
|
||||
role: string;
|
||||
setRole: (val: string) => void;
|
||||
company: string;
|
||||
setCompany: (val: string) => void;
|
||||
projectType: ProjectType;
|
||||
setProjectType: (val: ProjectType) => void;
|
||||
deadline: string;
|
||||
setDeadline: (val: string) => void;
|
||||
message: string;
|
||||
setMessage: (val: string) => void;
|
||||
onBack: () => void;
|
||||
onSubmit: () => void;
|
||||
isSubmitting: boolean;
|
||||
error?: string | null;
|
||||
}
|
||||
|
||||
export const DirectMessageFlow = ({
|
||||
name,
|
||||
setName,
|
||||
email,
|
||||
setEmail,
|
||||
phone,
|
||||
setPhone,
|
||||
role,
|
||||
setRole,
|
||||
company,
|
||||
setCompany,
|
||||
projectType,
|
||||
setProjectType,
|
||||
deadline,
|
||||
setDeadline,
|
||||
message,
|
||||
setMessage,
|
||||
onBack,
|
||||
onSubmit,
|
||||
isSubmitting,
|
||||
error,
|
||||
}: DirectMessageFlowProps) => {
|
||||
return (
|
||||
<div className="w-full max-w-3xl mx-auto px-4 py-12">
|
||||
<Reveal width="100%" delay={0.1}>
|
||||
<button
|
||||
onClick={onBack}
|
||||
className="flex items-center gap-2 text-[10px] font-mono font-bold uppercase tracking-widest text-slate-400 hover:text-slate-900 transition-colors mb-12"
|
||||
>
|
||||
<ArrowLeft size={14} /> Zurück zur Auswahl
|
||||
</button>
|
||||
</Reveal>
|
||||
|
||||
<div className="space-y-12">
|
||||
{error && (
|
||||
<Reveal width="100%" delay={0.1}>
|
||||
<div className="bg-red-50 border border-red-100 rounded-2xl p-6 flex items-start gap-4 mb-8">
|
||||
<div className="p-2 bg-red-100 rounded-lg text-red-600">
|
||||
<Mail size={20} className="animate-pulse" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<h3 className="text-red-900 font-bold text-sm uppercase tracking-wider">
|
||||
Übertragungsfehler
|
||||
</h3>
|
||||
<p className="text-red-700/80 font-medium text-base">
|
||||
{error}
|
||||
</p>
|
||||
<div className="mt-2 text-[10px] font-mono text-red-400 uppercase tracking-widest">
|
||||
Status: FEHLER_BEI_SEQUENZ_INIT
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Reveal>
|
||||
)}
|
||||
|
||||
<Reveal width="100%" delay={0.2}>
|
||||
<div className="space-y-2">
|
||||
<div className="space-y-4">
|
||||
<span className="text-[10px] font-mono text-green-600 uppercase tracking-[0.3em] font-bold">
|
||||
DIREKTNACHRICHT // MODUS_AKTIVIERT
|
||||
DIREKTANFRAGE // SCHRITT_01
|
||||
</span>
|
||||
<h2 className="text-3xl font-bold tracking-tight text-slate-900">
|
||||
Wie kann ich helfen, {name.split(" ")[0]}?
|
||||
<h2 className="text-3xl md:text-5xl font-bold tracking-tight text-slate-900 line-clamp-2">
|
||||
Lassen Sie uns sprechen.
|
||||
</h2>
|
||||
<p className="text-slate-500 font-medium">
|
||||
Sende mir eine Nachricht zu {company || "deinem Projekt"} und ich
|
||||
melde mich in Kürze.
|
||||
<p className="text-slate-500 font-medium text-lg">
|
||||
Senden Sie mir eine Nachricht und ich melde mich zeitnah zurück.
|
||||
</p>
|
||||
</div>
|
||||
</Reveal>
|
||||
|
||||
<div className="space-y-8">
|
||||
{/* Email Input */}
|
||||
<div className="space-y-12">
|
||||
{/* Section: Mission Focus */}
|
||||
<Reveal width="100%" delay={0.3} direction="up">
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-6">
|
||||
<label className="flex items-center gap-2 text-[10px] font-mono font-bold uppercase tracking-widest text-slate-400">
|
||||
<Mail size={12} /> Rückantwort an
|
||||
<Layers size={12} /> Mission // Projekt-Typ
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="ihre@email.de"
|
||||
className="w-full bg-slate-50 border border-slate-100 rounded-2xl px-6 py-4 text-lg font-medium focus:outline-none focus:ring-2 focus:ring-slate-900/5 focus:border-slate-900 transition-all"
|
||||
/>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
|
||||
{(["website", "web-app", "ecommerce"] as ProjectType[]).map(
|
||||
(type) => {
|
||||
const labels = {
|
||||
website: "Website",
|
||||
"web-app": "Web Application",
|
||||
ecommerce: "E-Commerce",
|
||||
};
|
||||
return (
|
||||
<button
|
||||
key={type}
|
||||
onClick={() => setProjectType(type)}
|
||||
className={cn(
|
||||
"px-6 py-4 rounded-xl border font-bold text-sm transition-all duration-200 text-left",
|
||||
projectType === type
|
||||
? "bg-slate-900 text-white border-slate-900 shadow-lg"
|
||||
: "bg-white border-slate-100 text-slate-500 hover:border-slate-300",
|
||||
)}
|
||||
>
|
||||
{labels[type]}
|
||||
</button>
|
||||
);
|
||||
},
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Reveal>
|
||||
|
||||
{/* Message Input */}
|
||||
{/* Section: Identity Details */}
|
||||
<Reveal width="100%" delay={0.4} direction="up">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div className="space-y-4">
|
||||
<label className="flex items-center gap-2 text-[10px] font-mono font-bold uppercase tracking-widest text-slate-400">
|
||||
<UserIcon size={12} /> Ihr Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="Max Mustermann"
|
||||
className="w-full bg-slate-50 border border-slate-100 rounded-xl px-4 py-3 text-base font-medium focus:outline-none focus:ring-2 focus:ring-slate-900/5 focus:border-slate-900 transition-all"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<label className="flex items-center gap-2 text-[10px] font-mono font-bold uppercase tracking-widest text-slate-400">
|
||||
<Layers size={12} /> Unternehmen (Optional)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={company}
|
||||
onChange={(e) => setCompany(e.target.value)}
|
||||
placeholder="Beispiel GmbH"
|
||||
className="w-full bg-slate-50 border border-slate-100 rounded-xl px-4 py-3 text-base font-medium focus:outline-none focus:ring-2 focus:ring-slate-900/5 focus:border-slate-900 transition-all"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<label className="flex items-center gap-2 text-[10px] font-mono font-bold uppercase tracking-widest text-slate-400">
|
||||
<Mail size={12} /> E-Mail Adresse
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="name@firma.de"
|
||||
className="w-full bg-slate-50 border border-slate-100 rounded-xl px-4 py-3 text-base font-medium focus:outline-none focus:ring-2 focus:ring-slate-900/5 focus:border-slate-900 transition-all"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<label className="flex items-center gap-2 text-[10px] font-mono font-bold uppercase tracking-widest text-slate-400">
|
||||
<Phone size={12} /> Rückruf-Nummer (Optional)
|
||||
</label>
|
||||
<input
|
||||
type="tel"
|
||||
value={phone}
|
||||
onChange={(e) => setPhone(e.target.value)}
|
||||
placeholder="+49 123 456789"
|
||||
className="w-full bg-slate-50 border border-slate-100 rounded-xl px-4 py-3 text-base font-medium focus:outline-none focus:ring-2 focus:ring-slate-900/5 focus:border-slate-900 transition-all"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Reveal>
|
||||
|
||||
{/* Section: Additional Context */}
|
||||
<Reveal width="100%" delay={0.5} direction="up">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div className="space-y-4">
|
||||
<label className="flex items-center gap-2 text-[10px] font-mono font-bold uppercase tracking-widest text-slate-400">
|
||||
<UserIcon size={12} /> Ihre Position
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={role}
|
||||
onChange={(e) => setRole(e.target.value)}
|
||||
placeholder="z.B. Gründer, Marketing Lead..."
|
||||
className="w-full bg-slate-50 border border-slate-100 rounded-xl px-4 py-3 text-base font-medium focus:outline-none focus:ring-2 focus:ring-slate-900/5 focus:border-slate-900 transition-all"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<label className="flex items-center gap-2 text-[10px] font-mono font-bold uppercase tracking-widest text-slate-400">
|
||||
<Calendar size={12} /> Zeitfenster
|
||||
</label>
|
||||
<select
|
||||
value={deadline}
|
||||
onChange={(e) => setDeadline(e.target.value)}
|
||||
className="w-full bg-slate-50 border border-slate-100 rounded-xl px-4 py-3 text-base font-medium focus:outline-none focus:ring-2 focus:ring-slate-900/5 focus:border-slate-900 transition-all appearance-none cursor-pointer"
|
||||
>
|
||||
<option value="asap">ASAP (Sofort)</option>
|
||||
<option value="1month">< 1 Monat (Priorität)</option>
|
||||
<option value="3months">1-3 Monate (Standard)</option>
|
||||
<option value="flexible">Flexibel</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</Reveal>
|
||||
|
||||
{/* Section: Payload */}
|
||||
<Reveal width="100%" delay={0.6} direction="up">
|
||||
<div className="space-y-4">
|
||||
<label className="flex items-center gap-2 text-[10px] font-mono font-bold uppercase tracking-widest text-slate-400">
|
||||
<MessageSquare size={12} /> Ihre Nachricht
|
||||
<MessageSquare size={12} /> Nachricht // Briefing
|
||||
</label>
|
||||
<textarea
|
||||
value={message}
|
||||
onChange={(e) => setMessage(e.target.value)}
|
||||
placeholder="Beschreiben Sie kurz Ihr Anliegen..."
|
||||
rows={6}
|
||||
className="w-full bg-slate-50 border border-slate-100 rounded-2xl px-6 py-4 text-lg font-medium focus:outline-none focus:ring-2 focus:ring-slate-900/5 focus:border-slate-900 transition-all resize-none"
|
||||
placeholder="Beschreiben Sie kurz Ihr Anliegen oder hinterlassen Sie einen Link zum Briefing..."
|
||||
rows={5}
|
||||
className="w-full bg-slate-50 border border-slate-100 rounded-2xl px-6 py-4 text-base font-medium focus:outline-none focus:ring-2 focus:ring-slate-900/5 focus:border-slate-900 transition-all resize-none"
|
||||
/>
|
||||
</div>
|
||||
</Reveal>
|
||||
|
||||
{/* Submit Button */}
|
||||
<Reveal width="100%" delay={0.5} direction="up">
|
||||
<Reveal width="100%" delay={0.7} direction="up">
|
||||
<button
|
||||
onClick={onSubmit}
|
||||
disabled={isSubmitting || !email || !message}
|
||||
disabled={isSubmitting || !email || !message || !name}
|
||||
className={cn(
|
||||
"group relative w-full py-5 rounded-2xl font-bold text-lg transition-all duration-300 flex items-center justify-center gap-3 overflow-hidden",
|
||||
isSubmitting || !email || !message
|
||||
isSubmitting || !email || !message || !name
|
||||
? "bg-slate-100 text-slate-400 cursor-not-allowed"
|
||||
: "bg-slate-900 text-white shadow-xl hover:shadow-2xl hover:-translate-y-1 active:scale-[0.98]",
|
||||
)}
|
||||
@@ -109,7 +257,7 @@ export const DirectMessageFlow = ({
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span>Nachricht absenden</span>
|
||||
<span>Anfrage senden</span>
|
||||
<Send
|
||||
size={20}
|
||||
className="group-hover:translate-x-1 group-hover:-translate-y-1 transition-transform"
|
||||
|
||||
@@ -4,55 +4,86 @@ export const getInquiryEmailHtml = (data: any) => `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<style>
|
||||
body { font-family: 'Courier New', Courier, monospace; background-color: #0f172a; color: #f8fafc; margin: 0; padding: 20px; }
|
||||
.container { max-width: 600px; margin: 0 auto; background-color: #1e293b; border: 1px solid #334155; padding: 40px; border-radius: 8px; }
|
||||
.header { border-bottom: 2px solid #22c55e; padding-bottom: 20px; margin-bottom: 30px; }
|
||||
.title { font-size: 24px; font-weight: bold; letter-spacing: 2px; color: #f8fafc; }
|
||||
.label { color: #94a3b8; font-size: 12px; text-transform: uppercase; letter-spacing: 1px; margin-bottom: 4px; }
|
||||
.value { font-size: 16px; margin-bottom: 20px; color: #22c55e; }
|
||||
.section { margin-bottom: 30px; }
|
||||
.footer { font-size: 10px; color: #64748b; margin-top: 40px; border-top: 1px solid #334155; padding-top: 20px; }
|
||||
body { font-family: Georgia, 'Times New Roman', Times, serif; background-color: #f8fafc; color: #1e293b; margin: 0; padding: 40px 20px; line-height: 1.6; }
|
||||
.wrapper { max-width: 600px; margin: 0 auto; background-color: #ffffff; border: 1px solid #e2e8f0; border-radius: 4px; overflow: hidden; box-shadow: 0 1px 3px rgba(0,0,0,0.05); }
|
||||
.top-bar { background-color: #0f172a; color: #ffffff; padding: 12px 24px; display: flex; align-items: center; justify-content: space-between; }
|
||||
.brand { font-family: system-ui, sans-serif; font-size: 10px; font-weight: 800; letter-spacing: 0.2em; text-transform: uppercase; }
|
||||
.status { font-family: ui-monospace, monospace; font-size: 9px; color: #10b981; text-transform: uppercase; font-weight: bold; }
|
||||
.header { padding: 40px 32px 24px 32px; border-bottom: 2px solid #f1f5f9; }
|
||||
.heading { font-family: system-ui, sans-serif; font-size: 24px; font-weight: 800; color: #0f172a; margin: 0; letter-spacing: -0.02em; }
|
||||
.content { padding: 32px; }
|
||||
.data-grid { width: 100%; border-collapse: collapse; margin-bottom: 32px; }
|
||||
.data-row td { padding: 12px 0; border-bottom: 1px solid #f8fafc; vertical-align: top; }
|
||||
.label { font-family: system-ui, sans-serif; font-size: 9px; font-weight: 700; color: #94a3b8; text-transform: uppercase; letter-spacing: 0.1em; width: 140px; }
|
||||
.value { font-size: 15px; color: #334155; }
|
||||
.message-container { background-color: #f8fafc; border: 1px solid #f1f5f9; border-radius: 4px; padding: 24px; margin-top: 12px; }
|
||||
.message-text { font-family: Georgia, serif; font-size: 16px; color: #1e293b; white-space: pre-wrap; margin: 0; }
|
||||
.config-code { font-family: ui-monospace, monospace; font-size: 12px; color: #64748b; background: #f1f5f9; padding: 16px; border-radius: 4px; display: block; overflow-x: auto; }
|
||||
.footer { padding: 32px; color: #94a3b8; font-size: 11px; text-align: center; border-top: 1px solid #f1f5f9; }
|
||||
.footer-meta { font-family: ui-monospace, monospace; font-size: 9px; margin-top: 12px; text-transform: uppercase; letter-spacing: 0.05em; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<div class="title">NEUE_ANFRAGE_INPUT</div>
|
||||
<div class="wrapper">
|
||||
<div class="top-bar">
|
||||
<div class="brand">MINTEL_TECHNICAL_OPERATIONS</div>
|
||||
<div class="status">● INCOMING_INQUIRY</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<div class="label">ABSENDER</div>
|
||||
<div class="value">${data.name} (${data.email})</div>
|
||||
|
||||
<div class="label">UNTERNEHMEN</div>
|
||||
<div class="value">${data.companyName || "N/A"}</div>
|
||||
|
||||
<div class="label">PROJEKT_TYP</div>
|
||||
<div class="value">${data.projectType}</div>
|
||||
<div class="header">
|
||||
<h1 class="heading">${data.isFreeText ? "Direktanfrage" : "System-Konfiguration"}</h1>
|
||||
<p style="margin: 8px 0 0 0; color: #64748b; font-size: 14px;">Eingang für ${data.companyName || data.name}</p>
|
||||
</div>
|
||||
|
||||
${
|
||||
data.isFreeText
|
||||
? `
|
||||
<div class="section">
|
||||
<div class="label">NACHRICHT (FREITEXT)</div>
|
||||
<div class="value" style="white-space: pre-wrap; color: #f8fafc;">${data.message}</div>
|
||||
</div>
|
||||
`
|
||||
: `
|
||||
<div class="section">
|
||||
<div class="label">KONFIGURATION</div>
|
||||
<div class="value" style="font-size: 12px; color: #94a3b8; background: #0f172a; padding: 15px; border-radius: 4px;">
|
||||
${JSON.stringify(data.config, null, 2)}
|
||||
<div class="content">
|
||||
<table class="data-grid">
|
||||
<tr class="data-row">
|
||||
<td class="label">Absender</td>
|
||||
<td class="value"><strong>${data.name}</strong><br/><span style="color: #94a3b8; font-size: 13px;">${data.email}</span></td>
|
||||
</tr>
|
||||
${data.phone ? `
|
||||
<tr class="data-row">
|
||||
<td class="label">Telefon</td>
|
||||
<td class="value">${data.phone}</td>
|
||||
</tr>` : ""}
|
||||
${data.role ? `
|
||||
<tr class="data-row">
|
||||
<td class="label">Position</td>
|
||||
<td class="value">${data.role}</td>
|
||||
</tr>` : ""}
|
||||
<tr class="data-row">
|
||||
<td class="label">Unternehmen</td>
|
||||
<td class="value">${data.companyName || "—"}</td>
|
||||
</tr>
|
||||
<tr class="data-row">
|
||||
<td class="label">Projekt-Typ</td>
|
||||
<td class="value">${data.projectType}</td>
|
||||
</tr>
|
||||
${data.deadline ? `
|
||||
<tr class="data-row">
|
||||
<td class="label">Zeitraum</td>
|
||||
<td class="value">${data.deadline}</td>
|
||||
</tr>` : ""}
|
||||
</table>
|
||||
|
||||
${data.isFreeText ? `
|
||||
<div class="label" style="margin-bottom: 8px;">Nachricht</div>
|
||||
<div class="message-container">
|
||||
<div class="message-text">${data.message}</div>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
` : `
|
||||
<div class="label" style="margin-bottom: 8px;">Konfigurations-Daten</div>
|
||||
<code class="config-code">${JSON.stringify(data.config, null, 2)}</code>
|
||||
`}
|
||||
</div>
|
||||
|
||||
<div class="footer">
|
||||
SISTEM_STATUS: VALIDATED<br>
|
||||
TIMESTAMP: ${new Date().toISOString()}
|
||||
Dies ist eine systemgenerierte Benachrichtigung von mintel.me.<br/>
|
||||
<div class="footer-meta">
|
||||
TIMESTAMP: ${new Date().toISOString()} | STATUS: VALIDATED
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
@@ -63,32 +94,42 @@ export const getConfirmationEmailHtml = (data: any) => `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<style>
|
||||
body { font-family: 'Courier New', Courier, monospace; background-color: #f8fafc; color: #0f172a; margin: 0; padding: 20px; }
|
||||
.container { max-width: 600px; margin: 0 auto; background-color: #ffffff; border: 1px solid #e2e8f0; padding: 40px; border-radius: 12px; box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1); }
|
||||
.header { text-align: center; margin-bottom: 40px; }
|
||||
.status-badge { display: inline-block; padding: 4px 12px; background-color: #22c55e; color: #0f172a; font-size: 10px; font-weight: bold; border-radius: 9999px; margin-bottom: 16px; }
|
||||
.title { font-size: 28px; font-weight: bold; letter-spacing: -0.02em; margin-bottom: 8px; }
|
||||
.subtitle { color: #64748b; font-size: 16px; line-height: 1.5; }
|
||||
.content { line-height: 1.6; color: #334155; margin-bottom: 40px; }
|
||||
.footer { text-align: center; font-size: 12px; color: #94a3b8; border-top: 1px solid #f1f5f9; padding-top: 30px; }
|
||||
body { font-family: Georgia, 'Times New Roman', Times, serif; background-color: #ffffff; color: #1e293b; margin: 0; padding: 40px 20px; line-height: 1.8; }
|
||||
.wrapper { max-width: 600px; margin: 0 auto; }
|
||||
.logo-container { padding-bottom: 48px; border-bottom: 1px solid #f1f5f9; margin-bottom: 48px; }
|
||||
.status-badge { display: inline-block; font-family: system-ui, sans-serif; font-size: 9px; font-weight: 800; color: #3b82f6; text-transform: uppercase; letter-spacing: 0.2em; border: 1px solid #dbeafe; background: #eff6ff; padding: 4px 12px; border-radius: 99px; margin-bottom: 24px; }
|
||||
.greeting { font-family: system-ui, sans-serif; font-size: 32px; font-weight: 800; color: #0f172a; margin: 0 0 16px 0; letter-spacing: -0.03em; }
|
||||
.body-text { font-size: 18px; color: #334155; margin-bottom: 32px; }
|
||||
.cta-box { border-left: 2px solid #0f172a; padding: 8px 0 8px 24px; margin: 40px 0; }
|
||||
.footer { margin-top: 80px; padding-top: 24px; border-top: 1px solid #f1f5f9; font-family: system-ui, sans-serif; font-size: 12px; color: #94a3b8; text-align: center; }
|
||||
.footer a { color: #0f172a; text-decoration: none; font-weight: 600; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<div class="status-badge">SEQUENZ_INITIIERT</div>
|
||||
<div class="title">Hallo ${data.name.split(" ")[0]},</div>
|
||||
<div class="subtitle">vielen Dank für deine Anfrage.</div>
|
||||
<div class="wrapper">
|
||||
<div class="logo-container">
|
||||
<div class="status-badge">Anfrage eingegangen</div>
|
||||
<h1 class="greeting">Hallo ${data.name.split(" ")[0]},</h1>
|
||||
<p class="body-text">
|
||||
vielen Dank für dein Interesse an einer Zusammenarbeit. Deine Nachricht bezüglich <strong>${data.companyName || "deines Projekts"}</strong> ist sicher bei mir angekommen.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="content">
|
||||
<p>Ich habe deine Nachricht erhalten und schaue mir die Details zu <strong>${data.companyName || "deinem Projekt"}</strong> umgehend an.</p>
|
||||
<p>Normalerweise melde ich mich innerhalb von 24 Stunden bei dir zurück, um die nächsten Schritte zu besprechen.</p>
|
||||
<div class="body-text">
|
||||
Ich werde mir die Details umgehend ansehen und mich in der Regel innerhalb der nächsten 24 Stunden persönlich bei dir zurückmelden, um die nächsten Schritte zu besprechen.
|
||||
</div>
|
||||
|
||||
<div class="cta-box">
|
||||
<p style="margin:0; font-style: italic; color: #64748b;">
|
||||
„Technical problems are just puzzles with more moving parts.“
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="footer">
|
||||
© ${new Date().getFullYear()} mintel.me — Technical Problem Solving
|
||||
© ${new Date().getFullYear()} <a href="https://mintel.me">mintel.me</a> — Marc Mintel<br/>
|
||||
Professional Website Systems & Technical Architecture
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
|
||||
@@ -77,12 +77,17 @@ export const LocalEstimationPDF = ({
|
||||
ustId: "DE367588065",
|
||||
};
|
||||
|
||||
const bankData = {
|
||||
name: "N26",
|
||||
bic: "NTSBDEB1XXX",
|
||||
iban: "DE50 1001 1001 2620 4328 65",
|
||||
};
|
||||
|
||||
const commonProps = {
|
||||
state,
|
||||
date,
|
||||
icon: headerIcon,
|
||||
headerIcon: headerIcon,
|
||||
footerLogo,
|
||||
companyData,
|
||||
bankData,
|
||||
};
|
||||
|
||||
let pageCounter = 1;
|
||||
@@ -103,12 +108,12 @@ export const LocalEstimationPDF = ({
|
||||
{/* BriefingModule Page REMOVED as per user request ("die zweite seite ist leer, weg damit") */}
|
||||
|
||||
{state.sitemap && state.sitemap.length > 0 && (
|
||||
<SimpleLayout {...commonProps} pageNumber={getPageNum()}>
|
||||
<SimpleLayout {...commonProps} showPageNumber={false}>
|
||||
<SitemapModule state={state} />
|
||||
</SimpleLayout>
|
||||
)}
|
||||
|
||||
<SimpleLayout {...commonProps} pageNumber={getPageNum()}>
|
||||
<SimpleLayout {...commonProps} showPageNumber={false}>
|
||||
<EstimationModule
|
||||
state={state}
|
||||
positions={positions}
|
||||
@@ -117,11 +122,11 @@ export const LocalEstimationPDF = ({
|
||||
/>
|
||||
</SimpleLayout>
|
||||
|
||||
<SimpleLayout {...commonProps} pageNumber={getPageNum()}>
|
||||
<SimpleLayout {...commonProps} showPageNumber={false}>
|
||||
<TransparenzModule pricing={pricing} />
|
||||
</SimpleLayout>
|
||||
|
||||
<SimpleLayout {...commonProps} pageNumber={getPageNum()}>
|
||||
<SimpleLayout {...commonProps} showPageNumber={false}>
|
||||
<ClosingModule />
|
||||
</SimpleLayout>
|
||||
</PDFDocument>
|
||||
|
||||
@@ -35,6 +35,7 @@ export interface FormState {
|
||||
storageExpansion: number;
|
||||
name: string;
|
||||
email: string;
|
||||
phone: string;
|
||||
role: string;
|
||||
message: string;
|
||||
sitemapFile: File | null;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { calculatePositions as logicCalculatePositions } from "@mintel/pdf";
|
||||
import { FormState } from "./types";
|
||||
|
||||
// @ts-ignore
|
||||
export type { Position } from "@mintel/pdf";
|
||||
|
||||
export const calculatePositions = (state: FormState, pricing: any) =>
|
||||
|
||||
@@ -47,7 +47,7 @@ export const HeroSection: React.FC = () => {
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{ duration: 1, delay: 0.2 }}
|
||||
className="mb-4 md:mb-10 inline-flex items-center gap-3 md:gap-4 px-4 md:px-6 py-2 border border-slate-100 bg-white/40 backdrop-blur-sm rounded-full"
|
||||
className="mb-4 md:mb-8 lg:mb-10 inline-flex items-center gap-3 md:gap-4 px-4 md:px-6 py-2 border border-slate-100 bg-white/40 backdrop-blur-sm rounded-full"
|
||||
>
|
||||
<div className="flex gap-1">
|
||||
<div className="w-1 h-1 rounded-full bg-blue-500 animate-pulse" />
|
||||
@@ -59,7 +59,7 @@ export const HeroSection: React.FC = () => {
|
||||
</motion.div>
|
||||
|
||||
{/* Headline */}
|
||||
<h1 className="text-3xl md:text-[11rem] font-black tracking-tighter leading-[0.9] md:leading-[0.8] text-slate-900 mb-6 md:mb-12 uppercase">
|
||||
<h1 className="text-4xl md:text-8xl lg:text-[10rem] xl:text-[11rem] font-black tracking-tighter leading-[0.9] md:leading-[0.85] lg:leading-[0.8] text-slate-900 mb-6 md:mb-8 lg:mb-12 uppercase">
|
||||
<div className="block">
|
||||
<GlitchText delay={0.5} duration={1.2}>
|
||||
Websites
|
||||
@@ -80,9 +80,9 @@ export const HeroSection: React.FC = () => {
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 1, delay: 0.8 }}
|
||||
className="flex flex-col items-center gap-6 md:gap-12"
|
||||
className="flex flex-col items-center gap-6 md:gap-8 lg:gap-12"
|
||||
>
|
||||
<p className="text-base md:text-3xl text-slate-400 font-medium max-w-2xl leading-relaxed px-4">
|
||||
<p className="text-base md:text-xl lg:text-3xl text-slate-400 font-medium max-w-2xl leading-relaxed px-4">
|
||||
Ein Entwickler. Ein Ansprechpartner.{" "}
|
||||
<br className="hidden md:block" />
|
||||
<span className="text-slate-900 font-bold tracking-tight">
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
"use client";
|
||||
|
||||
|
||||
import * as React from "react";
|
||||
import { cn } from "../utils/cn";
|
||||
import { ShieldCheck, ArrowLeft, ArrowRight, RefreshCw } from "lucide-react";
|
||||
@@ -31,8 +30,6 @@ interface IframeSectionProps {
|
||||
desktopHeight?: string;
|
||||
}
|
||||
|
||||
import PropTypes from "prop-types";
|
||||
|
||||
/**
|
||||
* Reusable Browser UI components to maintain consistency
|
||||
*/
|
||||
@@ -102,11 +99,6 @@ const BrowserChromeComponent: React.FC<{ url: string; minimal?: boolean }> = ({
|
||||
);
|
||||
};
|
||||
|
||||
BrowserChromeComponent.propTypes = {
|
||||
url: PropTypes.string.isRequired,
|
||||
minimal: PropTypes.bool,
|
||||
};
|
||||
|
||||
const BrowserChrome = React.memo(BrowserChromeComponent);
|
||||
|
||||
BrowserChrome.displayName = "BrowserChrome";
|
||||
@@ -204,15 +196,17 @@ export const IframeSection: React.FC<IframeSectionProps> = ({
|
||||
|
||||
const updateScrollState = React.useCallback(() => {
|
||||
try {
|
||||
const win = iframeRef.current?.contentWindow;
|
||||
const doc = iframeRef.current?.contentDocument?.documentElement;
|
||||
if (doc) {
|
||||
const atTop = doc.scrollTop <= 5;
|
||||
if (doc && win) {
|
||||
const scrollTop = doc.scrollTop || win.scrollY || 0;
|
||||
const atTop = scrollTop <= 5;
|
||||
const atBottom =
|
||||
doc.scrollTop + doc.clientHeight >= doc.scrollHeight - 5;
|
||||
scrollTop + doc.clientHeight >= doc.scrollHeight - 5;
|
||||
const isScrollable = doc.scrollHeight > doc.clientHeight + 10;
|
||||
setScrollState({ atTop, atBottom, isScrollable });
|
||||
}
|
||||
} catch (_e) { }
|
||||
} catch (_e) {}
|
||||
}, []);
|
||||
|
||||
// Ambilight effect (sampled from iframe if same-origin)
|
||||
@@ -257,7 +251,7 @@ export const IframeSection: React.FC<IframeSectionProps> = ({
|
||||
);
|
||||
|
||||
updateScrollState();
|
||||
} catch (_e) { }
|
||||
} catch (_e) {}
|
||||
}, [dynamicGlow, offsetY, updateScrollState]);
|
||||
|
||||
// Height parse helper
|
||||
@@ -376,9 +370,9 @@ export const IframeSection: React.FC<IframeSectionProps> = ({
|
||||
"w-full relative flex flex-col z-10",
|
||||
minimal ? "bg-transparent" : "bg-slate-50",
|
||||
!minimal &&
|
||||
"rounded-[2.5rem] border border-slate-200/50 shadow-[0_80px_160px_-40px_rgba(0,0,0,0.18),0_0_1px_rgba(0,0,0,0.1)]",
|
||||
"rounded-[2.5rem] border border-slate-200/50 shadow-[0_80px_160px_-40px_rgba(0,0,0,0.18),0_0_1px_rgba(0,0,0,0.1)]",
|
||||
perspective &&
|
||||
"hover:scale-[1.03] hover:-translate-y-3 transition-[transform,shadow,scale] duration-1000 ease-[cubic-bezier(0.23,1,0.32,1)]",
|
||||
"hover:scale-[1.03] hover:-translate-y-3 transition-[transform,shadow,scale] duration-1000 ease-[cubic-bezier(0.23,1,0.32,1)]",
|
||||
"overflow-hidden",
|
||||
)}
|
||||
style={chassisStyle}
|
||||
@@ -501,7 +495,6 @@ export const IframeSection: React.FC<IframeSectionProps> = ({
|
||||
style.textContent = `
|
||||
*::-webkit-scrollbar { display: none !important; }
|
||||
* { -ms-overflow-style: none !important; scrollbar-width: none !important; }
|
||||
body { background: transparent !important; }
|
||||
`;
|
||||
iframe.contentDocument.head.appendChild(style);
|
||||
setTimeout(updateAmbilight, 600);
|
||||
|
||||
@@ -1,256 +1,355 @@
|
||||
'use client';
|
||||
"use client";
|
||||
|
||||
import React from 'react';
|
||||
import { ComponentShareButton } from './ComponentShareButton';
|
||||
import { Reveal } from './Reveal';
|
||||
import React from "react";
|
||||
import { ComponentShareButton } from "./ComponentShareButton";
|
||||
import { Reveal } from "./Reveal";
|
||||
|
||||
interface MemeCardProps {
|
||||
/** Meme template type: drake, ds (daily struggle), gru, fine, clown, expanding, distracted, rollsafe */
|
||||
template: string;
|
||||
/** Pipe-delimited captions */
|
||||
captions: string;
|
||||
/** Optional local image path. If provided, overrides the text-based template. */
|
||||
image?: string;
|
||||
className?: string;
|
||||
/** Meme template type: drake, ds (daily struggle), gru, fine, clown, expanding, distracted, rollsafe */
|
||||
template: string;
|
||||
/** Pipe-delimited captions */
|
||||
captions: string;
|
||||
/** Optional local image path. If provided, overrides the text-based template. */
|
||||
image?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Premium text-based meme cards with dedicated layouts per template.
|
||||
* Uses emoji + typography instead of images for on-brand aesthetics.
|
||||
*/
|
||||
export const MemeCard: React.FC<MemeCardProps> = ({ template, captions, image, className = '' }) => {
|
||||
const captionList = (captions || '').split('|').map(s => s.trim()).filter(Boolean);
|
||||
const shareId = `meme-${Math.random().toString(36).substring(7).toUpperCase()}`;
|
||||
|
||||
if (image) {
|
||||
return (
|
||||
<Reveal direction="up" delay={0.1}>
|
||||
<div className={`not-prose max-w-xl mx-auto my-12 group relative transition-all duration-500 ease-out z-10 ${className}`}>
|
||||
<div className="absolute -inset-1 bg-gradient-to-r from-slate-200 to-slate-100 rounded-[2rem] blur opacity-10 group-hover:opacity-30 transition duration-1000 -z-10" />
|
||||
|
||||
<div id={shareId} className="glass bg-white/80 backdrop-blur-xl border border-slate-100 rounded-2xl overflow-hidden shadow-sm shadow-slate-200 group-hover:shadow-md transition-all duration-500 relative">
|
||||
<div data-share-wrapper="true" className="absolute top-4 right-4 md:opacity-0 group-hover:opacity-100 transition-opacity duration-500 z-50">
|
||||
<ComponentShareButton targetId={shareId} title={`Meme: ${template}`} />
|
||||
</div>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={image}
|
||||
alt={`Meme: ${template} - ${captionList.join(' ')}`}
|
||||
className="w-full h-auto object-cover block"
|
||||
loading="eager"
|
||||
decoding="sync"
|
||||
crossOrigin="anonymous"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Reveal>
|
||||
);
|
||||
}
|
||||
export const MemeCard: React.FC<MemeCardProps> = ({
|
||||
template,
|
||||
captions,
|
||||
image,
|
||||
className = "",
|
||||
}) => {
|
||||
// Also replace literal `\n` (slash-n) strings from AI output with actual newlines
|
||||
const processedCaptions = (captions || "").replace(/\\n/g, "\n");
|
||||
const captionList = processedCaptions
|
||||
.split("|")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
const shareId = `meme-${Math.random().toString(36).substring(7).toUpperCase()}`;
|
||||
|
||||
if (image) {
|
||||
return (
|
||||
<Reveal direction="up" delay={0.1}>
|
||||
<div className={`not-prose max-w-xl mx-auto my-12 group relative transition-all duration-500 ease-out z-10 ${className}`}>
|
||||
<div className="absolute -inset-1 bg-gradient-to-r from-slate-200 to-slate-100 rounded-[2rem] blur opacity-10 group-hover:opacity-30 transition duration-1000 -z-10" />
|
||||
<Reveal direction="up" delay={0.1}>
|
||||
<div
|
||||
className={`not-prose max-w-xl mx-auto my-12 group relative transition-all duration-500 ease-out z-10 ${className}`}
|
||||
>
|
||||
<div className="absolute -inset-1 bg-gradient-to-r from-slate-200 to-slate-100 rounded-[2rem] blur opacity-10 group-hover:opacity-30 transition duration-1000 -z-10" />
|
||||
|
||||
<div id={shareId} className="glass bg-white/80 backdrop-blur-xl border border-slate-100 rounded-2xl overflow-hidden shadow-sm shadow-slate-200 group-hover:shadow-md transition-all duration-500 relative">
|
||||
<div data-share-wrapper="true" className="absolute top-4 right-4 md:opacity-0 group-hover:opacity-100 transition-opacity duration-500 z-50">
|
||||
<ComponentShareButton targetId={shareId} title={`Meme: ${template}`} />
|
||||
</div>
|
||||
|
||||
{template === 'drake' && <DrakeMeme captions={captionList} />}
|
||||
{template === 'ds' && <DailyStruggleMeme captions={captionList} />}
|
||||
{template === 'gru' && <GruMeme captions={captionList} />}
|
||||
{template === 'fine' && <FineMeme captions={captionList} />}
|
||||
{template === 'clown' && <ClownMeme captions={captionList} />}
|
||||
{template === 'expanding' && <ExpandingBrainMeme captions={captionList} />}
|
||||
{template === 'distracted' && <DistractedMeme captions={captionList} />}
|
||||
<GenericMeme captions={captionList} template={template} />
|
||||
</div>
|
||||
<div
|
||||
id={shareId}
|
||||
className="glass bg-white/80 backdrop-blur-xl border border-slate-100 rounded-2xl overflow-hidden shadow-sm shadow-slate-200 group-hover:shadow-md transition-all duration-500 relative"
|
||||
>
|
||||
<div
|
||||
data-share-wrapper="true"
|
||||
className="absolute top-4 right-4 md:opacity-0 group-hover:opacity-100 transition-opacity duration-500 z-50"
|
||||
>
|
||||
<ComponentShareButton
|
||||
targetId={shareId}
|
||||
title={`Meme: ${template}`}
|
||||
/>
|
||||
</div>
|
||||
</Reveal>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={image}
|
||||
alt={`Meme: ${template} - ${captionList.join(" ")}`}
|
||||
className="w-full h-auto object-cover block"
|
||||
loading="eager"
|
||||
decoding="sync"
|
||||
crossOrigin="anonymous"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Reveal>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Reveal direction="up" delay={0.1}>
|
||||
<div
|
||||
className={`not-prose max-w-xl mx-auto my-12 group relative transition-all duration-500 ease-out z-10 ${className}`}
|
||||
>
|
||||
<div className="absolute -inset-1 bg-gradient-to-r from-slate-200 to-slate-100 rounded-[2rem] blur opacity-10 group-hover:opacity-30 transition duration-1000 -z-10" />
|
||||
|
||||
<div
|
||||
id={shareId}
|
||||
className="glass bg-white/80 backdrop-blur-xl border border-slate-100 rounded-2xl overflow-hidden shadow-sm shadow-slate-200 group-hover:shadow-md transition-all duration-500 relative"
|
||||
>
|
||||
<div
|
||||
data-share-wrapper="true"
|
||||
className="absolute top-4 right-4 md:opacity-0 group-hover:opacity-100 transition-opacity duration-500 z-50"
|
||||
>
|
||||
<ComponentShareButton
|
||||
targetId={shareId}
|
||||
title={`Meme: ${template}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{template === "drake" && <DrakeMeme captions={captionList} />}
|
||||
{template === "ds" && <DailyStruggleMeme captions={captionList} />}
|
||||
{template === "gru" && <GruMeme captions={captionList} />}
|
||||
{template === "fine" && <FineMeme captions={captionList} />}
|
||||
{template === "clown" && <ClownMeme captions={captionList} />}
|
||||
{template === "expanding" && (
|
||||
<ExpandingBrainMeme captions={captionList} />
|
||||
)}
|
||||
{template === "distracted" && (
|
||||
<DistractedMeme captions={captionList} />
|
||||
)}
|
||||
<GenericMeme captions={captionList} template={template} />
|
||||
</div>
|
||||
</div>
|
||||
</Reveal>
|
||||
);
|
||||
};
|
||||
|
||||
function DrakeMeme({ captions }: { captions: string[] }) {
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
<div className="flex items-stretch border-b border-slate-100">
|
||||
<div className="w-20 md:w-24 bg-red-400/10 flex items-center justify-center flex-shrink-0 border-r border-slate-100">
|
||||
<span className="text-3xl md:text-4xl select-none grayscale-0 group-hover:scale-110 transition-transform duration-500">🙅</span>
|
||||
</div>
|
||||
<div className="flex-1 p-5 md:p-6 flex items-center bg-white/40">
|
||||
<p className="text-lg md:text-xl font-medium text-slate-500 leading-snug">{captions[0]}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-stretch">
|
||||
<div className="w-20 md:w-24 bg-emerald-400/10 flex items-center justify-center flex-shrink-0 border-r border-slate-100">
|
||||
<span className="text-3xl md:text-4xl select-none group-hover:scale-110 transition-transform duration-500">😎</span>
|
||||
</div>
|
||||
<div className="flex-1 p-5 md:p-6 flex items-center bg-white">
|
||||
<p className="text-lg md:text-xl font-bold text-slate-900 leading-snug">{captions[1]}</p>
|
||||
</div>
|
||||
</div>
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
<div className="flex items-stretch border-b border-slate-100">
|
||||
<div className="w-20 md:w-24 bg-red-400/10 flex items-center justify-center flex-shrink-0 border-r border-slate-100">
|
||||
<span className="text-3xl md:text-4xl select-none grayscale-0 group-hover:scale-110 transition-transform duration-500">
|
||||
🙅
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
<div className="flex-1 p-5 md:p-6 flex items-center bg-white/40">
|
||||
<p className="text-lg md:text-xl font-medium text-slate-500 leading-snug whitespace-pre-wrap">
|
||||
{captions[0]}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-stretch">
|
||||
<div className="w-20 md:w-24 bg-emerald-400/10 flex items-center justify-center flex-shrink-0 border-r border-slate-100">
|
||||
<span className="text-3xl md:text-4xl select-none group-hover:scale-110 transition-transform duration-500">
|
||||
😎
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex-1 p-5 md:p-6 flex items-center bg-white">
|
||||
<p className="text-lg md:text-xl font-bold text-slate-900 leading-snug whitespace-pre-wrap">
|
||||
{captions[1]}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DailyStruggleMeme({ captions }: { captions: string[] }) {
|
||||
return (
|
||||
<div className="p-8 md:p-10 text-center">
|
||||
<div className="text-4xl md:text-5xl mb-6 select-none animate-bounce-subtle">😰</div>
|
||||
<p className="text-[10px] font-black text-slate-300 uppercase tracking-[0.3em] mb-8">Daily Struggle</p>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="p-5 rounded-2xl bg-white border border-slate-100 shadow-sm hover:border-slate-200 transition-colors">
|
||||
<div className="w-5 h-5 rounded-full bg-red-500 mx-auto mb-3 shadow-[0_0_10px_rgba(239,68,68,0.4)]" />
|
||||
<p className="text-sm md:text-base font-bold text-slate-700 leading-snug">{captions[0]}</p>
|
||||
</div>
|
||||
<div className="p-5 rounded-2xl bg-white border border-slate-100 shadow-sm hover:border-slate-200 transition-colors">
|
||||
<div className="w-5 h-5 rounded-full bg-red-500 mx-auto mb-3 shadow-[0_0_10px_rgba(239,68,68,0.4)]" />
|
||||
<p className="text-sm md:text-base font-bold text-slate-700 leading-snug">{captions[1]}</p>
|
||||
</div>
|
||||
</div>
|
||||
return (
|
||||
<div className="p-8 md:p-10 text-center">
|
||||
<div className="text-4xl md:text-5xl mb-6 select-none animate-bounce-subtle">
|
||||
😰
|
||||
</div>
|
||||
<p className="text-[10px] font-black text-slate-300 uppercase tracking-[0.3em] mb-8">
|
||||
Daily Struggle
|
||||
</p>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="p-5 rounded-2xl bg-white border border-slate-100 shadow-sm hover:border-slate-200 transition-colors">
|
||||
<div className="w-5 h-5 rounded-full bg-red-500 mx-auto mb-3 shadow-[0_0_10px_rgba(239,68,68,0.4)]" />
|
||||
<p className="text-sm md:text-base font-bold text-slate-700 leading-snug">
|
||||
{captions[0]}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
<div className="p-5 rounded-2xl bg-white border border-slate-100 shadow-sm hover:border-slate-200 transition-colors">
|
||||
<div className="w-5 h-5 rounded-full bg-red-500 mx-auto mb-3 shadow-[0_0_10px_rgba(239,68,68,0.4)]" />
|
||||
<p className="text-sm md:text-base font-bold text-slate-700 leading-snug">
|
||||
{captions[1]}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function GruMeme({ captions }: { captions: string[] }) {
|
||||
const steps = captions.slice(0, 4);
|
||||
return (
|
||||
<div className="grid grid-cols-2 grid-rows-2">
|
||||
{(steps || []).map((caption, i) => {
|
||||
const isLast = i >= 2;
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className={`p-6 md:p-8 ${i % 2 === 0 ? 'border-r' : ''} ${i < 2 ? 'border-b' : ''} border-slate-100 flex flex-col items-center justify-center text-center gap-3 transition-colors hover:bg-slate-50/30`}
|
||||
>
|
||||
<span className="text-2xl md:text-3xl select-none transition-transform group-hover:scale-110">
|
||||
{isLast ? '😱' : '😏'}
|
||||
</span>
|
||||
<p className={`text-base md:text-lg leading-tight ${isLast ? 'font-black text-red-500' : 'font-bold text-slate-700'}`}>
|
||||
{caption}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
const steps = captions.slice(0, 4);
|
||||
return (
|
||||
<div className="grid grid-cols-2 grid-rows-2">
|
||||
{(steps || []).map((caption, i) => {
|
||||
const isLast = i >= 2;
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className={`p-6 md:p-8 ${i % 2 === 0 ? "border-r" : ""} ${i < 2 ? "border-b" : ""} border-slate-100 flex flex-col items-center justify-center text-center gap-3 transition-colors hover:bg-slate-50/30`}
|
||||
>
|
||||
<span className="text-2xl md:text-3xl select-none transition-transform group-hover:scale-110">
|
||||
{isLast ? "😱" : "😏"}
|
||||
</span>
|
||||
<p
|
||||
className={`text-base md:text-lg leading-tight ${isLast ? "font-black text-red-500" : "font-bold text-slate-700"}`}
|
||||
>
|
||||
{caption}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FineMeme({ captions }: { captions: string[] }) {
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
<div className="bg-orange-50/50 border-b border-slate-100 p-6 md:p-8">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<span className="text-3xl md:text-4xl select-none">🔥</span>
|
||||
<p className="text-[10px] font-black text-slate-400 uppercase tracking-widest m-0">This is Fine</p>
|
||||
</div>
|
||||
<p className="text-lg md:text-xl font-bold text-slate-700 leading-snug">{captions[0]}</p>
|
||||
</div>
|
||||
<div className="p-6 md:p-8 bg-white">
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="text-3xl select-none group-hover:rotate-12 transition-transform">☕</span>
|
||||
<p className="text-lg md:text-2xl font-black text-slate-900 leading-tight italic tracking-tight">
|
||||
“{captions[1] || 'Alles im grünen Bereich.'}”
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
<div className="bg-orange-50/50 border-b border-slate-100 p-6 md:p-8">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<span className="text-3xl md:text-4xl select-none">🔥</span>
|
||||
<p className="text-[10px] font-black text-slate-400 uppercase tracking-widest m-0">
|
||||
This is Fine
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
<p className="text-lg md:text-xl font-bold text-slate-700 leading-snug">
|
||||
{captions[0]}
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-6 md:p-8 bg-white">
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="text-3xl select-none group-hover:rotate-12 transition-transform">
|
||||
☕
|
||||
</span>
|
||||
<p className="text-lg md:text-2xl font-black text-slate-900 leading-tight italic tracking-tight">
|
||||
“{captions[1] || "Alles im grünen Bereich."}”
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ClownMeme({ captions }: { captions: string[] }) {
|
||||
const steps = captions.slice(0, 4);
|
||||
const emojis = ['😐', '🤡', '💀', '🎪'];
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
<div className="p-4 md:p-5 border-b border-slate-100 bg-slate-50/50">
|
||||
<p className="text-[10px] font-black text-slate-300 uppercase tracking-[0.3em] m-0 text-center">Clown Progression</p>
|
||||
</div>
|
||||
{steps.map((caption, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={`flex items-center gap-5 p-5 md:p-6 ${i < steps.length - 1 ? 'border-b border-slate-100' : ''} hover:bg-slate-50 transition-colors`}
|
||||
>
|
||||
<span className="text-2xl md:text-3xl select-none flex-shrink-0 grayscale opacity-60 group-hover:grayscale-0 group-hover:opacity-100 transition-all duration-500">{emojis[i] || '🤡'}</span>
|
||||
<p className={`text-base md:text-lg leading-snug ${i === steps.length - 1 ? 'font-black text-red-500' : 'font-bold text-slate-700'}`}>
|
||||
{caption}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
const steps = captions.slice(0, 4);
|
||||
const emojis = ["😐", "🤡", "💀", "🎪"];
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
<div className="p-4 md:p-5 border-b border-slate-100 bg-slate-50/50">
|
||||
<p className="text-[10px] font-black text-slate-300 uppercase tracking-[0.3em] m-0 text-center">
|
||||
Clown Progression
|
||||
</p>
|
||||
</div>
|
||||
{steps.map((caption, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={`flex items-center gap-5 p-5 md:p-6 ${i < steps.length - 1 ? "border-b border-slate-100" : ""} hover:bg-slate-50 transition-colors`}
|
||||
>
|
||||
<span className="text-2xl md:text-3xl select-none flex-shrink-0 grayscale opacity-60 group-hover:grayscale-0 group-hover:opacity-100 transition-all duration-500">
|
||||
{emojis[i] || "🤡"}
|
||||
</span>
|
||||
<p
|
||||
className={`text-base md:text-lg leading-snug ${i === steps.length - 1 ? "font-black text-red-500" : "font-bold text-slate-700"}`}
|
||||
>
|
||||
{caption}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ExpandingBrainMeme({ captions }: { captions: string[] }) {
|
||||
const steps = captions.slice(0, 4);
|
||||
const emojis = ['🧠', '🧠✨', '🧠💡', '🧠🚀'];
|
||||
const shadows = [
|
||||
'',
|
||||
'shadow-[0_0_15px_rgba(59,130,246,0.1)]',
|
||||
'shadow-[0_0_20px_rgba(99,102,241,0.2)]',
|
||||
'shadow-[0_0_25px_rgba(168,85,247,0.3)]',
|
||||
];
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
<div className="p-4 md:p-5 border-b border-slate-100 bg-slate-50/50">
|
||||
<p className="text-[10px] font-black text-slate-300 uppercase tracking-[0.3em] m-0 text-center">Expanding Intelligence</p>
|
||||
</div>
|
||||
{steps.map((caption, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={`flex items-center gap-5 p-5 md:p-6 ${i < steps.length - 1 ? 'border-b border-slate-100' : ''} hover:bg-white transition-all duration-500 ${shadows[i]}`}
|
||||
>
|
||||
<span className="text-2xl md:text-3xl select-none flex-shrink-0 group-hover:scale-125 transition-transform duration-700">{emojis[i]}</span>
|
||||
<p className={`text-base md:text-lg leading-tight ${i === steps.length - 1 ? 'font-black text-indigo-600' : 'font-bold text-slate-700'}`}>
|
||||
{caption}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
const steps = captions.slice(0, 4);
|
||||
const emojis = ["🧠", "🧠✨", "🧠💡", "🧠🚀"];
|
||||
const shadows = [
|
||||
"",
|
||||
"shadow-[0_0_15px_rgba(59,130,246,0.1)]",
|
||||
"shadow-[0_0_20px_rgba(99,102,241,0.2)]",
|
||||
"shadow-[0_0_25px_rgba(168,85,247,0.3)]",
|
||||
];
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
<div className="p-4 md:p-5 border-b border-slate-100 bg-slate-50/50">
|
||||
<p className="text-[10px] font-black text-slate-300 uppercase tracking-[0.3em] m-0 text-center">
|
||||
Expanding Intelligence
|
||||
</p>
|
||||
</div>
|
||||
{steps.map((caption, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={`flex items-center gap-5 p-5 md:p-6 ${i < steps.length - 1 ? "border-b border-slate-100" : ""} hover:bg-white transition-all duration-500 ${shadows[i]}`}
|
||||
>
|
||||
<span className="text-2xl md:text-3xl select-none flex-shrink-0 group-hover:scale-125 transition-transform duration-700">
|
||||
{emojis[i]}
|
||||
</span>
|
||||
<p
|
||||
className={`text-base md:text-lg leading-tight ${i === steps.length - 1 ? "font-black text-indigo-600" : "font-bold text-slate-700"}`}
|
||||
>
|
||||
{caption}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DistractedMeme({ captions }: { captions: string[] }) {
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
<div className="p-4 md:p-5 border-b border-slate-100 bg-slate-50/50">
|
||||
<p className="text-[10px] font-black text-slate-300 uppercase tracking-[0.3em] m-0 text-center">The Distraction</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 divide-x divide-slate-100">
|
||||
<div className="p-6 md:p-8 flex flex-col items-center text-center gap-3 hover:bg-slate-50/50 transition-colors">
|
||||
<span className="text-3xl md:text-4xl select-none">👤</span>
|
||||
<p className="text-[9px] font-black text-slate-400 uppercase tracking-[0.2em] m-0">Subject</p>
|
||||
<p className="text-sm md:text-base font-bold text-slate-500 leading-tight">{captions[0]}</p>
|
||||
</div>
|
||||
<div className="p-6 md:p-8 flex flex-col items-center text-center gap-3 bg-emerald-50/30 hover:bg-emerald-50/60 transition-colors">
|
||||
<span className="text-3xl md:text-4xl select-none animate-pulse">✨</span>
|
||||
<p className="text-[9px] font-black text-emerald-500 uppercase tracking-[0.2em] m-0">Temptation</p>
|
||||
<p className="text-sm md:text-base font-black text-slate-900 leading-tight">{captions[1]}</p>
|
||||
</div>
|
||||
<div className="p-6 md:p-8 flex flex-col items-center text-center gap-3 bg-red-50/30 hover:bg-red-50/60 transition-colors">
|
||||
<span className="text-3xl md:text-4xl select-none">😤</span>
|
||||
<p className="text-[9px] font-black text-red-500 uppercase tracking-[0.2em] m-0">Reality</p>
|
||||
<p className="text-sm md:text-base font-bold text-red-600 leading-tight">{captions[2]}</p>
|
||||
</div>
|
||||
</div>
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
<div className="p-4 md:p-5 border-b border-slate-100 bg-slate-50/50">
|
||||
<p className="text-[10px] font-black text-slate-300 uppercase tracking-[0.3em] m-0 text-center">
|
||||
The Distraction
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 divide-x divide-slate-100">
|
||||
<div className="p-6 md:p-8 flex flex-col items-center text-center gap-3 hover:bg-slate-50/50 transition-colors">
|
||||
<span className="text-3xl md:text-4xl select-none">👤</span>
|
||||
<p className="text-[9px] font-black text-slate-400 uppercase tracking-[0.2em] m-0">
|
||||
Subject
|
||||
</p>
|
||||
<p className="text-sm md:text-base font-bold text-slate-500 leading-tight">
|
||||
{captions[0]}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
<div className="p-6 md:p-8 flex flex-col items-center text-center gap-3 bg-emerald-50/30 hover:bg-emerald-50/60 transition-colors">
|
||||
<span className="text-3xl md:text-4xl select-none animate-pulse">
|
||||
✨
|
||||
</span>
|
||||
<p className="text-[9px] font-black text-emerald-500 uppercase tracking-[0.2em] m-0">
|
||||
Temptation
|
||||
</p>
|
||||
<p className="text-sm md:text-base font-black text-slate-900 leading-tight">
|
||||
{captions[1]}
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-6 md:p-8 flex flex-col items-center text-center gap-3 bg-red-50/30 hover:bg-red-50/60 transition-colors">
|
||||
<span className="text-3xl md:text-4xl select-none">😤</span>
|
||||
<p className="text-[9px] font-black text-red-500 uppercase tracking-[0.2em] m-0">
|
||||
Reality
|
||||
</p>
|
||||
<p className="text-sm md:text-base font-bold text-red-600 leading-tight">
|
||||
{captions[2]}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function GenericMeme({ captions, template }: { captions: string[]; template: string }) {
|
||||
return (
|
||||
<div className="p-8 md:p-12 text-center bg-gradient-to-br from-white to-slate-50/50">
|
||||
<p className="text-[10px] font-black text-slate-400 uppercase tracking-[0.3em] mb-8">{template}</p>
|
||||
<div className="space-y-4">
|
||||
{(captions || []).map((caption, i) => (
|
||||
<div key={i} className="p-4 md:p-5 bg-white border border-slate-100 rounded-2xl shadow-sm group-hover:border-slate-200 transition-all duration-300">
|
||||
<p className="text-base md:text-lg font-bold text-slate-700 m-0">
|
||||
{caption}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
function GenericMeme({
|
||||
captions,
|
||||
template,
|
||||
}: {
|
||||
captions: string[];
|
||||
template: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="p-8 md:p-12 text-center bg-gradient-to-br from-white to-slate-50/50">
|
||||
<p className="text-[10px] font-black text-slate-400 uppercase tracking-[0.3em] mb-8">
|
||||
{template}
|
||||
</p>
|
||||
<div className="space-y-4">
|
||||
{(captions || []).map((caption, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="p-4 md:p-5 bg-white border border-slate-100 rounded-2xl shadow-sm group-hover:border-slate-200 transition-all duration-300"
|
||||
>
|
||||
<p className="text-base md:text-lg font-bold text-slate-700 m-0">
|
||||
{caption}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -82,9 +82,9 @@ export const Section: React.FC<SectionProps> = ({
|
||||
|
||||
<div className={cn("relative z-10", containerClass)}>
|
||||
{hasSidebar ? (
|
||||
<div className="grid grid-cols-1 md:grid-cols-12 gap-4 md:gap-24">
|
||||
<div className="grid grid-cols-1 md:grid-cols-12 gap-4 md:gap-12 lg:gap-24">
|
||||
{/* Sidebar: Number & Title */}
|
||||
<div className="md:col-span-3">
|
||||
<div className="md:col-span-4 lg:col-span-3">
|
||||
<div className="md:sticky md:top-40 flex flex-col gap-4 md:gap-8">
|
||||
<div className="flex items-end md:flex-col md:items-start gap-6 md:gap-8">
|
||||
{number && (
|
||||
@@ -124,7 +124,7 @@ export const Section: React.FC<SectionProps> = ({
|
||||
</div>
|
||||
|
||||
{/* Main Content */}
|
||||
<div className="md:col-span-9">{children}</div>
|
||||
<div className="md:col-span-8 lg:col-span-9">{children}</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-full">{children}</div>
|
||||
|
||||
43
apps/web/src/components/TLDR.tsx
Normal file
43
apps/web/src/components/TLDR.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import React from "react";
|
||||
|
||||
interface TLDRProps {
|
||||
children?: React.ReactNode;
|
||||
content?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const TLDR: React.FC<TLDRProps> = ({
|
||||
children,
|
||||
content,
|
||||
className = "",
|
||||
}) => {
|
||||
return (
|
||||
<div
|
||||
className={`my-8 p-6 bg-slate-900 border-l-4 border-indigo-500 rounded-r-lg shadow-xl ${className}`}
|
||||
>
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="bg-indigo-500 text-white p-1 rounded">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M12 2v20M2 12h20M4.93 4.93l14.14 14.14M4.93 19.07l14.14-14.14" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-white font-bold text-lg uppercase tracking-wider">
|
||||
TL;DR
|
||||
</h3>
|
||||
</div>
|
||||
<div className="text-slate-300 font-serif text-lg leading-relaxed italic">
|
||||
{children || content}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -16,6 +16,10 @@ interface WebVitalsScoreProps {
|
||||
}
|
||||
|
||||
export const WebVitalsScore: React.FC<WebVitalsScoreProps> = ({ values, description }) => {
|
||||
if (!values) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const getStatus = (metric: 'lcp' | 'inp' | 'cls', value: number): 'good' | 'needs-improvement' | 'poor' => {
|
||||
if (metric === 'lcp') return value <= 2.5 ? 'good' : value <= 4.0 ? 'needs-improvement' : 'poor';
|
||||
if (metric === 'inp') return value <= 200 ? 'good' : value <= 500 ? 'needs-improvement' : 'poor';
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
/* eslint-disable react/prop-types */
|
||||
import type {
|
||||
ThumbnailIcon,
|
||||
} from "./blogThumbnails";
|
||||
import type { ThumbnailIcon } from "./blogThumbnails";
|
||||
import { blogThumbnails } from "./blogThumbnails";
|
||||
|
||||
interface BlogThumbnailSVGProps {
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { ComponentShareButton } from '../ComponentShareButton';
|
||||
import { Reveal } from '../Reveal';
|
||||
import { Play, RotateCcw } from 'lucide-react';
|
||||
import { RotateCcw } from 'lucide-react';
|
||||
|
||||
export function LoadTimeSimulator({ className = '' }: { className?: string }) {
|
||||
const [isRunning, setIsRunning] = useState(false);
|
||||
const [timeElapsed, setTimeElapsed] = useState(0);
|
||||
const [legacyState, setLegacyState] = useState(0);
|
||||
const [hasAutoStarted, setHasAutoStarted] = useState(false);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [mintelState, setMintelState] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -36,6 +38,25 @@ export function LoadTimeSimulator({ className = '' }: { className?: string }) {
|
||||
return () => clearInterval(interval);
|
||||
}, [isRunning, timeElapsed]);
|
||||
|
||||
// Auto-start the race when scrolled into viewport
|
||||
useEffect(() => {
|
||||
if (hasAutoStarted) return;
|
||||
const el = containerRef.current;
|
||||
if (!el) return;
|
||||
const observer = new IntersectionObserver(
|
||||
([entry]) => {
|
||||
if (entry.isIntersecting) {
|
||||
setHasAutoStarted(true);
|
||||
setIsRunning(true);
|
||||
observer.disconnect();
|
||||
}
|
||||
},
|
||||
{ threshold: 0.4 }
|
||||
);
|
||||
observer.observe(el);
|
||||
return () => observer.disconnect();
|
||||
}, [hasAutoStarted]);
|
||||
|
||||
const startRace = () => {
|
||||
setTimeElapsed(0);
|
||||
setLegacyState(0);
|
||||
@@ -45,7 +66,7 @@ export function LoadTimeSimulator({ className = '' }: { className?: string }) {
|
||||
|
||||
return (
|
||||
<Reveal direction="up" delay={0.1}>
|
||||
<div className={`not-prose max-w-4xl mx-auto my-12 relative group ${className}`}>
|
||||
<div ref={containerRef} className={`not-prose max-w-4xl mx-auto my-12 relative group ${className}`}>
|
||||
<div className="absolute -inset-1 bg-gradient-to-r from-red-100 to-emerald-100 rounded-3xl blur opacity-30" />
|
||||
|
||||
<div id="sim-load-time" className="relative bg-white rounded-2xl border border-slate-200 shadow-sm overflow-hidden flex flex-col">
|
||||
@@ -63,13 +84,15 @@ export function LoadTimeSimulator({ className = '' }: { className?: string }) {
|
||||
Simulieren Sie den Unterschied zwischen dynamischem Server-Rendering (PHP/MySQL) und statischer Edge-Auslieferung (<span className="font-mono bg-slate-200 px-1 rounded text-[10px]">TTV < 500ms</span>).
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={startRace}
|
||||
className="shrink-0 flex items-center gap-2 px-6 py-2.5 bg-slate-900 !text-white rounded-full font-bold text-sm hover:hover:bg-black hover:scale-105 active:scale-95 transition-all shadow-md"
|
||||
>
|
||||
{timeElapsed > 0 ? <RotateCcw size={16} /> : <Play size={16} />}
|
||||
{timeElapsed > 0 ? "Neustart" : "Rennen Starten"}
|
||||
</button>
|
||||
{timeElapsed > 0 && !isRunning && (
|
||||
<button
|
||||
onClick={startRace}
|
||||
className="shrink-0 flex items-center gap-2 px-6 py-2.5 bg-slate-900 !text-white rounded-full font-bold text-sm hover:hover:bg-black hover:scale-105 active:scale-95 transition-all shadow-md"
|
||||
>
|
||||
<RotateCcw size={16} />
|
||||
Neustart
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid md:grid-cols-2 divide-y md:divide-y-0 md:divide-x divide-slate-100 bg-slate-50/50">
|
||||
|
||||
@@ -1,40 +1,39 @@
|
||||
import { LeadParagraph } from "../components/ArticleParagraph";
|
||||
import { H1, H2, H3 } from "../components/ArticleHeading";
|
||||
import { Paragraph } from "../components/ArticleParagraph";
|
||||
import { ArticleBlockquote } from "../components/ArticleBlockquote";
|
||||
import { Marker } from "../components/Marker";
|
||||
import { ComparisonRow } from "../components/Landing/ComparisonRow";
|
||||
import { StatsDisplay } from "../components/StatsDisplay";
|
||||
import { Mermaid } from "../components/Mermaid";
|
||||
import { DiagramState } from "../components/DiagramState";
|
||||
import { DiagramTimeline } from "../components/DiagramTimeline";
|
||||
import { DiagramGantt } from "../components/DiagramGantt";
|
||||
import { DiagramPie } from "../components/DiagramPie";
|
||||
import { DiagramSequence } from "../components/DiagramSequence";
|
||||
import { DiagramFlow } from "../components/DiagramFlow";
|
||||
import { IconList, IconListItem } from "../components/IconList";
|
||||
import { ArticleMeme } from "../components/ArticleMeme";
|
||||
import { MemeCard } from "../components/MemeCard";
|
||||
import { ExternalLink } from "../components/ExternalLink";
|
||||
import { StatsGrid } from "../components/StatsGrid";
|
||||
import { MetricBar } from "../components/MetricBar";
|
||||
import { ArticleQuote } from "../components/ArticleQuote";
|
||||
import { BoldNumber } from "../components/BoldNumber";
|
||||
import { WebVitalsScore } from "../components/WebVitalsScore";
|
||||
import { WaterfallChart } from "../components/WaterfallChart";
|
||||
import { Button } from "../components/Button";
|
||||
import { LeadMagnet } from "../components/LeadMagnet";
|
||||
import { TrackedLink } from "../components/analytics/TrackedLink";
|
||||
import { FAQSection } from "../components/FAQSection";
|
||||
|
||||
import { LeadParagraph } from '../components/ArticleParagraph';
|
||||
import { H1, H2, H3 } from '../components/ArticleHeading';
|
||||
import { Paragraph } from '../components/ArticleParagraph';
|
||||
import { ArticleBlockquote } from '../components/ArticleBlockquote';
|
||||
import { Marker } from '../components/Marker';
|
||||
import { ComparisonRow } from '../components/Landing/ComparisonRow';
|
||||
import { StatsDisplay } from '../components/StatsDisplay';
|
||||
import { Mermaid } from '../components/Mermaid';
|
||||
import { DiagramState } from '../components/DiagramState';
|
||||
import { DiagramTimeline } from '../components/DiagramTimeline';
|
||||
import { DiagramGantt } from '../components/DiagramGantt';
|
||||
import { DiagramPie } from '../components/DiagramPie';
|
||||
import { DiagramSequence } from '../components/DiagramSequence';
|
||||
import { DiagramFlow } from '../components/DiagramFlow';
|
||||
import { IconList, IconListItem } from '../components/IconList';
|
||||
import { ArticleMeme } from '../components/ArticleMeme';
|
||||
import { MemeCard } from '../components/MemeCard';
|
||||
import { ExternalLink } from '../components/ExternalLink';
|
||||
import { StatsGrid } from '../components/StatsGrid';
|
||||
import { MetricBar } from '../components/MetricBar';
|
||||
import { ArticleQuote } from '../components/ArticleQuote';
|
||||
import { BoldNumber } from '../components/BoldNumber';
|
||||
import { WebVitalsScore } from '../components/WebVitalsScore';
|
||||
import { WaterfallChart } from '../components/WaterfallChart';
|
||||
import { Button } from '../components/Button';
|
||||
import { LeadMagnet } from '../components/LeadMagnet';
|
||||
import { TrackedLink } from '../components/analytics/TrackedLink';
|
||||
import { FAQSection } from '../components/FAQSection';
|
||||
import { PremiumComparisonChart } from "../components/PremiumComparisonChart";
|
||||
import { ImageText } from "../components/ImageText";
|
||||
import { Carousel } from "../components/Carousel";
|
||||
|
||||
import { PremiumComparisonChart } from '../components/PremiumComparisonChart';
|
||||
import { ImageText } from '../components/ImageText';
|
||||
import { Carousel } from '../components/Carousel';
|
||||
|
||||
import { Section } from '../components/Section';
|
||||
import { Reveal } from '../components/Reveal';
|
||||
import { TableOfContents } from '../components/TableOfContents';
|
||||
import { Section } from "../components/Section";
|
||||
import { Reveal } from "../components/Reveal";
|
||||
import { TableOfContents } from "../components/TableOfContents";
|
||||
|
||||
import { RevenueLossCalculator } from "../components/RevenueLossCalculator";
|
||||
import { PerformanceChart } from "../components/PerformanceChart";
|
||||
@@ -43,56 +42,62 @@ import { LoadTimeSimulator } from "../components/simulations/LoadTimeSimulator";
|
||||
import { ArchitectureBuilder } from "../components/simulations/ArchitectureBuilder";
|
||||
import { DigitalAssetVisualizer } from "../components/simulations/DigitalAssetVisualizer";
|
||||
|
||||
import { TwitterEmbed } from '../components/TwitterEmbed';
|
||||
import { YouTubeEmbed } from '../components/YouTubeEmbed';
|
||||
import { LinkedInEmbed } from '../components/LinkedInEmbed';
|
||||
import { TwitterEmbed } from "../components/TwitterEmbed";
|
||||
import { YouTubeEmbed } from "../components/YouTubeEmbed";
|
||||
import { LinkedInEmbed } from "../components/LinkedInEmbed";
|
||||
import { TLDR } from "../components/TLDR";
|
||||
|
||||
/**
|
||||
* Single Source of Truth for MDX component rendering.
|
||||
* Handled separately from Payload blocks to avoid SVG import issues in Node.js.
|
||||
*/
|
||||
export const mdxComponents = {
|
||||
// Named exports for explicit MDX usage
|
||||
LeadParagraph,
|
||||
H1,
|
||||
H2,
|
||||
H3,
|
||||
Paragraph,
|
||||
ArticleBlockquote,
|
||||
Marker,
|
||||
ComparisonRow,
|
||||
StatsDisplay,
|
||||
Mermaid,
|
||||
DiagramState,
|
||||
DiagramTimeline,
|
||||
DiagramGantt,
|
||||
DiagramPie,
|
||||
DiagramSequence,
|
||||
DiagramFlow,
|
||||
IconList,
|
||||
IconListItem,
|
||||
ArticleMeme,
|
||||
MemeCard,
|
||||
ExternalLink,
|
||||
StatsGrid,
|
||||
MetricBar,
|
||||
ArticleQuote,
|
||||
BoldNumber,
|
||||
WebVitalsScore,
|
||||
WaterfallChart,
|
||||
PremiumComparisonChart,
|
||||
ImageText,
|
||||
Carousel,
|
||||
Section,
|
||||
Reveal,
|
||||
TableOfContents,
|
||||
RevenueLossCalculator,
|
||||
PerformanceChart,
|
||||
PerformanceROICalculator,
|
||||
LoadTimeSimulator,
|
||||
ArchitectureBuilder,
|
||||
DigitalAssetVisualizer,
|
||||
TwitterEmbed,
|
||||
YouTubeEmbed,
|
||||
LinkedInEmbed,
|
||||
Button,
|
||||
LeadMagnet,
|
||||
TrackedLink,
|
||||
FAQSection
|
||||
LeadParagraph,
|
||||
H1,
|
||||
H2,
|
||||
H3,
|
||||
Paragraph,
|
||||
ArticleBlockquote,
|
||||
Marker,
|
||||
ComparisonRow,
|
||||
StatsDisplay,
|
||||
Mermaid,
|
||||
DiagramState,
|
||||
DiagramTimeline,
|
||||
DiagramGantt,
|
||||
DiagramPie,
|
||||
DiagramSequence,
|
||||
DiagramFlow,
|
||||
IconList,
|
||||
IconListItem,
|
||||
ArticleMeme,
|
||||
MemeCard,
|
||||
ExternalLink,
|
||||
StatsGrid,
|
||||
MetricBar,
|
||||
ArticleQuote,
|
||||
BoldNumber,
|
||||
WebVitalsScore,
|
||||
WaterfallChart,
|
||||
PremiumComparisonChart,
|
||||
ImageText,
|
||||
Carousel,
|
||||
Section,
|
||||
Reveal,
|
||||
TableOfContents,
|
||||
TOC: TableOfContents,
|
||||
RevenueLossCalculator,
|
||||
PerformanceChart,
|
||||
PerformanceROICalculator,
|
||||
LoadTimeSimulator,
|
||||
ArchitectureBuilder,
|
||||
DigitalAssetVisualizer,
|
||||
TwitterEmbed,
|
||||
YouTubeEmbed,
|
||||
LinkedInEmbed,
|
||||
Button,
|
||||
LeadMagnet,
|
||||
TrackedLink,
|
||||
FAQSection,
|
||||
TLDR,
|
||||
};
|
||||
|
||||
@@ -1,296 +1,3 @@
|
||||
import { ComponentDefinition } from "@mintel/content-engine";
|
||||
|
||||
import { ComponentDefinition } from '@mintel/content-engine';
|
||||
|
||||
/**
|
||||
* Single Source of Truth for all MDX component definitions.
|
||||
* Used by:
|
||||
* - content-engine.config.ts (for the optimization script)
|
||||
* - The AI content pipeline (for component injection)
|
||||
*
|
||||
* Keep in sync with: src/content-engine/components.ts (the MDX runtime registry)
|
||||
*/
|
||||
export const componentDefinitions: ComponentDefinition[] = [
|
||||
{
|
||||
name: 'LeadParagraph',
|
||||
description: 'Larger, emphasized paragraph for the article introduction. Use 1-3 at the start.',
|
||||
usageExample: '<LeadParagraph>\n Unternehmen investieren oft Unsummen in glänzende Oberflächen, während das technische Fundament bröckelt.\n</LeadParagraph>'
|
||||
},
|
||||
{
|
||||
name: 'H2',
|
||||
description: 'Main section heading. Used for top-level content sections.',
|
||||
usageExample: '<H2>Der wirtschaftliche Case</H2>'
|
||||
},
|
||||
{
|
||||
name: 'BoldNumber',
|
||||
description: 'Large centerpiece number with label for primary statistics.',
|
||||
usageExample: '<BoldNumber value="5x" label="höhere Conversion-Rate" source="Portent" />'
|
||||
},
|
||||
{
|
||||
name: 'PremiumComparisonChart',
|
||||
description: 'Advanced chart for comparing performance metrics with industrial aesthetics.',
|
||||
usageExample: '<PremiumComparisonChart title="TTFB Vergleich" items={[{ label: "Alt", value: 800, max: 1000, color: "red" }, { label: "Mintel", value: 50, max: 1000, color: "green" }]} />'
|
||||
},
|
||||
{
|
||||
name: 'ImageText',
|
||||
description: 'Layout component for image next to explanatory text.',
|
||||
usageExample: '<ImageText image="/img.jpg" title="Architektur">Erklärung...</ImageText>'
|
||||
},
|
||||
{
|
||||
name: 'Carousel',
|
||||
description: 'Interactive swipeable slider for multi-step explanations. IMPORTANT: items array must contain at least 2 items with substantive title and content text (no empty content).',
|
||||
usageExample: '<Carousel items={[{ title: "Schritt 1", content: "Analyse der aktuellen Performance..." }, { title: "Schritt 2", content: "Architektur-Optimierung..." }]} />'
|
||||
},
|
||||
{
|
||||
name: 'H3',
|
||||
description: 'Subsection heading. Used within H2 sections.',
|
||||
usageExample: '<H3>Die drei Säulen meiner Umsetzung</H3>'
|
||||
},
|
||||
{
|
||||
name: 'Paragraph',
|
||||
description: 'Standard body text paragraph. All body text must be wrapped in this.',
|
||||
usageExample: '<Paragraph>\n Mein System ist kein Kostenfaktor, sondern ein <Marker>ROI-Beschleuniger</Marker>.\n</Paragraph>'
|
||||
},
|
||||
{
|
||||
name: 'ArticleBlockquote',
|
||||
description: 'Styled blockquote for expert quotes or key statements.',
|
||||
usageExample: '<ArticleBlockquote>\n Performance ist keine IT-Kennzahl, sondern ein ökonomischer Hebel.\n</ArticleBlockquote>'
|
||||
},
|
||||
{
|
||||
name: 'Marker',
|
||||
description: 'Inline highlight (yellow marker effect) for emphasizing key phrases within paragraphs.',
|
||||
usageExample: '<Marker>entscheidender Wettbewerbsvorteil</Marker>'
|
||||
},
|
||||
{
|
||||
name: 'ComparisonRow',
|
||||
description: 'Side-by-side comparison: negative "Standard" approach vs positive "Mintel" approach. Props include showShare boolean.',
|
||||
usageExample: `<ComparisonRow
|
||||
description="Architektur-Vergleich"
|
||||
negativeLabel="Legacy CMS"
|
||||
negativeText="Langsame Datenbankabfragen, verwundbare Plugins."
|
||||
positiveLabel="Mintel Stack"
|
||||
positiveText="Statische Generierung, perfekte Sicherheit."
|
||||
showShare={true}
|
||||
/>`
|
||||
},
|
||||
{
|
||||
name: 'StatsDisplay',
|
||||
description: 'A single large stat card with prominent value, label, and optional subtext.',
|
||||
usageExample: '<StatsDisplay value="-20%" label="Conversion" subtext="Jede Sekunde Verzögerung kostet." />'
|
||||
},
|
||||
{
|
||||
name: 'Mermaid',
|
||||
description: 'Renders a Mermaid.js diagram (flowchart, sequence, pie, etc.). Diagram code goes as children. Keep it tiny (max 3-4 nodes). Wrap in div with className="my-8".',
|
||||
usageExample: `<div className="my-8">
|
||||
<Mermaid id="my-diagram" title="System Architecture" showShare={true}>
|
||||
graph TD
|
||||
A["Request"] --> B["CDN Edge"]
|
||||
B --> C["Static HTML"]
|
||||
</Mermaid>
|
||||
</div>`
|
||||
},
|
||||
{
|
||||
name: 'DiagramFlow',
|
||||
description: 'Structured flowchart diagram. Use for process flows, architecture diagrams, etc. Supports structured nodes/edges. direction defaults to LR.',
|
||||
usageExample: `<DiagramFlow
|
||||
nodes={[
|
||||
{ id: "A", label: "Start" },
|
||||
{ id: "B", label: "Process", style: "fill:#f00" }
|
||||
]}
|
||||
edges={[
|
||||
{ from: "A", to: "B", label: "trigger" }
|
||||
]}
|
||||
title="Process Flow"
|
||||
id="flow-1"
|
||||
showShare={true}
|
||||
/>`
|
||||
},
|
||||
{
|
||||
name: 'DiagramPie',
|
||||
description: 'Pie chart with structured data props.',
|
||||
usageExample: `<DiagramPie
|
||||
data={[
|
||||
{ label: "JavaScript", value: 35 },
|
||||
{ label: "CSS", value: 25 },
|
||||
{ label: "Images", value: 20 }
|
||||
]}
|
||||
title="Performance Bottlenecks"
|
||||
id="perf-pie"
|
||||
showShare={true}
|
||||
/>`
|
||||
},
|
||||
{
|
||||
name: 'DiagramGantt',
|
||||
description: 'Gantt timeline chart comparing durations of tasks.',
|
||||
usageExample: `<DiagramGantt
|
||||
tasks={[
|
||||
{ id: "task-1", name: "Legacy: 4 Wochen", start: "2024-01-01", duration: "4w" },
|
||||
{ id: "task-2", name: "Mintel: 1 Woche", start: "2024-01-01", duration: "1w" }
|
||||
]}
|
||||
title="Zeitvergleich"
|
||||
id="gantt-comparison"
|
||||
showShare={true}
|
||||
/>`
|
||||
},
|
||||
{
|
||||
name: 'DiagramState',
|
||||
description: 'State diagram showing states and transitions.',
|
||||
usageExample: `<DiagramState
|
||||
states={["Idle", "Loading", "Loaded", "Error"]}
|
||||
transitions={[
|
||||
{ from: "Idle", to: "Loading", label: "fetch" },
|
||||
{ from: "Loading", to: "Loaded", label: "success" }
|
||||
]}
|
||||
initialState="Idle"
|
||||
title="Request Lifecycle"
|
||||
id="state-diagram"
|
||||
showShare={true}
|
||||
/>`
|
||||
},
|
||||
{
|
||||
name: 'DiagramSequence',
|
||||
description: 'Sequence diagram (uses raw Mermaid sequence syntax as children).',
|
||||
usageExample: `<DiagramSequence id="seq-diagram" title="Request Flow" showShare={true}>
|
||||
sequenceDiagram
|
||||
Browser->>CDN: GET /page
|
||||
CDN->>Browser: Static HTML (< 50ms)
|
||||
</DiagramSequence>`
|
||||
},
|
||||
{
|
||||
name: 'DiagramTimeline',
|
||||
description: 'Timeline diagram (uses raw Mermaid timeline syntax as children).',
|
||||
usageExample: `<DiagramTimeline id="timeline" title="Project Timeline" showShare={true}>
|
||||
timeline
|
||||
2024 : Planung
|
||||
2025 : Entwicklung
|
||||
2026 : Launch
|
||||
</DiagramTimeline>`
|
||||
},
|
||||
{
|
||||
name: 'IconList',
|
||||
description: 'Checklist with check/cross icons. Wrap IconListItem children inside.',
|
||||
usageExample: `<IconList>
|
||||
<IconListItem check>
|
||||
<strong>Zero-Computation:</strong> Statische Seiten, kein Serverwarten.
|
||||
</IconListItem>
|
||||
<IconListItem cross>
|
||||
<strong>Legacy CMS:</strong> Datenbankabfragen bei jedem Request.
|
||||
</IconListItem>
|
||||
</IconList>`
|
||||
},
|
||||
{
|
||||
name: 'ArticleMeme',
|
||||
description: 'Real meme image from memegen.link. template must be a valid memegen.link ID. IMPORTANT: Captions must be EXTREMELY SARCASTIC and PUNCHY (mocking bad B2B agencies, max 6 words per line). Best templates: drake (2-line prefer/dislike), gru (4-step plan backfire), disastergirl (burning house), fine (this is fine dog). Use German captions. Wrap in div with className="my-8".',
|
||||
usageExample: `<div className="my-8">
|
||||
<ArticleMeme template="drake" captions="47 WordPress Plugins installieren|Eine saubere Serverless Architektur" />
|
||||
</div>`
|
||||
},
|
||||
|
||||
{
|
||||
name: 'Section',
|
||||
description: 'Wraps a thematic section block with optional heading.',
|
||||
usageExample: '<Section>\n <h3>Section Title</h3>\n <p>Content here.</p>\n</Section>'
|
||||
},
|
||||
{
|
||||
name: 'Reveal',
|
||||
description: 'Scroll-triggered reveal animation wrapper. Wrap any content to animate on scroll.',
|
||||
usageExample: '<Reveal>\n <StatsDisplay value="100" label="PageSpeed Score" />\n</Reveal>'
|
||||
},
|
||||
{
|
||||
name: 'StatsGrid',
|
||||
description: 'Grid of 2–4 stat cards in a row. Use tilde (~) to separate stats, pipe (|) to separate value|label|subtext within each stat.',
|
||||
usageExample: '<StatsGrid stats="53%|Mehr Umsatz|Rakuten 24~33%|Conversion Boost|nach CWV Fix~24%|Top 3 Ranking|bei bestandenen CWV" />'
|
||||
},
|
||||
{
|
||||
name: 'MetricBar',
|
||||
description: 'Animated horizontal progress bar. Use multiple in sequence to compare metrics. IMPORTANT: value MUST be a real number > 0, never use 0 or placeholder values. Props: label, value (number), max (default 100), unit (default %), color (red|green|blue|slate).',
|
||||
usageExample: `<MetricBar label="WordPress Sites" value={33} color="red" />
|
||||
<MetricBar label="Static Sites" value={92} color="green" />`
|
||||
},
|
||||
{
|
||||
name: 'ArticleQuote',
|
||||
description: 'Dark-themed quote card. Use for expert quotes or statements. Use isCompany={true} for brands/orgs to show an entity icon instead of personal initials. MANDATORY: always include source and sourceUrl for verifiability. Props: quote, author, role (optional), source (REQUIRED), sourceUrl (REQUIRED), isCompany (optional), translated (optional boolean).',
|
||||
usageExample: '<ArticleQuote quote="Optimizing for speed." author="Google" isCompany={true} source="web.dev" sourceUrl="https://web.dev" translated={true} />'
|
||||
},
|
||||
{
|
||||
name: 'BoldNumber',
|
||||
description: 'Full-width hero number card with dark gradient, animated count-up, and share button. Use for the most impactful single statistics. IMPORTANT: Always provide source and sourceUrl. Numbers without comparison context should use PremiumComparisonChart or paired MetricBar instead. Props: value (string like "53%" or "2.5M€"), label (short description), source (REQUIRED), sourceUrl (REQUIRED).',
|
||||
usageExample: '<BoldNumber value="8.4%" label="Conversion-Steigerung pro 0.1s schnellere Ladezeit" source="Deloitte Digital" sourceUrl="https://www2.deloitte.com/..." />'
|
||||
},
|
||||
{
|
||||
name: 'WebVitalsScore',
|
||||
description: 'Displays Core Web Vitals (LCP, INP, CLS) in a premium card layout with automatic traffic light coloring (Good/Needs Improvement/Poor). Use for performance audits or comparisons.',
|
||||
usageExample: '<WebVitalsScore values={{ lcp: 2.5, inp: 200, cls: 0.1 }} description="All metrics passing Google standards." />'
|
||||
},
|
||||
{
|
||||
name: 'WaterfallChart',
|
||||
description: 'A timeline visualization of network requests (waterfall). Use to show loading sequences or bottlenecks. Labels auto-color coded by type (JS, HTML, IMG).',
|
||||
usageExample: `<WaterfallChart
|
||||
title="Initial Load"
|
||||
events={[
|
||||
{ name: "Document", start: 0, duration: 150 },
|
||||
{ name: "main.js", start: 150, duration: 50 },
|
||||
{ name: "hero.jpg", start: 200, duration: 300 }
|
||||
]}
|
||||
/>`
|
||||
},
|
||||
{
|
||||
name: 'ExternalLink',
|
||||
description: 'Inline external link with ↗ icon and outbound analytics tracking. Use for all source citations and external references within Paragraph text.',
|
||||
usageExample: '<ExternalLink href="https://web.dev/articles/vitals">Google Core Web Vitals</ExternalLink>'
|
||||
},
|
||||
{
|
||||
name: 'TwitterEmbed',
|
||||
description: 'Embeds a post from X.com (Twitter). Used to provide social proof, industry quotes, or examples. Provide the numerical tweetId.',
|
||||
usageExample: '<TwitterEmbed tweetId="1753464161943834945" theme="light" />'
|
||||
},
|
||||
{
|
||||
name: 'YouTubeEmbed',
|
||||
description: 'Embeds a YouTube video to visualize concepts or provide deep dives. Use the 11-character videoId.',
|
||||
usageExample: '<YouTubeEmbed videoId="dQw4w9WgXcQ" title="Performance Explanation" />'
|
||||
},
|
||||
{
|
||||
name: 'LinkedInEmbed',
|
||||
description: 'Embeds a professional post from LinkedIn. Use the activity URN (e.g. urn:li:activity:1234567890).',
|
||||
usageExample: '<LinkedInEmbed urn="urn:li:activity:7153664326573674496" />'
|
||||
},
|
||||
{
|
||||
name: 'TrackedLink',
|
||||
description: 'A wrapper around next/link that tracks clicks. Use for all INTERNAL navigational links that should be tracked.',
|
||||
usageExample: '<TrackedLink href="/contact" className="text-blue-600 font-bold">Jetzt anfragen</TrackedLink>'
|
||||
},
|
||||
{
|
||||
name: 'Button',
|
||||
description: 'DEPRECATED: Use <LeadMagnet /> instead for main CTAs. Only use for small secondary links.',
|
||||
usageExample: '<Button href="/contact" variant="outline">Webprojekt anfragen</Button>'
|
||||
},
|
||||
{
|
||||
name: 'LeadMagnet',
|
||||
description: 'Premium B2B conversion card. Use 1-2 per article as main high-impact CTAs. Props: title (strong headline), description (value prop), buttonText (action), href (link), variant (performance|security|standard).',
|
||||
usageExample: '<LeadMagnet title="Performance-Check anfragen" description="Wir analysieren Ihre Core Web Vitals und decken Umsatzpotenziale auf." buttonText="Jetzt analysieren lassen" href="/contact" variant="performance" />'
|
||||
},
|
||||
{
|
||||
name: 'PerformanceROICalculator',
|
||||
description: 'Interactive simulation calculator showing the monetary ROI of improving load times (based on Deloitte B2B metrics). Use exactly once in performance-related articles to provide a highly engaging simulation. Requires no props.',
|
||||
usageExample: '<PerformanceROICalculator />'
|
||||
},
|
||||
{
|
||||
name: 'LoadTimeSimulator',
|
||||
description: 'Interactive visual race simulating the loading experience of a slow legacy CMS vs a fast headless stack. Great for articles discussing load times, technical debt, or user frustration. Requires no props.',
|
||||
usageExample: '<LoadTimeSimulator />'
|
||||
},
|
||||
{
|
||||
name: 'FAQSection',
|
||||
description: 'Semantic wrapper for FAQ questions at the end of the article. Put standard Markdown H3/Paragraphs inside.',
|
||||
usageExample: '<FAQSection>\n <H3>Frage 1</H3>\n <Paragraph>Antwort 1</Paragraph>\n</FAQSection>'
|
||||
},
|
||||
{
|
||||
name: 'ArchitectureBuilder',
|
||||
description: 'Interactive comparison between a standard SaaS rental approach and a custom Built-First (Mintel) architecture. Useful for articles discussing digital ownership, software rent vs. build, or technological assets. Requires no props.',
|
||||
usageExample: '<ArchitectureBuilder />'
|
||||
},
|
||||
{
|
||||
name: 'DigitalAssetVisualizer',
|
||||
description: 'Interactive visualization illustrating the financial difference between software as a liability (SaaS/rent) and software as a digital asset (Custom IP). Great for articles concerning CTO strategies, business value of code, and digital independence. Requires no props.',
|
||||
usageExample: '<DigitalAssetVisualizer />'
|
||||
}
|
||||
];
|
||||
export const componentDefinitions: ComponentDefinition[] = [];
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user