Compare commits
1 Commits
v1.9.17
...
feature/aq
| Author | SHA1 | Date | |
|---|---|---|---|
| 9e4e296e3b |
@@ -1,82 +0,0 @@
|
||||
---
|
||||
description: How to manage and deploy Directus CMS infrastructure changes.
|
||||
---
|
||||
|
||||
# Directus CMS Infrastructure Workflow
|
||||
|
||||
This workflow ensures "Industrial Grade" consistency and stability across local, testing, and production environments for the `at-mintel` Directus CMS.
|
||||
|
||||
## 1. Local Development Lifecycle
|
||||
|
||||
### Starting the CMS
|
||||
To start the local Directus instance with extensions:
|
||||
```bash
|
||||
cd packages/cms-infra
|
||||
npm run up
|
||||
```
|
||||
|
||||
### Modifying Schema
|
||||
1. **Directus UI**: Make your changes directly in the local Directus Admin UI (Collections, Fields, Relations).
|
||||
2. **Take Snapshot**:
|
||||
```bash
|
||||
cd packages/cms-infra
|
||||
npm run snapshot:local
|
||||
```
|
||||
This updates `packages/cms-infra/schema/snapshot.yaml`.
|
||||
3. **Commit**: Commit the updated `snapshot.yaml`.
|
||||
|
||||
## 2. Deploying Schema Changes
|
||||
|
||||
### To Local Environment (Reconciliation)
|
||||
If you pull changes from Git and need to apply them to your local database:
|
||||
```bash
|
||||
cd packages/cms-infra
|
||||
npm run schema:apply:local
|
||||
```
|
||||
> [!IMPORTANT]
|
||||
> This command automatically runs `scripts/cms-reconcile.sh` to prevent "Field already exists" errors by registering database columns in Directus metadata first.
|
||||
|
||||
### To Production (Infra)
|
||||
To deploy the local snapshot to the production server:
|
||||
```bash
|
||||
cd packages/cms-infra
|
||||
npm run schema:apply:infra
|
||||
```
|
||||
This script:
|
||||
1. Syncs built extensions via rsync.
|
||||
2. Injects the `snapshot.yaml` into the remote container.
|
||||
3. Runs `directus schema apply`.
|
||||
4. Restarts Directus to clear the schema cache.
|
||||
|
||||
## 3. Data Synchronization
|
||||
|
||||
### Pulling from Production
|
||||
To update your local environment with production data and assets:
|
||||
```bash
|
||||
cd packages/cms-infra
|
||||
npm run sync:pull
|
||||
```
|
||||
|
||||
### Pushing to Production
|
||||
> [!CAUTION]
|
||||
> This will overwrite production data. Use with extreme care.
|
||||
```bash
|
||||
cd packages/cms-infra
|
||||
npm run sync:push
|
||||
```
|
||||
|
||||
## 4. Extension Management
|
||||
When modifying extensions in `packages/*-manager`:
|
||||
1. Extensions are automatically built and synced when running `npm run up`.
|
||||
2. To sync manually without restarting the stack:
|
||||
```bash
|
||||
cd packages/cms-infra
|
||||
npm run build:extensions
|
||||
```
|
||||
|
||||
## 5. Troubleshooting "Field already exists"
|
||||
If `schema:apply` fails with "Field already exists", run:
|
||||
```bash
|
||||
./scripts/cms-reconcile.sh
|
||||
```
|
||||
This script ensures the database state matches Directus's internal field registry (`directus_fields`).
|
||||
@@ -1,26 +1,12 @@
|
||||
node_modules
|
||||
**/node_modules
|
||||
.next
|
||||
**/.next
|
||||
.git
|
||||
# .npmrc is allowed as it contains the registry template
|
||||
dist
|
||||
**/dist
|
||||
build
|
||||
**/build
|
||||
out
|
||||
**/out
|
||||
coverage
|
||||
**/coverage
|
||||
.vercel
|
||||
**/.vercel
|
||||
.turbo
|
||||
**/.turbo
|
||||
*.log
|
||||
**/*.log
|
||||
.DS_Store
|
||||
**/.DS_Store
|
||||
.pnpm-store
|
||||
**/.pnpm-store
|
||||
.gitea
|
||||
**/.gitea
|
||||
|
||||
43
.env
43
.env
@@ -1,43 +0,0 @@
|
||||
# Project
|
||||
IMAGE_TAG=v1.8.19
|
||||
PROJECT_NAME=at-mintel
|
||||
PROJECT_COLOR=#82ed20
|
||||
GITEA_TOKEN=ccce002e30fe16a31a6c9d5a414740af2f72a582
|
||||
OPENROUTER_API_KEY=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
|
||||
ZYTE_API_KEY=1f0f74726f044f55aaafc7ead32cd489
|
||||
REPLICATE_API_KEY=r8_W3grtpXMRfi0u3AM9VdkKbuWdZMmhwU2Tn0yt
|
||||
SERPER_API_KEY=02f69a8db9578c41fb1c8ed9f7a999302da644ff
|
||||
DATA_FOR_SEO_API_KEY=bWFyY0BtaW50ZWwubWU6MjQ0YjBjZmIzOGY3NTIzZA==
|
||||
DATA_FOR_SEO_LOGIN=marc@mintel.me
|
||||
DATA_FOR_SEO_PASSWORD=244b0cfb38f7523d
|
||||
|
||||
# Authentication
|
||||
GATEKEEPER_PASSWORD=mintel
|
||||
AUTH_COOKIE_NAME=mintel_gatekeeper_session
|
||||
|
||||
# Host Config (Local)
|
||||
TRAEFIK_HOST=at-mintel.localhost
|
||||
DIRECTUS_HOST=cms-legacy.localhost
|
||||
|
||||
# Next.js
|
||||
NEXT_PUBLIC_BASE_URL=http://at-mintel.localhost
|
||||
|
||||
# Directus
|
||||
DIRECTUS_URL=http://cms-legacy.localhost
|
||||
DIRECTUS_KEY=F9IIfahEjPq6NZhKyRLw516D8GotuFj79EGK7pGfIWg=
|
||||
DIRECTUS_SECRET=OZfxMu8lBxzaEnFGRKreNBoJpRiRu58U+HsVg2yWk4o=
|
||||
CORS_ENABLED=true
|
||||
CORS_ORIGIN=true
|
||||
LOG_LEVEL=debug
|
||||
DIRECTUS_ADMIN_EMAIL=mmintel@mintel.me
|
||||
DIRECTUS_ADMIN_PASSWORD=Tim300493.
|
||||
DIRECTUS_DB_NAME=directus
|
||||
DIRECTUS_DB_USER=directus
|
||||
DIRECTUS_DB_PASSWORD=mintel-db-pass
|
||||
|
||||
# Sentry / Glitchtip
|
||||
SENTRY_DSN=
|
||||
|
||||
# Analytics (Umami)
|
||||
NEXT_PUBLIC_UMAMI_WEBSITE_ID=
|
||||
NEXT_PUBLIC_UMAMI_SCRIPT_URL=https://analytics.infra.mintel.me/script.js
|
||||
@@ -1,5 +1,5 @@
|
||||
# Project
|
||||
IMAGE_TAG=v1.9.17
|
||||
IMAGE_TAG=v1.7.0
|
||||
PROJECT_NAME=sample-website
|
||||
PROJECT_COLOR=#82ed20
|
||||
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
**/index.js
|
||||
**/dist/**
|
||||
packages/cms-infra/extensions/**
|
||||
packages/cms-infra/extensions/**
|
||||
@@ -1,41 +0,0 @@
|
||||
name: "Mintel Core Smoke Tests"
|
||||
description: "Executes standard fast HTTP, API, and Locale validation checks."
|
||||
|
||||
inputs:
|
||||
TARGET_URL:
|
||||
description: 'The deployed URL to test against'
|
||||
required: true
|
||||
GATEKEEPER_PASSWORD:
|
||||
description: 'Gatekeeper bypass password'
|
||||
required: true
|
||||
UMAMI_API_ENDPOINT:
|
||||
description: 'Umami Analytics Endpoint'
|
||||
required: false
|
||||
default: 'https://analytics.infra.mintel.me'
|
||||
SENTRY_DSN:
|
||||
description: 'Sentry / Glitchtip DSN'
|
||||
required: false
|
||||
|
||||
runs:
|
||||
using: "composite"
|
||||
steps:
|
||||
- name: 🌐 Full Sitemap HTTP Validation
|
||||
shell: bash
|
||||
env:
|
||||
NEXT_PUBLIC_BASE_URL: ${{ inputs.TARGET_URL }}
|
||||
GATEKEEPER_PASSWORD: ${{ inputs.GATEKEEPER_PASSWORD }}
|
||||
run: pnpm run check:http
|
||||
|
||||
- name: 🌐 Locale & Language Switcher Validation
|
||||
shell: bash
|
||||
env:
|
||||
NEXT_PUBLIC_BASE_URL: ${{ inputs.TARGET_URL }}
|
||||
GATEKEEPER_PASSWORD: ${{ inputs.GATEKEEPER_PASSWORD }}
|
||||
run: pnpm run check:locale
|
||||
|
||||
- name: 🌐 External API Smoke Test (Umami & Sentry)
|
||||
shell: bash
|
||||
env:
|
||||
UMAMI_API_ENDPOINT: ${{ inputs.UMAMI_API_ENDPOINT }}
|
||||
SENTRY_DSN: ${{ inputs.SENTRY_DSN }}
|
||||
run: pnpm run check:apis
|
||||
@@ -1,44 +0,0 @@
|
||||
name: 🏥 Server Maintenance
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 3 * * *' # Every day at 3:00 AM
|
||||
workflow_dispatch: # Allow manual trigger
|
||||
|
||||
jobs:
|
||||
maintenance:
|
||||
name: 🧹 Prune & Clean
|
||||
runs-on: docker
|
||||
container:
|
||||
image: catthehacker/ubuntu:act-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: 🚀 Execute Maintenance via SSH
|
||||
run: |
|
||||
mkdir -p ~/.ssh
|
||||
echo "${{ secrets.SSH_KEY }}" > ~/.ssh/id_ed25519
|
||||
chmod 600 ~/.ssh/id_ed25519
|
||||
ssh-keyscan -H ${{ secrets.SSH_HOST }} >> ~/.ssh/known_hosts 2>/dev/null
|
||||
|
||||
# Run the prune script on the host
|
||||
# We transfer the script and execute it to ensure it matches the repo version
|
||||
scp packages/infra/scripts/mintel-optimizer.sh root@${{ secrets.SSH_HOST }}:/tmp/mintel-optimizer.sh
|
||||
ssh root@${{ secrets.SSH_HOST }} "bash /tmp/mintel-optimizer.sh && rm /tmp/mintel-optimizer.sh"
|
||||
|
||||
- name: 🔔 Notification - Success
|
||||
if: success()
|
||||
run: |
|
||||
curl -s -k -X POST "${{ secrets.GOTIFY_URL }}/message?token=${{ secrets.GOTIFY_TOKEN }}" \
|
||||
-F "title=🏥 Maintenance Complete" \
|
||||
-F "message=Server-Wartung erfolgreich ausgeführt.\nRegistry & Docker Ressourcen bereinigt." \
|
||||
-F "priority=2" || true
|
||||
|
||||
- name: 🔔 Notification - Failure
|
||||
if: failure()
|
||||
run: |
|
||||
curl -s -k -X POST "${{ secrets.GOTIFY_URL }}/message?token=${{ secrets.GOTIFY_TOKEN }}" \
|
||||
-F "title=❌ Maintenance FAILED" \
|
||||
-F "message=Die automatische Server-Wartung ist fehlgeschlagen!\nBitte Logs prüfen." \
|
||||
-F "priority=8" || true
|
||||
@@ -5,74 +5,15 @@ on:
|
||||
branches:
|
||||
- '**'
|
||||
tags:
|
||||
- '*'
|
||||
- 'v*'
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
prioritize:
|
||||
name: ⚡ Prioritize Release
|
||||
runs-on: docker
|
||||
container:
|
||||
image: catthehacker/ubuntu:act-latest
|
||||
steps:
|
||||
- name: 🛑 Cancel Redundant Runs
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
REPO: ${{ github.repository }}
|
||||
RUN_ID: ${{ github.run_id }}
|
||||
REF: ${{ github.ref }}
|
||||
REF_NAME: ${{ github.ref_name }}
|
||||
EVENT: ${{ github.event_name }}
|
||||
SHA: ${{ github.sha }}
|
||||
run: |
|
||||
echo "🔎 Debug: Event=$EVENT, Ref=$REF, RefName=$REF_NAME, RunId=$RUN_ID"
|
||||
|
||||
# Fetch recent runs for the repository
|
||||
RUNS=$(curl -s -H "Authorization: token $GITEA_TOKEN" "https://git.infra.mintel.me/api/v1/repos/$REPO/actions/runs?limit=30")
|
||||
|
||||
case "$REF" in
|
||||
refs/tags/*)
|
||||
echo "🚀 Release detected ($REF_NAME). Cancelling non-tag runs..."
|
||||
|
||||
# Identify runs to cancel: in_progress/queued, NOT this run, and NOT a tag run
|
||||
echo "$RUNS" | jq -c '.workflow_runs[] | select(.status == "in_progress" or .status == "queued") | select(.id | tostring != "'$RUN_ID'")' | while read -r run; do
|
||||
ID=$(echo "$run" | jq -r '.id')
|
||||
RUN_REF=$(echo "$run" | jq -r '.ref')
|
||||
TITLE=$(echo "$run" | jq -r '.display_title')
|
||||
|
||||
case "$RUN_REF" in
|
||||
refs/tags/*)
|
||||
echo "⏭️ Skipping parallel release run $ID ($TITLE) on $RUN_REF"
|
||||
;;
|
||||
*)
|
||||
echo "🛑 Cancelling redundant branch run $ID ($TITLE) on $RUN_REF..."
|
||||
curl -X POST -s -H "Authorization: token $GITEA_TOKEN" "https://git.infra.mintel.me/api/v1/repos/$REPO/actions/runs/$ID/cancel"
|
||||
;;
|
||||
esac
|
||||
done
|
||||
;;
|
||||
*)
|
||||
echo "ℹ️ Regular push. Checking for parallel release tag for SHA $SHA..."
|
||||
|
||||
# Check if there's a tag run for the SAME commit
|
||||
TAG_RUN_ID=$(echo "$RUNS" | jq -r '.workflow_runs[] | select(.ref | startswith("refs/tags/")) | select(.head_sha == "'$SHA'") | .id' | head -n 1)
|
||||
|
||||
if [[ -n "$TAG_RUN_ID" && "$TAG_RUN_ID" != "null" ]]; then
|
||||
echo "🚀 Found parallel tag run $TAG_RUN_ID for commit $SHA. Cancelling this branch run ($RUN_ID)..."
|
||||
curl -X POST -s -H "Authorization: token $GITEA_TOKEN" "https://git.infra.mintel.me/api/v1/repos/$REPO/actions/runs/$RUN_ID/cancel"
|
||||
exit 0
|
||||
fi
|
||||
echo "✅ No parallel tag run found. Proceeding."
|
||||
;;
|
||||
esac
|
||||
|
||||
lint:
|
||||
name: 🧹 Lint
|
||||
needs: prioritize
|
||||
if: always() && !cancelled() && (needs.prioritize.result == 'success' || needs.prioritize.result == 'skipped')
|
||||
qa:
|
||||
name: 🧪 Quality Assurance
|
||||
runs-on: docker
|
||||
container:
|
||||
image: catthehacker/ubuntu:act-latest
|
||||
@@ -81,69 +22,37 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 10
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node_version: 20
|
||||
- name: Enable pnpm
|
||||
run: corepack enable && corepack prepare pnpm@10.2.0 --activate
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile --prefer-offline --ignore-scripts --no-color
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: 🏷️ Sync Versions (if Tagged)
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
run: pnpm sync-versions
|
||||
|
||||
- name: Lint
|
||||
run: pnpm lint
|
||||
- name: Check Dependencies (Depcheck)
|
||||
run: pnpm -r exec npx --yes depcheck --skip-missing --ignores="eslint*,@eslint/*,@types/*,typescript,tsup,tsx,vitest,tailwindcss,postcss,autoprefixer,@mintel/*,ts-node,*in-the-middle,pino*,@commitlint/*,@changesets/*,globals"
|
||||
|
||||
test:
|
||||
name: 🧪 Test
|
||||
needs: prioritize
|
||||
if: always() && !cancelled() && (needs.prioritize.result == 'success' || needs.prioritize.result == 'skipped')
|
||||
runs-on: docker
|
||||
container:
|
||||
image: catthehacker/ubuntu:act-latest
|
||||
env:
|
||||
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node_version: 20
|
||||
- name: Enable pnpm
|
||||
run: corepack enable && corepack prepare pnpm@10.2.0 --activate
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile --prefer-offline --ignore-scripts --no-color
|
||||
- name: Test
|
||||
run: pnpm test
|
||||
|
||||
build:
|
||||
name: 🏗️ Build
|
||||
needs: prioritize
|
||||
if: always() && !cancelled() && (needs.prioritize.result == 'success' || needs.prioritize.result == 'skipped')
|
||||
runs-on: docker
|
||||
container:
|
||||
image: catthehacker/ubuntu:act-latest
|
||||
env:
|
||||
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node_version: 20
|
||||
- name: Enable pnpm
|
||||
run: corepack enable && corepack prepare pnpm@10.2.0 --activate
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile --prefer-offline --ignore-scripts --no-color
|
||||
- name: Build
|
||||
run: pnpm build
|
||||
|
||||
release:
|
||||
name: 🚀 Release
|
||||
needs: [lint, test, build]
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
needs: qa
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
runs-on: docker
|
||||
container:
|
||||
image: catthehacker/ubuntu:act-latest
|
||||
@@ -155,16 +64,20 @@ jobs:
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 10
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node_version: 20
|
||||
- name: Enable pnpm
|
||||
run: corepack enable && corepack prepare pnpm@10.2.0 --activate
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile --prefer-offline --ignore-scripts --no-color
|
||||
- name: 🏷️ Sync Versions (if Tagged)
|
||||
run: pnpm sync-versions
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: 🏷️ Release Packages (Tag-Driven)
|
||||
run: |
|
||||
echo "🏷️ Tag detected [${{ github.ref_name }}], performing sync release..."
|
||||
@@ -172,14 +85,13 @@ jobs:
|
||||
|
||||
build-images:
|
||||
name: 🐳 Build ${{ matrix.name }}
|
||||
needs: [lint, test, build]
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
needs: qa
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
runs-on: docker
|
||||
container:
|
||||
image: catthehacker/ubuntu:act-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
max-parallel: 1
|
||||
matrix:
|
||||
include:
|
||||
- image: nextjs
|
||||
@@ -191,7 +103,9 @@ jobs:
|
||||
- image: gatekeeper
|
||||
file: packages/infra/docker/Dockerfile.gatekeeper
|
||||
name: Gatekeeper (Product)
|
||||
|
||||
- image: directus
|
||||
file: packages/infra/docker/Dockerfile.directus
|
||||
name: Directus (Base)
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
@@ -199,44 +113,26 @@ jobs:
|
||||
- name: 🐳 Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: 🔐 Discover Valid Registry Token
|
||||
id: discover_token
|
||||
run: |
|
||||
echo "Testing available secrets against git.infra.mintel.me Docker registry..."
|
||||
TOKENS="${{ secrets.GITEA_PAT }} ${{ secrets.MINTEL_PRIVATE_TOKEN }} ${{ secrets.NPM_TOKEN }}"
|
||||
USERS="${{ github.repository_owner }} ${{ github.actor }} marcmintel mintel mmintel"
|
||||
|
||||
for TOKEN in $TOKENS; do
|
||||
if [ -n "$TOKEN" ]; then
|
||||
for U in $USERS; do
|
||||
if [ -n "$U" ]; then
|
||||
echo "Attempting docker login for a token with user $U..."
|
||||
if echo "$TOKEN" | docker login git.infra.mintel.me -u "$U" --password-stdin > /dev/null 2>&1; then
|
||||
echo "✅ Successfully authenticated with a token."
|
||||
echo "::add-mask::$TOKEN"
|
||||
echo "token=$TOKEN" >> $GITHUB_OUTPUT
|
||||
echo "user=$U" >> $GITHUB_OUTPUT
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
done
|
||||
fi
|
||||
done
|
||||
echo "❌ All available tokens failed to authenticate!"
|
||||
exit 1
|
||||
- name: 🔐 Registry Login
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: registry.infra.mintel.me
|
||||
username: ${{ secrets.REGISTRY_USER }}
|
||||
password: ${{ secrets.REGISTRY_PASS }}
|
||||
|
||||
- name: 🏗️ Build & Push ${{ matrix.name }}
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
file: ${{ matrix.file }}
|
||||
platforms: linux/amd64
|
||||
platforms: linux/arm64
|
||||
pull: true
|
||||
provenance: false
|
||||
push: true
|
||||
secrets: |
|
||||
NPM_TOKEN=${{ steps.discover_token.outputs.token }}
|
||||
NPM_TOKEN=${{ secrets.NPM_TOKEN }}
|
||||
tags: |
|
||||
git.infra.mintel.me/mmintel/${{ matrix.image }}:${{ github.ref_name }}
|
||||
git.infra.mintel.me/mmintel/${{ matrix.image }}:latest
|
||||
registry.infra.mintel.me/mintel/${{ matrix.image }}:${{ github.ref_name }}
|
||||
registry.infra.mintel.me/mintel/${{ matrix.image }}:latest
|
||||
cache-from: type=registry,ref=registry.infra.mintel.me/mintel/${{ matrix.image }}:buildcache
|
||||
cache-to: type=registry,ref=registry.infra.mintel.me/mintel/${{ matrix.image }}:buildcache,mode=max
|
||||
|
||||
|
||||
@@ -1,243 +0,0 @@
|
||||
name: Reusable Nightly QA
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
TARGET_URL:
|
||||
description: 'The URL to test (e.g., https://testing.klz-cables.com)'
|
||||
required: true
|
||||
type: string
|
||||
PROJECT_NAME:
|
||||
description: 'The internal project name for notifications'
|
||||
required: true
|
||||
type: string
|
||||
secrets:
|
||||
GOTIFY_URL:
|
||||
required: true
|
||||
GOTIFY_TOKEN:
|
||||
required: true
|
||||
GATEKEEPER_PASSWORD:
|
||||
required: true
|
||||
NPM_TOKEN:
|
||||
required: false
|
||||
MINTEL_PRIVATE_TOKEN:
|
||||
required: false
|
||||
GITEA_PAT:
|
||||
required: false
|
||||
|
||||
jobs:
|
||||
prepare:
|
||||
name: 🏗️ Prepare & Install
|
||||
runs-on: docker
|
||||
container:
|
||||
image: catthehacker/ubuntu:act-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v3
|
||||
with:
|
||||
version: 10
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
- name: 🔐 Registry Auth
|
||||
run: |
|
||||
echo "@mintel:registry=https://git.infra.mintel.me/api/packages/mmintel/npm" > .npmrc
|
||||
echo "//git.infra.mintel.me/api/packages/mmintel/npm/:_authToken=${{ secrets.NPM_TOKEN || secrets.MINTEL_PRIVATE_TOKEN || secrets.GITEA_PAT }}" >> .npmrc
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
pnpm store prune
|
||||
pnpm install --no-frozen-lockfile
|
||||
- name: 📦 Archive dependencies
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: node_modules
|
||||
path: |
|
||||
node_modules
|
||||
.npmrc
|
||||
retention-days: 1
|
||||
|
||||
static:
|
||||
name: 🔍 Static Analysis
|
||||
needs: prepare
|
||||
runs-on: docker
|
||||
container:
|
||||
image: catthehacker/ubuntu:act-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v3
|
||||
with:
|
||||
version: 10
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
- name: 📥 Restore dependencies
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: node_modules
|
||||
- name: 🌐 HTML Validation
|
||||
env:
|
||||
NEXT_PUBLIC_BASE_URL: ${{ inputs.TARGET_URL }}
|
||||
GATEKEEPER_PASSWORD: ${{ secrets.GATEKEEPER_PASSWORD }}
|
||||
run: pnpm run check:html
|
||||
- name: 🖼️ Asset Scan
|
||||
env:
|
||||
NEXT_PUBLIC_BASE_URL: ${{ inputs.TARGET_URL }}
|
||||
GATEKEEPER_PASSWORD: ${{ secrets.GATEKEEPER_PASSWORD }}
|
||||
run: pnpm run check:assets
|
||||
|
||||
accessibility:
|
||||
name: ♿ Accessibility
|
||||
needs: prepare
|
||||
runs-on: docker
|
||||
container:
|
||||
image: catthehacker/ubuntu:act-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v3
|
||||
with:
|
||||
version: 10
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
- name: 📥 Restore dependencies
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: node_modules
|
||||
- name: 🔍 Install Chromium
|
||||
run: |
|
||||
apt-get update && apt-get install -y gnupg wget ca-certificates
|
||||
CODENAME=$(. /etc/os-release && echo $VERSION_CODENAME)
|
||||
mkdir -p /etc/apt/keyrings
|
||||
wget -qO- "https://keyserver.ubuntu.com/pks/lookup?op=get&search=0x82BB6851C64F6880" | gpg --dearmor > /etc/apt/keyrings/xtradeb.gpg
|
||||
echo "deb [signed-by=/etc/apt/keyrings/xtradeb.gpg] http://ppa.launchpad.net/xtradeb/apps/ubuntu $CODENAME main" > /etc/apt/sources.list.d/xtradeb-ppa.list
|
||||
printf "Package: *\nPin: release o=LP-PPA-xtradeb-apps\nPin-Priority: 1001\n" > /etc/apt/preferences.d/xtradeb
|
||||
apt-get update && apt-get install -y --allow-downgrades chromium
|
||||
ln -sf /usr/bin/chromium /usr/bin/google-chrome
|
||||
- name: ♿ WCAG Scan
|
||||
continue-on-error: true
|
||||
env:
|
||||
NEXT_PUBLIC_BASE_URL: ${{ inputs.TARGET_URL }}
|
||||
GATEKEEPER_PASSWORD: ${{ secrets.GATEKEEPER_PASSWORD }}
|
||||
run: pnpm run check:wcag
|
||||
|
||||
analysis:
|
||||
name: 🧪 Maintenance & Links
|
||||
needs: prepare
|
||||
runs-on: docker
|
||||
container:
|
||||
image: catthehacker/ubuntu:act-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v3
|
||||
with:
|
||||
version: 10
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
- name: 📥 Restore dependencies
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: node_modules
|
||||
- name: 📦 Depcheck
|
||||
continue-on-error: true
|
||||
run: pnpm dlx depcheck --ignores="*eslint*,*typescript*,*tailwindcss*,*postcss*,*prettier*,*@types/*,*husky*,*lint-staged*,*@next/*,*@lhci/*,*commitlint*,*cspell*,*rimraf*,*@payloadcms/*,*start-server-and-test*,*html-validate*,*critters*,*dotenv*,*turbo*"
|
||||
- name: 🔗 Lychee Link Check
|
||||
uses: lycheeverse/lychee-action@v2
|
||||
with:
|
||||
args: --accept 200,204,429 --timeout 15 content/ app/ public/
|
||||
fail: true
|
||||
|
||||
performance:
|
||||
name: 🎭 Lighthouse
|
||||
needs: prepare
|
||||
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: 📥 Restore dependencies
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: node_modules
|
||||
- name: 🔍 Install Chromium
|
||||
run: |
|
||||
apt-get update && apt-get install -y gnupg wget ca-certificates
|
||||
CODENAME=$(. /etc/os-release && echo $VERSION_CODENAME)
|
||||
mkdir -p /etc/apt/keyrings
|
||||
wget -qO- "https://keyserver.ubuntu.com/pks/lookup?op=get&search=0x82BB6851C64F6880" | gpg --dearmor > /etc/apt/keyrings/xtradeb.gpg
|
||||
echo "deb [signed-by=/etc/apt/keyrings/xtradeb.gpg] http://ppa.launchpad.net/xtradeb/apps/ubuntu $CODENAME main" > /etc/apt/sources.list.d/xtradeb-ppa.list
|
||||
printf "Package: *\nPin: release o=LP-PPA-xtradeb-apps\nPin-Priority: 1001\n" > /etc/apt/preferences.d/xtradeb
|
||||
apt-get update && apt-get install -y --allow-downgrades chromium
|
||||
ln -sf /usr/bin/chromium /usr/bin/google-chrome
|
||||
- name: 🎭 LHCI Desktop
|
||||
env:
|
||||
LHCI_URL: ${{ inputs.TARGET_URL }}
|
||||
GATEKEEPER_PASSWORD: ${{ secrets.GATEKEEPER_PASSWORD }}
|
||||
run: pnpm run pagespeed:test -- --collect.settings.preset=desktop
|
||||
- name: 📱 LHCI Mobile
|
||||
env:
|
||||
LHCI_URL: ${{ inputs.TARGET_URL }}
|
||||
GATEKEEPER_PASSWORD: ${{ secrets.GATEKEEPER_PASSWORD }}
|
||||
run: pnpm run pagespeed:test -- --collect.settings.preset=mobile
|
||||
|
||||
notifications:
|
||||
name: 🔔 Notify
|
||||
needs: [prepare, static, accessibility, analysis, performance]
|
||||
if: always()
|
||||
runs-on: docker
|
||||
container:
|
||||
image: catthehacker/ubuntu:act-latest
|
||||
steps:
|
||||
- name: 🔔 Gotify
|
||||
shell: bash
|
||||
run: |
|
||||
PREPARE="${{ needs.prepare.result }}"
|
||||
STATIC="${{ needs.static.result }}"
|
||||
A11Y="${{ needs.accessibility.result }}"
|
||||
ANALYSIS="${{ needs.analysis.result }}"
|
||||
PERF="${{ needs.performance.result }}"
|
||||
|
||||
PROJECT="${{ inputs.PROJECT_NAME }}"
|
||||
URL="${{ inputs.TARGET_URL }}"
|
||||
|
||||
if [[ "$PREPARE" != "success" || "$STATIC" != "success" || "$PERF" != "success" ]]; then
|
||||
PRIORITY=8
|
||||
EMOJI="🚨"
|
||||
STATUS_LINE="Nightly QA Failed! Action required."
|
||||
else
|
||||
PRIORITY=2
|
||||
EMOJI="✅"
|
||||
STATUS_LINE="Nightly QA Passed."
|
||||
fi
|
||||
|
||||
TITLE="$EMOJI $PROJECT Nightly QA"
|
||||
MESSAGE="$STATUS_LINE
|
||||
Prepare: $PREPARE | Static: $STATIC | A11y: $A11Y
|
||||
Analysis: $ANALYSIS | Perf: $PERF
|
||||
$URL"
|
||||
|
||||
curl -s -k -X POST "${{ secrets.GOTIFY_URL }}/message?token=${{ secrets.GOTIFY_TOKEN }}" \
|
||||
-F "title=$TITLE" \
|
||||
-F "message=$MESSAGE" \
|
||||
-F "priority=$PRIORITY" || true
|
||||
14
.gitignore
vendored
14
.gitignore
vendored
@@ -37,17 +37,3 @@ Thumbs.db
|
||||
|
||||
# Changesets
|
||||
.changeset/*.lock
|
||||
directus/extensions/
|
||||
packages/cms-infra/extensions/
|
||||
packages/cms-infra/uploads/
|
||||
|
||||
directus/uploads/directus-health-file
|
||||
|
||||
# Estimation Engine Data
|
||||
data/crawls/
|
||||
packages/estimation-engine/out/
|
||||
apps/web/out/estimations/
|
||||
|
||||
# Memory MCP
|
||||
data/qdrant/
|
||||
packages/memory-mcp/models/
|
||||
@@ -1,8 +1,16 @@
|
||||
# Validate Directus SDK imports before push
|
||||
# This prevents runtime crashes caused by importing non-existent exports
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
if [ -f "$SCRIPT_DIR/scripts/validate-sdk-imports.sh" ]; then
|
||||
"$SCRIPT_DIR/scripts/validate-sdk-imports.sh" || exit 1
|
||||
fi
|
||||
|
||||
|
||||
# Check if we are pushing a tag
|
||||
while read local_ref local_sha remote_ref remote_sha
|
||||
do
|
||||
if [[ "$remote_ref" == refs/tags/v* ]]; then
|
||||
TAG=${remote_ref#refs/tags/}
|
||||
echo "🏷️ Tag detected: $TAG, syncing versions..."
|
||||
pnpm sync-versions "$TAG"
|
||||
|
||||
# Stage the changed files
|
||||
git add package.json packages/*/package.json apps/*/package.json .env .env.example
|
||||
|
||||
echo "⚠️ package.json and .env files updated to match tag $TAG."
|
||||
echo "⚠️ Note: You might need to push again if these changes were not already in your commit/tag."
|
||||
fi
|
||||
done
|
||||
|
||||
5
.npmrc
5
.npmrc
@@ -1,5 +1,6 @@
|
||||
@mintel:registry=https://git.infra.mintel.me/api/packages/mmintel/npm/
|
||||
//git.infra.mintel.me/api/packages/mmintel/npm/:_authToken=${NPM_TOKEN}
|
||||
@mintel:registry=https://npm.infra.mintel.me/
|
||||
registry=https://npm.infra.mintel.me/
|
||||
//npm.infra.mintel.me/:_authToken=${NPM_TOKEN}
|
||||
always-auth=true
|
||||
|
||||
public-hoist-pattern[]=*
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
# Stage 1: Builder
|
||||
FROM git.infra.mintel.me/mmintel/nextjs:latest AS builder
|
||||
WORKDIR /app
|
||||
|
||||
# Clean the workspace in case the base image is dirty
|
||||
RUN rm -rf ./*
|
||||
|
||||
# Arguments for build-time configuration
|
||||
ARG NEXT_PUBLIC_BASE_URL
|
||||
ARG NEXT_PUBLIC_TARGET
|
||||
ARG DIRECTUS_URL
|
||||
ARG NPM_TOKEN
|
||||
|
||||
# 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 SKIP_RUNTIME_ENV_VALIDATION=true
|
||||
ENV CI=true
|
||||
|
||||
# Enable pnpm
|
||||
RUN corepack enable
|
||||
|
||||
# Copy lockfile and manifest for dependency installation caching
|
||||
COPY pnpm-lock.yaml package.json .npmrc* ./
|
||||
|
||||
# Install dependencies with cache mount
|
||||
RUN --mount=type=cache,id=pnpm,target=/pnpm/store \
|
||||
--mount=type=secret,id=NPM_TOKEN \
|
||||
export NPM_TOKEN=$(cat /run/secrets/NPM_TOKEN 2>/dev/null || echo $NPM_TOKEN) && \
|
||||
pnpm install --frozen-lockfile
|
||||
|
||||
# Copy source code
|
||||
COPY . .
|
||||
|
||||
# Build application
|
||||
RUN pnpm build
|
||||
|
||||
# Stage 2: Runner
|
||||
FROM git.infra.mintel.me/mmintel/runtime:latest AS runner
|
||||
WORKDIR /app
|
||||
|
||||
ENV HOSTNAME="0.0.0.0"
|
||||
ENV PORT=3000
|
||||
ENV NODE_ENV=production
|
||||
|
||||
# Copy standalone output and static files
|
||||
# Adjust paths if using a monorepo structure (e.g., /app/apps/web/public)
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/public ./public
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/cache ./.next/cache
|
||||
|
||||
USER nextjs
|
||||
|
||||
CMD ["node", "server.js"]
|
||||
@@ -80,5 +80,3 @@ Client websites scaffolded via the CLI use a **tag-based deployment** strategy:
|
||||
- **Git Tag `v*.*.*`**: Deploys to the `production` environment.
|
||||
|
||||
See the [`@mintel/infra`](packages/infra/README.md) package for detailed template documentation.
|
||||
|
||||
Trigger rebuilding for x86 architecture.
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
# Start from the pre-built Nextjs Base image
|
||||
FROM registry.infra.mintel.me/mintel/nextjs:latest AS builder
|
||||
ARG IMAGE_TAG=latest
|
||||
FROM registry.infra.mintel.me/mintel/nextjs:${IMAGE_TAG} AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
@@ -20,7 +21,7 @@ ENV DIRECTUS_URL=$DIRECTUS_URL
|
||||
RUN pnpm --filter sample-website build
|
||||
|
||||
# Production runner image
|
||||
FROM registry.infra.mintel.me/mintel/runtime:latest AS runner
|
||||
FROM registry.infra.mintel.me/mintel/runtime:${IMAGE_TAG} AS runner
|
||||
WORKDIR /app
|
||||
|
||||
COPY --from=builder /app/apps/sample-website/public ./apps/sample-website/public
|
||||
|
||||
0
apps/sample-website/directus/extensions/.gitkeep
Normal file
0
apps/sample-website/directus/extensions/.gitkeep
Normal file
@@ -1,8 +1,6 @@
|
||||
import mintelNextConfig from "@mintel/next-config";
|
||||
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
transpilePackages: ["@mintel/ui"],
|
||||
};
|
||||
const nextConfig = {};
|
||||
|
||||
export default mintelNextConfig(nextConfig);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "sample-website",
|
||||
"version": "1.9.17",
|
||||
"version": "1.7.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
@@ -15,11 +15,12 @@
|
||||
"pagespeed:test": "mintel pagespeed"
|
||||
},
|
||||
"dependencies": {
|
||||
"@mintel/next-observability": "workspace:*",
|
||||
"@mintel/next-utils": "workspace:*",
|
||||
"@mintel/observability": "workspace:*",
|
||||
"@mintel/next-observability": "workspace:*",
|
||||
"@sentry/nextjs": "10.38.0",
|
||||
"next": "16.1.6",
|
||||
"next-intl": "^4.8.2",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0"
|
||||
},
|
||||
|
||||
@@ -1,246 +0,0 @@
|
||||
Hallo Marc,
|
||||
|
||||
eine harte Deadline gibt es nicht – Was denkst du ist realistisch? Ich habe als Ziel so
|
||||
April / Mai im Kopf -> dann aber schon zu 95 % fertig. Viele Grüße
|
||||
|
||||
Mit freundlichen Grüßen
|
||||
|
||||
Danny Joseph
|
||||
Geschäftsführer
|
||||
|
||||
E-TIB GmbH
|
||||
Gewerbestraße 22
|
||||
D-03172 Guben
|
||||
|
||||
Mobil +49 15207230518
|
||||
E-Mail d.joseph@e-tib.com
|
||||
Web www.e-tib.com
|
||||
|
||||
--------------------------------------------------------------------------------------------------
|
||||
|
||||
Hey,
|
||||
|
||||
ich würde wie bei https://www.schleicher-gruppe.de/ ein Video auf der Startseite
|
||||
haben wollen. Da ginge sicherlich was vom bisherigen Messevideo. Liebe Grüße.
|
||||
|
||||
Mit freundlichen Grüßen
|
||||
|
||||
Danny Joseph
|
||||
Geschäftsführer
|
||||
|
||||
E-TIB GmbH
|
||||
Gewerbestraße 22
|
||||
D-03172 Guben
|
||||
|
||||
Mobil +49 15207230518
|
||||
E-Mail d.joseph@e-tib.com
|
||||
Web www.e-tib.com
|
||||
|
||||
|
||||
--------------------------------------------------------------------------------------------------
|
||||
Geschäftsführung: Danny Joseph
|
||||
Handelsregister: Amtsgericht Cottbus
|
||||
HRB: 12403 CB
|
||||
USt. ID-Nr.: DE304799919
|
||||
--------------------------------------------------------------------------------------------------
|
||||
|
||||
Von: Frieder Helmich <f.helmich@etib-ing.com>
|
||||
Gesendet: Donnerstag, 29. Januar 2026 08:49
|
||||
An: Marc Mintel <marc@cablecreations.de>; Danny Joseph <d.joseph@e-tib.com>
|
||||
Betreff: AW: Homepage E-TIB
|
||||
|
||||
Hi Marc,
|
||||
|
||||
brauchst du nur Fotos oder bindest du auch videos ein? Wir haben sehr viel Videomaterial. Wir haben auch einen kleinen Film den wir auf der Messe laufen lassen haben.
|
||||
|
||||
Mit freundlichen Grüßen
|
||||
|
||||
i.A. Frieder Helmich
|
||||
|
||||
E-TIB Ingenieurgesellschaft mbH
|
||||
Kampstraße 3
|
||||
D-27412 Bülstedt
|
||||
|
||||
Tel +49 4283 6979923
|
||||
Mobil +49 173 6560514
|
||||
Fax +49 4283 6084091
|
||||
|
||||
E-Mail f.helmich@etib-ing.com
|
||||
Web www.etib-ing.com
|
||||
|
||||
ETIB_Ing_logo_mk
|
||||
Datenschutzhinweise: www.etib-ing.com/datenschutz
|
||||
-----------------------------------------------------------------------------------------------
|
||||
Geschäftsführung: Julian Helmich
|
||||
Handelsregister: Amtsgericht Tostedt
|
||||
HRB: 207158
|
||||
-----------------------------------------------------------------------------------------------
|
||||
|
||||
Von: Marc Mintel <marc@cablecreations.de>
|
||||
Gesendet: Mittwoch, 28. Januar 2026 18:10
|
||||
An: Danny Joseph <d.joseph@e-tib.com>
|
||||
Cc: Frieder Helmich <f.helmich@etib-ing.com>
|
||||
Betreff: Re: Homepage E-TIB
|
||||
|
||||
Hallo Danny,
|
||||
|
||||
Vielen Dank für die schnelle Rückmeldung.
|
||||
Wie gesprochen werde ich mir die Unterlagen und Webseiten im Detail anschauen und mich dann noch einmal bei dir melden.
|
||||
|
||||
Gibt es eigentlich eine Deadline oder einen zeitlichen Rahmen, wo ihr mit der neuen Webseite rechnen möchtet?
|
||||
Je nach dem könnte man auch Features priorisieren, so dass der Kern der Seite schnellstmöglich modernisiert online geht und der Rest im Nachgang.
|
||||
|
||||
Das Foto-Material würde ich auch gerne sichten, dann kann man schon sehen, wie viel sich damit arbeiten lässt.
|
||||
|
||||
Viele Grüße
|
||||
|
||||
|
||||
From: Danny Joseph <d.joseph@e-tib.com>
|
||||
Organization: E-TIB GmbH
|
||||
Date: Wednesday, 28. January 2026 at 16:16
|
||||
To: Marc Mintel <marc@cablecreations.de>
|
||||
Cc: 'Frieder Helmich' <f.helmich@etib-ing.com>
|
||||
Subject: Homepage E-TIB
|
||||
|
||||
Hallo Marc,
|
||||
|
||||
wie telefonisch besprochen erste wirre Gedanken:
|
||||
|
||||
Wir möchten eine minimalistische, hochwertige Homepage die sowohl am PV, als auch
|
||||
Auf Smartphone / Tablet etc. vernünftig ausschaut.
|
||||
|
||||
Bisher war unser Aufhänger:
|
||||
DIE EXPERTEN FÜR KABELTIEFBAU …
|
||||
|
||||
Alles nur Ideen: …
|
||||
|
||||
# Schaltflächen ähnlich: https://www.schleicher-gruppe.de/
|
||||
|
||||
E-TIB GmbH
|
||||
E-TIB Verwaltung GmbH
|
||||
E-TIB Ingenieurgesellschaft mbH
|
||||
E-TIB Bohrtechnik GmbH
|
||||
|
||||
# Schaltflächen ähnlich: https://www.schleicher-gruppe.de/
|
||||
(ehemals Kompetenzen www.e-tib.com)
|
||||
|
||||
Kabelbau
|
||||
Kabelpflugarbeiten
|
||||
Horizontalspülbohrungen
|
||||
Elektromontagen bis 110 kV
|
||||
Glasfaser-Kabelmontagen
|
||||
Wartung & Störungsdienst
|
||||
Genehmigungs- und Ausführungsplanung
|
||||
Komplexe Querung (Bahn, Autobahn, Gewässer)
|
||||
Elektro- und Netzanschlussplanung
|
||||
Vermessung & Dokumentation
|
||||
|
||||
Input für Über uns: Grid … Timeline?
|
||||
Gründung E-TIB GmbH: 16.12.2015
|
||||
Kabelbau
|
||||
Kabelpflugarbeiten
|
||||
Horizontalspülbohrungen
|
||||
Elektromontagen bis 110 kV
|
||||
Glasfaser-Kabelmontagen
|
||||
Wartung & Störungsdienst
|
||||
Elektro- und Netzanschlussplanung
|
||||
Vermessung & Dokumentation
|
||||
|
||||
Gründung E-TIB Verwaltung GmbH: 14.11.2019
|
||||
Der Erwerb, die Vermietung, Verpachtung und Verwaltung
|
||||
von Immobilien, Grundstücken, Maschinen und Geräten.
|
||||
|
||||
Gründung E-TIB Ingenieurgesellschaft mbH: 04.02.2019
|
||||
Genehmigungs- und Ausführungsplanung
|
||||
Komplexe Querung (Bahn, Autobahn, Gewässer)
|
||||
Elektro- und Netzanschlussplanung
|
||||
|
||||
Gründung E-TIB Bohrtechnik GmbH: 21.10.2025
|
||||
Horizontalspülbohrungen in allen Bodenklassen
|
||||
|
||||
Gruppen‑Kacheln (Beispieltexte) ...
|
||||
|
||||
E‑TIB GmbH – Ausführung elektrischer Infrastrukturprojekte
|
||||
E‑TIB Bohrtechnik GmbH – Präzise Horizontalbohrungen in allen Bodenklassen
|
||||
E‑TIB Verwaltung GmbH – Zentrale Dienste, Einkauf, Finanzen
|
||||
E‑TIB Ingenieurgesellschaft mbH – Planung, Projektierung, Dokumentation
|
||||
|
||||
Kontaktseite siehe: www.e-tib.com
|
||||
|
||||
Karriere: ...
|
||||
|
||||
Messen: wo wir dieses Jahr einen Stand haben: Intersolar München, Windenergietage Linstow, Kabelwerkstatt Wiesbaden
|
||||
|
||||
Referenzen: … müsste ich dir zur Verfügung stellen
|
||||
|
||||
Pflichtseiten
|
||||
Impressum (vollständig, Verantwortliche, Registernummer, USt‑ID).
|
||||
Datenschutz (Verarbeitungen, Rechtsgrundlagen, AVV, Cookie‑Gruppen, Löschfristen, Rechte).
|
||||
Cookie‑Einstellungen (Consent Manager: ...)
|
||||
|
||||
www.e-tib.com
|
||||
www.etib-ing.com
|
||||
|
||||
Hier mein instagram account:
|
||||
me.and.eloise
|
||||
Verstehst du mich vielleicht ein kleines Stück mehr…
|
||||
|
||||
Unser Frieder Helmich kann erstes Foto-/Videomaterial zur Verfügung stellen:
|
||||
f.helmich@etib-ing.com
|
||||
|
||||
Lass mir mal eine Idee vom Stundenaufwand / Kosten pro Stunde für Erstellung zukommen,
|
||||
damit wir eine Vertragsgrundlage haben. Danach lass uns loslegen.
|
||||
|
||||
Besten Dank dir.
|
||||
|
||||
Mit freundlichen Grüßen
|
||||
|
||||
Danny Joseph
|
||||
Geschäftsführer
|
||||
|
||||
E-TIB GmbH
|
||||
Gewerbestraße 22
|
||||
D-03172 Guben
|
||||
|
||||
Mobil +49 15207230518
|
||||
E-Mail d.joseph@e-tib.com
|
||||
Web www.e-tib.com
|
||||
|
||||
|
||||
--------------------------------------------------------------------------------------------------
|
||||
Geschäftsführung: Danny Joseph
|
||||
Handelsregister: Amtsgericht Cottbus
|
||||
HRB: 12403 CB
|
||||
USt. ID-Nr.: DE304799919
|
||||
--------------------------------------------------------------------------------------------------
|
||||
|
||||
Von: Marc Mintel <marc@cablecreations.de>
|
||||
Gesendet: Donnerstag, 13. November 2025 16:30
|
||||
An: d.joseph@e-tib.com
|
||||
Betreff: Homepage
|
||||
|
||||
Hi Danny,
|
||||
|
||||
mein Vater meinte, ich könnte mich mal bei dir melden, weil ihr jemanden für eure Website sucht.
|
||||
|
||||
Kurz zu mir: Ich habe über 10 Jahre in der Webentwicklung gearbeitet. Inzwischen liegt mein Schwerpunkt zwar im 3D-Bereich (u. a. cablecreations.de), aber ich betreue weiterhin Websites für Firmen, die das Ganze unkompliziert abgegeben haben möchten. Unter anderem betreue ich auch die Seite von KLZ (klz-cables.com). Der Ablauf ist bei mir recht einfach: Wenn ihr etwas braucht, reicht in der Regel eine kurze Mail – Anpassungen, Inhalte oder technische Themen erledige ich dann im Hintergrund. Dadurch spart ihr euch Schulungen, Zugänge oder lange Meetings, wie man sie oft mit Agenturen hat.
|
||||
|
||||
Wichtig ist: Eine Website braucht auch nach dem Aufbau regelmäßige Pflege, damit Technik und Sicherheit sauber laufen – das übernehme ich dann ebenfalls, damit ihr im Alltag keinen Aufwand damit habt.
|
||||
|
||||
Um einschätzen zu können, ob und wie ich euch unterstützen kann, wäre es gut zu wissen, was ihr mit der Website vorhabt und was an der aktuellen Seite nicht mehr passt. Wenn du magst, können wir dazu auch kurz telefonieren.
|
||||
|
||||
Viele Grüße
|
||||
Marc
|
||||
|
||||
Marc Mintel
|
||||
Founder & 3D Artist
|
||||
marc@cablecreations.de
|
||||
|
||||
Cable Creations
|
||||
www.cablecreations.de
|
||||
info@cablecreations.de
|
||||
VAT: DE367588065
|
||||
|
||||
Georg-Meistermann-Straße 7
|
||||
54586 Schüller
|
||||
Germany
|
||||
0
directus/schema/.gitkeep
Normal file
0
directus/schema/.gitkeep
Normal file
19
directus/schema/snapshot.yaml
Normal file
19
directus/schema/snapshot.yaml
Normal file
@@ -0,0 +1,19 @@
|
||||
version: 1
|
||||
directus: 11.15.1
|
||||
vendor: postgres
|
||||
collections: []
|
||||
fields: []
|
||||
systemFields:
|
||||
- collection: directus_activity
|
||||
field: timestamp
|
||||
schema:
|
||||
is_indexed: true
|
||||
- collection: directus_revisions
|
||||
field: activity
|
||||
schema:
|
||||
is_indexed: true
|
||||
- collection: directus_revisions
|
||||
field: parent
|
||||
schema:
|
||||
is_indexed: true
|
||||
relations: []
|
||||
0
directus/uploads/.gitkeep
Normal file
0
directus/uploads/.gitkeep
Normal file
@@ -1,39 +0,0 @@
|
||||
services:
|
||||
gatekeeper-proxy:
|
||||
image: alpine:latest
|
||||
command: sleep infinity
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- infra
|
||||
labels:
|
||||
- "caddy=http://gatekeeper.localhost"
|
||||
- "caddy.route=/*"
|
||||
- "caddy.route.0_redir=/ /gatekeeper/login 302"
|
||||
- "caddy.route.1_reverse_proxy=gatekeeper-app:3000"
|
||||
|
||||
gatekeeper-app:
|
||||
image: node:20-alpine
|
||||
working_dir: /app
|
||||
volumes:
|
||||
- .:/app
|
||||
- gatekeeper_root_node_modules:/app/node_modules
|
||||
- gatekeeper_pkg_node_modules:/app/packages/gatekeeper/node_modules
|
||||
- gatekeeper_next_cache:/app/packages/gatekeeper/.next
|
||||
- gatekeeper_pnpm_store:/pnpm
|
||||
environment:
|
||||
- NODE_ENV=development
|
||||
- NPM_TOKEN=${NPM_TOKEN:-}
|
||||
networks:
|
||||
- infra
|
||||
command: >
|
||||
sh -c "corepack enable && pnpm config set store-dir /pnpm && pnpm install --no-frozen-lockfile && pnpm --filter @mintel/gatekeeper run dev --hostname 0.0.0.0 --port 3000"
|
||||
|
||||
networks:
|
||||
infra:
|
||||
external: true
|
||||
|
||||
volumes:
|
||||
gatekeeper_root_node_modules:
|
||||
gatekeeper_pkg_node_modules:
|
||||
gatekeeper_next_cache:
|
||||
gatekeeper_pnpm_store:
|
||||
@@ -1,16 +0,0 @@
|
||||
services:
|
||||
qdrant:
|
||||
image: qdrant/qdrant:latest
|
||||
container_name: qdrant-mcp
|
||||
ports:
|
||||
- "6333:6333"
|
||||
- "6334:6334"
|
||||
volumes:
|
||||
- ./data/qdrant:/qdrant/storage
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- mcp-network
|
||||
|
||||
networks:
|
||||
mcp-network:
|
||||
driver: bridge
|
||||
@@ -11,6 +11,8 @@ services:
|
||||
restart: always
|
||||
networks:
|
||||
- infra
|
||||
environment:
|
||||
- DIRECTUS_URL=${DIRECTUS_URL:-http://directus:8055}
|
||||
env_file:
|
||||
- .env
|
||||
ports:
|
||||
@@ -19,9 +21,53 @@ services:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.http.routers.sample-website.rule=Host(`${TRAEFIK_HOST:-sample-website.localhost}`)"
|
||||
- "traefik.http.services.sample-website.loadbalancer.server.port=3000"
|
||||
- "caddy=http://${TRAEFIK_HOST:-acquisition.localhost}"
|
||||
- "caddy.reverse_proxy={{upstreams 3000}}"
|
||||
|
||||
directus:
|
||||
image: registry.infra.mintel.me/mintel/directus:${IMAGE_TAG:-latest}
|
||||
restart: always
|
||||
networks:
|
||||
- infra
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
KEY: ${DIRECTUS_KEY:-mintel-key}
|
||||
SECRET: ${DIRECTUS_SECRET:-mintel-secret}
|
||||
ADMIN_EMAIL: ${DIRECTUS_ADMIN_EMAIL:-admin@mintel.me}
|
||||
ADMIN_PASSWORD: ${DIRECTUS_ADMIN_PASSWORD:-mintel-admin}
|
||||
DB_CLIENT: 'pg'
|
||||
DB_HOST: 'directus-db'
|
||||
DB_PORT: '5432'
|
||||
DB_DATABASE: ${DIRECTUS_DB_NAME:-directus}
|
||||
DB_USER: ${DIRECTUS_DB_USER:-directus}
|
||||
DB_PASSWORD: ${DIRECTUS_DB_PASSWORD:-mintel-db-pass}
|
||||
WEBSOCKETS_ENABLED: 'true'
|
||||
PUBLIC_URL: ${DIRECTUS_URL:-http://localhost:8055}
|
||||
ports:
|
||||
- "8055:8055"
|
||||
volumes:
|
||||
- ./directus/uploads:/directus/uploads
|
||||
- ./directus/extensions:/directus/extensions
|
||||
- ./directus/schema:/directus/schema
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.http.routers.sample-website-directus.rule=Host(`${DIRECTUS_HOST:-cms.sample-website.localhost}`)"
|
||||
- "traefik.http.services.sample-website-directus.loadbalancer.server.port=8055"
|
||||
|
||||
directus-db:
|
||||
image: postgres:15-alpine
|
||||
restart: always
|
||||
networks:
|
||||
- infra
|
||||
environment:
|
||||
POSTGRES_DB: ${DIRECTUS_DB_NAME:-directus}
|
||||
POSTGRES_USER: ${DIRECTUS_DB_USER:-directus}
|
||||
POSTGRES_PASSWORD: ${DIRECTUS_DB_PASSWORD:-mintel-db-pass}
|
||||
volumes:
|
||||
- directus-db-data:/var/lib/postgresql/data
|
||||
|
||||
networks:
|
||||
infra:
|
||||
external: true
|
||||
|
||||
volumes:
|
||||
directus-db-data:
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
module.exports = {
|
||||
apps: [
|
||||
{
|
||||
name: 'gitea-mcp',
|
||||
script: 'node',
|
||||
args: 'dist/start.js',
|
||||
cwd: './packages/gitea-mcp',
|
||||
watch: false,
|
||||
},
|
||||
{
|
||||
name: 'memory-mcp',
|
||||
script: 'node',
|
||||
args: 'dist/start.js',
|
||||
cwd: './packages/memory-mcp',
|
||||
watch: false,
|
||||
},
|
||||
{
|
||||
name: 'umami-mcp',
|
||||
script: 'node',
|
||||
args: 'dist/start.js',
|
||||
cwd: './packages/umami-mcp',
|
||||
watch: false,
|
||||
},
|
||||
{
|
||||
name: 'serpbear-mcp',
|
||||
script: 'node',
|
||||
args: 'dist/start.js',
|
||||
cwd: './packages/serpbear-mcp',
|
||||
watch: false,
|
||||
},
|
||||
{
|
||||
name: 'glitchtip-mcp',
|
||||
script: 'node',
|
||||
args: 'dist/start.js',
|
||||
cwd: './packages/glitchtip-mcp',
|
||||
watch: false,
|
||||
},
|
||||
{
|
||||
name: 'klz-payload-mcp',
|
||||
script: 'node',
|
||||
args: 'dist/start.js',
|
||||
cwd: './packages/klz-payload-mcp',
|
||||
watch: false,
|
||||
},
|
||||
]
|
||||
};
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ export default [
|
||||
{
|
||||
ignores: [
|
||||
"packages/cms-infra/extensions/**",
|
||||
"**/index.js",
|
||||
"packages/customer-manager/index.js",
|
||||
"**/*.db",
|
||||
"**/build/**",
|
||||
"**/data/**",
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
import fs from 'fs';
|
||||
import glob from 'glob';
|
||||
|
||||
const files = glob.sync('/Users/marcmintel/Projects/at-mintel/packages/*/package.json');
|
||||
files.forEach(f => {
|
||||
const content = fs.readFileSync(f, 'utf8');
|
||||
if (content.includes('"private": true,')) {
|
||||
console.log(`Fixing ${f}`);
|
||||
const newContent = content.replace(/\s*"private": true,?\n/g, '\n');
|
||||
fs.writeFileSync(f, newContent);
|
||||
}
|
||||
});
|
||||
@@ -1 +0,0 @@
|
||||
404: Not Found
|
||||
@@ -1,30 +0,0 @@
|
||||
[
|
||||
{
|
||||
"weights":
|
||||
[
|
||||
{"name":"conv0/filters","shape":[3,3,3,16],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.009007044399485869,"min":-1.2069439495311063}},
|
||||
{"name":"conv0/bias","shape":[16],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.005263455241334205,"min":-0.9211046672334858}},
|
||||
{"name":"conv1/depthwise_filter","shape":[3,3,16,1],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.004001977630690033,"min":-0.5042491814669441}},
|
||||
{"name":"conv1/pointwise_filter","shape":[1,1,16,32],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.013836609615999109,"min":-1.411334180831909}},
|
||||
{"name":"conv1/bias","shape":[32],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.0015159862590771096,"min":-0.30926119685173037}},
|
||||
{"name":"conv2/depthwise_filter","shape":[3,3,32,1],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.002666276225856706,"min":-0.317286870876948}},
|
||||
{"name":"conv2/pointwise_filter","shape":[1,1,32,64],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.015265831292844286,"min":-1.6792414422128714}},
|
||||
{"name":"conv2/bias","shape":[64],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.0020280554598453,"min":-0.37113414915168985}},
|
||||
{"name":"conv3/depthwise_filter","shape":[3,3,64,1],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.006100742489683862,"min":-0.8907084034938438}},
|
||||
{"name":"conv3/pointwise_filter","shape":[1,1,64,128],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.016276211832083907,"min":-2.0508026908425725}},
|
||||
{"name":"conv3/bias","shape":[128],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.003394414279975143,"min":-0.7637432129944072}},
|
||||
{"name":"conv4/depthwise_filter","shape":[3,3,128,1],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.006716050119961009,"min":-0.8059260143953211}},
|
||||
{"name":"conv4/pointwise_filter","shape":[1,1,128,256],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.021875603993733724,"min":-2.8875797271728514}},
|
||||
{"name":"conv4/bias","shape":[256],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.0041141652009066415,"min":-0.8187188749804216}},
|
||||
{"name":"conv5/depthwise_filter","shape":[3,3,256,1],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.008423839597141042,"min":-0.9013508368940915}},
|
||||
{"name":"conv5/pointwise_filter","shape":[1,1,256,512],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.030007277283014035,"min":-3.8709387695088107}},
|
||||
{"name":"conv5/bias","shape":[512],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.008402082966823203,"min":-1.4871686851277068}},
|
||||
{"name":"conv8/filters","shape":[1,1,512,25],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.028336129469030042,"min":-4.675461362389957}},
|
||||
{"name":"conv8/bias","shape":[25],"dtype":"float32","quantization":{"dtype":"uint8","scale":0.002268134028303857,"min":-0.41053225912299807}}
|
||||
],
|
||||
"paths":
|
||||
[
|
||||
"tiny_face_detector_model.bin"
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -1,14 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Ghost Image Optimizer
|
||||
# Target directory for Ghost content
|
||||
TARGET_DIR="/home/deploy/sites/marisas.world/content/images"
|
||||
|
||||
echo "Starting image optimization for $TARGET_DIR..."
|
||||
|
||||
# Find all original images, excluding the 'size/' directory where Ghost stores thumbnails
|
||||
# Resize images larger than 2500px down to 2500px width
|
||||
# Compress JPEG/PNG to 80% quality
|
||||
find "$TARGET_DIR" -type d -name "size" -prune -o \( -iname "*.jpg" -o -iname "*.jpeg" -o -iname "*.png" \) -type f -exec mogrify -resize '2500x>' -quality 80 {} +
|
||||
|
||||
echo "Optimization complete."
|
||||
32
package.json
32
package.json
@@ -5,19 +5,17 @@
|
||||
"scripts": {
|
||||
"build": "pnpm -r build",
|
||||
"dev": "pnpm -r dev",
|
||||
"dev:gatekeeper": "bash -c 'trap \"COMPOSE_PROJECT_NAME=gatekeeper docker-compose -f docker-compose.gatekeeper.yml down\" EXIT INT TERM; docker network create infra 2>/dev/null || true && COMPOSE_PROJECT_NAME=gatekeeper docker-compose -f docker-compose.gatekeeper.yml down && COMPOSE_PROJECT_NAME=gatekeeper docker-compose -f docker-compose.gatekeeper.yml up --build --remove-orphans'",
|
||||
"dev:mcps:up": "docker-compose -f docker-compose.mcps.yml up -d",
|
||||
"dev:mcps:down": "docker-compose -f docker-compose.mcps.yml down && pm2 delete ecosystem.mcps.config.cjs || true",
|
||||
"dev:mcps:watch": "pnpm -r --filter=\"./packages/*-mcp\" exec tsc -w",
|
||||
"dev:mcps": "npm run dev:mcps:up && pm2 start ecosystem.mcps.config.cjs --watch && npm run dev:mcps:watch",
|
||||
"start:mcps:run": "pm2 start ecosystem.mcps.config.cjs",
|
||||
"start:mcps": "npm run dev:mcps:up && npm run start:mcps:run",
|
||||
"lint": "pnpm -r --filter='./packages/**' --filter='./apps/**' lint",
|
||||
"test": "pnpm -r test",
|
||||
"changeset": "changeset",
|
||||
"version-packages": "changeset version",
|
||||
"sync-versions": "tsx scripts/sync-versions.ts --",
|
||||
"release:version": "bash scripts/release.sh",
|
||||
"cms:push:infra": "./scripts/sync-directus.sh push infra",
|
||||
"cms:pull:infra": "./scripts/sync-directus.sh pull infra",
|
||||
"cms:schema:snapshot": "./scripts/cms-snapshot.sh",
|
||||
"cms:schema:apply": "./scripts/cms-apply.sh local",
|
||||
"cms:schema:apply:infra": "./scripts/cms-apply.sh infra",
|
||||
"dev:infra": "docker-compose up -d directus directus-db",
|
||||
"release": "pnpm build && changeset publish",
|
||||
"release:tag": "pnpm build && pnpm -r publish --no-git-checks --access public",
|
||||
"prepare": "husky"
|
||||
@@ -28,7 +26,6 @@
|
||||
"@commitlint/config-conventional": "^20.4.0",
|
||||
"@mintel/eslint-config": "workspace:*",
|
||||
"@mintel/husky-config": "workspace:*",
|
||||
"@next/eslint-plugin-next": "16.1.6",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@types/node": "^20.17.16",
|
||||
@@ -36,13 +33,13 @@
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^5.1.2",
|
||||
"eslint": "^9.39.2",
|
||||
"@next/eslint-plugin-next": "16.1.6",
|
||||
"eslint-plugin-react": "^7.37.5",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"happy-dom": "^20.4.0",
|
||||
"husky": "^9.1.7",
|
||||
"jsdom": "^27.4.0",
|
||||
"lint-staged": "^16.2.7",
|
||||
"pm2": "^6.0.14",
|
||||
"prettier": "^3.8.1",
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "^5.0.0",
|
||||
@@ -50,26 +47,13 @@
|
||||
"vitest": "^4.0.18"
|
||||
},
|
||||
"dependencies": {
|
||||
"globals": "^17.3.0",
|
||||
"import-in-the-middle": "^3.0.0",
|
||||
"pino": "^10.3.1",
|
||||
"pino-pretty": "^13.1.3",
|
||||
"require-in-the-middle": "^8.0.1"
|
||||
},
|
||||
"version": "1.9.17",
|
||||
"version": "1.7.0",
|
||||
"pnpm": {
|
||||
"onlyBuiltDependencies": [
|
||||
"@parcel/watcher",
|
||||
"@sentry/cli",
|
||||
"@swc/core",
|
||||
"@tensorflow/tfjs-node",
|
||||
"canvas",
|
||||
"core-js",
|
||||
"esbuild",
|
||||
"sharp",
|
||||
"unrs-resolver",
|
||||
"vue-demi"
|
||||
],
|
||||
"overrides": {
|
||||
"next": "16.1.6",
|
||||
"@sentry/nextjs": "10.38.0"
|
||||
|
||||
32
packages/acquisition/package.json
Normal file
32
packages/acquisition/package.json
Normal file
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"name": "@mintel/acquisition",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"scripts": {
|
||||
"build": "tsup src/index.ts --format esm --dts --clean",
|
||||
"dev": "tsup src/index.ts --format esm --watch --dts",
|
||||
"lint": "eslint src",
|
||||
"test": "vitest run",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "^1.7.9",
|
||||
"crawlee": "^3.12.2",
|
||||
"cheerio": "^1.0.0",
|
||||
"react": "^19.0.0",
|
||||
"@react-pdf/renderer": "^4.3.0",
|
||||
"framer-motion": "^12.4.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@mintel/tsconfig": "workspace:*",
|
||||
"@mintel/eslint-config": "workspace:*",
|
||||
"tsup": "^8.3.5",
|
||||
"typescript": "^5.0.0",
|
||||
"vitest": "^3.0.4",
|
||||
"@types/node": "^20.17.16"
|
||||
}
|
||||
}
|
||||
@@ -6,15 +6,18 @@ import { pdfStyles } from "./pdf/SharedUI.js";
|
||||
import { SimpleLayout } from "./pdf/SimpleLayout.js";
|
||||
|
||||
// Modules
|
||||
import { FrontPageModule } from "./pdf/modules/FrontPageModule.js";
|
||||
import { BriefingModule } from "./pdf/modules/BriefingModule.js";
|
||||
import { SitemapModule } from "./pdf/modules/SitemapModule.js";
|
||||
import { EstimationModule } from "./pdf/modules/EstimationModule.js";
|
||||
import { TransparenzModule } from "./pdf/modules/TransparenzModule.js";
|
||||
import { ClosingModule } from "./pdf/modules/CommonModules.js";
|
||||
|
||||
import { calculatePositions } from "../logic/pricing/calculator.js";
|
||||
|
||||
interface PDFProps {
|
||||
state: any;
|
||||
totalPrice: number;
|
||||
monthlyPrice?: number;
|
||||
totalPagesCount?: number;
|
||||
pricing: any;
|
||||
headerIcon?: string;
|
||||
footerLogo?: string;
|
||||
@@ -45,7 +48,7 @@ export const EstimationPDF = ({
|
||||
const commonProps = {
|
||||
state,
|
||||
date,
|
||||
headerIcon,
|
||||
icon: headerIcon,
|
||||
footerLogo,
|
||||
companyData,
|
||||
};
|
||||
@@ -55,7 +58,21 @@ export const EstimationPDF = ({
|
||||
|
||||
return (
|
||||
<PDFDocument title={`Angebot - ${state.companyName || "Projekt"}`}>
|
||||
<SimpleLayout {...commonProps}>
|
||||
<PDFPage size="A4" style={pdfStyles.titlePage}>
|
||||
<FrontPageModule state={state} headerIcon={headerIcon} date={date} />
|
||||
</PDFPage>
|
||||
|
||||
<SimpleLayout {...commonProps} pageNumber={getPageNum()}>
|
||||
<BriefingModule state={state} />
|
||||
</SimpleLayout>
|
||||
|
||||
{state.sitemap && state.sitemap.length > 0 && (
|
||||
<SimpleLayout {...commonProps} pageNumber={getPageNum()}>
|
||||
<SitemapModule state={state} />
|
||||
</SimpleLayout>
|
||||
)}
|
||||
|
||||
<SimpleLayout {...commonProps} pageNumber={getPageNum()}>
|
||||
<EstimationModule
|
||||
state={state}
|
||||
positions={positions}
|
||||
@@ -63,6 +80,14 @@ export const EstimationPDF = ({
|
||||
date={date}
|
||||
/>
|
||||
</SimpleLayout>
|
||||
|
||||
<SimpleLayout {...commonProps} pageNumber={getPageNum()}>
|
||||
<TransparenzModule pricing={pricing} />
|
||||
</SimpleLayout>
|
||||
|
||||
<SimpleLayout {...commonProps} pageNumber={getPageNum()}>
|
||||
<ClosingModule />
|
||||
</SimpleLayout>
|
||||
</PDFDocument>
|
||||
);
|
||||
};
|
||||
401
packages/acquisition/src/components/pdf/SharedUI.tsx
Normal file
401
packages/acquisition/src/components/pdf/SharedUI.tsx
Normal file
@@ -0,0 +1,401 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import {
|
||||
View as PDFView,
|
||||
Text as PDFText,
|
||||
StyleSheet,
|
||||
Image as PDFImage,
|
||||
} from "@react-pdf/renderer";
|
||||
|
||||
// INDUSTRIAL DESIGN SYSTEM TOKENS
|
||||
export const COLORS = {
|
||||
CHARCOAL: "#0f172a", // Slate 900
|
||||
TEXT_MAIN: "#334155", // Slate 700
|
||||
TEXT_DIM: "#64748b", // Slate 500
|
||||
TEXT_LIGHT: "#94a3b8", // Slate 400
|
||||
DIVIDER: "#cbd5e1", // Slate 300
|
||||
GRID: "#f1f5f9", // Slate 100
|
||||
BLUEPRINT: "#e2e8f0", // Slate 200
|
||||
WHITE: "#ffffff",
|
||||
};
|
||||
|
||||
export const FONT_SIZES = {
|
||||
HERO: 24, // Main Page Titles
|
||||
HEADING: 14, // Section Headers
|
||||
BODY: 11, // Standard Content
|
||||
LABEL: 10, // Bold Labels / Keys
|
||||
SMALL: 9, // Descriptions / Footnotes
|
||||
TINY: 8, // Metadata / Unit prices
|
||||
};
|
||||
|
||||
export const pdfStyles = StyleSheet.create({
|
||||
page: {
|
||||
paddingTop: 45, // DIN 5008
|
||||
paddingLeft: 70, // ~25mm
|
||||
paddingRight: 57, // ~20mm
|
||||
paddingBottom: 80, // Safe buffer for absolute footer
|
||||
backgroundColor: COLORS.WHITE,
|
||||
fontFamily: "Helvetica",
|
||||
fontSize: FONT_SIZES.BODY,
|
||||
color: COLORS.CHARCOAL,
|
||||
},
|
||||
titlePage: {
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
backgroundColor: COLORS.WHITE,
|
||||
fontFamily: "Helvetica",
|
||||
color: COLORS.CHARCOAL,
|
||||
padding: 0,
|
||||
},
|
||||
header: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "flex-start",
|
||||
marginBottom: 20,
|
||||
minHeight: 120,
|
||||
},
|
||||
addressBlock: {
|
||||
width: "55%",
|
||||
marginTop: 45,
|
||||
},
|
||||
senderLine: {
|
||||
fontSize: FONT_SIZES.TINY,
|
||||
textDecoration: "underline",
|
||||
color: COLORS.TEXT_DIM,
|
||||
marginBottom: 8,
|
||||
},
|
||||
recipientAddress: {
|
||||
fontSize: FONT_SIZES.BODY,
|
||||
lineHeight: 1.4,
|
||||
},
|
||||
brandLogoContainer: {
|
||||
width: "40%",
|
||||
alignItems: "flex-end",
|
||||
},
|
||||
brandIconContainer: {
|
||||
width: 40,
|
||||
height: 40,
|
||||
backgroundColor: "#0f172a",
|
||||
borderRadius: 8,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
marginBottom: 12,
|
||||
},
|
||||
brandIconText: {
|
||||
color: COLORS.WHITE,
|
||||
fontSize: 20,
|
||||
fontWeight: "bold",
|
||||
},
|
||||
titleInfo: {
|
||||
marginBottom: 24,
|
||||
},
|
||||
mainTitle: {
|
||||
fontSize: FONT_SIZES.HEADING,
|
||||
fontWeight: "bold",
|
||||
marginBottom: 4,
|
||||
color: COLORS.CHARCOAL,
|
||||
letterSpacing: 0.5,
|
||||
},
|
||||
subTitle: {
|
||||
fontSize: FONT_SIZES.BODY,
|
||||
color: COLORS.TEXT_DIM,
|
||||
marginTop: 2,
|
||||
lineHeight: 1.4,
|
||||
},
|
||||
section: {
|
||||
marginBottom: 32,
|
||||
},
|
||||
sectionTitle: {
|
||||
fontSize: FONT_SIZES.LABEL,
|
||||
fontWeight: "bold",
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: 1,
|
||||
color: COLORS.TEXT_LIGHT,
|
||||
marginBottom: 8,
|
||||
},
|
||||
footer: {
|
||||
position: "absolute",
|
||||
bottom: 32,
|
||||
left: 70,
|
||||
right: 57,
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: COLORS.GRID,
|
||||
paddingTop: 16,
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "flex-start",
|
||||
},
|
||||
footerColumn: {
|
||||
flex: 1,
|
||||
alignItems: "flex-start",
|
||||
},
|
||||
footerLogo: {
|
||||
height: 20,
|
||||
width: "auto",
|
||||
objectFit: "contain",
|
||||
marginBottom: 8,
|
||||
},
|
||||
footerText: {
|
||||
fontSize: FONT_SIZES.TINY,
|
||||
color: COLORS.TEXT_LIGHT,
|
||||
lineHeight: 1.4,
|
||||
},
|
||||
asymmetryContainer: {
|
||||
flexDirection: "row",
|
||||
gap: 32,
|
||||
},
|
||||
asymmetryLeft: {
|
||||
width: "32%",
|
||||
},
|
||||
asymmetryRight: {
|
||||
width: "63%",
|
||||
},
|
||||
specRow: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
paddingVertical: 6,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: COLORS.GRID,
|
||||
},
|
||||
specLabel: {
|
||||
fontSize: FONT_SIZES.TINY,
|
||||
fontWeight: "bold",
|
||||
color: COLORS.TEXT_LIGHT,
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: 0.5,
|
||||
},
|
||||
specValue: {
|
||||
fontSize: FONT_SIZES.SMALL,
|
||||
color: COLORS.CHARCOAL,
|
||||
fontWeight: "bold",
|
||||
},
|
||||
blueprintBox: {
|
||||
borderWidth: 1,
|
||||
borderColor: COLORS.GRID,
|
||||
padding: 16,
|
||||
backgroundColor: "#fafafa",
|
||||
},
|
||||
footerLabel: {
|
||||
fontWeight: "bold",
|
||||
color: COLORS.TEXT_DIM,
|
||||
},
|
||||
pageNumber: {
|
||||
fontSize: FONT_SIZES.TINY,
|
||||
color: COLORS.DIVIDER,
|
||||
fontWeight: "bold",
|
||||
marginTop: 8,
|
||||
textAlign: "right",
|
||||
},
|
||||
foldingMark: {
|
||||
position: "absolute",
|
||||
left: 20,
|
||||
width: 10,
|
||||
borderTopWidth: 0.5,
|
||||
borderTopColor: COLORS.DIVIDER,
|
||||
},
|
||||
divider: {
|
||||
width: "100%",
|
||||
height: 1,
|
||||
backgroundColor: COLORS.DIVIDER,
|
||||
marginVertical: 12,
|
||||
},
|
||||
industrialListItem: {
|
||||
flexDirection: "row",
|
||||
alignItems: "flex-start",
|
||||
marginBottom: 6,
|
||||
},
|
||||
industrialBulletBox: {
|
||||
width: 6,
|
||||
height: 6,
|
||||
backgroundColor: COLORS.DIVIDER,
|
||||
marginRight: 8,
|
||||
marginTop: 5,
|
||||
},
|
||||
industrialTitle: {
|
||||
fontSize: FONT_SIZES.HERO,
|
||||
fontWeight: "bold",
|
||||
color: COLORS.CHARCOAL,
|
||||
marginBottom: 6,
|
||||
letterSpacing: 0,
|
||||
},
|
||||
});
|
||||
|
||||
export const IndustrialListItem = ({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) => (
|
||||
<PDFView style={pdfStyles.industrialListItem}>
|
||||
<PDFView style={pdfStyles.industrialBulletBox} />
|
||||
{children}
|
||||
</PDFView>
|
||||
);
|
||||
|
||||
export const Divider = ({ style = {} }: { style?: any }) => (
|
||||
<PDFView style={[pdfStyles.divider, style]} />
|
||||
);
|
||||
|
||||
export const Footer = ({
|
||||
logo,
|
||||
companyData,
|
||||
showDetails = true,
|
||||
showPageNumber = true,
|
||||
}: {
|
||||
logo?: string;
|
||||
companyData: any;
|
||||
showDetails?: boolean;
|
||||
showPageNumber?: boolean;
|
||||
}) => (
|
||||
<PDFView style={pdfStyles.footer}>
|
||||
<PDFView style={pdfStyles.footerColumn}>
|
||||
{logo ? (
|
||||
<PDFImage src={logo} style={pdfStyles.footerLogo} />
|
||||
) : (
|
||||
<PDFText style={{ fontSize: 12, fontWeight: "bold", marginBottom: 8 }}>
|
||||
marc mintel
|
||||
</PDFText>
|
||||
)}
|
||||
</PDFView>
|
||||
{showDetails && (
|
||||
<>
|
||||
<PDFView style={pdfStyles.footerColumn}>
|
||||
<PDFText style={pdfStyles.footerText}>
|
||||
<PDFText style={pdfStyles.footerLabel}>{companyData.name}</PDFText>
|
||||
{"\n"}
|
||||
{companyData.address1}
|
||||
{"\n"}
|
||||
{companyData.address2}
|
||||
{"\n"}UST: {companyData.ustId}
|
||||
</PDFText>
|
||||
</PDFView>
|
||||
<PDFView style={[pdfStyles.footerColumn, { alignItems: "flex-end" }]}>
|
||||
{showPageNumber && (
|
||||
<PDFText
|
||||
style={pdfStyles.pageNumber}
|
||||
render={({ pageNumber, totalPages }) =>
|
||||
`${pageNumber} / ${totalPages}`
|
||||
}
|
||||
fixed
|
||||
/>
|
||||
)}
|
||||
</PDFView>
|
||||
</>
|
||||
)}
|
||||
</PDFView>
|
||||
);
|
||||
|
||||
export const Header = ({
|
||||
sender,
|
||||
recipient,
|
||||
icon,
|
||||
showAddress = true,
|
||||
}: {
|
||||
sender?: string;
|
||||
recipient?: {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
email?: string;
|
||||
address?: string;
|
||||
phone?: string;
|
||||
taxId?: string;
|
||||
};
|
||||
icon?: string;
|
||||
showAddress?: boolean;
|
||||
}) => (
|
||||
<PDFView
|
||||
style={[
|
||||
pdfStyles.header,
|
||||
showAddress ? {} : { minHeight: 40, marginBottom: 0 },
|
||||
]}
|
||||
>
|
||||
<PDFView style={pdfStyles.addressBlock}>
|
||||
{showAddress && sender && (
|
||||
<>
|
||||
<PDFText style={pdfStyles.senderLine}>{sender}</PDFText>
|
||||
{recipient && (
|
||||
<PDFView style={pdfStyles.recipientAddress}>
|
||||
<PDFText style={{ fontWeight: "bold" }}>
|
||||
{recipient.title}
|
||||
</PDFText>
|
||||
{recipient.subtitle && <PDFText>{recipient.subtitle}</PDFText>}
|
||||
{recipient.address && <PDFText>{recipient.address}</PDFText>}
|
||||
{recipient.phone && <PDFText>{recipient.phone}</PDFText>}
|
||||
{recipient.email && <PDFText>{recipient.email}</PDFText>}
|
||||
{recipient.taxId && <PDFText>USt-ID: {recipient.taxId}</PDFText>}
|
||||
</PDFView>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</PDFView>
|
||||
<PDFView style={pdfStyles.brandLogoContainer}>
|
||||
<PDFView style={pdfStyles.brandIconContainer}>
|
||||
{icon ? (
|
||||
<PDFImage src={icon} style={{ width: 24, height: 24 }} />
|
||||
) : (
|
||||
<PDFText style={pdfStyles.brandIconText}>M</PDFText>
|
||||
)}
|
||||
</PDFView>
|
||||
</PDFView>
|
||||
</PDFView>
|
||||
);
|
||||
|
||||
export const DocumentTitle = ({
|
||||
title,
|
||||
subLines,
|
||||
isHero = false,
|
||||
}: {
|
||||
title: string;
|
||||
subLines?: string[];
|
||||
isHero?: boolean;
|
||||
}) => (
|
||||
<PDFView style={pdfStyles.titleInfo}>
|
||||
<PDFText
|
||||
style={[
|
||||
pdfStyles.mainTitle,
|
||||
{ fontSize: isHero ? FONT_SIZES.HERO : FONT_SIZES.HEADING },
|
||||
]}
|
||||
>
|
||||
{title}
|
||||
</PDFText>
|
||||
{subLines?.map((line, i) => (
|
||||
<PDFText
|
||||
key={i}
|
||||
style={[
|
||||
pdfStyles.subTitle,
|
||||
i === 1 ? { fontWeight: "bold", color: COLORS.CHARCOAL } : {},
|
||||
]}
|
||||
>
|
||||
{line}
|
||||
</PDFText>
|
||||
))}
|
||||
</PDFView>
|
||||
);
|
||||
|
||||
export const TechnicalSpec = ({
|
||||
label,
|
||||
value,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
}) => (
|
||||
<PDFView style={pdfStyles.specRow}>
|
||||
<PDFText style={pdfStyles.specLabel}>{label}</PDFText>
|
||||
<PDFText style={pdfStyles.specValue}>{value}</PDFText>
|
||||
</PDFView>
|
||||
);
|
||||
|
||||
export const AsymmetryView = ({
|
||||
left,
|
||||
right,
|
||||
style = {},
|
||||
}: {
|
||||
left: React.ReactNode;
|
||||
right: React.ReactNode;
|
||||
style?: any;
|
||||
}) => (
|
||||
<PDFView style={[pdfStyles.asymmetryContainer, style]}>
|
||||
<PDFView style={pdfStyles.asymmetryLeft}>{left}</PDFView>
|
||||
<PDFView style={pdfStyles.asymmetryRight}>{right}</PDFView>
|
||||
</PDFView>
|
||||
);
|
||||
64
packages/acquisition/src/components/pdf/SimpleLayout.tsx
Normal file
64
packages/acquisition/src/components/pdf/SimpleLayout.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { Page as PDFPage, View as PDFView, Text as PDFText, StyleSheet } from '@react-pdf/renderer';
|
||||
import { Header, Footer, pdfStyles } from './SharedUI.js';
|
||||
|
||||
const simpleStyles = StyleSheet.create({
|
||||
industrialPage: {
|
||||
padding: 30,
|
||||
paddingTop: 20,
|
||||
backgroundColor: '#ffffff',
|
||||
},
|
||||
industrialNumber: {
|
||||
fontSize: 60,
|
||||
fontWeight: 'bold',
|
||||
color: '#f1f5f9',
|
||||
position: 'absolute',
|
||||
top: -10,
|
||||
right: 0,
|
||||
zIndex: -1,
|
||||
},
|
||||
industrialSection: {
|
||||
marginTop: 16,
|
||||
paddingTop: 12,
|
||||
flexDirection: 'row',
|
||||
position: 'relative',
|
||||
},
|
||||
});
|
||||
|
||||
interface SimpleLayoutProps {
|
||||
children: React.ReactNode;
|
||||
pageNumber?: string;
|
||||
icon?: string;
|
||||
footerLogo?: string;
|
||||
companyData: any;
|
||||
showPageNumber?: boolean;
|
||||
}
|
||||
|
||||
export const SimpleLayout = ({
|
||||
children,
|
||||
pageNumber,
|
||||
icon,
|
||||
footerLogo,
|
||||
companyData,
|
||||
showPageNumber = true
|
||||
}: SimpleLayoutProps) => {
|
||||
return (
|
||||
<PDFPage size="A4" style={[pdfStyles.page, simpleStyles.industrialPage]}>
|
||||
<Header icon={icon} showAddress={false} />
|
||||
{pageNumber && <PDFText style={simpleStyles.industrialNumber}>{pageNumber}</PDFText>}
|
||||
<PDFView style={simpleStyles.industrialSection}>
|
||||
<PDFView style={{ width: '100%' }}>
|
||||
{children}
|
||||
</PDFView>
|
||||
</PDFView>
|
||||
<Footer
|
||||
logo={footerLogo}
|
||||
companyData={companyData}
|
||||
showDetails={false}
|
||||
showPageNumber={showPageNumber}
|
||||
/>
|
||||
</PDFPage>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,69 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import {
|
||||
View as PDFView,
|
||||
Text as PDFText,
|
||||
StyleSheet,
|
||||
} from "@react-pdf/renderer";
|
||||
import { DocumentTitle, COLORS, FONT_SIZES } from "../SharedUI.js";
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
section: { marginBottom: 24 },
|
||||
sectionTitle: {
|
||||
fontSize: FONT_SIZES.LABEL,
|
||||
fontWeight: "bold",
|
||||
marginBottom: 8,
|
||||
color: COLORS.CHARCOAL,
|
||||
},
|
||||
visionText: {
|
||||
fontSize: FONT_SIZES.BODY,
|
||||
color: COLORS.TEXT_MAIN,
|
||||
lineHeight: 1.4,
|
||||
textAlign: "justify",
|
||||
},
|
||||
});
|
||||
|
||||
export const BriefingModule = ({ state }: any) => (
|
||||
<>
|
||||
<DocumentTitle title="Projektdetails" isHero={true} />
|
||||
{state.briefingSummary && (
|
||||
<PDFView style={styles.section}>
|
||||
<PDFText style={styles.sectionTitle}>Briefing Analyse</PDFText>
|
||||
<PDFText
|
||||
style={{
|
||||
fontSize: FONT_SIZES.BODY,
|
||||
color: COLORS.TEXT_MAIN,
|
||||
lineHeight: 1.6,
|
||||
textAlign: "justify",
|
||||
}}
|
||||
>
|
||||
{state.briefingSummary}
|
||||
</PDFText>
|
||||
</PDFView>
|
||||
)}
|
||||
{state.designVision && (
|
||||
<PDFView
|
||||
style={[
|
||||
styles.section,
|
||||
{
|
||||
padding: 12,
|
||||
borderLeftWidth: 2,
|
||||
borderLeftColor: COLORS.DIVIDER,
|
||||
backgroundColor: COLORS.GRID,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<PDFText
|
||||
style={[
|
||||
styles.sectionTitle,
|
||||
{ color: COLORS.CHARCOAL, marginBottom: 4 },
|
||||
]}
|
||||
>
|
||||
Strategische Vision
|
||||
</PDFText>
|
||||
<PDFText style={styles.visionText}>{state.designVision}</PDFText>
|
||||
</PDFView>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
@@ -24,10 +24,9 @@ const styles = StyleSheet.create({
|
||||
borderBottomColor: COLORS.GRID,
|
||||
alignItems: "flex-start",
|
||||
},
|
||||
colPos: { width: "6%" },
|
||||
colDesc: { width: "46%", paddingRight: 10 },
|
||||
colQty: { width: "8%", textAlign: "center" },
|
||||
colUnitPrice: { width: "20%", textAlign: "right", paddingRight: 10 },
|
||||
colPos: { width: "8%" },
|
||||
colDesc: { width: "62%" },
|
||||
colQty: { width: "10%", textAlign: "center" },
|
||||
colPrice: { width: "20%", textAlign: "right" },
|
||||
headerText: {
|
||||
fontSize: FONT_SIZES.TINY,
|
||||
@@ -112,8 +111,7 @@ export const EstimationModule = ({
|
||||
Beschreibung
|
||||
</PDFText>
|
||||
<PDFText style={[styles.headerText, styles.colQty]}>Menge</PDFText>
|
||||
<PDFText style={[styles.headerText, styles.colUnitPrice]}>E-Preis</PDFText>
|
||||
<PDFText style={[styles.headerText, styles.colPrice]}>Gesamt</PDFText>
|
||||
<PDFText style={[styles.headerText, styles.colPrice]}>Betrag</PDFText>
|
||||
</PDFView>
|
||||
{positions.map((item: any, i: number) => (
|
||||
<PDFView key={i} style={styles.tableRow} wrap={false}>
|
||||
@@ -127,11 +125,6 @@ export const EstimationModule = ({
|
||||
</PDFText>
|
||||
</PDFView>
|
||||
<PDFText style={[styles.posText, styles.colQty]}>{item.qty}</PDFText>
|
||||
<PDFText style={[styles.priceText, styles.colUnitPrice, { fontSize: FONT_SIZES.SMALL, color: COLORS.TEXT_MAIN, fontWeight: "normal" }]}>
|
||||
{item.price > 0 && item.qty > 0
|
||||
? `${(item.price / item.qty).toLocaleString("de-DE")} €`
|
||||
: "n. A."}
|
||||
</PDFText>
|
||||
<PDFText style={[styles.priceText, styles.colPrice]}>
|
||||
{item.price > 0
|
||||
? `${item.price.toLocaleString("de-DE")} €`
|
||||
@@ -0,0 +1,56 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { View as PDFView, Text as PDFText, StyleSheet } from "@react-pdf/renderer";
|
||||
import { DocumentTitle, COLORS, FONT_SIZES, IndustrialListItem } from "../SharedUI.js";
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
section: { marginBottom: 24 },
|
||||
categoryBox: {
|
||||
marginBottom: 20,
|
||||
padding: 12,
|
||||
backgroundColor: COLORS.GRID,
|
||||
borderLeftWidth: 2,
|
||||
borderLeftColor: COLORS.DIVIDER,
|
||||
},
|
||||
categoryTitle: {
|
||||
fontSize: FONT_SIZES.TINY,
|
||||
fontWeight: "bold",
|
||||
color: COLORS.TEXT_LIGHT,
|
||||
textTransform: "uppercase",
|
||||
marginBottom: 10,
|
||||
letterSpacing: 1,
|
||||
},
|
||||
pageTitle: {
|
||||
fontSize: FONT_SIZES.LABEL,
|
||||
fontWeight: "bold",
|
||||
color: COLORS.CHARCOAL,
|
||||
marginBottom: 2,
|
||||
},
|
||||
pageDesc: {
|
||||
fontSize: FONT_SIZES.TINY,
|
||||
color: COLORS.TEXT_DIM,
|
||||
lineHeight: 1.4,
|
||||
},
|
||||
});
|
||||
|
||||
export const SitemapModule = ({ state }: any) => (
|
||||
<>
|
||||
<DocumentTitle title="Informations-Architektur" isHero={true} />
|
||||
<PDFView style={styles.section}>
|
||||
{state.sitemap?.map((cat: any, i: number) => (
|
||||
<PDFView key={i} style={styles.categoryBox}>
|
||||
<PDFText style={styles.categoryTitle}>{cat.category}</PDFText>
|
||||
{cat.pages?.map((p: any, j: number) => (
|
||||
<IndustrialListItem key={j}>
|
||||
<PDFView style={{ marginBottom: 8 }}>
|
||||
<PDFText style={styles.pageTitle}>{p.title}</PDFText>
|
||||
<PDFText style={styles.pageDesc}>{p.desc}</PDFText>
|
||||
</PDFView>
|
||||
</IndustrialListItem>
|
||||
))}
|
||||
</PDFView>
|
||||
))}
|
||||
</PDFView>
|
||||
</>
|
||||
);
|
||||
@@ -81,7 +81,7 @@ export const TransparenzModule = ({ pricing }: any) => {
|
||||
},
|
||||
{
|
||||
l: "Sprachversionen",
|
||||
d: "Skalierung der Architektur für weitere Sprachen (+20% Aufschlag auf die Zwischensumme aller vorherigen Positionen).",
|
||||
d: "Skalierung der System-Architektur auf Zweit-Sprachen.",
|
||||
p: "+20%",
|
||||
},
|
||||
{
|
||||
6
packages/acquisition/src/index.ts
Normal file
6
packages/acquisition/src/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export * from "./logic/pricing/types.js";
|
||||
export * from "./logic/pricing/constants.js";
|
||||
export * from "./logic/pricing/calculator.js";
|
||||
export * from "./services/AcquisitionService.js";
|
||||
export * from "./services/PdfEngine.js";
|
||||
export * from "./components/EstimationPDF.js";
|
||||
@@ -1,7 +1,7 @@
|
||||
import { FormState } from "./types.js";
|
||||
|
||||
export const PRICING = {
|
||||
BASE_WEBSITE: 4000, // Foundation server infrastructure setup
|
||||
BASE_WEBSITE: 5440, // Updated to match AI prompt requirement in Pass 1
|
||||
PAGE: 600,
|
||||
FEATURE: 1500,
|
||||
FUNCTION: 800,
|
||||
@@ -1,4 +1,4 @@
|
||||
import { CheerioCrawler } from "@crawlee/cheerio";
|
||||
import { CheerioCrawler } from "crawlee";
|
||||
import axios from "axios";
|
||||
import { FileCacheAdapter } from "../utils/cache/FileCacheAdapter.js";
|
||||
import { initialState } from "../logic/pricing/constants.js";
|
||||
24
packages/acquisition/src/services/PdfEngine.ts
Normal file
24
packages/acquisition/src/services/PdfEngine.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { renderToFile } from "@react-pdf/renderer";
|
||||
import { createElement } from "react";
|
||||
import { EstimationPDF } from "../components/EstimationPDF.js";
|
||||
import { PRICING } from "../logic/pricing/constants.js";
|
||||
import { calculateTotals } from "../logic/pricing/calculator.js";
|
||||
|
||||
export class PdfEngine {
|
||||
constructor() { }
|
||||
|
||||
async generateEstimatePdf(state: any, outputPath: string): Promise<string> {
|
||||
const totals = calculateTotals(state, PRICING);
|
||||
|
||||
await renderToFile(
|
||||
createElement(EstimationPDF as any, {
|
||||
state,
|
||||
totalPrice: totals.totalPrice,
|
||||
pricing: PRICING,
|
||||
} as any) as any,
|
||||
outputPath
|
||||
);
|
||||
|
||||
return outputPath;
|
||||
}
|
||||
}
|
||||
59
packages/acquisition/test/pricing.test.ts
Normal file
59
packages/acquisition/test/pricing.test.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { calculateTotals, calculatePositions } from "../src/logic/pricing/calculator.js";
|
||||
import { PRICING, initialState } from "../src/logic/pricing/constants.js";
|
||||
import { FormState } from "../src/logic/pricing/types.js";
|
||||
|
||||
describe("Pricing Logic", () => {
|
||||
it("should calculate base website price correctly", () => {
|
||||
const state: FormState = {
|
||||
...initialState,
|
||||
projectType: "website",
|
||||
selectedPages: [] // Clear for base test
|
||||
};
|
||||
const totals = calculateTotals(state, PRICING);
|
||||
expect(totals.totalPrice).toBe(PRICING.BASE_WEBSITE);
|
||||
});
|
||||
|
||||
it("should add page costs correctly", () => {
|
||||
const state: FormState = {
|
||||
...initialState,
|
||||
projectType: "website",
|
||||
selectedPages: [], // Clear for clean test
|
||||
otherPagesCount: 5
|
||||
};
|
||||
const totals = calculateTotals(state, PRICING);
|
||||
expect(totals.totalPrice).toBe(PRICING.BASE_WEBSITE + (5 * PRICING.PAGE));
|
||||
});
|
||||
|
||||
it("should apply multi-language multiplier", () => {
|
||||
const state: FormState = {
|
||||
...initialState,
|
||||
projectType: "website",
|
||||
selectedPages: [], // Clear for clean test
|
||||
languagesList: ["Deutsch", "Englisch"]
|
||||
};
|
||||
const totals = calculateTotals(state, PRICING);
|
||||
expect(totals.totalPrice).toBe(Math.round(PRICING.BASE_WEBSITE * 1.2));
|
||||
});
|
||||
|
||||
it("should generate correct positions for a website", () => {
|
||||
const state: FormState = {
|
||||
...initialState,
|
||||
projectType: "website",
|
||||
selectedPages: ["Home"],
|
||||
otherPagesCount: 2
|
||||
};
|
||||
const positions = calculatePositions(state, PRICING);
|
||||
|
||||
// Find "Fundament" position (Das technische Fundament)
|
||||
const basePos = positions.find(p => p.title.includes("Fundament"));
|
||||
expect(basePos).toBeDefined();
|
||||
expect(basePos?.price).toBe(PRICING.BASE_WEBSITE);
|
||||
|
||||
// Find "Individuelle Seiten" position
|
||||
const pagesPos = positions.find(p => p.title.includes("Seiten"));
|
||||
expect(pagesPos).toBeDefined();
|
||||
expect(pagesPos?.qty).toBe(3); // 1 selected + 2 other
|
||||
expect(pagesPos?.price).toBe(3 * PRICING.PAGE);
|
||||
});
|
||||
});
|
||||
15
packages/acquisition/tsconfig.json
Normal file
15
packages/acquisition/tsconfig.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"extends": "@mintel/tsconfig/base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"jsx": "react-jsx"
|
||||
},
|
||||
"include": [
|
||||
"src/**/*"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules",
|
||||
"dist"
|
||||
]
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
{
|
||||
"name": "@mintel/cli",
|
||||
"version": "1.9.17",
|
||||
"version": "1.7.0",
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
"registry": "https://git.infra.mintel.me/api/packages/mmintel/npm"
|
||||
"registry": "https://npm.infra.mintel.me"
|
||||
},
|
||||
"type": "module",
|
||||
"bin": {
|
||||
@@ -16,19 +16,16 @@
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"chalk": "^5.3.0",
|
||||
"commander": "^11.0.0",
|
||||
"fs-extra": "^11.1.0"
|
||||
"fs-extra": "^11.1.0",
|
||||
"chalk": "^5.3.0",
|
||||
"prompts": "^2.4.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@mintel/tsconfig": "workspace:*",
|
||||
"tsup": "^8.0.0",
|
||||
"typescript": "^5.0.0",
|
||||
"@types/fs-extra": "^11.0.0",
|
||||
"@types/prompts": "^2.4.4",
|
||||
"tsup": "^8.0.0",
|
||||
"typescript": "^5.0.0"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://git.infra.mintel.me/mmintel/at-mintel.git"
|
||||
"@mintel/tsconfig": "workspace:*"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,15 +36,153 @@ program
|
||||
console.log(
|
||||
chalk.yellow(`
|
||||
📱 App: http://localhost:3000
|
||||
🗄️ CMS: http://localhost:8055/admin
|
||||
🚦 Traefik: http://localhost:8080
|
||||
`),
|
||||
);
|
||||
execSync(
|
||||
"docker compose down --remove-orphans && docker compose up -d app",
|
||||
"docker-compose down --remove-orphans && docker-compose up app directus directus-db",
|
||||
{ stdio: "inherit" },
|
||||
);
|
||||
});
|
||||
|
||||
const directus = program
|
||||
.command("directus")
|
||||
.description("Directus management commands");
|
||||
|
||||
directus
|
||||
.command("bootstrap")
|
||||
.description("Setup Directus branding and settings")
|
||||
.action(async () => {
|
||||
const { execSync } = await import("child_process");
|
||||
console.log(chalk.blue("🎨 Bootstrapping Directus..."));
|
||||
execSync("npx tsx --env-file=.env scripts/setup-directus.ts", {
|
||||
stdio: "inherit",
|
||||
});
|
||||
});
|
||||
|
||||
directus
|
||||
.command("bootstrap-feedback")
|
||||
.description("Setup Directus collections and flows for Feedback")
|
||||
.action(async () => {
|
||||
const { execSync } = await import("child_process");
|
||||
console.log(chalk.blue("📧 Bootstrapping Visual Feedback System..."));
|
||||
// Use the logic from setup-feedback-hardened.ts
|
||||
const bootstrapScript = `
|
||||
import { createDirectus, rest, authentication, createCollection, createDashboard, createPanel, createItems, createPermission, readPolicies, readRoles, readUsers } from '@directus/sdk';
|
||||
|
||||
async function setup() {
|
||||
const url = process.env.DIRECTUS_URL || 'http://localhost:8055';
|
||||
const email = process.env.DIRECTUS_ADMIN_EMAIL;
|
||||
const password = process.env.DIRECTUS_ADMIN_PASSWORD;
|
||||
|
||||
if (!email || !password) {
|
||||
console.error('❌ DIRECTUS_ADMIN_EMAIL or DIRECTUS_ADMIN_PASSWORD not set');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const client = createDirectus(url).with(authentication('json')).with(rest());
|
||||
|
||||
try {
|
||||
console.log('🔑 Authenticating...');
|
||||
await client.login(email, password);
|
||||
|
||||
const roles = await client.request(readRoles());
|
||||
const adminRole = roles.find(r => r.name === 'Administrator');
|
||||
const policies = await client.request(readPolicies());
|
||||
const adminPolicy = policies.find(p => p.name === 'Administrator');
|
||||
|
||||
console.log('🏗️ Creating Collection "visual_feedback"...');
|
||||
try {
|
||||
await client.request(createCollection({
|
||||
collection: 'visual_feedback',
|
||||
meta: { icon: 'feedback', display_template: '{{user_name}}: {{text}}' },
|
||||
fields: [
|
||||
{ field: 'id', type: 'uuid', schema: { is_primary_key: true } },
|
||||
{ field: 'status', type: 'string', schema: { default_value: 'open' }, meta: { interface: 'select-dropdown' } },
|
||||
{ field: 'url', type: 'string' },
|
||||
{ field: 'selector', type: 'string' },
|
||||
{ field: 'x', type: 'float' },
|
||||
{ field: 'y', type: 'float' },
|
||||
{ field: 'type', type: 'string' },
|
||||
{ field: 'text', type: 'text' },
|
||||
{ field: 'user_name', type: 'string' },
|
||||
{ field: 'user_identity', type: 'string' },
|
||||
{ field: 'screenshot', type: 'uuid', meta: { interface: 'file' } },
|
||||
{ field: 'date_created', type: 'timestamp', schema: { default_value: 'NOW()' } }
|
||||
]
|
||||
} as any));
|
||||
} catch (_e) { console.log(' (Collection might already exist)'); }
|
||||
|
||||
try {
|
||||
await client.request(createCollection({
|
||||
collection: 'visual_feedback_comments',
|
||||
meta: { icon: 'comment' },
|
||||
fields: [
|
||||
{ field: 'id', type: 'integer', schema: { is_primary_key: true, has_auto_increment: true } },
|
||||
{ field: 'feedback_id', type: 'uuid', meta: { interface: 'select-dropdown' } },
|
||||
{ field: 'user_name', type: 'string' },
|
||||
{ field: 'text', type: 'text' },
|
||||
{ field: 'date_created', type: 'timestamp', schema: { default_value: 'NOW()' } }
|
||||
]
|
||||
} as any));
|
||||
} catch (e) { }
|
||||
|
||||
if (adminPolicy) {
|
||||
console.log('🔐 Granting ALL permissions to Administrator Policy...');
|
||||
for (const coll of ['visual_feedback', 'visual_feedback_comments']) {
|
||||
for (const action of ['create', 'read', 'update', 'delete']) {
|
||||
try {
|
||||
await client.request(createPermission({
|
||||
collection: coll,
|
||||
action,
|
||||
fields: ['*'],
|
||||
policy: adminPolicy.id
|
||||
} as any));
|
||||
} catch (_e) { }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log('📊 Creating Dashboard...');
|
||||
try {
|
||||
const dash = await client.request(createDashboard({ name: 'Visual Feedback', icon: 'feedback', color: '#6366f1' }));
|
||||
await client.request(createPanel({
|
||||
dashboard: dash.id,
|
||||
name: 'Total Feedbacks',
|
||||
type: 'metric',
|
||||
width: 12, height: 6, position_x: 1, position_y: 1,
|
||||
options: { collection: 'visual_feedback', function: 'count', field: 'id' }
|
||||
} as any));
|
||||
} catch (e) { }
|
||||
|
||||
console.log('✨ FEEDBACK BOOTSTRAP DONE.');
|
||||
} catch (e) { console.error('❌ FAILURE:', e); }
|
||||
}
|
||||
setup();
|
||||
`;
|
||||
const tempFile = path.join(process.cwd(), "temp-bootstrap-feedback.ts");
|
||||
await fs.writeFile(tempFile, bootstrapScript);
|
||||
try {
|
||||
execSync("npx tsx --env-file=.env " + tempFile, { stdio: "inherit" });
|
||||
} finally {
|
||||
await fs.remove(tempFile);
|
||||
}
|
||||
});
|
||||
|
||||
directus
|
||||
.command("sync <action> <env>")
|
||||
.description("Sync Directus data (push/pull) for a specific environment")
|
||||
.action(async (action, env) => {
|
||||
const { execSync } = await import("child_process");
|
||||
console.log(
|
||||
chalk.blue(`📥 Executing Directus sync: ${action} -> ${env}...`),
|
||||
);
|
||||
execSync(`./scripts/sync-directus.sh ${action} ${env}`, {
|
||||
stdio: "inherit",
|
||||
});
|
||||
});
|
||||
|
||||
program
|
||||
.command("pagespeed")
|
||||
.description("Run PageSpeed (Lighthouse) tests")
|
||||
@@ -83,6 +221,13 @@ program
|
||||
lint: "next lint",
|
||||
typecheck: "tsc --noEmit",
|
||||
test: "vitest run --passWithNoTests",
|
||||
"directus:bootstrap": "mintel directus bootstrap",
|
||||
"directus:push:testing": "mintel directus sync push testing",
|
||||
"directus:pull:testing": "mintel directus sync pull testing",
|
||||
"directus:push:staging": "mintel directus sync push staging",
|
||||
"directus:pull:staging": "mintel directus sync pull staging",
|
||||
"directus:push:prod": "mintel directus sync push production",
|
||||
"directus:pull:prod": "mintel directus sync pull production",
|
||||
"pagespeed:test": "mintel pagespeed",
|
||||
},
|
||||
dependencies: {
|
||||
@@ -91,6 +236,7 @@ program
|
||||
"react-dom": "^19.0.0",
|
||||
"@mintel/next-utils": "workspace:*",
|
||||
"@mintel/next-observability": "workspace:*",
|
||||
"@directus/sdk": "^21.0.0",
|
||||
},
|
||||
devDependencies: {
|
||||
"@types/node": "^20.0.0",
|
||||
@@ -327,6 +473,15 @@ export default function Home() {
|
||||
}
|
||||
}
|
||||
|
||||
// Create Directus structure
|
||||
await fs.ensureDir(path.join(fullPath, "directus/uploads"));
|
||||
await fs.ensureDir(path.join(fullPath, "directus/extensions"));
|
||||
await fs.writeFile(path.join(fullPath, "directus/uploads/.gitkeep"), "");
|
||||
await fs.writeFile(
|
||||
path.join(fullPath, "directus/extensions/.gitkeep"),
|
||||
"",
|
||||
);
|
||||
|
||||
// Create .env.example
|
||||
const envExample = `# Project
|
||||
PROJECT_NAME=${projectName}
|
||||
@@ -338,10 +493,21 @@ AUTH_COOKIE_NAME=mintel_gatekeeper_session
|
||||
|
||||
# Host Config (Local)
|
||||
TRAEFIK_HOST=\`${projectName}.localhost\`
|
||||
DIRECTUS_HOST=\`cms.${projectName}.localhost\`
|
||||
|
||||
# Next.js
|
||||
NEXT_PUBLIC_BASE_URL=http://${projectName}.localhost
|
||||
|
||||
# Directus
|
||||
DIRECTUS_URL=http://cms.${projectName}.localhost
|
||||
DIRECTUS_KEY=$(openssl rand -hex 32 2>/dev/null || echo "mintel-key")
|
||||
DIRECTUS_SECRET=$(openssl rand -hex 32 2>/dev/null || echo "mintel-secret")
|
||||
DIRECTUS_ADMIN_EMAIL=admin@mintel.me
|
||||
DIRECTUS_ADMIN_PASSWORD=mintel-admin-pass
|
||||
DIRECTUS_DB_NAME=directus
|
||||
DIRECTUS_DB_USER=directus
|
||||
DIRECTUS_DB_PASSWORD=mintel-db-pass
|
||||
|
||||
# Sentry / Glitchtip
|
||||
SENTRY_DSN=
|
||||
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
import { build } from 'esbuild';
|
||||
import { resolve, dirname } from 'path';
|
||||
import { mkdirSync } from 'fs';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
||||
const entryPoints = [
|
||||
resolve(__dirname, 'src/index.ts')
|
||||
];
|
||||
|
||||
try {
|
||||
mkdirSync(resolve(__dirname, 'dist'), { recursive: true });
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
console.log(`Building entry point...`);
|
||||
|
||||
build({
|
||||
entryPoints: entryPoints,
|
||||
bundle: true,
|
||||
platform: 'node',
|
||||
target: 'node18',
|
||||
outdir: resolve(__dirname, 'dist'),
|
||||
format: 'esm',
|
||||
loader: {
|
||||
'.ts': 'ts',
|
||||
'.js': 'js',
|
||||
},
|
||||
external: ["playwright", "crawlee", "axios", "cheerio", "fs", "path", "os", "http", "https", "url", "stream", "util", "child_process"],
|
||||
}).then(() => {
|
||||
console.log("Build succeeded!");
|
||||
}).catch((e) => {
|
||||
if (e.errors) {
|
||||
console.error("Build failed with errors:");
|
||||
e.errors.forEach(err => console.error(` ${err.text} at ${err.location?.file}:${err.location?.line}`));
|
||||
} else {
|
||||
console.error("Build failed:", e);
|
||||
}
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -1,33 +0,0 @@
|
||||
{
|
||||
"name": "@mintel/cloner",
|
||||
"version": "1.9.17",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
"module": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js",
|
||||
"default": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "node build.mjs",
|
||||
"dev": "node build.mjs --watch"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.0.0",
|
||||
"esbuild": "^0.25.0",
|
||||
"typescript": "^5.6.3"
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "^1.6.0",
|
||||
"crawlee": "^3.7.0",
|
||||
"playwright": "^1.40.0"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://git.infra.mintel.me/mmintel/at-mintel.git"
|
||||
}
|
||||
}
|
||||
@@ -1,98 +0,0 @@
|
||||
import axios from "axios";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
export interface AssetMap {
|
||||
[originalUrl: string]: string;
|
||||
}
|
||||
|
||||
export class AssetManager {
|
||||
private userAgent: string;
|
||||
|
||||
constructor(
|
||||
userAgent: string = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36",
|
||||
) {
|
||||
this.userAgent = userAgent;
|
||||
}
|
||||
|
||||
public sanitizePath(rawPath: string): string {
|
||||
return rawPath
|
||||
.split("/")
|
||||
.map((p) => p.replace(/[^a-z0-9._-]/gi, "_"))
|
||||
.join("/");
|
||||
}
|
||||
|
||||
public async downloadFile(
|
||||
url: string,
|
||||
assetsDir: string,
|
||||
): Promise<string | null> {
|
||||
if (url.startsWith("//")) url = `https:${url}`;
|
||||
if (!url.startsWith("http")) return null;
|
||||
|
||||
try {
|
||||
const u = new URL(url);
|
||||
const relPath = this.sanitizePath(u.hostname + u.pathname);
|
||||
const dest = path.join(assetsDir, relPath);
|
||||
|
||||
if (fs.existsSync(dest)) return `./assets/${relPath}`;
|
||||
|
||||
const res = await axios.get(url, {
|
||||
responseType: "arraybuffer",
|
||||
headers: { "User-Agent": this.userAgent },
|
||||
timeout: 15000,
|
||||
validateStatus: () => true,
|
||||
});
|
||||
|
||||
if (res.status !== 200) return null;
|
||||
|
||||
if (!fs.existsSync(path.dirname(dest)))
|
||||
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
||||
fs.writeFileSync(dest, Buffer.from(res.data));
|
||||
return `./assets/${relPath}`;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public async processCssRecursively(
|
||||
cssContent: string,
|
||||
cssUrl: string,
|
||||
assetsDir: string,
|
||||
urlMap: AssetMap,
|
||||
depth = 0,
|
||||
): Promise<string> {
|
||||
if (depth > 5) return cssContent;
|
||||
|
||||
const urlRegex = /(?:url\(["']?|@import\s+["'])([^"')]*)["']?\)?/gi;
|
||||
let match;
|
||||
let newContent = cssContent;
|
||||
|
||||
while ((match = urlRegex.exec(cssContent)) !== null) {
|
||||
const originalUrl = match[1];
|
||||
if (originalUrl.startsWith("data:") || originalUrl.startsWith("blob:"))
|
||||
continue;
|
||||
|
||||
try {
|
||||
const absUrl = new URL(originalUrl, cssUrl).href;
|
||||
const local = await this.downloadFile(absUrl, assetsDir);
|
||||
|
||||
if (local) {
|
||||
const u = new URL(cssUrl);
|
||||
const cssPath = u.hostname + u.pathname;
|
||||
const assetPath = new URL(absUrl).hostname + new URL(absUrl).pathname;
|
||||
|
||||
const rel = path.relative(
|
||||
path.dirname(this.sanitizePath(cssPath)),
|
||||
this.sanitizePath(assetPath),
|
||||
);
|
||||
|
||||
newContent = newContent.split(originalUrl).join(rel);
|
||||
urlMap[absUrl] = local;
|
||||
}
|
||||
} catch {
|
||||
// Ignore
|
||||
}
|
||||
}
|
||||
return newContent;
|
||||
}
|
||||
}
|
||||
@@ -1,256 +0,0 @@
|
||||
import { chromium } from "playwright";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import axios from "axios";
|
||||
import { AssetManager, AssetMap } from "./AssetManager.js";
|
||||
|
||||
export interface PageClonerOptions {
|
||||
outputDir: string;
|
||||
userAgent?: string;
|
||||
}
|
||||
|
||||
export class PageCloner {
|
||||
private options: PageClonerOptions;
|
||||
private assetManager: AssetManager;
|
||||
private userAgent: string;
|
||||
|
||||
constructor(options: PageClonerOptions) {
|
||||
this.options = options;
|
||||
this.userAgent =
|
||||
options.userAgent ||
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36";
|
||||
this.assetManager = new AssetManager(this.userAgent);
|
||||
}
|
||||
|
||||
public async clone(targetUrl: string): Promise<string> {
|
||||
const urlObj = new URL(targetUrl);
|
||||
const domainSlug = urlObj.hostname.replace("www.", "");
|
||||
const domainDir = path.resolve(this.options.outputDir, domainSlug);
|
||||
const assetsDir = path.join(domainDir, "assets");
|
||||
|
||||
if (!fs.existsSync(assetsDir)) fs.mkdirSync(assetsDir, { recursive: true });
|
||||
|
||||
let pageSlug = urlObj.pathname.split("/").filter(Boolean).join("-");
|
||||
if (!pageSlug) pageSlug = "index";
|
||||
const htmlFilename = `${pageSlug}.html`;
|
||||
|
||||
console.log(`🚀 INDUSTRIAL CLONE: ${targetUrl}`);
|
||||
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const context = await browser.newContext({
|
||||
userAgent: this.userAgent,
|
||||
viewport: { width: 1920, height: 1080 },
|
||||
});
|
||||
const page = await context.newPage();
|
||||
|
||||
const urlMap: AssetMap = {};
|
||||
const foundAssets = new Set<string>();
|
||||
|
||||
page.on("response", (response) => {
|
||||
if (response.status() === 200) {
|
||||
const url = response.url();
|
||||
if (
|
||||
url.match(
|
||||
/\.(css|js|png|jpg|jpeg|gif|svg|woff2?|ttf|otf|mp4|webm|webp|ico)/i,
|
||||
)
|
||||
) {
|
||||
foundAssets.add(url);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
await page.goto(targetUrl, { waitUntil: "networkidle", timeout: 90000 });
|
||||
|
||||
// Scroll Wave
|
||||
await page.evaluate(async () => {
|
||||
await new Promise((resolve) => {
|
||||
let totalHeight = 0;
|
||||
const distance = 400;
|
||||
const timer = setInterval(() => {
|
||||
const scrollHeight = document.body.scrollHeight;
|
||||
window.scrollBy(0, distance);
|
||||
totalHeight += distance;
|
||||
if (totalHeight >= scrollHeight) {
|
||||
clearInterval(timer);
|
||||
window.scrollTo(0, 0);
|
||||
resolve(true);
|
||||
}
|
||||
}, 100);
|
||||
});
|
||||
});
|
||||
|
||||
const fullHeight = await page.evaluate(() => document.body.scrollHeight);
|
||||
await page.setViewportSize({ width: 1920, height: fullHeight + 1000 });
|
||||
await page.waitForTimeout(3000);
|
||||
|
||||
// Sanitization
|
||||
await page.evaluate(() => {
|
||||
const assetPattern =
|
||||
/\.(jpg|jpeg|png|gif|svg|webp|mp4|webm|woff2?|ttf|otf)/i;
|
||||
document.querySelectorAll("*").forEach((el) => {
|
||||
if (
|
||||
["META", "LINK", "HEAD", "SCRIPT", "STYLE", "SVG", "PATH"].includes(
|
||||
el.tagName,
|
||||
)
|
||||
)
|
||||
return;
|
||||
const htmlEl = el as HTMLElement;
|
||||
const style = window.getComputedStyle(htmlEl);
|
||||
if (style.opacity === "0" || style.visibility === "hidden") {
|
||||
htmlEl.style.setProperty("opacity", "1", "important");
|
||||
htmlEl.style.setProperty("visibility", "visible", "important");
|
||||
}
|
||||
for (const attr of Array.from(el.attributes)) {
|
||||
const name = attr.name.toLowerCase();
|
||||
const val = attr.value;
|
||||
if (
|
||||
assetPattern.test(val) ||
|
||||
name.includes("src") ||
|
||||
name.includes("image")
|
||||
) {
|
||||
if (el.tagName === "IMG") {
|
||||
const img = el as HTMLImageElement;
|
||||
if (name.includes("srcset")) img.srcset = val;
|
||||
else if (!img.src || img.src.includes("data:")) img.src = val;
|
||||
}
|
||||
if (el.tagName === "SOURCE")
|
||||
(el as HTMLSourceElement).srcset = val;
|
||||
if (el.tagName === "VIDEO" || el.tagName === "AUDIO")
|
||||
(el as HTMLMediaElement).src = val;
|
||||
if (
|
||||
val.match(/^(https?:\/\/|\/\/|\/)/) &&
|
||||
!name.includes("href")
|
||||
) {
|
||||
const bg = htmlEl.style.backgroundImage;
|
||||
if (!bg || bg === "none")
|
||||
htmlEl.style.backgroundImage = `url('${val}')`;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
if (document.body) {
|
||||
document.body.style.setProperty("opacity", "1", "important");
|
||||
document.body.style.setProperty("visibility", "visible", "important");
|
||||
}
|
||||
});
|
||||
|
||||
await page.waitForLoadState("networkidle");
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
const content = await page.content();
|
||||
const regexPatterns = [
|
||||
/(?:src|href|url|data-[a-z-]+|srcset)=["']([^"'<>\s]+?\.(?:css|js|png|jpg|jpeg|gif|svg|woff2?|ttf|otf|mp4|webm|webp|ico)(?:\?[^"']*)?)["']/gi,
|
||||
/url\(["']?([^"')]*)["']?\)/gi,
|
||||
];
|
||||
|
||||
for (const pattern of regexPatterns) {
|
||||
let match;
|
||||
while ((match = pattern.exec(content)) !== null) {
|
||||
try {
|
||||
foundAssets.add(new URL(match[1], targetUrl).href);
|
||||
} catch {
|
||||
// Ignore invalid URLs
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const url of foundAssets) {
|
||||
const local = await this.assetManager.downloadFile(url, assetsDir);
|
||||
if (local) {
|
||||
urlMap[url] = local;
|
||||
const clean = url.split("?")[0];
|
||||
urlMap[clean] = local;
|
||||
if (clean.endsWith(".css")) {
|
||||
try {
|
||||
const { data } = await axios.get(url, {
|
||||
headers: { "User-Agent": this.userAgent },
|
||||
});
|
||||
const processedCss =
|
||||
await this.assetManager.processCssRecursively(
|
||||
data,
|
||||
url,
|
||||
assetsDir,
|
||||
urlMap,
|
||||
);
|
||||
const relPath = this.assetManager.sanitizePath(
|
||||
new URL(url).hostname + new URL(url).pathname,
|
||||
);
|
||||
fs.writeFileSync(path.join(assetsDir, relPath), processedCss);
|
||||
} catch {
|
||||
// Ignore stylesheet download/process failures
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let finalContent = content;
|
||||
const sortedUrls = Object.keys(urlMap).sort(
|
||||
(a, b) => b.length - a.length,
|
||||
);
|
||||
if (sortedUrls.length > 0) {
|
||||
const escaped = sortedUrls.map((u) =>
|
||||
u.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"),
|
||||
);
|
||||
const masterRegex = new RegExp(`(${escaped.join("|")})`, "g");
|
||||
finalContent = finalContent.replace(
|
||||
masterRegex,
|
||||
(match) => urlMap[match] || match,
|
||||
);
|
||||
}
|
||||
|
||||
const commonDirs = [
|
||||
"/wp-content/",
|
||||
"/wp-includes/",
|
||||
"/assets/",
|
||||
"/static/",
|
||||
"/images/",
|
||||
];
|
||||
for (const dir of commonDirs) {
|
||||
const localDir = `./assets/${urlObj.hostname}${dir}`;
|
||||
finalContent = finalContent
|
||||
.split(`"${dir}`)
|
||||
.join(`"${localDir}`)
|
||||
.split(`'${dir}`)
|
||||
.join(`'${localDir}`)
|
||||
.split(`(${dir}`)
|
||||
.join(`(${localDir}`);
|
||||
}
|
||||
|
||||
const domainPattern = new RegExp(
|
||||
`https?://(www\\.)?${urlObj.hostname.replace(/\./g, "\\.")}[^"']*`,
|
||||
"gi",
|
||||
);
|
||||
finalContent = finalContent.replace(domainPattern, () => "./");
|
||||
|
||||
finalContent = finalContent.replace(
|
||||
/<script\b[^>]*>([\s\S]*?)<\/script>/gi,
|
||||
(match, scriptContent) => {
|
||||
const lower = scriptContent.toLowerCase();
|
||||
return lower.includes("google-analytics") ||
|
||||
lower.includes("gtag") ||
|
||||
lower.includes("fbq") ||
|
||||
lower.includes("lazy") ||
|
||||
lower.includes("tracker")
|
||||
? ""
|
||||
: match;
|
||||
},
|
||||
);
|
||||
|
||||
const headEnd = finalContent.indexOf("</head>");
|
||||
if (headEnd > -1) {
|
||||
const stabilityCss = `\n<style>* { transition: none !important; animation: none !important; scroll-behavior: auto !important; } [data-aos], .reveal, .lazypath, .lazy-load, [data-src] { opacity: 1 !important; visibility: visible !important; transform: none !important; clip-path: none !important; } img, video, iframe { max-width: 100%; display: block; } a { pointer-events: none; cursor: default; } </style>`;
|
||||
finalContent =
|
||||
finalContent.slice(0, headEnd) +
|
||||
stabilityCss +
|
||||
finalContent.slice(headEnd);
|
||||
}
|
||||
|
||||
const finalPath = path.join(domainDir, htmlFilename);
|
||||
fs.writeFileSync(finalPath, finalContent);
|
||||
return finalPath;
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,150 +0,0 @@
|
||||
import { PlaywrightCrawler, RequestQueue } from "crawlee";
|
||||
import * as path from "node:path";
|
||||
import * as fs from "node:fs";
|
||||
import { execSync } from "node:child_process";
|
||||
|
||||
export interface WebsiteClonerOptions {
|
||||
baseOutputDir: string;
|
||||
maxRequestsPerCrawl?: number;
|
||||
maxConcurrency?: number;
|
||||
}
|
||||
|
||||
export class WebsiteCloner {
|
||||
private options: WebsiteClonerOptions;
|
||||
|
||||
constructor(options: WebsiteClonerOptions) {
|
||||
this.options = {
|
||||
maxRequestsPerCrawl: 100,
|
||||
maxConcurrency: 3,
|
||||
...options,
|
||||
};
|
||||
}
|
||||
|
||||
public async clone(
|
||||
targetUrl: string,
|
||||
outputDirName?: string,
|
||||
): Promise<string> {
|
||||
const urlObj = new URL(targetUrl);
|
||||
const domain = urlObj.hostname;
|
||||
const finalOutputDirName = outputDirName || domain.replace(/\./g, "-");
|
||||
const baseOutputDir = path.resolve(
|
||||
this.options.baseOutputDir,
|
||||
finalOutputDirName,
|
||||
);
|
||||
|
||||
if (fs.existsSync(baseOutputDir)) {
|
||||
fs.rmSync(baseOutputDir, { recursive: true, force: true });
|
||||
}
|
||||
fs.mkdirSync(baseOutputDir, { recursive: true });
|
||||
|
||||
console.log(`🚀 Starting perfect recursive clone of ${targetUrl}...`);
|
||||
console.log(`📂 Output: ${baseOutputDir}`);
|
||||
|
||||
const requestQueue = await RequestQueue.open();
|
||||
await requestQueue.addRequest({ url: targetUrl });
|
||||
|
||||
const crawler = new PlaywrightCrawler({
|
||||
requestQueue,
|
||||
maxRequestsPerCrawl: this.options.maxRequestsPerCrawl,
|
||||
maxConcurrency: this.options.maxConcurrency,
|
||||
|
||||
async requestHandler({ request, enqueueLinks, log }) {
|
||||
const url = request.url;
|
||||
log.info(`Capturing ${url}...`);
|
||||
|
||||
const u = new URL(url);
|
||||
let relPath = u.pathname;
|
||||
if (relPath === "/" || relPath === "") relPath = "/index.html";
|
||||
if (!relPath.endsWith(".html") && !path.extname(relPath))
|
||||
relPath += "/index.html";
|
||||
if (relPath.startsWith("/")) relPath = relPath.substring(1);
|
||||
|
||||
const fullPath = path.join(baseOutputDir, relPath);
|
||||
fs.mkdirSync(path.dirname(fullPath), { recursive: true });
|
||||
|
||||
try {
|
||||
// Note: This assumes single-file-cli is available in the environment
|
||||
execSync(
|
||||
`npx single-file-cli "${url}" "${fullPath}" --browser-headless=true --browser-wait-until=networkidle0`,
|
||||
{
|
||||
stdio: "inherit",
|
||||
},
|
||||
);
|
||||
} catch (_e) {
|
||||
log.error(`Failed to capture ${url} with SingleFile`);
|
||||
}
|
||||
|
||||
await enqueueLinks({
|
||||
strategy: "same-domain",
|
||||
transformRequestFunction: (req) => {
|
||||
if (
|
||||
/\.(download|pdf|zip|gz|exe|png|jpg|jpeg|gif|svg|css|js)$/i.test(
|
||||
req.url,
|
||||
)
|
||||
)
|
||||
return false;
|
||||
return req;
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
await crawler.run();
|
||||
|
||||
console.log("🔗 Rewriting internal links for offline navigation...");
|
||||
const allFiles = this.getFiles(baseOutputDir).filter((f) =>
|
||||
f.endsWith(".html"),
|
||||
);
|
||||
|
||||
for (const file of allFiles) {
|
||||
let content = fs.readFileSync(file, "utf8");
|
||||
const fileRelToRoot = path.relative(baseOutputDir, file);
|
||||
|
||||
content = content.replace(/href="([^"]+)"/g, (match, href) => {
|
||||
if (
|
||||
href.startsWith(targetUrl) ||
|
||||
href.startsWith("/") ||
|
||||
(!href.includes("://") && !href.startsWith("data:"))
|
||||
) {
|
||||
try {
|
||||
const linkUrl = new URL(href, targetUrl);
|
||||
if (linkUrl.hostname === domain) {
|
||||
let linkPath = linkUrl.pathname;
|
||||
if (linkPath === "/" || linkPath === "") linkPath = "/index.html";
|
||||
if (!linkPath.endsWith(".html") && !path.extname(linkPath))
|
||||
linkPath += "/index.html";
|
||||
if (linkPath.startsWith("/")) linkPath = linkPath.substring(1);
|
||||
|
||||
const relativeLink = path.relative(
|
||||
path.dirname(fileRelToRoot),
|
||||
linkPath,
|
||||
);
|
||||
return `href="${relativeLink}"`;
|
||||
}
|
||||
} catch (_e) {
|
||||
// Ignore link rewriting failures
|
||||
}
|
||||
}
|
||||
return match;
|
||||
});
|
||||
|
||||
fs.writeFileSync(file, content);
|
||||
}
|
||||
|
||||
console.log(`\n✅ Done! Perfect clone complete in: ${baseOutputDir}`);
|
||||
return baseOutputDir;
|
||||
}
|
||||
|
||||
private getFiles(dir: string, fileList: string[] = []) {
|
||||
const files = fs.readdirSync(dir);
|
||||
for (const file of files) {
|
||||
const name = path.join(dir, file);
|
||||
if (fs.statSync(name).isDirectory()) {
|
||||
this.getFiles(name, fileList);
|
||||
} else {
|
||||
fileList.push(name);
|
||||
}
|
||||
}
|
||||
return fileList;
|
||||
}
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
export * from "./AssetManager.js";
|
||||
export * from "./PageCloner.js";
|
||||
export * from "./WebsiteCloner.js";
|
||||
@@ -1,17 +0,0 @@
|
||||
{
|
||||
"extends": "../tsconfig/base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"declaration": true,
|
||||
"emitDeclarationOnly": true,
|
||||
"module": "ESNext",
|
||||
"target": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"noEmit": false
|
||||
},
|
||||
"include": [
|
||||
"src/**/*"
|
||||
]
|
||||
}
|
||||
42
packages/cms-infra/Dockerfile
Normal file
42
packages/cms-infra/Dockerfile
Normal file
@@ -0,0 +1,42 @@
|
||||
# Build Stage
|
||||
FROM node:20-slim AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Core environment for pnpm
|
||||
RUN corepack enable && corepack prepare pnpm@latest --activate
|
||||
|
||||
# Copy root configurations
|
||||
COPY pnpm-lock.yaml pnpm-workspace.yaml package.json ./
|
||||
|
||||
# Copy all packages for extensions build
|
||||
COPY packages ./packages
|
||||
|
||||
# Install dependencies (only what's needed for extensions)
|
||||
RUN pnpm install --no-frozen-lockfile \
|
||||
--filter "@mintel/directus-extension-*" \
|
||||
--filter "acquisition" \
|
||||
--filter "acquisition-manager" \
|
||||
--filter "customer-manager" \
|
||||
--filter "feedback-commander" \
|
||||
--filter "people-manager" \
|
||||
--filter "./packages/acquisition" \
|
||||
--filter "./packages/mail"
|
||||
|
||||
|
||||
# Runtime Stage
|
||||
FROM directus/directus:11
|
||||
|
||||
WORKDIR /directus
|
||||
|
||||
# Copy built extensions
|
||||
COPY --from=builder /app/packages/cms-infra/extensions ./extensions
|
||||
|
||||
# Environment defaults (can be overridden)
|
||||
ENV KEY="infra-cms-key"
|
||||
ENV SECRET="infra-cms-secret"
|
||||
ENV DB_CLIENT="sqlite3"
|
||||
ENV DB_FILENAME="/directus/database/data.db"
|
||||
|
||||
# Expose port
|
||||
EXPOSE 8055
|
||||
0
packages/cms-infra/database/RELOAD_TEST
Normal file
0
packages/cms-infra/database/RELOAD_TEST
Normal file
BIN
packages/cms-infra/database/data.db
Normal file
BIN
packages/cms-infra/database/data.db
Normal file
Binary file not shown.
43
packages/cms-infra/docker-compose.yml
Normal file
43
packages/cms-infra/docker-compose.yml
Normal file
@@ -0,0 +1,43 @@
|
||||
services:
|
||||
infra-cms:
|
||||
build:
|
||||
context: ../../
|
||||
dockerfile: packages/cms-infra/Dockerfile
|
||||
image: mintel/cms-infra:latest
|
||||
ports:
|
||||
- "8059:8055"
|
||||
networks:
|
||||
- default
|
||||
- infra
|
||||
environment:
|
||||
KEY: "infra-cms-key"
|
||||
SECRET: "infra-cms-secret"
|
||||
ADMIN_EMAIL: "marc@mintel.me"
|
||||
ADMIN_PASSWORD: "Tim300493."
|
||||
DB_CLIENT: "sqlite3"
|
||||
DB_FILENAME: "/directus/database/data.db"
|
||||
WEBSOCKETS_ENABLED: "true"
|
||||
EMAIL_TRANSPORT: "smtp"
|
||||
EMAIL_SMTP_HOST: "smtp.eu.mailgun.org"
|
||||
EMAIL_SMTP_PORT: "587"
|
||||
EMAIL_SMTP_USER: "postmaster@mg.mintel.me"
|
||||
EMAIL_SMTP_PASSWORD: "4592fcb94599ee1a45b4ac2386fd0a64-102c75d8-ca2870e6"
|
||||
EMAIL_SMTP_SECURE: "false"
|
||||
EMAIL_FROM: "postmaster@mg.mintel.me"
|
||||
LOG_LEVEL: "trace"
|
||||
volumes:
|
||||
- ./database:/directus/database
|
||||
- ./uploads:/directus/uploads
|
||||
- ./schema:/directus/schema
|
||||
- ./extensions:/directus/extensions
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.http.routers.infra-cms.rule=Host(`cms.localhost`)"
|
||||
- "traefik.http.services.infra-cms.loadbalancer.server.port=8055"
|
||||
- "traefik.docker.network=infra"
|
||||
|
||||
networks:
|
||||
default:
|
||||
name: mintel-infra-cms-internal
|
||||
infra:
|
||||
external: true
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"name": "acquisition-manager",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"directus:extension": {
|
||||
"type": "module",
|
||||
"path": "dist/index.js",
|
||||
"source": "src/index.ts",
|
||||
"host": "^11.0.0"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "directus-extension build",
|
||||
"dev": "directus-extension build -w"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@directus/extensions-sdk": "11.0.2",
|
||||
"vue": "^3.4.34",
|
||||
"typescript": "^5.6.3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { defineModule } from "@directus/extensions-sdk";
|
||||
import ModuleComponent from "./module.vue";
|
||||
|
||||
export default defineModule({
|
||||
id: "acquisition-manager",
|
||||
name: "Acquisition",
|
||||
icon: "auto_awesome",
|
||||
routes: [
|
||||
{
|
||||
path: "",
|
||||
component: ModuleComponent,
|
||||
},
|
||||
{
|
||||
path: ":id",
|
||||
component: ModuleComponent,
|
||||
props: true
|
||||
}
|
||||
],
|
||||
});
|
||||
403
packages/cms-infra/extensions/acquisition-manager/src/module.vue
Normal file
403
packages/cms-infra/extensions/acquisition-manager/src/module.vue
Normal file
@@ -0,0 +1,403 @@
|
||||
<template>
|
||||
<private-view title="Acquisition Manager">
|
||||
<template #navigation>
|
||||
<v-list nav>
|
||||
<v-list-item @click="showAddLead = true" clickable>
|
||||
<v-list-item-icon><v-icon name="add" color="var(--theme--primary)" /></v-list-item-icon>
|
||||
<v-list-item-content>
|
||||
<v-text-overflow text="Neuen Lead anlegen" />
|
||||
</v-list-item-content>
|
||||
</v-list-item>
|
||||
|
||||
<v-divider />
|
||||
|
||||
<v-list-item
|
||||
v-for="lead in leads"
|
||||
:key="lead.id"
|
||||
:active="selectedLeadId === lead.id"
|
||||
class="lead-item"
|
||||
clickable
|
||||
@click="selectLead(lead.id)"
|
||||
>
|
||||
<v-list-item-icon>
|
||||
<v-icon :name="getStatusIcon(lead.status)" :color="getStatusColor(lead.status)" />
|
||||
</v-list-item-icon>
|
||||
<v-list-item-content>
|
||||
<v-text-overflow :text="lead.company_name" />
|
||||
</v-list-item-content>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</template>
|
||||
|
||||
<template #title-outer:after>
|
||||
<v-notice v-if="notice" :type="notice.type" @close="notice = null" dismissible>
|
||||
{{ notice.message }}
|
||||
</v-notice>
|
||||
</template>
|
||||
|
||||
<div class="content-wrapper">
|
||||
<v-notice type="success" style="margin-bottom: 16px;">
|
||||
DEBUG: Module Version 1.1.0 - Native Build - {{ new Date().toISOString() }}
|
||||
</v-notice>
|
||||
|
||||
<div v-if="!selectedLead" class="empty-state">
|
||||
<v-info title="Lead auswählen" icon="auto_awesome" center>
|
||||
Wähle einen Lead in der Navigation aus oder
|
||||
<v-button x-small @click="showAddLead = true">registriere einen neuen Lead</v-button>.
|
||||
</v-info>
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<header class="header">
|
||||
<div class="header-left">
|
||||
<h1 class="title">{{ selectedLead.company_name }}</h1>
|
||||
<p class="subtitle">
|
||||
<v-icon name="language" x-small />
|
||||
<a :href="selectedLead.website_url" target="_blank" class="url-link">
|
||||
{{ selectedLead.website_url.replace(/^https?:\/\//, '') }}
|
||||
</a>
|
||||
· Status: {{ selectedLead.status.toUpperCase() }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<v-button
|
||||
v-if="selectedLead.status === 'new'"
|
||||
secondary
|
||||
:loading="loadingAudit"
|
||||
@click="runAudit"
|
||||
>
|
||||
<v-icon name="settings_suggest" left />
|
||||
Audit starten
|
||||
</v-button>
|
||||
|
||||
<template v-if="selectedLead.status === 'audit_ready'">
|
||||
<v-button secondary :loading="loadingEmail" @click="sendAuditEmail">
|
||||
<v-icon name="mail" left />
|
||||
Audit E-Mail
|
||||
</v-button>
|
||||
<v-button :loading="loadingPdf" @click="generatePdf">
|
||||
<v-icon name="picture_as_pdf" left />
|
||||
PDF Erstellen
|
||||
</v-button>
|
||||
</template>
|
||||
|
||||
<v-button v-if="selectedLead.audit_pdf_path" secondary icon v-tooltip.bottom="'PDF öffnen'" @click="openPdf">
|
||||
<v-icon name="open_in_new" />
|
||||
</v-button>
|
||||
|
||||
<v-button
|
||||
v-if="selectedLead.audit_pdf_path"
|
||||
primary
|
||||
:loading="loadingEmail"
|
||||
@click="sendEstimateEmail"
|
||||
>
|
||||
<v-icon name="send" left />
|
||||
Angebot senden
|
||||
</v-button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="sections">
|
||||
<div class="main-info">
|
||||
<div class="form-grid">
|
||||
<div class="field">
|
||||
<span class="label">Kontaktperson</span>
|
||||
<div v-if="selectedLead.contact_person" class="value person-link" @click="goToPerson(selectedLead.contact_person)">
|
||||
{{ getPersonName(selectedLead.contact_person) }}
|
||||
</div>
|
||||
<div v-else class="value text-subdued">Keine Person verknüpft</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<span class="label">E-Mail (Legacy)</span>
|
||||
<div class="value">{{ selectedLead.contact_email || '—' }}</div>
|
||||
</div>
|
||||
<div class="field full">
|
||||
<span class="label">Briefing / Fokus</span>
|
||||
<div class="value text-block">{{ selectedLead.briefing || 'Kein Briefing hinterlegt.' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<v-divider />
|
||||
|
||||
<div v-if="selectedLead.ai_state" class="ai-observations">
|
||||
<h3 class="section-title">AI Observations & Estimation</h3>
|
||||
|
||||
<div class="metrics">
|
||||
<v-info label="Projekt-Modus" :value="selectedLead.ai_state.projectType || 'Unbekannt'" />
|
||||
<v-info label="Seitenanzahl" :value="selectedLead.ai_state.sitemap?.length || '0'" />
|
||||
</div>
|
||||
|
||||
<v-table
|
||||
v-if="selectedLead.ai_state.sitemap"
|
||||
:headers="[ { text: 'Seite', value: 'title' }, { text: 'URL', value: 'url' } ]"
|
||||
:items="selectedLead.ai_state.sitemap"
|
||||
class="observation-table"
|
||||
>
|
||||
<template #[`item.title`]="{ item }">
|
||||
<span class="page-title">{{ item.title }}</span>
|
||||
</template>
|
||||
<template #[`item.url`]="{ item }">
|
||||
<span class="page-url">{{ item.url }}</span>
|
||||
</template>
|
||||
</v-table>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Drawer: New Lead -->
|
||||
<v-drawer
|
||||
v-model="showAddLead"
|
||||
title="Neuen Lead registrieren"
|
||||
icon="person_add"
|
||||
@cancel="showAddLead = false"
|
||||
>
|
||||
<div class="drawer-content">
|
||||
<div class="form-section">
|
||||
<div class="field">
|
||||
<span class="label">Firma</span>
|
||||
<v-input v-model="newLead.company_name" placeholder="z.B. Schmidt GmbH" autofocus />
|
||||
</div>
|
||||
<div class="field">
|
||||
<span class="label">Website URL</span>
|
||||
<v-input v-model="newLead.website_url" placeholder="https://..." />
|
||||
</div>
|
||||
<div class="field">
|
||||
<span class="label">Ansprechpartner</span>
|
||||
<v-input v-model="newLead.contact_name" placeholder="Vorname Nachname" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<span class="label">E-Mail Adresse</span>
|
||||
<v-input v-model="newLead.contact_email" placeholder="email@beispiel.de" type="email" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<span class="label">Briefing / Fokus</span>
|
||||
<v-textarea v-model="newLead.briefing" placeholder="Besonderheiten für das Audit..." />
|
||||
</div>
|
||||
<div class="field">
|
||||
<span class="label">Kontaktperson (Optional)</span>
|
||||
<v-select
|
||||
v-model="newLead.contact_person"
|
||||
:items="peopleOptions"
|
||||
placeholder="Person auswählen..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="drawer-actions">
|
||||
<v-button primary block :loading="savingLead" @click="saveLead">Lead speichern</v-button>
|
||||
</div>
|
||||
</div>
|
||||
</v-drawer>
|
||||
</private-view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, computed } from 'vue';
|
||||
import { useApi } from '@directus/extensions-sdk';
|
||||
|
||||
const api = useApi();
|
||||
const leads = ref<any[]>([]);
|
||||
const selectedLeadId = ref<string | null>(null);
|
||||
const loadingAudit = ref(false);
|
||||
const loadingPdf = ref(false);
|
||||
const loadingEmail = ref(false);
|
||||
const showAddLead = ref(false);
|
||||
const savingLead = ref(false);
|
||||
const notice = ref<{ type: string; message: string } | null>(null);
|
||||
|
||||
const newLead = ref({
|
||||
company_name: '',
|
||||
website_url: '',
|
||||
contact_name: '',
|
||||
contact_email: '',
|
||||
contact_person: null,
|
||||
briefing: '',
|
||||
status: 'new'
|
||||
});
|
||||
|
||||
const people = ref<any[]>([]);
|
||||
|
||||
const peopleOptions = computed(() =>
|
||||
people.value.map(p => ({
|
||||
text: `${p.first_name} ${p.last_name}`,
|
||||
value: p.id
|
||||
}))
|
||||
);
|
||||
|
||||
function getPersonName(id: string) {
|
||||
const person = people.value.find(p => p.id === id);
|
||||
return person ? `${person.first_name} ${person.last_name}` : id;
|
||||
}
|
||||
|
||||
function goToPerson(id: string) {
|
||||
// Logic to navigate to people manager or open details
|
||||
notice.value = { type: 'info', message: `Navigiere zu Person: ${id}` };
|
||||
}
|
||||
|
||||
const selectedLead = computed(() => leads.value.find(l => l.id === selectedLeadId.value));
|
||||
|
||||
onMounted(fetchLeads);
|
||||
|
||||
async function fetchLeads() {
|
||||
const [leadsResp, peopleResp] = await Promise.all([
|
||||
api.get('/items/leads', { params: { sort: '-date_created' } }),
|
||||
api.get('/items/people', { params: { sort: 'last_name' } })
|
||||
]);
|
||||
leads.value = leadsResp.data.data;
|
||||
people.value = peopleResp.data.data;
|
||||
if (!selectedLeadId.value && leads.value.length > 0) {
|
||||
selectedLeadId.value = leads.value[0].id;
|
||||
}
|
||||
}
|
||||
|
||||
function selectLead(id: string) {
|
||||
selectedLeadId.value = id;
|
||||
}
|
||||
|
||||
async function runAudit() {
|
||||
if (!selectedLeadId.value) return;
|
||||
loadingAudit.value = true;
|
||||
try {
|
||||
await api.post(`/acquisition/audit/${selectedLeadId.value}`);
|
||||
notice.value = { type: 'success', message: 'Audit erfolgreich gestartet!' };
|
||||
await fetchLeads();
|
||||
} catch (e: any) {
|
||||
notice.value = { type: 'danger', message: `Fehler beim Audit: ${e.message}` };
|
||||
} finally {
|
||||
loadingAudit.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function sendAuditEmail() {
|
||||
if (!selectedLeadId.value) return;
|
||||
loadingEmail.value = true;
|
||||
try {
|
||||
await api.post(`/acquisition/audit-email/${selectedLeadId.value}`);
|
||||
notice.value = { type: 'success', message: 'Audit E-Mail versendet!' };
|
||||
await fetchLeads();
|
||||
} catch (e: any) {
|
||||
notice.value = { type: 'danger', message: `Fehler beim Versenden: ${e.message}` };
|
||||
} finally {
|
||||
loadingEmail.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function generatePdf() {
|
||||
if (!selectedLeadId.value) return;
|
||||
loadingPdf.value = true;
|
||||
try {
|
||||
await api.post(`/acquisition/estimate/${selectedLeadId.value}`);
|
||||
notice.value = { type: 'success', message: 'Angebot (PDF) wurde generiert!' };
|
||||
await fetchLeads();
|
||||
} catch (e: any) {
|
||||
notice.value = { type: 'danger', message: `Fehler bei PDF Generierung: ${e.message}` };
|
||||
} finally {
|
||||
loadingPdf.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function sendEstimateEmail() {
|
||||
if (!selectedLeadId.value) return;
|
||||
loadingEmail.value = true;
|
||||
try {
|
||||
await api.post(`/acquisition/estimate-email/${selectedLeadId.value}`);
|
||||
notice.value = { type: 'success', message: 'Angebot erfolgreich versendet!' };
|
||||
await fetchLeads();
|
||||
} catch (e: any) {
|
||||
notice.value = { type: 'danger', message: `Fehler beim Versenden: ${e.message}` };
|
||||
} finally {
|
||||
loadingEmail.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function openPdf() {
|
||||
if (!selectedLead.value?.audit_pdf_path) return;
|
||||
window.open(`${window.location.origin}/assets/${selectedLead.value.audit_pdf_path}`, '_blank');
|
||||
}
|
||||
|
||||
async function saveLead() {
|
||||
if (!newLead.value.company_name) return;
|
||||
savingLead.value = true;
|
||||
try {
|
||||
const payload = {
|
||||
id: crypto.randomUUID(),
|
||||
...newLead.value
|
||||
};
|
||||
await api.post('/items/leads', payload);
|
||||
notice.value = { type: 'success', message: 'Lead erfolgreich registriert!' };
|
||||
showAddLead.value = false;
|
||||
await fetchLeads();
|
||||
selectedLeadId.value = payload.id;
|
||||
newLead.value = {
|
||||
company_name: '',
|
||||
website_url: '',
|
||||
contact_name: '',
|
||||
contact_email: '',
|
||||
contact_person: null,
|
||||
briefing: '',
|
||||
status: 'new'
|
||||
};
|
||||
} catch (e: any) {
|
||||
notice.value = { type: 'danger', message: `Fehler beim Speichern: ${e.message}` };
|
||||
} finally {
|
||||
savingLead.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function getStatusIcon(status: string) {
|
||||
switch(status) {
|
||||
case 'new': return 'fiber_new';
|
||||
case 'auditing': return 'hourglass_empty';
|
||||
case 'audit_ready': return 'check_circle';
|
||||
case 'contacted': return 'mail_outline';
|
||||
default: return 'help_outline';
|
||||
}
|
||||
}
|
||||
|
||||
function getStatusColor(status: string) {
|
||||
switch(status) {
|
||||
case 'new': return 'var(--theme--primary)';
|
||||
case 'auditing': return 'var(--theme--warning)';
|
||||
case 'audit_ready': return 'var(--theme--success)';
|
||||
case 'contacted': return 'var(--theme--secondary)';
|
||||
default: return 'var(--theme--foreground-subdued)';
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.content-wrapper { padding: 32px; height: 100%; display: flex; flex-direction: column; overflow-y: auto; }
|
||||
.lead-item { cursor: pointer; }
|
||||
.header { margin-bottom: 24px; display: flex; justify-content: space-between; align-items: flex-end; }
|
||||
.header-right { display: flex; gap: 12px; }
|
||||
.title { font-size: 24px; font-weight: 800; margin-bottom: 4px; color: var(--theme--foreground); }
|
||||
.subtitle { color: var(--theme--foreground-subdued); font-size: 14px; display: flex; align-items: center; gap: 8px; }
|
||||
.url-link { color: inherit; text-decoration: none; border-bottom: 1px solid transparent; }
|
||||
.url-link:hover { border-bottom-color: currentColor; }
|
||||
.empty-state { height: 100%; display: flex; align-items: center; justify-content: center; }
|
||||
|
||||
.sections { display: flex; flex-direction: column; gap: 32px; }
|
||||
|
||||
.form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 24px; }
|
||||
.field { display: flex; flex-direction: column; gap: 8px; }
|
||||
.field.full { grid-column: span 2; }
|
||||
.label { font-size: 11px; font-weight: 700; text-transform: uppercase; color: var(--theme--foreground-subdued); letter-spacing: 0.5px; }
|
||||
.value { font-size: 15px; color: var(--theme--foreground); }
|
||||
.text-block { line-height: 1.6; white-space: pre-wrap; background: var(--theme--background-subdued); padding: 16px; border-radius: 8px; }
|
||||
|
||||
.ai-observations { display: flex; flex-direction: column; gap: 16px; }
|
||||
.section-title { font-size: 16px; font-weight: 700; color: var(--theme--foreground); margin-bottom: 8px; }
|
||||
.metrics { display: flex; gap: 32px; margin-bottom: 16px; }
|
||||
|
||||
.observation-table { border: 1px solid var(--theme--border); border-radius: 8px; overflow: hidden; }
|
||||
.page-title { font-weight: 600; }
|
||||
.page-url { font-family: var(--family-monospace); font-size: 12px; color: var(--theme--foreground-subdued); }
|
||||
|
||||
.drawer-content { padding: 24px; display: flex; flex-direction: column; gap: 32px; }
|
||||
.form-section { display: flex; flex-direction: column; gap: 20px; }
|
||||
.drawer-actions { margin-top: 24px; display: flex; flex-direction: column; gap: 12px; }
|
||||
|
||||
:deep(.v-list-item) { cursor: pointer !important; }
|
||||
</style>
|
||||
50
packages/cms-infra/extensions/acquisition/build.js
Normal file
50
packages/cms-infra/extensions/acquisition/build.js
Normal file
@@ -0,0 +1,50 @@
|
||||
import { build } from 'esbuild';
|
||||
import { resolve, dirname } from 'path';
|
||||
import { mkdirSync } from 'fs';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
||||
const entryPoint = resolve(__dirname, 'src/index.ts');
|
||||
const outfile = resolve(__dirname, 'dist/index.js');
|
||||
|
||||
try {
|
||||
mkdirSync(dirname(outfile), { recursive: true });
|
||||
} catch (e) { }
|
||||
|
||||
console.log(`Building from ${entryPoint} to ${outfile}...`);
|
||||
|
||||
build({
|
||||
entryPoints: [entryPoint],
|
||||
bundle: true,
|
||||
platform: 'node',
|
||||
target: 'node18',
|
||||
outfile: outfile,
|
||||
format: 'esm',
|
||||
// Bundle everything, including Directus SDK, to avoid resolution issues in Docker
|
||||
external: [],
|
||||
plugins: [{
|
||||
name: 'mock-jquery',
|
||||
setup(build) {
|
||||
build.onResolve({ filter: /^jquery$/ }, args => ({ path: args.path, namespace: 'mock-jquery' }));
|
||||
build.onLoad({ filter: /.*/, namespace: 'mock-jquery' }, () => ({ contents: 'export default {};', loader: 'js' }));
|
||||
}
|
||||
}, {
|
||||
name: 'mock-canvas',
|
||||
setup(build) {
|
||||
build.onResolve({ filter: /^canvas$/ }, args => ({ path: args.path, namespace: 'mock-canvas' }));
|
||||
build.onLoad({ filter: /.*/, namespace: 'mock-canvas' }, () => ({ contents: 'export default {};', loader: 'js' }));
|
||||
}
|
||||
}, {
|
||||
name: 'mock-jsdom',
|
||||
setup(build) {
|
||||
return;
|
||||
}
|
||||
}]
|
||||
}).then(() => {
|
||||
console.log("Build succeeded!");
|
||||
}).catch((e) => {
|
||||
console.error("Build failed:", e);
|
||||
process.exit(1);
|
||||
});
|
||||
513
packages/cms-infra/extensions/acquisition/index.js
Normal file
513
packages/cms-infra/extensions/acquisition/index.js
Normal file
File diff suppressed because one or more lines are too long
27
packages/cms-infra/extensions/acquisition/package.json
Normal file
27
packages/cms-infra/extensions/acquisition/package.json
Normal file
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"name": "acquisition",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"directus:extension": {
|
||||
"type": "endpoint",
|
||||
"path": "dist/index.js",
|
||||
"source": "src/index.ts",
|
||||
"host": "^11.0.0"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "node build.js",
|
||||
"dev": "node build.js --watch"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@directus/extensions-sdk": "11.0.2",
|
||||
"@mintel/acquisition": "workspace:*",
|
||||
"@mintel/mail": "workspace:*",
|
||||
"esbuild": "^0.25.0",
|
||||
"typescript": "^5.6.3"
|
||||
},
|
||||
"dependencies": {
|
||||
"jquery": "^3.7.1",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4"
|
||||
}
|
||||
}
|
||||
172
packages/cms-infra/extensions/acquisition/src/index.ts
Normal file
172
packages/cms-infra/extensions/acquisition/src/index.ts
Normal file
@@ -0,0 +1,172 @@
|
||||
import "./shim";
|
||||
import { defineEndpoint } from "@directus/extensions-sdk";
|
||||
import { AcquisitionService, PdfEngine } from "@mintel/acquisition";
|
||||
import { render, SiteAuditTemplate, ProjectEstimateTemplate } from "@mintel/mail";
|
||||
import { createElement } from "react";
|
||||
import * as path from "path";
|
||||
import * as fs from "fs";
|
||||
|
||||
export default defineEndpoint((router, { services, env }) => {
|
||||
const { ItemsService, MailService } = services;
|
||||
|
||||
router.get("/ping", (req, res) => res.send("pong"));
|
||||
|
||||
router.post("/audit/:id", async (req: any, res: any) => {
|
||||
const { id } = req.params;
|
||||
const leadsService = new ItemsService("leads", { schema: req.schema, accountability: req.accountability });
|
||||
|
||||
try {
|
||||
const lead = await leadsService.readOne(id);
|
||||
if (!lead) return res.status(404).send({ error: "Lead not found" });
|
||||
|
||||
await leadsService.updateOne(id, { status: "auditing" });
|
||||
|
||||
const acqService = new AcquisitionService(env.OPENROUTER_API_KEY);
|
||||
const result = await acqService.runFullSequence(lead.website_url, lead.briefing, lead.comments);
|
||||
|
||||
await leadsService.updateOne(id, {
|
||||
status: "audit_ready",
|
||||
ai_state: result.state,
|
||||
audit_context: JSON.stringify(result.usage),
|
||||
});
|
||||
|
||||
res.send({ success: true, result });
|
||||
} catch (error: any) {
|
||||
console.error("Audit failed:", error);
|
||||
await leadsService.updateOne(id, { status: "new", comments: `Audit failed: ${error.message}` });
|
||||
res.status(500).send({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.post("/audit-email/:id", async (req: any, res: any) => {
|
||||
const { id } = req.params;
|
||||
const leadsService = new ItemsService("leads", { schema: req.schema, accountability: req.accountability });
|
||||
const peopleService = new ItemsService("people", { schema: req.schema, accountability: req.accountability });
|
||||
const mailService = new MailService({ schema: req.schema, accountability: req.accountability });
|
||||
|
||||
try {
|
||||
const lead = await leadsService.readOne(id);
|
||||
if (!lead || !lead.ai_state) return res.status(400).send({ error: "Lead or Audit not ready" });
|
||||
|
||||
let recipientEmail = lead.contact_email;
|
||||
let companyName = lead.company_name;
|
||||
|
||||
if (lead.contact_person) {
|
||||
const person = await peopleService.readOne(lead.contact_person);
|
||||
if (person && person.email) {
|
||||
recipientEmail = person.email;
|
||||
companyName = person.company || lead.company_name;
|
||||
}
|
||||
}
|
||||
|
||||
if (!recipientEmail) return res.status(400).send({ error: "No recipient email found" });
|
||||
|
||||
const auditHighlights = [
|
||||
`Projekt-Typ: ${lead.ai_state.projectType === "website" ? "Website" : "Web App"}`,
|
||||
...(lead.ai_state.sitemap || []).slice(0, 3).map((item: any) => `Potenzial in: ${item.category}`),
|
||||
];
|
||||
|
||||
const html = await render(createElement(SiteAuditTemplate, {
|
||||
companyName: companyName,
|
||||
websiteUrl: lead.website_url,
|
||||
auditHighlights
|
||||
}));
|
||||
|
||||
await mailService.send({
|
||||
to: recipientEmail,
|
||||
subject: `Analyse Ihrer Webpräsenz: ${companyName}`,
|
||||
html
|
||||
});
|
||||
|
||||
await leadsService.updateOne(id, {
|
||||
status: "contacted",
|
||||
last_contacted_at: new Date().toISOString(),
|
||||
});
|
||||
|
||||
res.send({ success: true });
|
||||
} catch (error: any) {
|
||||
console.error("Audit Email failed:", error);
|
||||
res.status(500).send({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.post("/estimate/:id", async (req: any, res: any) => {
|
||||
const { id } = req.params;
|
||||
const leadsService = new ItemsService("leads", { schema: req.schema, accountability: req.accountability });
|
||||
|
||||
try {
|
||||
const lead = await leadsService.readOne(id);
|
||||
if (!lead || !lead.ai_state) return res.status(400).send({ error: "Lead or AI state not found" });
|
||||
|
||||
const pdfEngine = new PdfEngine();
|
||||
const filename = `estimate_${id}_${Date.now()}.pdf`;
|
||||
const storageRoot = env.STORAGE_LOCAL_ROOT || "./storage";
|
||||
const outputPath = path.join(storageRoot, filename);
|
||||
|
||||
await pdfEngine.generateEstimatePdf(lead.ai_state, outputPath);
|
||||
|
||||
await leadsService.updateOne(id, {
|
||||
audit_pdf_path: filename,
|
||||
});
|
||||
|
||||
res.send({ success: true, filename });
|
||||
} catch (error: any) {
|
||||
console.error("PDF Generation failed:", error);
|
||||
res.status(500).send({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.post("/estimate-email/:id", async (req: any, res: any) => {
|
||||
const { id } = req.params;
|
||||
const leadsService = new ItemsService("leads", { schema: req.schema, accountability: req.accountability });
|
||||
const peopleService = new ItemsService("people", { schema: req.schema, accountability: req.accountability });
|
||||
const mailService = new MailService({ schema: req.schema, accountability: req.accountability });
|
||||
|
||||
try {
|
||||
const lead = await leadsService.readOne(id);
|
||||
if (!lead || !lead.audit_pdf_path) return res.status(400).send({ error: "PDF not generated" });
|
||||
|
||||
let recipientEmail = lead.contact_email;
|
||||
let companyName = lead.company_name;
|
||||
|
||||
if (lead.contact_person) {
|
||||
const person = await peopleService.readOne(lead.contact_person);
|
||||
if (person && person.email) {
|
||||
recipientEmail = person.email;
|
||||
companyName = person.company || lead.company_name;
|
||||
}
|
||||
}
|
||||
|
||||
if (!recipientEmail) return res.status(400).send({ error: "No recipient email found" });
|
||||
|
||||
const html = await render(createElement(ProjectEstimateTemplate, {
|
||||
companyName: companyName,
|
||||
}));
|
||||
|
||||
const storageRoot = env.STORAGE_LOCAL_ROOT || "./storage";
|
||||
const attachmentPath = path.join(storageRoot, lead.audit_pdf_path);
|
||||
|
||||
await mailService.send({
|
||||
to: recipientEmail,
|
||||
subject: `Ihre Projekt-Schätzung: ${companyName}`,
|
||||
html,
|
||||
attachments: [
|
||||
{
|
||||
filename: `Angebot_${companyName}.pdf`,
|
||||
content: fs.readFileSync(attachmentPath)
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
await leadsService.updateOne(id, {
|
||||
status: "contacted",
|
||||
last_contacted_at: new Date().toISOString(),
|
||||
});
|
||||
|
||||
res.send({ success: true });
|
||||
} catch (error: any) {
|
||||
console.error("Estimate Email failed:", error);
|
||||
res.status(500).send({ error: error.message });
|
||||
}
|
||||
});
|
||||
});
|
||||
22
packages/cms-infra/extensions/acquisition/src/shim.ts
Normal file
22
packages/cms-infra/extensions/acquisition/src/shim.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { fileURLToPath } from 'url';
|
||||
import { dirname } from 'path';
|
||||
import { createRequire } from 'module';
|
||||
|
||||
try {
|
||||
const url = import.meta?.url;
|
||||
// Hardcode fallback path for Directus Docker environment
|
||||
const fallbackPath = '/directus/extensions/acquisition/dist/index.js';
|
||||
const filename = url ? fileURLToPath(url) : fallbackPath;
|
||||
const dir = dirname(filename);
|
||||
|
||||
// @ts-ignore
|
||||
globalThis.__filename = filename;
|
||||
// @ts-ignore
|
||||
globalThis.__dirname = dir;
|
||||
// @ts-ignore
|
||||
globalThis.require = createRequire(url || `file://${fallbackPath}`);
|
||||
|
||||
console.log(`[Shim] Loaded. __dirname: ${dir}`);
|
||||
} catch (e) {
|
||||
console.warn("[Shim] Failed to shim __dirname/require", e);
|
||||
}
|
||||
1
packages/cms-infra/extensions/customer-manager/index.js
Normal file
1
packages/cms-infra/extensions/customer-manager/index.js
Normal file
File diff suppressed because one or more lines are too long
20
packages/cms-infra/extensions/customer-manager/package.json
Normal file
20
packages/cms-infra/extensions/customer-manager/package.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"name": "customer-manager",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"directus:extension": {
|
||||
"type": "module",
|
||||
"path": "dist/index.js",
|
||||
"source": "src/index.ts",
|
||||
"host": "^11.0.0"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "directus-extension build",
|
||||
"dev": "directus-extension build -w"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@directus/extensions-sdk": "11.0.2",
|
||||
"vue": "^3.4.34",
|
||||
"typescript": "^5.6.3"
|
||||
}
|
||||
}
|
||||
14
packages/cms-infra/extensions/customer-manager/src/index.ts
Normal file
14
packages/cms-infra/extensions/customer-manager/src/index.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { defineModule } from '@directus/extensions-sdk';
|
||||
import ModuleComponent from './module.vue';
|
||||
|
||||
export default defineModule({
|
||||
id: 'customer-manager',
|
||||
name: 'Customer Manager',
|
||||
icon: 'supervisor_account',
|
||||
routes: [
|
||||
{
|
||||
path: '',
|
||||
component: ModuleComponent,
|
||||
},
|
||||
],
|
||||
});
|
||||
399
packages/cms-infra/extensions/customer-manager/src/module.vue
Normal file
399
packages/cms-infra/extensions/customer-manager/src/module.vue
Normal file
@@ -0,0 +1,399 @@
|
||||
<template>
|
||||
<private-view title="Customer Manager">
|
||||
<template #navigation>
|
||||
<v-list nav>
|
||||
<v-list-item @click="openCreateCompany" clickable>
|
||||
<v-list-item-icon><v-icon name="add" color="var(--theme--primary)" /></v-list-item-icon>
|
||||
<v-list-item-content>
|
||||
<v-text-overflow text="Neue Firma anlegen" />
|
||||
</v-list-item-content>
|
||||
</v-list-item>
|
||||
|
||||
<v-divider />
|
||||
|
||||
<v-list-item
|
||||
v-for="company in companies"
|
||||
:key="company.id"
|
||||
:active="selectedCompany?.id === company.id"
|
||||
class="company-item"
|
||||
clickable
|
||||
@click="selectCompany(company)"
|
||||
>
|
||||
<v-list-item-icon><v-icon name="business" /></v-list-item-icon>
|
||||
<v-list-item-content>
|
||||
<v-text-overflow :text="company.name" />
|
||||
</v-list-item-content>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</template>
|
||||
|
||||
<template #title-outer:after>
|
||||
<v-notice v-if="notice" :type="notice.type" @close="notice = null" dismissible>
|
||||
{{ notice.message }}
|
||||
</v-notice>
|
||||
</template>
|
||||
|
||||
<div class="content-wrapper">
|
||||
<div v-if="!selectedCompany" class="empty-state">
|
||||
<v-info title="Firmen auswählen" icon="business" center>
|
||||
Wähle eine Firma in der Navigation aus oder
|
||||
<v-button x-small @click="openCreateCompany">erstelle eine neue Firma</v-button>.
|
||||
</v-info>
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<header class="header">
|
||||
<div class="header-left">
|
||||
<h1 class="title">{{ selectedCompany.name }}</h1>
|
||||
<p class="subtitle">{{ employees.length }} Kunden-Mitarbeiter</p>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<v-button secondary rounded icon v-tooltip.bottom="'Firma bearbeiten'" @click="openEditCompany">
|
||||
<v-icon name="edit" />
|
||||
</v-button>
|
||||
<v-button primary @click="openCreateEmployee">
|
||||
Mitarbeiter hinzufügen
|
||||
</v-button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<v-table
|
||||
:headers="tableHeaders"
|
||||
:items="employees"
|
||||
:loading="loading"
|
||||
class="clickable-table"
|
||||
fixed-header
|
||||
@click:row="onRowClick"
|
||||
>
|
||||
<template #[`item.name`]="{ item }">
|
||||
<div class="user-cell">
|
||||
<v-avatar :name="item.first_name" x-small />
|
||||
<span class="user-name">{{ item.first_name }} {{ item.last_name }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #[`item.last_invited`]="{ item }">
|
||||
<span v-if="item.last_invited" class="status-date">
|
||||
{{ formatDate(item.last_invited) }}
|
||||
</span>
|
||||
<v-chip v-else x-small>Noch nie</v-chip>
|
||||
</template>
|
||||
</v-table>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Drawer: Company Form -->
|
||||
<v-drawer
|
||||
v-model="drawerCompanyActive"
|
||||
:title="isEditingCompany ? 'Firma bearbeiten' : 'Neue Firma anlegen'"
|
||||
icon="business"
|
||||
@cancel="drawerCompanyActive = false"
|
||||
>
|
||||
<div v-if="drawerCompanyActive" class="drawer-content">
|
||||
<div class="form-section">
|
||||
<div class="field">
|
||||
<span class="label">Firmenname</span>
|
||||
<v-input v-model="companyForm.name" placeholder="z.B. KLZ Cables" autofocus />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="drawer-actions">
|
||||
<v-button primary block :loading="saving" @click="saveCompany">Speichern</v-button>
|
||||
</div>
|
||||
</div>
|
||||
</v-drawer>
|
||||
|
||||
<!-- Drawer: Employee Form -->
|
||||
<v-drawer
|
||||
v-model="drawerEmployeeActive"
|
||||
:title="isEditingEmployee ? 'Mitarbeiter bearbeiten' : 'Neuen Mitarbeiter anlegen'"
|
||||
icon="person"
|
||||
@cancel="drawerEmployeeActive = false"
|
||||
>
|
||||
<div v-if="drawerEmployeeActive" class="drawer-content">
|
||||
<div class="form-section">
|
||||
<div class="field">
|
||||
<span class="label">Vorname</span>
|
||||
<v-input v-model="employeeForm.first_name" placeholder="Vorname" autofocus />
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<span class="label">Nachname</span>
|
||||
<v-input v-model="employeeForm.last_name" placeholder="Nachname" />
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<span class="label">E-Mail</span>
|
||||
<v-input v-model="employeeForm.email" placeholder="E-Mail Adresse" type="email" />
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<span class="label">Zugehörige Person (Zentral)</span>
|
||||
<v-select
|
||||
v-model="employeeForm.contact_person"
|
||||
:items="peopleOptions"
|
||||
placeholder="Zentrale Person auswählen..."
|
||||
show-deselect
|
||||
/>
|
||||
<p class="field-note">Verknüpft diesen Mitarbeiter mit dem globalen Personen-Verzeichnis.</p>
|
||||
</div>
|
||||
|
||||
<v-divider v-if="isEditingEmployee" />
|
||||
|
||||
<div v-if="isEditingEmployee" class="field">
|
||||
<span class="label">Temporäres Passwort</span>
|
||||
<v-input v-model="employeeForm.temporary_password" readonly class="password-input" />
|
||||
<p class="field-note">Wird beim Senden der Zugangsdaten automatisch generiert.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="drawer-actions">
|
||||
<v-button primary block :loading="saving" @click="saveEmployee">Daten speichern</v-button>
|
||||
|
||||
<template v-if="isEditingEmployee">
|
||||
<v-divider />
|
||||
<v-button
|
||||
v-tooltip.bottom="'Generiert PW, speichert es und sendet E-Mail'"
|
||||
secondary
|
||||
block
|
||||
:loading="invitingId === employeeForm.id"
|
||||
@click="inviteUser(employeeForm)"
|
||||
>
|
||||
<v-icon name="send" left /> Zugangsdaten senden
|
||||
</v-button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</v-drawer>
|
||||
</private-view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, nextTick, computed } from 'vue';
|
||||
import { useApi } from '@directus/extensions-sdk';
|
||||
|
||||
const api = useApi();
|
||||
|
||||
const companies = ref<any[]>([]);
|
||||
const selectedCompany = ref<any>(null);
|
||||
const employees = ref<any[]>([]);
|
||||
const people = ref<any[]>([]);
|
||||
const loading = ref(false);
|
||||
const saving = ref(false);
|
||||
const invitingId = ref<string | null>(null);
|
||||
const notice = ref<{ type: string; message: string } | null>(null);
|
||||
|
||||
const peopleOptions = computed(() =>
|
||||
people.value.map(p => ({
|
||||
text: `${p.first_name} ${p.last_name} (${p.company || 'Keine Firma'})`,
|
||||
value: p.id
|
||||
}))
|
||||
);
|
||||
|
||||
// Forms State
|
||||
const drawerCompanyActive = ref(false);
|
||||
const isEditingCompany = ref(false);
|
||||
const companyForm = ref({ id: '', name: '' });
|
||||
|
||||
const drawerEmployeeActive = ref(false);
|
||||
const isEditingEmployee = ref(false);
|
||||
const employeeForm = ref({
|
||||
id: '',
|
||||
first_name: '',
|
||||
last_name: '',
|
||||
email: '',
|
||||
contact_person: null as string | null,
|
||||
temporary_password: ''
|
||||
});
|
||||
|
||||
const tableHeaders = [
|
||||
{ text: 'Name', value: 'name', sortable: true },
|
||||
{ text: 'E-Mail', value: 'email', sortable: true },
|
||||
{ text: 'Zuletzt eingeladen', value: 'last_invited', sortable: true }
|
||||
];
|
||||
|
||||
async function fetchCompanies() {
|
||||
const [companyRes, peopleRes] = await Promise.all([
|
||||
api.get('/items/companies', { params: { fields: ['id', 'name'], sort: 'name' } }),
|
||||
api.get('/items/people', { params: { sort: 'last_name' } })
|
||||
]);
|
||||
companies.value = companyRes.data.data;
|
||||
people.value = peopleRes.data.data;
|
||||
}
|
||||
|
||||
async function selectCompany(company: any) {
|
||||
selectedCompany.value = company;
|
||||
loading.value = true;
|
||||
try {
|
||||
const res = await api.get('/items/client_users', {
|
||||
params: {
|
||||
filter: { company: { _eq: company.id } },
|
||||
fields: ['*'],
|
||||
sort: 'first_name',
|
||||
},
|
||||
});
|
||||
employees.value = res.data.data;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Company Actions
|
||||
function openCreateCompany() {
|
||||
isEditingCompany.value = false;
|
||||
companyForm.value = { id: '', name: '' };
|
||||
drawerCompanyActive.value = true;
|
||||
}
|
||||
|
||||
async function openEditCompany() {
|
||||
if (!selectedCompany.value) return;
|
||||
companyForm.value = {
|
||||
id: selectedCompany.value.id,
|
||||
name: selectedCompany.value.name
|
||||
};
|
||||
isEditingCompany.value = true;
|
||||
await nextTick();
|
||||
drawerCompanyActive.value = true;
|
||||
}
|
||||
|
||||
async function saveCompany() {
|
||||
if (!companyForm.value.name) return;
|
||||
saving.value = true;
|
||||
try {
|
||||
if (isEditingCompany.value) {
|
||||
await api.patch(`/items/companies/${companyForm.value.id}`, { name: companyForm.value.name });
|
||||
notice.value = { type: 'success', message: 'Firma aktualisiert!' };
|
||||
} else {
|
||||
await api.post('/items/companies', { name: companyForm.value.name });
|
||||
notice.value = { type: 'success', message: 'Firma angelegt!' };
|
||||
}
|
||||
drawerCompanyActive.value = false;
|
||||
await fetchCompanies();
|
||||
if (selectedCompany.value?.id === companyForm.value.id) {
|
||||
selectedCompany.value.name = companyForm.value.name;
|
||||
}
|
||||
} catch (e: any) {
|
||||
notice.value = { type: 'danger', message: e.message };
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Employee Actions
|
||||
function openCreateEmployee() {
|
||||
isEditingEmployee.value = false;
|
||||
employeeForm.value = { id: '', first_name: '', last_name: '', email: '', contact_person: null, temporary_password: '' };
|
||||
drawerEmployeeActive.value = true;
|
||||
}
|
||||
|
||||
async function openEditEmployee(item: any) {
|
||||
employeeForm.value = {
|
||||
id: item.id || '',
|
||||
first_name: item.first_name || '',
|
||||
last_name: item.last_name || '',
|
||||
email: item.email || '',
|
||||
contact_person: item.contact_person || null,
|
||||
temporary_password: item.temporary_password || ''
|
||||
};
|
||||
isEditingEmployee.value = true;
|
||||
await nextTick();
|
||||
drawerEmployeeActive.value = true;
|
||||
}
|
||||
|
||||
async function saveEmployee() {
|
||||
if (!employeeForm.value.email || !selectedCompany.value) return;
|
||||
saving.value = true;
|
||||
try {
|
||||
if (isEditingEmployee.value) {
|
||||
await api.patch(`/items/client_users/${employeeForm.value.id}`, {
|
||||
first_name: employeeForm.value.first_name,
|
||||
last_name: employeeForm.value.last_name,
|
||||
email: employeeForm.value.email,
|
||||
contact_person: employeeForm.value.contact_person
|
||||
});
|
||||
notice.value = { type: 'success', message: 'Mitarbeiter aktualisiert!' };
|
||||
} else {
|
||||
await api.post('/items/client_users', {
|
||||
first_name: employeeForm.value.first_name,
|
||||
last_name: employeeForm.value.last_name,
|
||||
email: employeeForm.value.email,
|
||||
company: selectedCompany.value.id,
|
||||
contact_person: employeeForm.value.contact_person
|
||||
});
|
||||
notice.value = { type: 'success', message: 'Mitarbeiter angelegt!' };
|
||||
}
|
||||
drawerEmployeeActive.value = false;
|
||||
await selectCompany(selectedCompany.value);
|
||||
} catch (e: any) {
|
||||
notice.value = { type: 'danger', message: e.message };
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function inviteUser(user: any) {
|
||||
invitingId.value = user.id;
|
||||
try {
|
||||
await api.post(`/flows/trigger/33443f6b-cec7-4668-9607-f33ea674d501`, [user.id]);
|
||||
notice.value = { type: 'success', message: `Zugangsdaten für ${user.first_name} versendet. 📧` };
|
||||
await selectCompany(selectedCompany.value);
|
||||
if (drawerEmployeeActive.value && employeeForm.value.id === user.id) {
|
||||
const updated = employees.value.find(e => e.id === user.id);
|
||||
if (updated) {
|
||||
employeeForm.value.temporary_password = updated.temporary_password;
|
||||
}
|
||||
}
|
||||
} catch (e: any) {
|
||||
notice.value = { type: 'danger', message: `Fehler: ${e.message}` };
|
||||
} finally {
|
||||
invitingId.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
function onRowClick(event: any) {
|
||||
const item = event?.item || event;
|
||||
if (item && item.id) {
|
||||
openEditEmployee(item);
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(dateStr: string) {
|
||||
return new Date(dateStr).toLocaleString('de-DE', {
|
||||
day: '2-digit', month: '2-digit', year: 'numeric',
|
||||
hour: '2-digit', minute: '2-digit'
|
||||
});
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchCompanies();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.content-wrapper { padding: 32px; height: 100%; display: flex; flex-direction: column; }
|
||||
.company-item { cursor: pointer; }
|
||||
.header { margin-bottom: 24px; display: flex; justify-content: space-between; align-items: flex-end; }
|
||||
.header-right { display: flex; gap: 12px; }
|
||||
.title { font-size: 24px; font-weight: 800; margin-bottom: 4px; }
|
||||
.subtitle { color: var(--theme--foreground-subdued); font-size: 14px; }
|
||||
.empty-state { height: 100%; display: flex; align-items: center; justify-content: center; }
|
||||
.user-cell { display: flex; align-items: center; gap: 12px; }
|
||||
.user-name { font-weight: 600; }
|
||||
.status-date { font-size: 12px; color: var(--theme--foreground-subdued); }
|
||||
.drawer-content { padding: 24px; display: flex; flex-direction: column; gap: 32px; }
|
||||
.form-section { display: flex; flex-direction: column; gap: 20px; }
|
||||
.field { display: flex; flex-direction: column; gap: 8px; }
|
||||
.label { font-size: 12px; font-weight: 700; text-transform: uppercase; color: var(--theme--foreground-subdued); letter-spacing: 0.5px; }
|
||||
.field-note { font-size: 11px; color: var(--theme--foreground-subdued); margin-top: 4px; }
|
||||
.drawer-actions { margin-top: 24px; display: flex; flex-direction: column; gap: 12px; }
|
||||
.password-input :deep(textarea) {
|
||||
font-family: var(--family-monospace);
|
||||
font-weight: 800;
|
||||
color: var(--theme--primary) !important;
|
||||
background: var(--theme--background-subdued) !important;
|
||||
}
|
||||
|
||||
.clickable-table :deep(tbody tr) { cursor: pointer; transition: background-color 0.2s ease; }
|
||||
.clickable-table :deep(tbody tr:hover) { background-color: var(--theme--background-subdued) !important; }
|
||||
:deep(.v-list-item) { cursor: pointer !important; }
|
||||
</style>
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"name": "feedback-commander",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"directus:extension": {
|
||||
"type": "module",
|
||||
"path": "dist/index.js",
|
||||
"source": "src/index.ts",
|
||||
"host": "^11.0.0"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "directus-extension build",
|
||||
"dev": "directus-extension build -w"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@directus/extensions-sdk": "11.0.2",
|
||||
"vue": "^3.4.34",
|
||||
"typescript": "^5.6.3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { defineModule } from '@directus/extensions-sdk';
|
||||
import ModuleComponent from './module.vue';
|
||||
|
||||
export default defineModule({
|
||||
id: 'feedback-commander',
|
||||
name: 'Feedback Commander',
|
||||
icon: 'view_kanban',
|
||||
routes: [
|
||||
{
|
||||
path: '',
|
||||
component: ModuleComponent,
|
||||
},
|
||||
],
|
||||
});
|
||||
746
packages/cms-infra/extensions/feedback-commander/src/module.vue
Normal file
746
packages/cms-infra/extensions/feedback-commander/src/module.vue
Normal file
@@ -0,0 +1,746 @@
|
||||
<template>
|
||||
<private-view title="Feedback Commander">
|
||||
<template #headline>
|
||||
<v-breadcrumb :items="[{ name: 'Feedback', to: '/feedback-commander' }]" />
|
||||
</template>
|
||||
|
||||
<template #title-outer:after>
|
||||
<v-chip v-if="loading" label color="blue" small>Loading...</v-chip>
|
||||
<v-chip v-else-if="fetchError" label color="red" small>Fetch Error</v-chip>
|
||||
<v-chip v-else label color="green" small>{{ items.length }} Items</v-chip>
|
||||
</template>
|
||||
|
||||
<template #navigation>
|
||||
<div class="sidebar-header">
|
||||
<v-text-overflow text="Websites" class="header-text" />
|
||||
</div>
|
||||
<v-list nav>
|
||||
<v-list-item
|
||||
:active="currentProject === 'all'"
|
||||
@click="currentProject = 'all'"
|
||||
clickable
|
||||
>
|
||||
<v-list-item-icon><v-icon name="language" /></v-list-item-icon>
|
||||
<v-list-item-content><v-text-overflow text="All Projects" /></v-list-item-content>
|
||||
</v-list-item>
|
||||
|
||||
<v-list-item
|
||||
v-for="project in projects"
|
||||
:key="project"
|
||||
:active="currentProject === project"
|
||||
@click="currentProject = project"
|
||||
clickable
|
||||
>
|
||||
<v-list-item-icon><v-icon name="public" color="var(--primary)" /></v-list-item-icon>
|
||||
<v-list-item-content><v-text-overflow :text="project || 'Unknown'" /></v-list-item-content>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</template>
|
||||
|
||||
<div class="feedback-container">
|
||||
<div v-if="!items.length && !loading && !fetchError" class="empty-state">
|
||||
<v-info icon="inbox" title="Clean Inbox" center>
|
||||
All feedback has been processed. Great job!
|
||||
</v-info>
|
||||
</div>
|
||||
|
||||
<div v-if="fetchError" class="empty-state">
|
||||
<v-info icon="error" title="Fetch Failed" :description="fetchError" center />
|
||||
<v-button @click="fetchData" secondary small>Retry</v-button>
|
||||
</div>
|
||||
|
||||
<div class="operational-layout" v-else-if="items.length">
|
||||
<!-- Detailed Triage Lane -->
|
||||
<aside class="triage-lane">
|
||||
<div class="lane-header">
|
||||
<v-select
|
||||
v-model="currentStatusFilter"
|
||||
:items="statusOptions"
|
||||
small
|
||||
placeholder="Status Filter"
|
||||
/>
|
||||
</div>
|
||||
<div class="lane-content scrollbar">
|
||||
<TransitionGroup name="list">
|
||||
<div
|
||||
v-for="item in filteredItems"
|
||||
:key="item.id"
|
||||
class="feedback-card"
|
||||
:class="{ active: selectedItem?.id === item.id }"
|
||||
@click="selectItem(item)"
|
||||
>
|
||||
<div class="card-status-bar" :style="{ background: getStatusColor(item.status || 'open') }"></div>
|
||||
<div class="card-body">
|
||||
<header class="card-header">
|
||||
<span class="card-user">{{ item.user_name }}</span>
|
||||
<span class="card-date">{{ formatDate(item.date_created || item.id) }}</span>
|
||||
</header>
|
||||
<div class="card-text">{{ item.text }}</div>
|
||||
<footer class="card-footer">
|
||||
<div class="meta-tags">
|
||||
<v-chip x-small outline>{{ item.project }}</v-chip>
|
||||
<v-icon :name="item.type === 'bug' ? 'bug_report' : 'lightbulb'" :color="item.type === 'bug' ? '#E91E63' : '#FFC107'" small />
|
||||
</div>
|
||||
<v-icon v-if="selectedItem?.id === item.id" name="chevron_right" small />
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
</TransitionGroup>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- Elaborated Master-Detail Desk -->
|
||||
<main class="processing-desk scrollbar">
|
||||
<Transition name="fade" mode="out-in">
|
||||
<div v-if="selectedItem" :key="selectedItem.id" class="desk-content">
|
||||
<header class="desk-header">
|
||||
<div class="headline-group">
|
||||
<div class="status-indicator">
|
||||
<div class="status-dot" :style="{ background: getStatusColor(selectedItem.status || 'open') }"></div>
|
||||
<span class="status-text">{{ capitalize(selectedItem.status || 'open') }}</span>
|
||||
</div>
|
||||
<h2>{{ selectedItem.user_name }}'s Submission</h2>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<v-select
|
||||
v-model="selectedItem.contact_person"
|
||||
:items="peopleOptions"
|
||||
inline
|
||||
placeholder="Bezugsperson..."
|
||||
show-deselect
|
||||
@update:model-value="updatePerson"
|
||||
/>
|
||||
<v-button primary @click="openDeepLink(selectedItem)">
|
||||
<v-icon name="open_in_new" left /> Open & Highlight
|
||||
</v-button>
|
||||
<v-select
|
||||
v-model="selectedItem.status"
|
||||
:items="statuses"
|
||||
inline
|
||||
@update:model-value="updateStatus"
|
||||
/>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="desk-grid">
|
||||
<!-- Message Container -->
|
||||
<div class="main-column">
|
||||
<v-card class="content-card">
|
||||
<v-card-title>
|
||||
<v-icon name="format_quote" left />
|
||||
Feedback Content
|
||||
</v-card-title>
|
||||
<v-card-text class="feedback-body">
|
||||
<div v-if="selectedItem.screenshot" class="visual-proof">
|
||||
<label class="proof-label"><v-icon name="photo" x-small /> Element Snapshot</label>
|
||||
<img :src="getAssetUrl(selectedItem.screenshot)" class="screenshot-img" />
|
||||
</div>
|
||||
<div class="main-text">{{ selectedItem.text }}</div>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
|
||||
<section class="reply-section">
|
||||
<div class="section-divider">
|
||||
<v-divider />
|
||||
<span class="divider-label">Internal Communication</span>
|
||||
<v-divider />
|
||||
</div>
|
||||
|
||||
<div class="thread">
|
||||
<TransitionGroup name="thread-list">
|
||||
<div v-for="reply in comments" :key="reply.id" class="reply-bubble">
|
||||
<header class="reply-header">
|
||||
<span class="reply-user">{{ reply.user_name }}</span>
|
||||
<span class="reply-date">{{ formatDate(reply.date_created || reply.id) }}</span>
|
||||
</header>
|
||||
<div class="reply-text">{{ reply.text }}</div>
|
||||
</div>
|
||||
</TransitionGroup>
|
||||
<div v-if="!comments.length" class="empty-state-mini">
|
||||
<v-icon name="auto_awesome" small /> No replies yet. Start the thread.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="composer">
|
||||
<v-textarea v-model="replyText" placeholder="Compose internal response..." auto-grow />
|
||||
<div class="composer-actions">
|
||||
<v-button secondary :loading="sending" @click="sendReply">Post Reply</v-button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<!-- Technical Sidebar -->
|
||||
<aside class="meta-column">
|
||||
<v-card class="meta-card">
|
||||
<v-card-title>Context</v-card-title>
|
||||
<v-card-text class="meta-list">
|
||||
<div class="meta-item">
|
||||
<label><v-icon name="public" x-small /> Website</label>
|
||||
<strong>{{ selectedItem.project }}</strong>
|
||||
</div>
|
||||
<div class="meta-item">
|
||||
<label><v-icon name="link" x-small /> Source Path</label>
|
||||
<span class="truncate-path" :title="selectedItem.url">{{ formatUrl(selectedItem.url) }}</span>
|
||||
<v-button icon small @click="openExternal(selectedItem.url)"><v-icon name="launch" /></v-button>
|
||||
</div>
|
||||
<v-divider />
|
||||
<div class="meta-item">
|
||||
<label><v-icon name="layers" x-small /> Element Trace</label>
|
||||
<code class="trace-code">{{ selectedItem.selector || 'Body' }}</code>
|
||||
</div>
|
||||
<div class="meta-item">
|
||||
<label><v-icon name="location_searching" x-small /> Precise Mark</label>
|
||||
<span class="coords">X: {{ Math.round(selectedItem.x) }}px / Y: {{ Math.round(selectedItem.y) }}px</span>
|
||||
</div>
|
||||
<div class="meta-item">
|
||||
<label><v-icon name="fingerprint" x-small /> Reference ID</label>
|
||||
<code class="id-code">{{ selectedItem.id }}</code>
|
||||
</div>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
|
||||
<div class="help-box">
|
||||
<v-icon name="help_outline" x-small />
|
||||
<span>Click "Open & Highlight" to jump directly to this element on the live site.</span>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="no-selection-desk">
|
||||
<v-info icon="touch_app" title="Select Feedback" center>
|
||||
Choose an entry from the triage list to view details and process.
|
||||
</v-info>
|
||||
</div>
|
||||
</Transition>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</private-view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue';
|
||||
import { useApi } from '@directus/extensions-sdk';
|
||||
|
||||
const api = useApi();
|
||||
const items = ref([]);
|
||||
const comments = ref([]);
|
||||
const people = ref([]);
|
||||
const loading = ref(true);
|
||||
const fetchError = ref(null);
|
||||
const sending = ref(false);
|
||||
const selectedItem = ref(null);
|
||||
const currentProject = ref('all');
|
||||
const currentStatusFilter = ref('open');
|
||||
const replyText = ref('');
|
||||
|
||||
const statuses = [
|
||||
{ text: 'Open', value: 'open', icon: 'warning', color: '#E91E63' },
|
||||
{ text: 'In Progress', value: 'in_progress', icon: 'play_arrow', color: '#2196F3' },
|
||||
{ text: 'Resolved', value: 'resolved', icon: 'check_circle', color: '#4CAF50' }
|
||||
];
|
||||
|
||||
const statusOptions = [
|
||||
{ text: 'All Statuses', value: 'all' },
|
||||
...statuses
|
||||
];
|
||||
|
||||
const projects = computed(() => {
|
||||
const projSet = new Set(items.value.map(i => i.project).filter(Boolean));
|
||||
return Array.from(projSet).sort();
|
||||
});
|
||||
|
||||
const peopleOptions = computed(() =>
|
||||
people.value.map(p => ({
|
||||
text: `${p.first_name} ${p.last_name}`,
|
||||
value: p.id
|
||||
}))
|
||||
);
|
||||
|
||||
async function fetchData() {
|
||||
loading.value = true;
|
||||
fetchError.value = null;
|
||||
try {
|
||||
const [feedbackRes, peopleRes] = await Promise.all([
|
||||
api.get('/items/visual_feedback', {
|
||||
params: {
|
||||
sort: '-date_created,-id',
|
||||
limit: 300
|
||||
}
|
||||
}),
|
||||
api.get('/items/people', { params: { sort: 'last_name' } })
|
||||
]);
|
||||
items.value = feedbackRes.data.data;
|
||||
people.value = peopleRes.data.data;
|
||||
} catch (e: any) {
|
||||
fetchError.value = e.message;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function selectItem(item) {
|
||||
selectedItem.value = null;
|
||||
setTimeout(async () => {
|
||||
selectedItem.value = item;
|
||||
comments.value = [];
|
||||
try {
|
||||
const response = await api.get('/items/visual_feedback_comments', {
|
||||
params: {
|
||||
filter: { feedback_id: { _eq: item.id } },
|
||||
sort: '-date_created,-id'
|
||||
}
|
||||
});
|
||||
comments.value = response.data.data;
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
}, 10);
|
||||
}
|
||||
|
||||
async function updateStatus(val) {
|
||||
if (!selectedItem.value) return;
|
||||
try {
|
||||
await api.patch(`/items/visual_feedback/${selectedItem.value.id}`, {
|
||||
status: val
|
||||
});
|
||||
fetchData();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
}
|
||||
|
||||
async function updatePerson(val) {
|
||||
if (!selectedItem.value) return;
|
||||
try {
|
||||
await api.patch(`/items/visual_feedback/${selectedItem.value.id}`, {
|
||||
contact_person: val
|
||||
});
|
||||
fetchData();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
}
|
||||
|
||||
async function sendReply() {
|
||||
if (!replyText.value.trim() || !selectedItem.value) return;
|
||||
sending.value = true;
|
||||
try {
|
||||
const response = await api.post('/items/visual_feedback_comments', {
|
||||
feedback_id: selectedItem.value.id,
|
||||
user_name: 'Operator',
|
||||
text: replyText.value
|
||||
});
|
||||
comments.value.unshift(response.data.data);
|
||||
replyText.value = '';
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} finally {
|
||||
sending.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(dateStr) {
|
||||
if (!dateStr || typeof dateStr === 'number') return 'Legacy';
|
||||
return new Date(dateStr).toLocaleDateString() + ' ' + new Date(dateStr).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||
}
|
||||
|
||||
function formatUrl(url) {
|
||||
if (!url) return '';
|
||||
return url.replace(/^https?:\/\//, '');
|
||||
}
|
||||
|
||||
function capitalize(s) {
|
||||
return s.charAt(0).toUpperCase() + s.slice(1).replace('_', ' ');
|
||||
}
|
||||
|
||||
function getDeepLinkUrl(item) {
|
||||
if (!item || !item.url) return '';
|
||||
try {
|
||||
const url = new URL(item.url);
|
||||
url.searchParams.set('fb_id', item.id);
|
||||
return url.toString();
|
||||
} catch (e) {
|
||||
return item.url + '?fb_id=' + item.id;
|
||||
}
|
||||
}
|
||||
|
||||
function openDeepLink(item) {
|
||||
const url = getDeepLinkUrl(item);
|
||||
if (url) window.open(url, '_blank');
|
||||
}
|
||||
|
||||
function openExternal(url) {
|
||||
if (url) window.open(url, '_blank');
|
||||
}
|
||||
|
||||
function getAssetUrl(id) {
|
||||
if (!id) return '';
|
||||
return `/assets/${id}`;
|
||||
}
|
||||
|
||||
function getStatusColor(status) {
|
||||
const s = statuses.find(st => st.value === status);
|
||||
return s ? s.color : 'var(--foreground-subdued)';
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchData();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.feedback-container {
|
||||
height: calc(100vh - 64px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--background-subdued);
|
||||
}
|
||||
|
||||
.operational-layout {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* Triage Lane Polish */
|
||||
.triage-lane {
|
||||
width: 360px;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--background-normal);
|
||||
border-right: 1px solid var(--border-normal);
|
||||
box-shadow: 2px 0 8px rgba(0,0,0,0.02);
|
||||
}
|
||||
|
||||
.lane-header {
|
||||
padding: 16px;
|
||||
background: var(--background-normal);
|
||||
border-bottom: 1px solid var(--border-normal);
|
||||
}
|
||||
|
||||
.lane-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.feedback-card {
|
||||
background: var(--background-normal);
|
||||
border: 1px solid var(--border-subdued);
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
transition: all var(--transition);
|
||||
}
|
||||
|
||||
.feedback-card:hover {
|
||||
border-color: var(--border-normal);
|
||||
background: var(--background-subdued);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 8px rgba(0,0,0,0.04);
|
||||
}
|
||||
|
||||
.feedback-card.active {
|
||||
border-color: var(--primary);
|
||||
background: var(--background-accent);
|
||||
box-shadow: 0 4px 12px rgba(var(--primary-rgb), 0.1);
|
||||
}
|
||||
|
||||
.card-status-bar {
|
||||
width: 4px;
|
||||
}
|
||||
|
||||
.card-body {
|
||||
flex: 1;
|
||||
padding: 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.card-user { font-weight: bold; color: var(--foreground-normal); }
|
||||
.card-date { color: var(--foreground-subdued); }
|
||||
|
||||
.card-text {
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
color: var(--foreground-normal);
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.card-footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.meta-tags {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* Processing Desk Refinement */
|
||||
.processing-desk {
|
||||
flex: 1;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
padding: 32px;
|
||||
}
|
||||
|
||||
.desk-content {
|
||||
max-width: 1100px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.desk-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-end;
|
||||
margin-bottom: 32px;
|
||||
border-bottom: 2px solid var(--border-normal);
|
||||
padding-bottom: 20px;
|
||||
}
|
||||
|
||||
.headline-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.status-indicator {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 12px;
|
||||
font-weight: bold;
|
||||
text-transform: uppercase;
|
||||
color: var(--foreground-subdued);
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.status-text { letter-spacing: 0.5px; }
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.desk-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 300px;
|
||||
gap: 24px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.content-card {
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.feedback-body {
|
||||
font-size: 18px;
|
||||
line-height: 1.6;
|
||||
padding: 24px;
|
||||
color: var(--foreground-normal);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.visual-proof {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.proof-label {
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
font-weight: 800;
|
||||
color: var(--foreground-subdued);
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.screenshot-img {
|
||||
width: 100%;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border-normal);
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
|
||||
background: var(--background-subdued);
|
||||
}
|
||||
|
||||
.main-text {
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.reply-section {
|
||||
margin-top: 40px;
|
||||
}
|
||||
|
||||
.section-divider {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.divider-label {
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
font-weight: 800;
|
||||
color: var(--foreground-subdued);
|
||||
white-space: nowrap;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.thread {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.reply-bubble {
|
||||
padding: 16px;
|
||||
border-radius: 12px;
|
||||
background: var(--background-normal);
|
||||
border: 1px solid var(--border-subdued);
|
||||
}
|
||||
|
||||
.reply-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 11px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.reply-user { font-weight: 800; color: var(--primary); }
|
||||
.reply-date { color: var(--foreground-subdued); }
|
||||
|
||||
.reply-text { font-size: 14px; line-height: 1.5; }
|
||||
|
||||
.composer {
|
||||
background: var(--background-normal);
|
||||
border: 1px solid var(--border-normal);
|
||||
border-radius: 12px;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.composer-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.meta-card {
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.meta-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.meta-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.meta-item label {
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
font-weight: bold;
|
||||
color: var(--foreground-subdued);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.truncate-path {
|
||||
color: var(--primary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.trace-code, .id-code {
|
||||
background: var(--background-subdued);
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.coords { font-weight: bold; font-family: var(--family-monospace); }
|
||||
|
||||
.help-box {
|
||||
margin-top: 20px;
|
||||
padding: 16px;
|
||||
background: rgba(var(--primary-rgb), 0.05);
|
||||
border-radius: 12px;
|
||||
font-size: 12px;
|
||||
color: var(--primary);
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.no-selection-desk {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.empty-state-mini {
|
||||
text-align: center;
|
||||
padding: 24px;
|
||||
font-size: 12px;
|
||||
color: var(--foreground-subdued);
|
||||
background: var(--background-subdued);
|
||||
border-radius: 12px;
|
||||
border: 1px dashed var(--border-normal);
|
||||
}
|
||||
|
||||
/* Animations */
|
||||
.list-enter-active, .list-leave-active { transition: all 0.3s ease; }
|
||||
.list-enter-from, .list-leave-to { opacity: 0; transform: translateX(-20px); }
|
||||
|
||||
.fade-enter-active, .fade-leave-active { transition: opacity 0.2s ease, transform 0.2s ease; }
|
||||
.fade-enter-from { opacity: 0; transform: translateY(10px); }
|
||||
.fade-leave-to { opacity: 0; transform: translateY(-10px); }
|
||||
|
||||
.thread-list-enter-active { transition: all 0.4s ease; transform-origin: top; }
|
||||
.thread-list-enter-from { opacity: 0; transform: scaleY(0.9); }
|
||||
|
||||
.scrollbar::-webkit-scrollbar { width: 6px; }
|
||||
.scrollbar::-webkit-scrollbar-track { background: transparent; }
|
||||
.scrollbar::-webkit-scrollbar-thumb { background: var(--border-subdued); border-radius: 3px; }
|
||||
.scrollbar::-webkit-scrollbar-thumb:hover { background: var(--border-normal); }
|
||||
</style>
|
||||
1
packages/cms-infra/extensions/people-manager/index.js
Normal file
1
packages/cms-infra/extensions/people-manager/index.js
Normal file
File diff suppressed because one or more lines are too long
20
packages/cms-infra/extensions/people-manager/package.json
Normal file
20
packages/cms-infra/extensions/people-manager/package.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"name": "people-manager",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"directus:extension": {
|
||||
"type": "module",
|
||||
"path": "dist/index.js",
|
||||
"source": "src/index.ts",
|
||||
"host": "^11.0.0"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "directus-extension build",
|
||||
"dev": "directus-extension build -w"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@directus/extensions-sdk": "11.0.2",
|
||||
"vue": "^3.4.34",
|
||||
"typescript": "^5.6.3"
|
||||
}
|
||||
}
|
||||
14
packages/cms-infra/extensions/people-manager/src/index.ts
Normal file
14
packages/cms-infra/extensions/people-manager/src/index.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { defineModule } from '@directus/extensions-sdk';
|
||||
import ModuleComponent from './module.vue';
|
||||
|
||||
export default defineModule({
|
||||
id: 'people-manager',
|
||||
name: 'People Manager',
|
||||
icon: 'person',
|
||||
routes: [
|
||||
{
|
||||
path: '',
|
||||
component: ModuleComponent,
|
||||
},
|
||||
],
|
||||
});
|
||||
296
packages/cms-infra/extensions/people-manager/src/module.vue
Normal file
296
packages/cms-infra/extensions/people-manager/src/module.vue
Normal file
@@ -0,0 +1,296 @@
|
||||
<template>
|
||||
<private-view title="People Manager">
|
||||
<template #navigation>
|
||||
<v-list nav>
|
||||
<v-list-item @click="openCreateDrawer" clickable>
|
||||
<v-list-item-icon>
|
||||
<v-icon name="add" color="var(--theme--primary)" />
|
||||
</v-list-item-icon>
|
||||
<v-list-item-content>
|
||||
<v-text-overflow text="Neue Person anlegen" />
|
||||
</v-list-item-content>
|
||||
</v-list-item>
|
||||
|
||||
<v-divider />
|
||||
|
||||
<v-list-item
|
||||
v-for="person in people"
|
||||
:key="person.id"
|
||||
:active="selectedPerson?.id === person.id"
|
||||
class="person-item"
|
||||
clickable
|
||||
@click="selectPerson(person)"
|
||||
>
|
||||
<v-list-item-icon>
|
||||
<v-icon name="person" />
|
||||
</v-list-item-icon>
|
||||
<v-list-item-content>
|
||||
<v-text-overflow :text="`${person.first_name} ${person.last_name}`" />
|
||||
</v-list-item-content>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</template>
|
||||
|
||||
<div class="content-wrapper">
|
||||
<v-notice v-if="feedback" :type="feedback.type" @close="feedback = null" dismissible>
|
||||
{{ feedback.message }}
|
||||
</v-notice>
|
||||
|
||||
<div v-if="!selectedPerson" class="empty-state">
|
||||
<v-info title="Person auswählen" icon="person" center>
|
||||
Wähle eine Person in der Navigation aus oder
|
||||
<v-button x-small @click="openCreateDrawer">erstelle eine neue Person</v-button>.
|
||||
</v-info>
|
||||
</div>
|
||||
|
||||
<div v-else>
|
||||
<header class="header">
|
||||
<div class="header-left">
|
||||
<h1 class="title">{{ selectedPerson.first_name }} {{ selectedPerson.last_name }}</h1>
|
||||
<p class="subtitle">{{ selectedPerson.email || 'Keine E-Mail angegeben' }}</p>
|
||||
</div>
|
||||
|
||||
<div class="header-right">
|
||||
<v-button secondary rounded icon v-tooltip="'Person bearbeiten'" @click="openEditDrawer">
|
||||
<v-icon name="edit" />
|
||||
</v-button>
|
||||
<v-button danger rounded icon v-tooltip="'Person löschen'" @click="deletePerson">
|
||||
<v-icon name="delete" />
|
||||
</v-button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<v-divider />
|
||||
|
||||
<div class="details-grid">
|
||||
<div class="detail-item">
|
||||
<span class="label">Organisation</span>
|
||||
<p class="value">{{ selectedPerson.company || '---' }}</p>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<span class="label">Telefon</span>
|
||||
<p class="value">{{ selectedPerson.phone || '---' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Create/Edit Drawer -->
|
||||
<v-drawer
|
||||
v-model="drawerActive"
|
||||
:title="isEditing ? 'Person bearbeiten' : 'Neue Person anlegen'"
|
||||
icon="person"
|
||||
@cancel="drawerActive = false"
|
||||
>
|
||||
<template #default>
|
||||
<div class="drawer-content">
|
||||
<div class="form-section">
|
||||
<div class="field">
|
||||
<span class="label">Vorname</span>
|
||||
<v-input v-model="form.first_name" placeholder="Vorname" autofocus />
|
||||
</div>
|
||||
<div class="field">
|
||||
<span class="label">Nachname</span>
|
||||
<v-input v-model="form.last_name" placeholder="Nachname" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<span class="label">E-Mail</span>
|
||||
<v-input v-model="form.email" placeholder="email@beispiel.de" type="email" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<span class="label">Organisation / Firma</span>
|
||||
<v-input v-model="form.company" placeholder="z.B. Mintel" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<span class="label">Telefon</span>
|
||||
<v-input v-model="form.phone" placeholder="+49 ..." />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="drawer-actions">
|
||||
<v-button primary block :loading="saving" @click="savePerson">
|
||||
Person speichern
|
||||
</v-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</v-drawer>
|
||||
</private-view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { useApi } from '@directus/extensions-sdk';
|
||||
|
||||
const api = useApi();
|
||||
const people = ref([]);
|
||||
const selectedPerson = ref(null);
|
||||
const feedback = ref(null);
|
||||
const saving = ref(false);
|
||||
const drawerActive = ref(false);
|
||||
const isEditing = ref(false);
|
||||
|
||||
const form = ref({
|
||||
id: null,
|
||||
first_name: '',
|
||||
last_name: '',
|
||||
email: '',
|
||||
company: '',
|
||||
phone: ''
|
||||
});
|
||||
|
||||
async function fetchPeople() {
|
||||
try {
|
||||
const response = await api.get('/items/people', {
|
||||
params: { sort: 'last_name' }
|
||||
});
|
||||
people.ref = response.data.data;
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch people:', error);
|
||||
}
|
||||
}
|
||||
|
||||
function selectPerson(person) {
|
||||
selectedPerson.value = person;
|
||||
}
|
||||
|
||||
function openCreateDrawer() {
|
||||
isEditing.value = false;
|
||||
form.value = { id: null, first_name: '', last_name: '', email: '', company: '', phone: '' };
|
||||
drawerActive.value = true;
|
||||
}
|
||||
|
||||
function openEditDrawer() {
|
||||
isEditing.value = true;
|
||||
form.value = { ...selectedPerson.value };
|
||||
drawerActive.value = true;
|
||||
}
|
||||
|
||||
async function savePerson() {
|
||||
if (!form.value.first_name || !form.value.last_name) {
|
||||
feedback.value = { type: 'danger', message: 'Vor- und Nachname sind erforderlich.' };
|
||||
return;
|
||||
}
|
||||
|
||||
saving.value = true;
|
||||
try {
|
||||
if (isEditing.value) {
|
||||
await api.patch(`/items/people/${form.value.id}`, form.value);
|
||||
feedback.value = { type: 'success', message: 'Person aktualisiert!' };
|
||||
} else {
|
||||
await api.post('/items/people', form.value);
|
||||
feedback.value = { type: 'success', message: 'Person angelegt!' };
|
||||
}
|
||||
drawerActive.value = false;
|
||||
await fetchPeople();
|
||||
if (isEditing.value) {
|
||||
selectedPerson.value = form.value;
|
||||
}
|
||||
} catch (error) {
|
||||
feedback.value = { type: 'danger', message: error.message };
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function deletePerson() {
|
||||
if (!confirm('Soll diese Person wirklich gelöscht werden?')) return;
|
||||
|
||||
try {
|
||||
await api.delete(`/items/people/${selectedPerson.value.id}`);
|
||||
feedback.value = { type: 'success', message: 'Person gelöscht.' };
|
||||
selectedPerson.value = null;
|
||||
await fetchPeople();
|
||||
} catch (error) {
|
||||
feedback.value = { type: 'danger', message: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(fetchPeople);
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.content-wrapper {
|
||||
padding: 32px;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.header {
|
||||
margin-bottom: 24px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 24px;
|
||||
font-weight: 800;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
color: var(--theme--foreground-subdued);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.header-right {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.details-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 32px;
|
||||
margin-top: 32px;
|
||||
}
|
||||
|
||||
.detail-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.label {
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
color: var(--theme--foreground-subdued);
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.value {
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.drawer-content {
|
||||
padding: 24px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 32px;
|
||||
}
|
||||
|
||||
.form-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.drawer-actions {
|
||||
margin-top: 24px;
|
||||
}
|
||||
</style>
|
||||
14
packages/cms-infra/package.json
Normal file
14
packages/cms-infra/package.json
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"name": "@mintel/cms-infra",
|
||||
"version": "1.7.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "pnpm --filter \"./extensions/**\" build",
|
||||
"dev": "pnpm --filter \"./extensions/**\" dev",
|
||||
"up": "docker compose up -d",
|
||||
"up:build": "docker compose up -d --build",
|
||||
"down": "docker compose down",
|
||||
"logs": "docker compose logs -f"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export default (router) => {
|
||||
router.get('/ping', (req, res) => res.send('pong'));
|
||||
};
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"name": "test-extension",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"directus:extension": {
|
||||
"type": "endpoint",
|
||||
"path": "index.js",
|
||||
"host": "^11.0.0"
|
||||
}
|
||||
}
|
||||
1416
packages/cms-infra/schema/snapshot.yaml
Normal file
1416
packages/cms-infra/schema/snapshot.yaml
Normal file
File diff suppressed because it is too large
Load Diff
75
packages/cms-infra/scripts/bootstrap-schema.sh
Normal file
75
packages/cms-infra/scripts/bootstrap-schema.sh
Normal file
@@ -0,0 +1,75 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Configuration
|
||||
API_URL="http://localhost:8059"
|
||||
EMAIL="marc@mintel.me"
|
||||
PASSWORD="Tim300493."
|
||||
|
||||
echo "Logging in to Directus..."
|
||||
TOKEN=$(curl -s -X POST "${API_URL}/auth/login" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"email\":\"${EMAIL}\", \"password\":\"${PASSWORD}\"}" | grep -o '"access_token":"[^"]*' | cut -d'"' -f4)
|
||||
|
||||
if [ -z "$TOKEN" ]; then
|
||||
echo "Login failed!"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Hiding 'leads' collection..."
|
||||
curl -s -X PATCH "${API_URL}/collections/leads" \
|
||||
-H "Authorization: Bearer ${TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"meta": {"hidden": true}}'
|
||||
|
||||
echo "Creating 'people' collection..."
|
||||
curl -s -X POST "${API_URL}/collections" \
|
||||
-H "Authorization: Bearer ${TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"collection": "people",
|
||||
"schema": {},
|
||||
"meta": {
|
||||
"icon": "person",
|
||||
"display_template": "{{first_name}} {{last_name}}",
|
||||
"show_status_indicator": true
|
||||
}
|
||||
}'
|
||||
|
||||
echo "Adding fields to 'people'..."
|
||||
FIELDS='[
|
||||
{"field": "first_name", "type": "string", "meta": {"interface": "input", "width": "half"}},
|
||||
{"field": "last_name", "type": "string", "meta": {"interface": "input", "width": "half"}},
|
||||
{"field": "email", "type": "string", "meta": {"interface": "input", "width": "half"}},
|
||||
{"field": "phone", "type": "string", "meta": {"interface": "input", "width": "half"}},
|
||||
{"field": "company", "type": "string", "meta": {"interface": "input", "width": "full"}}
|
||||
]'
|
||||
|
||||
for field in $(echo "${FIELDS}" | jq -c '.[]'); do
|
||||
curl -s -X POST "${API_URL}/fields/people" \
|
||||
-H "Authorization: Bearer ${TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "${field}"
|
||||
done
|
||||
|
||||
echo "Adding 'contact_person' to 'leads', 'client_users', and 'visual_feedback'..."
|
||||
for collection in leads client_users visual_feedback; do
|
||||
curl -s -X POST "${API_URL}/fields/${collection}" \
|
||||
-H "Authorization: Bearer ${TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"field": "contact_person",
|
||||
"type": "uuid",
|
||||
"meta": {
|
||||
"interface": "select-dropdown-m2o",
|
||||
"options": {
|
||||
"template": "{{first_name}} {{last_name}}"
|
||||
}
|
||||
},
|
||||
"schema": {
|
||||
"foreign_key_column": "id",
|
||||
"foreign_key_table": "people"
|
||||
}
|
||||
}'
|
||||
done
|
||||
|
||||
echo "Done!"
|
||||
1
packages/cms-infra/uploads/directus-health-file
Normal file
1
packages/cms-infra/uploads/directus-health-file
Normal file
@@ -0,0 +1 @@
|
||||
xmKX5
|
||||
@@ -1,35 +0,0 @@
|
||||
{
|
||||
"name": "@mintel/concept-engine",
|
||||
"version": "1.9.17",
|
||||
"description": "AI-powered web project concept generation and analysis",
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"bin": {
|
||||
"concept": "./dist/cli.js"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsup",
|
||||
"dev": "tsup --watch",
|
||||
"test": "vitest",
|
||||
"clean": "rm -rf dist",
|
||||
"lint": "eslint src --ext .ts",
|
||||
"concept": "tsx src/cli.ts run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@mintel/journaling": "workspace:*",
|
||||
"@mintel/page-audit": "workspace:*",
|
||||
"axios": "^1.7.9",
|
||||
"cheerio": "1.0.0-rc.12",
|
||||
"commander": "^13.1.0",
|
||||
"dotenv": "^16.4.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.17.17",
|
||||
"tsup": "^8.3.6",
|
||||
"tsx": "^4.19.2",
|
||||
"typescript": "^5.7.3",
|
||||
"vitest": "^3.0.5"
|
||||
}
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
import { config as dotenvConfig } from "dotenv";
|
||||
import * as path from "node:path";
|
||||
import * as fs from "node:fs/promises";
|
||||
import { ConceptPipeline } from "./pipeline.js";
|
||||
|
||||
dotenvConfig({ path: path.resolve(process.cwd(), "../../.env") });
|
||||
|
||||
const briefing = await fs.readFile(
|
||||
path.resolve(process.cwd(), "../../data/briefings/etib.txt"),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
console.log(`Briefing loaded: ${briefing.length} chars`);
|
||||
|
||||
const pipeline = new ConceptPipeline(
|
||||
{
|
||||
openrouterKey: process.env.OPENROUTER_API_KEY || "",
|
||||
zyteApiKey: process.env.ZYTE_API_KEY,
|
||||
outputDir: path.resolve(process.cwd(), "../../out/estimations"),
|
||||
crawlDir: path.resolve(process.cwd(), "../../data/crawls"),
|
||||
},
|
||||
{
|
||||
onStepStart: (id, _name) => console.log(`[CB] Starting: ${id}`),
|
||||
onStepComplete: (id) => console.log(`[CB] Done: ${id}`),
|
||||
onStepError: (id, err) => console.error(`[CB] Error in ${id}: ${err}`),
|
||||
},
|
||||
);
|
||||
|
||||
try {
|
||||
await pipeline.run({
|
||||
briefing,
|
||||
url: "https://www.e-tib.com",
|
||||
});
|
||||
|
||||
console.log("\n✨ Pipeline complete!");
|
||||
} catch (err: any) {
|
||||
console.error("\n❌ Pipeline failed:", err.message);
|
||||
console.error(err.stack);
|
||||
}
|
||||
@@ -1,334 +0,0 @@
|
||||
// ============================================================================
|
||||
// Analyzer — Deterministic Site Analysis (NO LLM!)
|
||||
// Builds a SiteProfile from crawled pages using pure code logic.
|
||||
// This is the core fix against hallucinated page structures.
|
||||
// ============================================================================
|
||||
|
||||
import type {
|
||||
CrawledPage,
|
||||
SiteProfile,
|
||||
NavItem,
|
||||
CompanyInfo,
|
||||
PageInventoryItem,
|
||||
} from "./types.js";
|
||||
|
||||
/**
|
||||
* Build a complete SiteProfile from an array of crawled pages.
|
||||
* This is 100% deterministic — no LLM calls involved.
|
||||
*/
|
||||
export function analyzeSite(pages: CrawledPage[], domain: string): SiteProfile {
|
||||
const navigation = extractNavigation(pages);
|
||||
const existingFeatures = extractExistingFeatures(pages);
|
||||
const services = extractAllServices(pages);
|
||||
const companyInfo = extractCompanyInfo(pages);
|
||||
const colors = extractColors(pages);
|
||||
const socialLinks = extractSocialLinks(pages);
|
||||
const externalDomains = extractExternalDomains(pages, domain);
|
||||
const images = extractAllImages(pages);
|
||||
const employeeCount = extractEmployeeCount(pages);
|
||||
const pageInventory = buildPageInventory(pages);
|
||||
|
||||
return {
|
||||
domain,
|
||||
crawledAt: new Date().toISOString(),
|
||||
totalPages: pages.filter((p) => p.type !== "legal").length,
|
||||
navigation,
|
||||
existingFeatures,
|
||||
services,
|
||||
companyInfo,
|
||||
pageInventory,
|
||||
colors,
|
||||
socialLinks,
|
||||
externalDomains,
|
||||
images,
|
||||
employeeCount,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the site's main navigation structure from <nav> elements.
|
||||
* Uses the HOME page's nav as the canonical source.
|
||||
*/
|
||||
function extractNavigation(pages: CrawledPage[]): NavItem[] {
|
||||
// Prefer the home page's nav
|
||||
const homePage = pages.find((p) => p.type === "home");
|
||||
const sourcePage = homePage || pages[0];
|
||||
if (!sourcePage) return [];
|
||||
|
||||
// Deduplicate nav items
|
||||
const seen = new Set<string>();
|
||||
const navItems: NavItem[] = [];
|
||||
|
||||
for (const label of sourcePage.navItems) {
|
||||
const normalized = label.toLowerCase().trim();
|
||||
if (seen.has(normalized)) continue;
|
||||
if (normalized.length < 2) continue;
|
||||
seen.add(normalized);
|
||||
navItems.push({ label, href: "" });
|
||||
}
|
||||
|
||||
return navItems;
|
||||
}
|
||||
|
||||
/**
|
||||
* Aggregate all detected interactive features across all pages.
|
||||
*/
|
||||
function extractExistingFeatures(pages: CrawledPage[]): string[] {
|
||||
const allFeatures = new Set<string>();
|
||||
for (const page of pages) {
|
||||
for (const feature of page.features) {
|
||||
allFeatures.add(feature);
|
||||
}
|
||||
}
|
||||
return [...allFeatures];
|
||||
}
|
||||
|
||||
/**
|
||||
* Aggregate all images found across all pages.
|
||||
*/
|
||||
function extractAllImages(pages: CrawledPage[]): string[] {
|
||||
const allImages = new Set<string>();
|
||||
for (const page of pages) {
|
||||
if (!page.images) continue;
|
||||
for (const img of page.images) {
|
||||
allImages.add(img);
|
||||
}
|
||||
}
|
||||
return [...allImages];
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract employee count from page text.
|
||||
* Looks for patterns like "über 50 Mitarbeitern", "200 Mitarbeiter", "50+ employees".
|
||||
*/
|
||||
function extractEmployeeCount(pages: CrawledPage[]): string | null {
|
||||
const allText = pages.map((p) => p.text).join(" ");
|
||||
|
||||
// German patterns: 'über 50 Mitarbeitern', '120 Beschäftigte', '+200 MA'
|
||||
const patterns = [
|
||||
/(über|ca\.?|rund|mehr als|\+)?\s*(\d{1,4})\s*(Mitarbeiter(?:innen)?|Beschäftigte|MA|Fachkräfte)\b/gi,
|
||||
/(\d{1,4})\+?\s*(employees|team members)/gi,
|
||||
];
|
||||
|
||||
for (const pattern of patterns) {
|
||||
const match = allText.match(pattern);
|
||||
if (match && match[0]) {
|
||||
const num = match[0].match(/(\d{1,4})/)?.[1];
|
||||
const prefix = match[0].match(/über|ca\.?|rund|mehr als/i)?.[0];
|
||||
if (num) return prefix ? `${prefix} ${num}` : num;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract services/competencies from service-type pages.
|
||||
* Focuses on H2-H3 headings and list items on service pages.
|
||||
*/
|
||||
function extractAllServices(pages: CrawledPage[]): string[] {
|
||||
const servicePages = pages.filter(
|
||||
(p) => p.type === "service" || p.pathname.includes("kompetenz"),
|
||||
);
|
||||
|
||||
const services = new Set<string>();
|
||||
for (const page of servicePages) {
|
||||
// Use headings as primary service indicators
|
||||
for (const heading of page.headings) {
|
||||
const clean = heading.trim();
|
||||
if (clean.length > 3 && clean.length < 100) {
|
||||
// Skip generic headings
|
||||
if (/^(home|kontakt|impressum|datenschutz|menü|navigation|suche)/i.test(clean)) continue;
|
||||
services.add(clean);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If no service pages found, look at the home page headings too
|
||||
if (services.size === 0) {
|
||||
const homePage = pages.find((p) => p.type === "home");
|
||||
if (homePage) {
|
||||
for (const heading of homePage.headings) {
|
||||
const clean = heading.trim();
|
||||
if (clean.length > 3 && clean.length < 80) {
|
||||
services.add(clean);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [...services];
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract company information from Impressum / footer content.
|
||||
*/
|
||||
function extractCompanyInfo(pages: CrawledPage[]): CompanyInfo {
|
||||
const info: CompanyInfo = {};
|
||||
|
||||
// Find Impressum or legal page
|
||||
const legalPage = pages.find(
|
||||
(p) =>
|
||||
p.type === "legal" &&
|
||||
(p.pathname.includes("impressum") || p.title.toLowerCase().includes("impressum")),
|
||||
);
|
||||
|
||||
const sourceText = legalPage?.text || pages.find((p) => p.type === "home")?.text || "";
|
||||
|
||||
// USt-ID
|
||||
const taxMatch = sourceText.match(/USt[.\s-]*(?:ID[.\s-]*Nr\.?|IdNr\.?)[:\s]*([A-Z]{2}\d{9,11})/i);
|
||||
if (taxMatch) info.taxId = taxMatch[1];
|
||||
|
||||
// HRB number
|
||||
const hrbMatch = sourceText.match(/HRB[:\s]*(\d+\s*[A-Z]*)/i);
|
||||
if (hrbMatch) info.registerNumber = `HRB ${hrbMatch[1].trim()}`;
|
||||
|
||||
// Phone
|
||||
const phoneMatch = sourceText.match(/(?:Tel|Telefon|Fon)[.:\s]*([+\d\s()/-]{10,20})/i);
|
||||
if (phoneMatch) info.phone = phoneMatch[1].trim();
|
||||
|
||||
// Email
|
||||
const emailMatch = sourceText.match(/[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/);
|
||||
if (emailMatch) info.email = emailMatch[0];
|
||||
|
||||
// Address (look for German postal code pattern)
|
||||
const addressMatch = sourceText.match(
|
||||
/(?:[\w\s.-]+(?:straße|str\.|weg|platz|ring|allee|gasse)\s*\d+[a-z]?\s*,?\s*)?(?:D-)?(\d{5})\s+\w+/i,
|
||||
);
|
||||
if (addressMatch) info.address = addressMatch[0].trim();
|
||||
|
||||
// GF / Geschäftsführer
|
||||
const gfMatch = sourceText.match(
|
||||
/Geschäftsführ(?:er|ung)[:\s]*([A-ZÄÖÜ][a-zäöüß]+(?:\s+[A-ZÄÖÜ][a-zäöüß]+){1,3})/,
|
||||
);
|
||||
if (gfMatch) info.managingDirector = gfMatch[1].trim();
|
||||
|
||||
return info;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract brand colors from HTML (inline styles, CSS variables).
|
||||
*/
|
||||
function extractColors(pages: CrawledPage[]): string[] {
|
||||
const colors = new Set<string>();
|
||||
const homePage = pages.find((p) => p.type === "home");
|
||||
if (!homePage) return [];
|
||||
|
||||
const hexMatches = homePage.html.match(/#(?:[0-9a-fA-F]{3}){1,2}\b/g) || [];
|
||||
for (const hex of hexMatches) {
|
||||
colors.add(hex.toLowerCase());
|
||||
if (colors.size >= 8) break;
|
||||
}
|
||||
|
||||
return [...colors];
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract social media links from footers / headers.
|
||||
*/
|
||||
function extractSocialLinks(pages: CrawledPage[]): Record<string, string> {
|
||||
const socials: Record<string, string> = {};
|
||||
const platforms = [
|
||||
{ key: "linkedin", patterns: ["linkedin.com"] },
|
||||
{ key: "instagram", patterns: ["instagram.com"] },
|
||||
{ key: "facebook", patterns: ["facebook.com", "fb.com"] },
|
||||
{ key: "youtube", patterns: ["youtube.com", "youtu.be"] },
|
||||
{ key: "twitter", patterns: ["twitter.com", "x.com"] },
|
||||
{ key: "xing", patterns: ["xing.com"] },
|
||||
];
|
||||
|
||||
const homePage = pages.find((p) => p.type === "home");
|
||||
if (!homePage) return socials;
|
||||
|
||||
const urlMatches = homePage.html.match(/https?:\/\/[^\s"'<>]+/g) || [];
|
||||
for (const url of urlMatches) {
|
||||
for (const platform of platforms) {
|
||||
if (platform.patterns.some((p) => url.includes(p)) && !socials[platform.key]) {
|
||||
socials[platform.key] = url;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return socials;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find domains that are linked but separate from the main domain.
|
||||
* Critical for detecting sister companies with own websites (e.g. etib-ing.com).
|
||||
*/
|
||||
function extractExternalDomains(pages: CrawledPage[], mainDomain: string): string[] {
|
||||
const externalDomains = new Set<string>();
|
||||
const cleanMain = mainDomain.replace(/^www\./, "");
|
||||
// Extract meaningful base parts: "e-tib.com" → ["e", "tib", "etib"]
|
||||
const mainParts = cleanMain.split(".")[0].toLowerCase().split(/[-_]/).filter(p => p.length > 1);
|
||||
const mainJoined = mainParts.join(""); // "etib"
|
||||
|
||||
for (const page of pages) {
|
||||
const linkMatches = page.html.match(/https?:\/\/[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/g) || [];
|
||||
for (const url of linkMatches) {
|
||||
try {
|
||||
const urlObj = new URL(url);
|
||||
const domain = urlObj.hostname.replace(/^www\./, "");
|
||||
// Skip same domain
|
||||
if (domain === cleanMain) continue;
|
||||
// Skip common third-party services
|
||||
if (
|
||||
domain.includes("google") ||
|
||||
domain.includes("facebook") ||
|
||||
domain.includes("twitter") ||
|
||||
domain.includes("linkedin") ||
|
||||
domain.includes("instagram") ||
|
||||
domain.includes("youtube") ||
|
||||
domain.includes("cookie") ||
|
||||
domain.includes("analytics") ||
|
||||
domain.includes("cdn") ||
|
||||
domain.includes("cloudflare") ||
|
||||
domain.includes("fonts") ||
|
||||
domain.includes("jquery") ||
|
||||
domain.includes("bootstrap") ||
|
||||
domain.includes("wordpress") ||
|
||||
domain.includes("jimdo") ||
|
||||
domain.includes("wix")
|
||||
)
|
||||
continue;
|
||||
|
||||
// Fuzzy match: check if the domain contains any base part of the main domain
|
||||
// e.g. main="e-tib.com" → mainParts=["e","tib"], mainJoined="etib"
|
||||
// target="etib-ing.com" → domainBase="etib-ing", domainJoined="etibing"
|
||||
const domainBase = domain.split(".")[0].toLowerCase();
|
||||
const domainJoined = domainBase.replace(/[-_]/g, "");
|
||||
|
||||
const isRelated =
|
||||
domainJoined.includes(mainJoined) ||
|
||||
mainJoined.includes(domainJoined) ||
|
||||
mainParts.some(part => part.length > 2 && domainBase.includes(part));
|
||||
|
||||
if (isRelated) {
|
||||
externalDomains.add(domain);
|
||||
}
|
||||
} catch {
|
||||
// Invalid URL
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [...externalDomains];
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a structured inventory of all pages.
|
||||
*/
|
||||
function buildPageInventory(pages: CrawledPage[]): PageInventoryItem[] {
|
||||
return pages.map((page) => ({
|
||||
url: page.url,
|
||||
pathname: page.pathname,
|
||||
title: page.title,
|
||||
type: page.type,
|
||||
headings: page.headings.slice(0, 10),
|
||||
services: page.type === "service" ? page.headings.filter((h) => h.length > 3 && h.length < 80) : [],
|
||||
hasSearch: page.features.includes("search"),
|
||||
hasForms: page.features.includes("forms"),
|
||||
hasMap: page.features.includes("maps"),
|
||||
hasVideo: page.features.includes("video"),
|
||||
contentSummary: page.text.substring(0, 500),
|
||||
}));
|
||||
}
|
||||
@@ -1,163 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
// ============================================================================
|
||||
// @mintel/concept-engine — CLI Entry Point
|
||||
// Simple commander-based CLI for concept generation.
|
||||
// ============================================================================
|
||||
|
||||
import { Command } from "commander";
|
||||
import * as path from "node:path";
|
||||
import * as fs from "node:fs/promises";
|
||||
import { existsSync } from "node:fs";
|
||||
import { config as dotenvConfig } from "dotenv";
|
||||
import { ConceptPipeline } from "./pipeline.js";
|
||||
|
||||
// Load .env from monorepo root
|
||||
dotenvConfig({ path: path.resolve(process.cwd(), "../../.env") });
|
||||
dotenvConfig({ path: path.resolve(process.cwd(), ".env") });
|
||||
|
||||
const program = new Command();
|
||||
|
||||
program
|
||||
.name("concept")
|
||||
.description("AI-powered project concept generator")
|
||||
.version("1.0.0");
|
||||
|
||||
program
|
||||
.command("run")
|
||||
.description("Run the full concept pipeline")
|
||||
.argument("[briefing]", "Briefing text or @path/to/file.txt")
|
||||
.option("--url <url>", "Target website URL")
|
||||
.option("--comments <comments>", "Additional notes")
|
||||
.option("--clear-cache", "Clear crawl cache and re-crawl")
|
||||
.option("--output <dir>", "Output directory", "../../out/concepts")
|
||||
.option("--crawl-dir <dir>", "Crawl data directory", "../../data/crawls")
|
||||
.action(async (briefingArg: string | undefined, options: any) => {
|
||||
const openrouterKey =
|
||||
process.env.OPENROUTER_API_KEY || process.env.OPENROUTER_KEY;
|
||||
if (!openrouterKey) {
|
||||
console.error("❌ OPENROUTER_API_KEY not found in environment.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
let briefing = briefingArg || "";
|
||||
|
||||
// Handle @file references
|
||||
if (briefing.startsWith("@")) {
|
||||
const rawPath = briefing.substring(1);
|
||||
const filePath = rawPath.startsWith("/")
|
||||
? rawPath
|
||||
: path.resolve(process.cwd(), rawPath);
|
||||
if (!existsSync(filePath)) {
|
||||
console.error(`❌ Briefing file not found: ${filePath}`);
|
||||
process.exit(1);
|
||||
}
|
||||
briefing = await fs.readFile(filePath, "utf8");
|
||||
console.log(`📄 Loaded briefing from: ${filePath}`);
|
||||
}
|
||||
|
||||
// Auto-discover URL from briefing
|
||||
let url = options.url;
|
||||
if (!url && briefing) {
|
||||
const urlMatch = briefing.match(/https?:\/\/[^\s]+/);
|
||||
if (urlMatch) {
|
||||
url = urlMatch[0];
|
||||
console.log(`🔗 Discovered URL in briefing: ${url}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (!briefing && !url) {
|
||||
console.error("❌ Provide a briefing text or --url");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const pipeline = new ConceptPipeline(
|
||||
{
|
||||
openrouterKey,
|
||||
zyteApiKey: process.env.ZYTE_API_KEY,
|
||||
outputDir: path.resolve(process.cwd(), options.output),
|
||||
crawlDir: path.resolve(process.cwd(), options.crawlDir),
|
||||
},
|
||||
{
|
||||
onStepStart: (_id, _name) => {
|
||||
// Will be enhanced with Ink spinner later
|
||||
},
|
||||
onStepComplete: (_id, _result) => {
|
||||
// Will be enhanced with Ink UI later
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
try {
|
||||
await pipeline.run({
|
||||
briefing,
|
||||
url,
|
||||
comments: options.comments,
|
||||
clearCache: options.clearCache,
|
||||
});
|
||||
|
||||
console.log("\n✨ Concept generation complete!");
|
||||
} catch (err) {
|
||||
console.error(`\n❌ Pipeline failed: ${(err as Error).message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
program
|
||||
.command("analyze")
|
||||
.description("Only crawl and analyze a website (no LLM)")
|
||||
.argument("<url>", "Website URL to analyze")
|
||||
.option("--crawl-dir <dir>", "Crawl data directory", "../../data/crawls")
|
||||
.option("--clear-cache", "Clear existing crawl cache")
|
||||
.action(async (url: string, options: any) => {
|
||||
const { crawlSite } = await import("./scraper.js");
|
||||
const { analyzeSite } = await import("./analyzer.js");
|
||||
|
||||
if (options.clearCache) {
|
||||
const { clearCrawlCache } = await import("./scraper.js");
|
||||
const domain = new URL(url).hostname;
|
||||
await clearCrawlCache(
|
||||
path.resolve(process.cwd(), options.crawlDir),
|
||||
domain,
|
||||
);
|
||||
}
|
||||
|
||||
const pages = await crawlSite(url, {
|
||||
zyteApiKey: process.env.ZYTE_API_KEY,
|
||||
crawlDir: path.resolve(process.cwd(), options.crawlDir),
|
||||
});
|
||||
|
||||
const domain = new URL(url).hostname;
|
||||
const profile = analyzeSite(pages, domain);
|
||||
|
||||
console.log("\n📊 Site Profile:");
|
||||
console.log(` Domain: ${profile.domain}`);
|
||||
console.log(` Total Pages: ${profile.totalPages}`);
|
||||
console.log(
|
||||
` Navigation: ${profile.navigation.map((n) => n.label).join(", ")}`,
|
||||
);
|
||||
console.log(` Features: ${profile.existingFeatures.join(", ") || "none"}`);
|
||||
console.log(` Services: ${profile.services.join(", ") || "none"}`);
|
||||
console.log(
|
||||
` External Domains: ${profile.externalDomains.join(", ") || "none"}`,
|
||||
);
|
||||
console.log(` Company: ${profile.companyInfo.name || "unbekannt"}`);
|
||||
console.log(` Tax ID: ${profile.companyInfo.taxId || "unbekannt"}`);
|
||||
console.log(` Colors: ${profile.colors.join(", ")}`);
|
||||
console.log(` Images Found: ${profile.images.length}`);
|
||||
console.log(
|
||||
` Social: ${
|
||||
Object.entries(profile.socialLinks)
|
||||
.map(([_k, _v]) => `${_k}`)
|
||||
.join(", ") || "none"
|
||||
}`,
|
||||
);
|
||||
|
||||
const outputPath = path.join(
|
||||
path.resolve(process.cwd(), options.crawlDir),
|
||||
domain.replace(/\./g, "-"),
|
||||
"_site_profile.json",
|
||||
);
|
||||
console.log(`\n📦 Full profile saved to: ${outputPath}`);
|
||||
});
|
||||
|
||||
program.parse();
|
||||
@@ -1,7 +0,0 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
describe("concept-engine", () => {
|
||||
it("should pass", () => {
|
||||
expect(true).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,10 +0,0 @@
|
||||
// ============================================================================
|
||||
// @mintel/concept-engine — Public API
|
||||
// ============================================================================
|
||||
|
||||
export { ConceptPipeline } from "./pipeline.js";
|
||||
export type { PipelineCallbacks } from "./pipeline.js";
|
||||
export { crawlSite, clearCrawlCache } from "./scraper.js";
|
||||
export { analyzeSite } from "./analyzer.js";
|
||||
export { llmRequest, llmJsonRequest, cleanJson } from "./llm-client.js";
|
||||
export * from "./types.js";
|
||||
@@ -1,142 +0,0 @@
|
||||
// ============================================================================
|
||||
// LLM Client — Unified interface with model routing via OpenRouter
|
||||
// ============================================================================
|
||||
|
||||
import axios from "axios";
|
||||
|
||||
interface LLMRequestOptions {
|
||||
model: string;
|
||||
systemPrompt: string;
|
||||
userPrompt: string;
|
||||
jsonMode?: boolean;
|
||||
apiKey: string;
|
||||
}
|
||||
|
||||
interface LLMResponse {
|
||||
content: string;
|
||||
usage: {
|
||||
promptTokens: number;
|
||||
completionTokens: number;
|
||||
cost: number;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean raw LLM output to parseable JSON.
|
||||
* Handles markdown fences, control chars, trailing commas.
|
||||
*/
|
||||
export function cleanJson(str: string): string {
|
||||
let cleaned = str.replace(/```json\n?|```/g, "").trim();
|
||||
// eslint-disable-next-line no-control-regex
|
||||
cleaned = cleaned.replace(/[\x00-\x1f\x7f-\x9f]/gi, " ");
|
||||
cleaned = cleaned.replace(/,\s*([\]}])/g, "$1");
|
||||
return cleaned;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a request to an LLM via OpenRouter.
|
||||
*/
|
||||
export async function llmRequest(
|
||||
options: LLMRequestOptions,
|
||||
): Promise<LLMResponse> {
|
||||
const { model, systemPrompt, userPrompt, jsonMode = true, apiKey } = options;
|
||||
|
||||
const resp = await axios
|
||||
.post(
|
||||
"https://openrouter.ai/api/v1/chat/completions",
|
||||
{
|
||||
model,
|
||||
messages: [
|
||||
{ role: "system", content: systemPrompt },
|
||||
{ role: "user", content: userPrompt },
|
||||
],
|
||||
...(jsonMode ? { response_format: { type: "json_object" } } : {}),
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
timeout: 120000,
|
||||
},
|
||||
)
|
||||
.catch((err) => {
|
||||
if (err.response) {
|
||||
console.error(
|
||||
"OpenRouter API Error:",
|
||||
JSON.stringify(err.response.data, null, 2),
|
||||
);
|
||||
}
|
||||
throw err;
|
||||
});
|
||||
|
||||
const content = resp.data.choices?.[0]?.message?.content;
|
||||
if (!content) {
|
||||
throw new Error(`LLM returned no content. Model: ${model}`);
|
||||
}
|
||||
|
||||
let cost = 0;
|
||||
const usage = resp.data.usage || {};
|
||||
if (usage.cost !== undefined) {
|
||||
cost = usage.cost;
|
||||
} else {
|
||||
// Fallback estimation
|
||||
cost =
|
||||
(usage.prompt_tokens || 0) * (0.1 / 1_000_000) +
|
||||
(usage.completion_tokens || 0) * (0.4 / 1_000_000);
|
||||
}
|
||||
|
||||
return {
|
||||
content,
|
||||
usage: {
|
||||
promptTokens: usage.prompt_tokens || 0,
|
||||
completionTokens: usage.completion_tokens || 0,
|
||||
cost,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a request and parse the response as JSON.
|
||||
*/
|
||||
export async function llmJsonRequest<T = any>(
|
||||
options: LLMRequestOptions,
|
||||
): Promise<{ data: T; usage: LLMResponse["usage"] }> {
|
||||
const response = await llmRequest({ ...options, jsonMode: true });
|
||||
const cleaned = cleanJson(response.content);
|
||||
|
||||
let parsed: T;
|
||||
try {
|
||||
parsed = JSON.parse(cleaned);
|
||||
} catch (e) {
|
||||
throw new Error(
|
||||
`Failed to parse LLM JSON response: ${(e as Error).message}\nRaw: ${cleaned.substring(0, 500)}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Unwrap common LLM artifacts: {"0": {...}}, {"state": {...}}, etc.
|
||||
const unwrapped = unwrapResponse(parsed);
|
||||
|
||||
return { data: unwrapped as T, usage: response.usage };
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively unwrap common LLM wrapping patterns.
|
||||
*/
|
||||
function unwrapResponse(obj: any): any {
|
||||
if (!obj || typeof obj !== "object" || Array.isArray(obj)) return obj;
|
||||
const keys = Object.keys(obj);
|
||||
if (keys.length === 1) {
|
||||
const key = keys[0];
|
||||
if (
|
||||
key === "0" ||
|
||||
key === "state" ||
|
||||
key === "facts" ||
|
||||
key === "result" ||
|
||||
key === "data"
|
||||
) {
|
||||
return unwrapResponse(obj[key]);
|
||||
}
|
||||
}
|
||||
return obj;
|
||||
}
|
||||
@@ -1,296 +0,0 @@
|
||||
// ============================================================================
|
||||
// Pipeline Orchestrator
|
||||
// Runs all steps sequentially, tracks state, supports re-running individual steps.
|
||||
// ============================================================================
|
||||
|
||||
import * as fs from "node:fs/promises";
|
||||
import * as path from "node:path";
|
||||
import { crawlSite, clearCrawlCache } from "./scraper.js";
|
||||
import { analyzeSite } from "./analyzer.js";
|
||||
import { executeResearch } from "./steps/00b-research.js";
|
||||
import { executeExtract } from "./steps/01-extract.js";
|
||||
import { executeSiteAudit } from "./steps/00a-site-audit.js";
|
||||
import { executeAudit } from "./steps/02-audit.js";
|
||||
import { executeStrategize } from "./steps/03-strategize.js";
|
||||
import { executeArchitect } from "./steps/04-architect.js";
|
||||
import type {
|
||||
PipelineConfig,
|
||||
PipelineInput,
|
||||
ConceptState,
|
||||
ProjectConcept,
|
||||
StepResult,
|
||||
} from "./types.js";
|
||||
|
||||
export interface PipelineCallbacks {
|
||||
onStepStart?: (stepId: string, stepName: string) => void;
|
||||
onStepComplete?: (stepId: string, result: StepResult) => void;
|
||||
onStepError?: (stepId: string, error: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* The main concept pipeline orchestrator.
|
||||
* Runs conceptual steps sequentially and builds the ProjectConcept.
|
||||
*/
|
||||
export class ConceptPipeline {
|
||||
private config: PipelineConfig;
|
||||
private state: ConceptState;
|
||||
private callbacks: PipelineCallbacks;
|
||||
|
||||
constructor(config: PipelineConfig, callbacks: PipelineCallbacks = {}) {
|
||||
this.config = config;
|
||||
this.callbacks = callbacks;
|
||||
this.state = this.createInitialState();
|
||||
}
|
||||
|
||||
private createInitialState(): ConceptState {
|
||||
return {
|
||||
briefing: "",
|
||||
usage: {
|
||||
totalPromptTokens: 0,
|
||||
totalCompletionTokens: 0,
|
||||
totalCost: 0,
|
||||
perStep: [],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the full concept pipeline from scratch.
|
||||
*/
|
||||
async run(input: PipelineInput): Promise<ProjectConcept> {
|
||||
this.state.briefing = input.briefing;
|
||||
this.state.url = input.url;
|
||||
this.state.comments = input.comments;
|
||||
|
||||
// Ensure output directories
|
||||
await fs.mkdir(this.config.outputDir, { recursive: true });
|
||||
await fs.mkdir(this.config.crawlDir, { recursive: true });
|
||||
|
||||
// Step 0: Scrape & Analyze (deterministic)
|
||||
if (input.url) {
|
||||
if (input.clearCache) {
|
||||
const domain = new URL(input.url).hostname;
|
||||
await clearCrawlCache(this.config.crawlDir, domain);
|
||||
}
|
||||
await this.runStep(
|
||||
"00-scrape",
|
||||
"Scraping & Analyzing Website",
|
||||
async () => {
|
||||
const pages = await crawlSite(input.url!, {
|
||||
zyteApiKey: this.config.zyteApiKey,
|
||||
crawlDir: this.config.crawlDir,
|
||||
});
|
||||
const domain = new URL(input.url!).hostname;
|
||||
const siteProfile = analyzeSite(pages, domain);
|
||||
this.state.siteProfile = siteProfile;
|
||||
this.state.crawlDir = path.join(
|
||||
this.config.crawlDir,
|
||||
domain.replace(/\./g, "-"),
|
||||
);
|
||||
|
||||
// Save site profile
|
||||
await fs.writeFile(
|
||||
path.join(this.state.crawlDir!, "_site_profile.json"),
|
||||
JSON.stringify(siteProfile, null, 2),
|
||||
);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: siteProfile,
|
||||
usage: {
|
||||
step: "00-scrape",
|
||||
model: "none",
|
||||
promptTokens: 0,
|
||||
completionTokens: 0,
|
||||
cost: 0,
|
||||
durationMs: 0,
|
||||
},
|
||||
};
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// Step 00a: Site Audit (DataForSEO)
|
||||
await this.runStep(
|
||||
"00a-site-audit",
|
||||
"IST-Analysis (DataForSEO)",
|
||||
async () => {
|
||||
const result = await executeSiteAudit(this.state, this.config);
|
||||
if (result.success && result.data) {
|
||||
this.state.siteAudit = result.data;
|
||||
}
|
||||
return result;
|
||||
},
|
||||
);
|
||||
|
||||
// Step 00b: Research (real web data via journaling)
|
||||
await this.runStep(
|
||||
"00b-research",
|
||||
"Industry & Company Research",
|
||||
async () => {
|
||||
const result = await executeResearch(this.state);
|
||||
if (result.success && result.data) {
|
||||
this.state.researchData = result.data;
|
||||
}
|
||||
return result;
|
||||
},
|
||||
);
|
||||
|
||||
// Step 1: Extract facts
|
||||
await this.runStep(
|
||||
"01-extract",
|
||||
"Extracting Facts from Briefing",
|
||||
async () => {
|
||||
const result = await executeExtract(this.state, this.config);
|
||||
if (result.success) this.state.facts = result.data;
|
||||
return result;
|
||||
},
|
||||
);
|
||||
|
||||
// Step 2: Audit features
|
||||
await this.runStep(
|
||||
"02-audit",
|
||||
"Auditing Features (Skeptical Review)",
|
||||
async () => {
|
||||
const result = await executeAudit(this.state, this.config);
|
||||
if (result.success) this.state.auditedFacts = result.data;
|
||||
return result;
|
||||
},
|
||||
);
|
||||
|
||||
// Step 3: Strategic analysis
|
||||
await this.runStep("03-strategize", "Strategic Analysis", async () => {
|
||||
const result = await executeStrategize(this.state, this.config);
|
||||
if (result.success) {
|
||||
this.state.briefingSummary = result.data.briefingSummary;
|
||||
this.state.designVision = result.data.designVision;
|
||||
}
|
||||
return result;
|
||||
});
|
||||
|
||||
// Step 4: Sitemap architecture
|
||||
await this.runStep("04-architect", "Information Architecture", async () => {
|
||||
const result = await executeArchitect(this.state, this.config);
|
||||
if (result.success) {
|
||||
this.state.sitemap = result.data.sitemap;
|
||||
this.state.websiteTopic = result.data.websiteTopic;
|
||||
}
|
||||
return result;
|
||||
});
|
||||
|
||||
const projectConcept = this.buildProjectConcept();
|
||||
await this.saveState(projectConcept);
|
||||
|
||||
return projectConcept;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a single step with callbacks and error handling.
|
||||
*/
|
||||
private async runStep(
|
||||
stepId: string,
|
||||
stepName: string,
|
||||
executor: () => Promise<StepResult>,
|
||||
): Promise<void> {
|
||||
this.callbacks.onStepStart?.(stepId, stepName);
|
||||
console.log(`\n📍 ${stepName}...`);
|
||||
|
||||
try {
|
||||
const result = await executor();
|
||||
if (result.usage) {
|
||||
this.state.usage.perStep.push(result.usage);
|
||||
this.state.usage.totalPromptTokens += result.usage.promptTokens;
|
||||
this.state.usage.totalCompletionTokens += result.usage.completionTokens;
|
||||
this.state.usage.totalCost += result.usage.cost;
|
||||
}
|
||||
|
||||
if (result.success) {
|
||||
const cost = result.usage?.cost
|
||||
? ` ($${result.usage.cost.toFixed(4)})`
|
||||
: "";
|
||||
const duration = result.usage?.durationMs
|
||||
? ` [${(result.usage.durationMs / 1000).toFixed(1)}s]`
|
||||
: "";
|
||||
console.log(` ✅ ${stepName} complete${cost}${duration}`);
|
||||
this.callbacks.onStepComplete?.(stepId, result);
|
||||
} else {
|
||||
console.error(` ❌ ${stepName} failed: ${result.error}`);
|
||||
this.callbacks.onStepError?.(stepId, result.error || "Unknown error");
|
||||
throw new Error(result.error);
|
||||
}
|
||||
} catch (err) {
|
||||
const errorMsg = (err as Error).message;
|
||||
this.callbacks.onStepError?.(stepId, errorMsg);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the final Concept object.
|
||||
*/
|
||||
private buildProjectConcept(): ProjectConcept {
|
||||
return {
|
||||
domain: this.state.siteProfile?.domain || "unknown",
|
||||
timestamp: new Date().toISOString(),
|
||||
briefing: this.state.briefing,
|
||||
auditedFacts: this.state.auditedFacts || {},
|
||||
siteProfile: this.state.siteProfile,
|
||||
siteAudit: this.state.siteAudit,
|
||||
researchData: this.state.researchData,
|
||||
strategy: {
|
||||
briefingSummary: this.state.briefingSummary || "",
|
||||
designVision: this.state.designVision || "",
|
||||
},
|
||||
architecture: {
|
||||
websiteTopic: this.state.websiteTopic || "",
|
||||
sitemap: this.state.sitemap || [],
|
||||
},
|
||||
usage: this.state.usage,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Save the full concept generated state to disk.
|
||||
*/
|
||||
private async saveState(concept: ProjectConcept): Promise<void> {
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
|
||||
const companyName = this.state.auditedFacts?.companyName || "unknown";
|
||||
|
||||
const stateDir = path.join(this.config.outputDir, "concepts");
|
||||
await fs.mkdir(stateDir, { recursive: true });
|
||||
|
||||
const statePath = path.join(stateDir, `${companyName}_${timestamp}.json`);
|
||||
await fs.writeFile(statePath, JSON.stringify(concept, null, 2));
|
||||
console.log(`\n📦 Saved Project Concept to: ${statePath}`);
|
||||
|
||||
// Save debug trace
|
||||
const debugPath = path.join(
|
||||
stateDir,
|
||||
`${companyName}_${timestamp}_debug.json`,
|
||||
);
|
||||
await fs.writeFile(debugPath, JSON.stringify(this.state, null, 2));
|
||||
|
||||
// Print usage summary
|
||||
console.log("\n──────────────────────────────────────────────");
|
||||
console.log("📊 PIPELINE USAGE SUMMARY");
|
||||
console.log("──────────────────────────────────────────────");
|
||||
for (const step of this.state.usage.perStep) {
|
||||
if (step.cost > 0) {
|
||||
console.log(
|
||||
` ${step.step}: ${step.model} — $${step.cost.toFixed(6)} (${(step.durationMs / 1000).toFixed(1)}s)`,
|
||||
);
|
||||
}
|
||||
}
|
||||
console.log("──────────────────────────────────────────────");
|
||||
console.log(` TOTAL: $${this.state.usage.totalCost.toFixed(6)}`);
|
||||
console.log(
|
||||
` Tokens: ${(this.state.usage.totalPromptTokens + this.state.usage.totalCompletionTokens).toLocaleString()}`,
|
||||
);
|
||||
console.log("──────────────────────────────────────────────\n");
|
||||
}
|
||||
|
||||
/** Get the current internal state (for CLI inspection). */
|
||||
getState(): ConceptState {
|
||||
return this.state;
|
||||
}
|
||||
}
|
||||
@@ -1,478 +0,0 @@
|
||||
// ============================================================================
|
||||
// Scraper — Zyte API + Local Persistence
|
||||
// Crawls all pages of a website, stores them locally for reuse.
|
||||
// Crawls all pages of a website, stores them locally for reuse.
|
||||
// ============================================================================
|
||||
import * as cheerio from "cheerio";
|
||||
import * as fs from "node:fs/promises";
|
||||
import * as path from "node:path";
|
||||
import { existsSync } from "node:fs";
|
||||
import type { CrawledPage, PageType } from "./types.js";
|
||||
|
||||
interface ScraperConfig {
|
||||
zyteApiKey?: string;
|
||||
crawlDir: string;
|
||||
maxPages?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Classify a URL pathname into a page type.
|
||||
*/
|
||||
function classifyPage(pathname: string): PageType {
|
||||
const p = pathname.toLowerCase();
|
||||
if (p === "/" || p === "" || p === "/index.html") return "home";
|
||||
if (
|
||||
p.includes("service") ||
|
||||
p.includes("leistung") ||
|
||||
p.includes("kompetenz")
|
||||
)
|
||||
return "service";
|
||||
if (
|
||||
p.includes("about") ||
|
||||
p.includes("ueber") ||
|
||||
p.includes("über") ||
|
||||
p.includes("unternehmen")
|
||||
)
|
||||
return "about";
|
||||
if (p.includes("contact") || p.includes("kontakt")) return "contact";
|
||||
if (
|
||||
p.includes("job") ||
|
||||
p.includes("karriere") ||
|
||||
p.includes("career") ||
|
||||
p.includes("human-resources")
|
||||
)
|
||||
return "career";
|
||||
if (
|
||||
p.includes("portfolio") ||
|
||||
p.includes("referenz") ||
|
||||
p.includes("projekt") ||
|
||||
p.includes("case-study")
|
||||
)
|
||||
return "portfolio";
|
||||
if (
|
||||
p.includes("blog") ||
|
||||
p.includes("news") ||
|
||||
p.includes("aktuelles") ||
|
||||
p.includes("magazin")
|
||||
)
|
||||
return "blog";
|
||||
if (
|
||||
p.includes("legal") ||
|
||||
p.includes("impressum") ||
|
||||
p.includes("datenschutz") ||
|
||||
p.includes("privacy") ||
|
||||
p.includes("agb")
|
||||
)
|
||||
return "legal";
|
||||
return "other";
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect interactive features present on a page.
|
||||
*/
|
||||
function detectFeatures($: cheerio.CheerioAPI): string[] {
|
||||
const features: string[] = [];
|
||||
|
||||
// Search
|
||||
if (
|
||||
$('input[type="search"]').length > 0 ||
|
||||
$('form[role="search"]').length > 0 ||
|
||||
$(".search-form, .search-box, #search, .searchbar").length > 0 ||
|
||||
$('input[name="q"], input[name="s"], input[name="search"]').length > 0
|
||||
) {
|
||||
features.push("search");
|
||||
}
|
||||
|
||||
// Forms (beyond search)
|
||||
const formCount = $("form").length;
|
||||
const searchForms = $('form[role="search"], .search-form').length;
|
||||
if (formCount > searchForms) {
|
||||
features.push("forms");
|
||||
}
|
||||
|
||||
// Maps
|
||||
if (
|
||||
$(
|
||||
'iframe[src*="google.com/maps"], iframe[src*="openstreetmap"], .map-container, #map, [data-map]',
|
||||
).length > 0
|
||||
) {
|
||||
features.push("maps");
|
||||
}
|
||||
|
||||
// Video
|
||||
if (
|
||||
$("video, iframe[src*='youtube'], iframe[src*='vimeo'], .video-container")
|
||||
.length > 0
|
||||
) {
|
||||
features.push("video");
|
||||
}
|
||||
|
||||
// Calendar / Events
|
||||
if ($(".calendar, .event, [data-calendar]").length > 0) {
|
||||
features.push("calendar");
|
||||
}
|
||||
|
||||
// Cookie consent
|
||||
if (
|
||||
$(".cookie-banner, .cookie-consent, #cookie-notice, [data-cookie]").length >
|
||||
0
|
||||
) {
|
||||
features.push("cookie-consent");
|
||||
}
|
||||
|
||||
return features;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract all internal links from a page.
|
||||
*/
|
||||
function extractInternalLinks($: cheerio.CheerioAPI, origin: string): string[] {
|
||||
const links: string[] = [];
|
||||
$("a[href]").each((_, el) => {
|
||||
const href = $(el).attr("href");
|
||||
if (!href) return;
|
||||
try {
|
||||
const url = new URL(href, origin);
|
||||
if (url.origin === origin) {
|
||||
// Skip assets
|
||||
if (
|
||||
/\.(pdf|zip|jpg|jpeg|png|svg|webp|gif|css|js|ico|woff|woff2|ttf|eot)$/i.test(
|
||||
url.pathname,
|
||||
)
|
||||
)
|
||||
return;
|
||||
// Skip anchors-only
|
||||
if (url.pathname === "/" && url.hash) return;
|
||||
links.push(url.pathname);
|
||||
}
|
||||
} catch {
|
||||
// Invalid URL, skip
|
||||
}
|
||||
});
|
||||
return [...new Set(links)];
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract all images from a page.
|
||||
*/
|
||||
function extractImages($: cheerio.CheerioAPI, origin: string): string[] {
|
||||
const images: string[] = [];
|
||||
|
||||
// Regular img tags
|
||||
$("img[src]").each((_, el) => {
|
||||
const src = $(el).attr("src");
|
||||
if (src) images.push(src);
|
||||
});
|
||||
|
||||
// CSS background images (inline styles)
|
||||
$("[style*='background-image']").each((_, el) => {
|
||||
const style = $(el).attr("style");
|
||||
const match = style?.match(/url\(['"]?(.*?)['"]?\)/);
|
||||
if (match && match[1]) {
|
||||
images.push(match[1]);
|
||||
}
|
||||
});
|
||||
|
||||
// Resolve URLs to absolute
|
||||
const absoluteImages: string[] = [];
|
||||
for (const img of images) {
|
||||
if (img.startsWith("data:image")) continue; // Skip inline base64
|
||||
try {
|
||||
const url = new URL(img, origin);
|
||||
// Ignore small tracking pixels or generic vectors
|
||||
if (url.pathname.endsWith(".svg") && !url.pathname.includes("logo"))
|
||||
continue;
|
||||
absoluteImages.push(url.href);
|
||||
} catch {
|
||||
// Invalid URL
|
||||
}
|
||||
}
|
||||
|
||||
return [...new Set(absoluteImages)];
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a page via Zyte API with browser rendering.
|
||||
*/
|
||||
async function fetchWithZyte(url: string, apiKey: string): Promise<string> {
|
||||
const auth = Buffer.from(`${apiKey}:`).toString("base64");
|
||||
const resp = await fetch("https://api.zyte.com/v1/extract", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Basic ${auth}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
url,
|
||||
browserHtml: true,
|
||||
}),
|
||||
signal: AbortSignal.timeout(60000),
|
||||
});
|
||||
|
||||
if (!resp.ok) {
|
||||
const errorText = await resp.text();
|
||||
console.error(
|
||||
` ❌ Zyte API error ${resp.status} for ${url}: ${errorText}`,
|
||||
);
|
||||
// Rate limited — wait and retry once
|
||||
if (resp.status === 429) {
|
||||
console.log(" ⏳ Rate limited, waiting 5s and retrying...");
|
||||
await new Promise((r) => setTimeout(r, 5000));
|
||||
return fetchWithZyte(url, apiKey);
|
||||
}
|
||||
throw new Error(`HTTP ${resp.status}: ${errorText}`);
|
||||
}
|
||||
|
||||
const data = await resp.json();
|
||||
const html = data.browserHtml || "";
|
||||
if (!html) {
|
||||
console.warn(` ⚠️ Zyte returned empty browserHtml for ${url}`);
|
||||
}
|
||||
return html;
|
||||
}
|
||||
/**
|
||||
* Fetch a page via simple HTTP GET (fallback).
|
||||
*/
|
||||
async function fetchDirect(url: string): Promise<string> {
|
||||
const resp = await fetch(url, {
|
||||
headers: {
|
||||
"User-Agent":
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36",
|
||||
},
|
||||
signal: AbortSignal.timeout(30000),
|
||||
}).catch(() => null);
|
||||
|
||||
if (!resp || !resp.ok) return "";
|
||||
return await resp.text();
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse an HTML string into a CrawledPage.
|
||||
*/
|
||||
function parsePage(html: string, url: string): CrawledPage {
|
||||
const $ = cheerio.load(html);
|
||||
const urlObj = new URL(url);
|
||||
|
||||
const title = $("title").text().trim();
|
||||
const headings = $("h1, h2, h3")
|
||||
.map((_, el) => $(el).text().trim())
|
||||
.get()
|
||||
.filter((h) => h.length > 0);
|
||||
|
||||
const navItems = $("nav a")
|
||||
.map((_, el) => $(el).text().trim())
|
||||
.get()
|
||||
.filter((t) => t.length > 0 && t.length < 100);
|
||||
|
||||
const bodyText = $("body")
|
||||
.text()
|
||||
.replace(/\s+/g, " ")
|
||||
.substring(0, 50000)
|
||||
.trim();
|
||||
|
||||
const features = detectFeatures($);
|
||||
const links = extractInternalLinks($, urlObj.origin);
|
||||
const images = extractImages($, urlObj.origin);
|
||||
|
||||
const description =
|
||||
$('meta[name="description"]').attr("content") || undefined;
|
||||
const ogTitle = $('meta[property="og:title"]').attr("content") || undefined;
|
||||
const ogImage = $('meta[property="og:image"]').attr("content") || undefined;
|
||||
|
||||
return {
|
||||
url,
|
||||
pathname: urlObj.pathname,
|
||||
title,
|
||||
html,
|
||||
text: bodyText,
|
||||
headings,
|
||||
navItems,
|
||||
features,
|
||||
type: classifyPage(urlObj.pathname),
|
||||
links,
|
||||
images,
|
||||
meta: { description, ogTitle, ogImage },
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Crawl a website and persist all pages locally.
|
||||
*
|
||||
* Returns an array of CrawledPage objects.
|
||||
*/
|
||||
export async function crawlSite(
|
||||
targetUrl: string,
|
||||
config: ScraperConfig,
|
||||
): Promise<CrawledPage[]> {
|
||||
const urlObj = new URL(targetUrl);
|
||||
const origin = urlObj.origin;
|
||||
const domain = urlObj.hostname;
|
||||
const domainDir = path.join(config.crawlDir, domain.replace(/\./g, "-"));
|
||||
|
||||
// Check for existing crawl
|
||||
const metaFile = path.join(domainDir, "_crawl_meta.json");
|
||||
if (existsSync(metaFile)) {
|
||||
console.log(`📦 Found existing crawl for ${domain}. Loading from disk...`);
|
||||
return loadCrawlFromDisk(domainDir);
|
||||
}
|
||||
|
||||
console.log(
|
||||
`🔍 Crawling ${targetUrl} via ${config.zyteApiKey ? "Zyte API" : "direct HTTP"}...`,
|
||||
);
|
||||
|
||||
// Ensure output dir
|
||||
await fs.mkdir(domainDir, { recursive: true });
|
||||
|
||||
const maxPages = config.maxPages || 30;
|
||||
const visited = new Set<string>();
|
||||
const queue: string[] = [targetUrl];
|
||||
const pages: CrawledPage[] = [];
|
||||
|
||||
while (queue.length > 0 && visited.size < maxPages) {
|
||||
const url = queue.shift()!;
|
||||
const urlPath = new URL(url).pathname;
|
||||
|
||||
if (visited.has(urlPath)) continue;
|
||||
visited.add(urlPath);
|
||||
|
||||
try {
|
||||
console.log(` ↳ Fetching ${url} (${visited.size}/${maxPages})...`);
|
||||
|
||||
let html: string;
|
||||
if (config.zyteApiKey) {
|
||||
html = await fetchWithZyte(url, config.zyteApiKey);
|
||||
} else {
|
||||
html = await fetchDirect(url);
|
||||
}
|
||||
|
||||
if (!html || html.length < 100) {
|
||||
console.warn(` ⚠️ Empty/tiny response for ${url}, skipping.`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const page = parsePage(html, url);
|
||||
pages.push(page);
|
||||
|
||||
// Save HTML + metadata to disk
|
||||
const safeName =
|
||||
urlPath === "/"
|
||||
? "index"
|
||||
: urlPath.replace(/\//g, "_").replace(/^_/, "");
|
||||
await fs.writeFile(path.join(domainDir, `${safeName}.html`), html);
|
||||
await fs.writeFile(
|
||||
path.join(domainDir, `${safeName}.meta.json`),
|
||||
JSON.stringify(
|
||||
{
|
||||
url: page.url,
|
||||
pathname: page.pathname,
|
||||
title: page.title,
|
||||
type: page.type,
|
||||
headings: page.headings,
|
||||
navItems: page.navItems,
|
||||
features: page.features,
|
||||
links: page.links,
|
||||
images: page.images,
|
||||
meta: page.meta,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
|
||||
// Discover new links
|
||||
for (const link of page.links) {
|
||||
if (!visited.has(link)) {
|
||||
const fullUrl = `${origin}${link}`;
|
||||
queue.push(fullUrl);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn(` ⚠️ Failed to fetch ${url}: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Save crawl metadata
|
||||
await fs.writeFile(
|
||||
metaFile,
|
||||
JSON.stringify(
|
||||
{
|
||||
domain,
|
||||
crawledAt: new Date().toISOString(),
|
||||
totalPages: pages.length,
|
||||
urls: pages.map((p) => p.url),
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
|
||||
console.log(
|
||||
`✅ Crawled ${pages.length} pages for ${domain}. Saved to ${domainDir}`,
|
||||
);
|
||||
return pages;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a previously crawled site from disk.
|
||||
*/
|
||||
async function loadCrawlFromDisk(domainDir: string): Promise<CrawledPage[]> {
|
||||
const files = await fs.readdir(domainDir);
|
||||
const metaFiles = files.filter(
|
||||
(f) => f.endsWith(".meta.json") && f !== "_crawl_meta.json",
|
||||
);
|
||||
|
||||
const pages: CrawledPage[] = [];
|
||||
for (const metaFile of metaFiles) {
|
||||
const baseName = metaFile.replace(".meta.json", "");
|
||||
const htmlFile = `${baseName}.html`;
|
||||
|
||||
const meta = JSON.parse(
|
||||
await fs.readFile(path.join(domainDir, metaFile), "utf8"),
|
||||
);
|
||||
let html = "";
|
||||
if (files.includes(htmlFile)) {
|
||||
html = await fs.readFile(path.join(domainDir, htmlFile), "utf8");
|
||||
}
|
||||
|
||||
const text = html
|
||||
? cheerio
|
||||
.load(html)("body")
|
||||
.text()
|
||||
.replace(/\s+/g, " ")
|
||||
.substring(0, 50000)
|
||||
.trim()
|
||||
: "";
|
||||
|
||||
pages.push({
|
||||
url: meta.url,
|
||||
pathname: meta.pathname,
|
||||
title: meta.title,
|
||||
html,
|
||||
text,
|
||||
headings: meta.headings || [],
|
||||
navItems: meta.navItems || [],
|
||||
features: meta.features || [],
|
||||
type: meta.type || "other",
|
||||
links: meta.links || [],
|
||||
images: meta.images || [],
|
||||
meta: meta.meta || {},
|
||||
});
|
||||
}
|
||||
|
||||
console.log(` 📂 Loaded ${pages.length} cached pages from disk.`);
|
||||
return pages;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a cached crawl to force re-crawl.
|
||||
*/
|
||||
export async function clearCrawlCache(
|
||||
crawlDir: string,
|
||||
domain: string,
|
||||
): Promise<void> {
|
||||
const domainDir = path.join(crawlDir, domain.replace(/\./g, "-"));
|
||||
if (existsSync(domainDir)) {
|
||||
await fs.rm(domainDir, { recursive: true, force: true });
|
||||
console.log(`🧹 Cleared crawl cache for ${domain}`);
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user