Compare commits
50 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 92fe089619 | |||
| 7dcef0bc28 | |||
| 2ba091f738 | |||
| 5757c1172b | |||
| e7d5798857 | |||
| 29a414f385 | |||
| 69764e42c6 | |||
| d69ade6268 | |||
| ceaf3ae3ea | |||
| 169cb83f69 | |||
| f831a7e67e | |||
| cb4ffcaeda | |||
| 9b1f3fb7e8 | |||
| f48d89c368 | |||
| ad40e71757 | |||
| 911ceffdc5 | |||
| 23358fc708 | |||
| 73ea958655 | |||
| f2035d79dd | |||
| f514349ccf | |||
| a71f86560b | |||
| de8314732d | |||
| bdf7773310 | |||
| a25e4aa1d4 | |||
| ecc2163b8e | |||
| af02378d29 | |||
| f8847a7a10 | |||
| 117b23db1e | |||
| d6f9a24823 | |||
| 422e4fccba | |||
| 57ec4d7544 | |||
| a4d021c658 | |||
| 269d19bbef | |||
| 30ff08c66d | |||
| 81deaf447f | |||
| a0ebc58d6d | |||
| 7498c24c9a | |||
| efba82337c | |||
| c083b309fb | |||
| eb8bf60408 | |||
| a3819490ac | |||
| 1127954fea | |||
| fa0b133012 | |||
| 1b40baebd4 | |||
| 316c03869a | |||
| 63d2acfab5 | |||
| bdeae0aca6 | |||
| 47c70a16f1 | |||
| b96d44bf6d | |||
| 73b60f14a9 |
82
.agent/workflows/cms-workflow.md
Normal file
82
.agent/workflows/cms-workflow.md
Normal file
@@ -0,0 +1,82 @@
|
||||
---
|
||||
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`).
|
||||
7
.changeset/resilient-build-scripts.md
Normal file
7
.changeset/resilient-build-scripts.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@mintel/monorepo": patch
|
||||
"acquisition-manager": patch
|
||||
"feedback-commander": patch
|
||||
---
|
||||
|
||||
fix: make directus extension build scripts more resilient
|
||||
36
.env
Normal file
36
.env
Normal file
@@ -0,0 +1,36 @@
|
||||
# Project
|
||||
IMAGE_TAG=v1.8.10
|
||||
PROJECT_NAME=at-mintel
|
||||
PROJECT_COLOR=#82ed20
|
||||
GITEA_TOKEN=ccce002e30fe16a31a6c9d5a414740af2f72a582
|
||||
|
||||
# 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.7.3
|
||||
IMAGE_TAG=v1.8.10
|
||||
PROJECT_NAME=sample-website
|
||||
PROJECT_COLOR=#82ed20
|
||||
|
||||
|
||||
4
.eslintignore
Normal file
4
.eslintignore
Normal file
@@ -0,0 +1,4 @@
|
||||
**/index.js
|
||||
**/dist/**
|
||||
packages/cms-infra/extensions/**
|
||||
packages/cms-infra/extensions/**
|
||||
@@ -5,7 +5,7 @@ on:
|
||||
branches:
|
||||
- '**'
|
||||
tags:
|
||||
- 'v*'
|
||||
- '*'
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
@@ -26,16 +26,17 @@ jobs:
|
||||
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/v*)
|
||||
refs/tags/*)
|
||||
echo "🚀 Release detected ($REF_NAME). Cancelling non-tag runs..."
|
||||
|
||||
# Fetch all runs
|
||||
RUNS=$(curl -s -H "Authorization: token $GITEA_TOKEN" "https://git.infra.mintel.me/api/v1/repos/$REPO/actions/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')
|
||||
@@ -43,7 +44,7 @@ jobs:
|
||||
TITLE=$(echo "$run" | jq -r '.display_title')
|
||||
|
||||
case "$RUN_REF" in
|
||||
refs/tags/v*)
|
||||
refs/tags/*)
|
||||
echo "⏭️ Skipping parallel release run $ID ($TITLE) on $RUN_REF"
|
||||
;;
|
||||
*)
|
||||
@@ -54,7 +55,17 @@ jobs:
|
||||
done
|
||||
;;
|
||||
*)
|
||||
echo "ℹ️ Regular push. No prioritization needed."
|
||||
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
|
||||
|
||||
@@ -130,7 +141,7 @@ jobs:
|
||||
release:
|
||||
name: 🚀 Release
|
||||
needs: [lint, test, build]
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
runs-on: docker
|
||||
container:
|
||||
image: catthehacker/ubuntu:act-latest
|
||||
@@ -160,12 +171,13 @@ jobs:
|
||||
build-images:
|
||||
name: 🐳 Build ${{ matrix.name }}
|
||||
needs: [lint, test, build]
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
runs-on: docker
|
||||
container:
|
||||
image: catthehacker/ubuntu:act-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
max-parallel: 1
|
||||
matrix:
|
||||
include:
|
||||
- image: nextjs
|
||||
|
||||
@@ -1,16 +1,44 @@
|
||||
# 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
|
||||
if [[ "$remote_ref" == refs/tags/* ]]; then
|
||||
TAG=${remote_ref#refs/tags/}
|
||||
echo "🏷️ Tag detected: $TAG, syncing versions..."
|
||||
echo "🏷️ Tag detected: $TAG, ensuring versions are synced..."
|
||||
|
||||
# Run sync script
|
||||
pnpm sync-versions "$TAG"
|
||||
|
||||
# Stage the changed files (excluding ignored files like .env)
|
||||
git add package.json packages/*/package.json apps/*/package.json .env.example
|
||||
# Check for changes in relevant files
|
||||
SYNC_FILES="package.json packages/*/package.json apps/*/package.json .env.example"
|
||||
CHANGES=$(git status --porcelain $SYNC_FILES)
|
||||
|
||||
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."
|
||||
if [[ -n "$CHANGES" ]]; then
|
||||
echo "📝 Version sync made changes. Integrating into tag..."
|
||||
|
||||
# Stage and commit
|
||||
git add $SYNC_FILES
|
||||
git commit -m "chore: sync versions to $TAG" --no-verify
|
||||
|
||||
# Force update the local tag to point to the new commit
|
||||
git tag -f "$TAG" > /dev/null
|
||||
|
||||
echo "✅ Tag $TAG has been updated locally with synced versions."
|
||||
echo "🚀 Auto-pushing updated tag..."
|
||||
|
||||
# Push the updated tag directly (using --no-verify to avoid recursion)
|
||||
git push origin "$TAG" --force --no-verify
|
||||
|
||||
echo "✨ All done! Hook integrated the sync and pushed for you."
|
||||
exit 1 # Still exit 1 to abort the original (now outdated) push attempt
|
||||
else
|
||||
echo "✨ Versions already in sync for $TAG."
|
||||
fi
|
||||
fi
|
||||
done
|
||||
|
||||
@@ -80,3 +80,4 @@ 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.
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "sample-website",
|
||||
"version": "1.7.3",
|
||||
"version": "1.8.10",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
1
directus/extensions/acquisition-manager/index.js
Normal file
1
directus/extensions/acquisition-manager/index.js
Normal file
File diff suppressed because one or more lines are too long
28
directus/extensions/acquisition-manager/package.json
Normal file
28
directus/extensions/acquisition-manager/package.json
Normal file
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"name": "acquisition-manager",
|
||||
"description": "Custom High-Fidelity Management for Directus",
|
||||
"icon": "extension",
|
||||
"version": "1.8.2",
|
||||
"type": "module",
|
||||
"keywords": [
|
||||
"directus",
|
||||
"directus-extension",
|
||||
"directus-extension-module"
|
||||
],
|
||||
"directus:extension": {
|
||||
"type": "module",
|
||||
"path": "index.js",
|
||||
"source": "src/index.ts",
|
||||
"host": "app",
|
||||
"name": "acquisition manager"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "directus-extension build",
|
||||
"dev": "directus-extension build -w"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@directus/extensions-sdk": "11.0.2",
|
||||
"@mintel/directus-extension-toolkit": "workspace:*",
|
||||
"vue": "^3.4.0"
|
||||
}
|
||||
}
|
||||
121545
directus/extensions/acquisition/index.js
Normal file
121545
directus/extensions/acquisition/index.js
Normal file
File diff suppressed because one or more lines are too long
27
directus/extensions/acquisition/package.json
Normal file
27
directus/extensions/acquisition/package.json
Normal file
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"name": "acquisition",
|
||||
"version": "1.8.2",
|
||||
"type": "module",
|
||||
"directus:extension": {
|
||||
"type": "endpoint",
|
||||
"path": "index.js",
|
||||
"source": "src/index.ts",
|
||||
"host": "^11.0.0"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "node build.mjs",
|
||||
"dev": "node build.mjs --watch"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@directus/extensions-sdk": "11.0.2",
|
||||
"@mintel/pdf": "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"
|
||||
}
|
||||
}
|
||||
1
directus/extensions/company-manager/index.js
Normal file
1
directus/extensions/company-manager/index.js
Normal file
File diff suppressed because one or more lines are too long
28
directus/extensions/company-manager/package.json
Normal file
28
directus/extensions/company-manager/package.json
Normal file
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"name": "company-manager",
|
||||
"description": "Custom High-Fidelity Management for Directus",
|
||||
"icon": "extension",
|
||||
"version": "1.8.2",
|
||||
"type": "module",
|
||||
"keywords": [
|
||||
"directus",
|
||||
"directus-extension",
|
||||
"directus-extension-module"
|
||||
],
|
||||
"directus:extension": {
|
||||
"type": "module",
|
||||
"path": "index.js",
|
||||
"source": "src/index.ts",
|
||||
"host": "app",
|
||||
"name": "company manager"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "directus-extension build",
|
||||
"dev": "directus-extension build -w"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@directus/extensions-sdk": "11.0.2",
|
||||
"@mintel/directus-extension-toolkit": "workspace:*",
|
||||
"vue": "^3.4.0"
|
||||
}
|
||||
}
|
||||
1
directus/extensions/customer-manager/index.js
Normal file
1
directus/extensions/customer-manager/index.js
Normal file
File diff suppressed because one or more lines are too long
28
directus/extensions/customer-manager/package.json
Normal file
28
directus/extensions/customer-manager/package.json
Normal file
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"name": "customer-manager",
|
||||
"description": "Custom High-Fidelity Management for Directus",
|
||||
"icon": "extension",
|
||||
"version": "1.8.2",
|
||||
"type": "module",
|
||||
"keywords": [
|
||||
"directus",
|
||||
"directus-extension",
|
||||
"directus-extension-module"
|
||||
],
|
||||
"directus:extension": {
|
||||
"type": "module",
|
||||
"path": "index.js",
|
||||
"source": "src/index.ts",
|
||||
"host": "app",
|
||||
"name": "customer manager"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "directus-extension build",
|
||||
"dev": "directus-extension build -w"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@directus/extensions-sdk": "11.0.2",
|
||||
"@mintel/directus-extension-toolkit": "workspace:*",
|
||||
"vue": "^3.4.0"
|
||||
}
|
||||
}
|
||||
1
directus/extensions/feedback-commander/index.js
Normal file
1
directus/extensions/feedback-commander/index.js
Normal file
File diff suppressed because one or more lines are too long
27
directus/extensions/feedback-commander/package.json
Normal file
27
directus/extensions/feedback-commander/package.json
Normal file
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"name": "feedback-commander",
|
||||
"description": "Custom High-Fidelity Management for Directus",
|
||||
"icon": "extension",
|
||||
"version": "1.8.2",
|
||||
"type": "module",
|
||||
"keywords": [
|
||||
"directus",
|
||||
"directus-extension",
|
||||
"directus-extension-module"
|
||||
],
|
||||
"directus:extension": {
|
||||
"type": "module",
|
||||
"path": "index.js",
|
||||
"source": "src/index.ts",
|
||||
"host": "app",
|
||||
"name": "feedback commander"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "directus-extension build",
|
||||
"dev": "directus-extension build -w"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@directus/extensions-sdk": "11.0.2",
|
||||
"vue": "^3.4.0"
|
||||
}
|
||||
}
|
||||
1
directus/extensions/people-manager/index.js
Normal file
1
directus/extensions/people-manager/index.js
Normal file
File diff suppressed because one or more lines are too long
28
directus/extensions/people-manager/package.json
Normal file
28
directus/extensions/people-manager/package.json
Normal file
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"name": "people-manager",
|
||||
"description": "Custom High-Fidelity Management for Directus",
|
||||
"icon": "extension",
|
||||
"version": "1.8.2",
|
||||
"type": "module",
|
||||
"keywords": [
|
||||
"directus",
|
||||
"directus-extension",
|
||||
"directus-extension-module"
|
||||
],
|
||||
"directus:extension": {
|
||||
"type": "module",
|
||||
"path": "index.js",
|
||||
"source": "src/index.ts",
|
||||
"host": "app",
|
||||
"name": "people manager"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "directus-extension build",
|
||||
"dev": "directus-extension build -w"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@directus/extensions-sdk": "11.0.2",
|
||||
"@mintel/directus-extension-toolkit": "workspace:*",
|
||||
"vue": "^3.4.0"
|
||||
}
|
||||
}
|
||||
1
directus/extensions/unified-dashboard/index.js
Normal file
1
directus/extensions/unified-dashboard/index.js
Normal file
File diff suppressed because one or more lines are too long
27
directus/extensions/unified-dashboard/package.json
Normal file
27
directus/extensions/unified-dashboard/package.json
Normal file
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"name": "unified-dashboard",
|
||||
"description": "Custom High-Fidelity Management for Directus",
|
||||
"icon": "extension",
|
||||
"version": "1.8.2",
|
||||
"type": "module",
|
||||
"keywords": [
|
||||
"directus",
|
||||
"directus-extension",
|
||||
"directus-extension-module"
|
||||
],
|
||||
"directus:extension": {
|
||||
"type": "module",
|
||||
"path": "index.js",
|
||||
"source": "src/index.ts",
|
||||
"host": "app",
|
||||
"name": "unified dashboard"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "directus-extension build",
|
||||
"dev": "directus-extension build -w"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@directus/extensions-sdk": "11.0.2",
|
||||
"vue": "^3.4.0"
|
||||
}
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
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: []
|
||||
@@ -24,6 +24,12 @@ services:
|
||||
|
||||
directus:
|
||||
image: registry.infra.mintel.me/mintel/directus:${IMAGE_TAG:-latest}
|
||||
healthcheck:
|
||||
test: [ "CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://127.0.0.1:8055/server/health" ]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 5s
|
||||
restart: always
|
||||
networks:
|
||||
- infra
|
||||
@@ -35,7 +41,7 @@ services:
|
||||
ADMIN_EMAIL: ${DIRECTUS_ADMIN_EMAIL:-admin@mintel.me}
|
||||
ADMIN_PASSWORD: ${DIRECTUS_ADMIN_PASSWORD:-mintel-admin}
|
||||
DB_CLIENT: 'pg'
|
||||
DB_HOST: 'directus-db'
|
||||
DB_HOST: 'at-mintel-directus-db'
|
||||
DB_PORT: '5432'
|
||||
DB_DATABASE: ${DIRECTUS_DB_NAME:-directus}
|
||||
DB_USER: ${DIRECTUS_DB_USER:-directus}
|
||||
@@ -53,7 +59,7 @@ services:
|
||||
- "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:
|
||||
at-mintel-directus-db:
|
||||
image: postgres:15-alpine
|
||||
restart: always
|
||||
networks:
|
||||
|
||||
@@ -5,7 +5,7 @@ export default [
|
||||
{
|
||||
ignores: [
|
||||
"packages/cms-infra/extensions/**",
|
||||
"packages/customer-manager/index.js",
|
||||
"**/index.js",
|
||||
"**/*.db",
|
||||
"**/build/**",
|
||||
"**/data/**",
|
||||
|
||||
@@ -15,6 +15,9 @@
|
||||
"cms:schema:snapshot": "./scripts/cms-snapshot.sh",
|
||||
"cms:schema:apply": "./scripts/cms-apply.sh local",
|
||||
"cms:schema:apply:infra": "./scripts/cms-apply.sh infra",
|
||||
"cms:up": "cd packages/cms-infra && npm run up -- --force-recreate",
|
||||
"cms:down": "cd packages/cms-infra && npm run down",
|
||||
"cms:logs": "cd packages/cms-infra && npm run logs",
|
||||
"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",
|
||||
@@ -26,6 +29,7 @@
|
||||
"@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",
|
||||
@@ -33,7 +37,6 @@
|
||||
"@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",
|
||||
@@ -47,12 +50,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.7.9",
|
||||
"version": "1.8.10",
|
||||
"pnpm": {
|
||||
"overrides": {
|
||||
"next": "16.1.6",
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
import{defineModule as e}from"@directus/extensions-sdk";import{defineComponent as t,resolveComponent as n,openBlock as a,createBlock as i,withCtx as r,createElementVNode as o}from"vue";var s=t({__name:"module",setup:e=>(e,t)=>{const s=n("private-view");return a(),i(s,{title:"Acquisition Manager"},{default:r(()=>[...t[0]||(t[0]=[o("div",{class:"acquisition-manager"},[o("h1",null,"Acquisition Manager"),o("p",null,"Modern Industrial Acquisition Management Interface")],-1)])]),_:1})}}),u=[],c=[];!function(e,t){if(e&&"undefined"!=typeof document){var n,a=!0===t.prepend?"prepend":"append",i=!0===t.singleTag,r="string"==typeof t.container?document.querySelector(t.container):document.getElementsByTagName("head")[0];if(i){var o=u.indexOf(r);-1===o&&(o=u.push(r)-1,c[o]={}),n=c[o]&&c[o][a]?c[o][a]:c[o][a]=s()}else n=s();65279===e.charCodeAt(0)&&(e=e.substring(1)),n.styleSheet?n.styleSheet.cssText+=e:n.appendChild(document.createTextNode(e))}function s(){var e=document.createElement("style");if(e.setAttribute("type","text/css"),t.attributes)for(var n=Object.keys(t.attributes),i=0;i<n.length;i++)e.setAttribute(n[i],t.attributes[n[i]]);var o="prepend"===a?"afterbegin":"beforeend";return r.insertAdjacentElement(o,e),e}}("\n.acquisition-manager[data-v-19f4e937] {\n\tpadding: 20px;\n}\n",{});var d=e({id:"acquisition-manager",name:"Acquisition Manager",icon:"account_balance_wallet",routes:[{path:"",component:((e,t)=>{const n=e.__vccOpts||e;for(const[e,a]of t)n[e]=a;return n})(s,[["__scopeId","data-v-19f4e937"],["__file","module.vue"]])}]});export{d as default};
|
||||
@@ -1,30 +1,28 @@
|
||||
{
|
||||
"name": "acquisition-manager",
|
||||
"description": "Custom High-Fidelity Acquisition Management for Directus",
|
||||
"icon": "account_balance_wallet",
|
||||
"version": "1.0.0",
|
||||
"description": "Custom High-Fidelity Management for Directus",
|
||||
"icon": "extension",
|
||||
"version": "1.8.10",
|
||||
"type": "module",
|
||||
"keywords": [
|
||||
"directus",
|
||||
"directus-extension",
|
||||
"directus-extension-module"
|
||||
],
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"directus:extension": {
|
||||
"type": "module",
|
||||
"path": "index.js",
|
||||
"path": "dist/index.js",
|
||||
"source": "src/index.ts",
|
||||
"host": "*",
|
||||
"name": "Acquisition Manager"
|
||||
"host": "app",
|
||||
"name": "acquisition manager"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "directus-extension build && (cp -f dist/index.js index.js 2>/dev/null || true)",
|
||||
"build": "directus-extension build",
|
||||
"dev": "directus-extension build -w"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@directus/extensions-sdk": "11.0.2",
|
||||
"@mintel/directus-extension-toolkit": "workspace:*",
|
||||
"vue": "^3.4.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,19 @@
|
||||
import { defineModule } from '@directus/extensions-sdk';
|
||||
import ModuleComponent from './module.vue';
|
||||
import { defineModule } from "@directus/extensions-sdk";
|
||||
import ModuleComponent from "./module.vue";
|
||||
|
||||
export default defineModule({
|
||||
id: 'acquisition-manager',
|
||||
name: 'Acquisition Manager',
|
||||
icon: 'account_balance_wallet',
|
||||
id: "acquisition-manager",
|
||||
name: "Acquisition",
|
||||
icon: "auto_awesome",
|
||||
routes: [
|
||||
{
|
||||
path: '',
|
||||
path: "",
|
||||
component: ModuleComponent,
|
||||
},
|
||||
{
|
||||
path: ":id",
|
||||
component: ModuleComponent,
|
||||
props: true
|
||||
}
|
||||
],
|
||||
});
|
||||
|
||||
@@ -1,18 +1,421 @@
|
||||
<template>
|
||||
<private-view title="Acquisition Manager">
|
||||
<div class="acquisition-manager">
|
||||
<h1>Acquisition Manager</h1>
|
||||
<p>Modern Industrial Acquisition Management Interface</p>
|
||||
<MintelManagerLayout
|
||||
title="Acquisition Manager"
|
||||
:item-title="getCompanyName(selectedLead) || 'Lead wählen'"
|
||||
:is-empty="!selectedLead"
|
||||
empty-title="Lead auswählen"
|
||||
empty-icon="auto_awesome"
|
||||
:notice="notice"
|
||||
@close-notice="notice = null"
|
||||
>
|
||||
<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="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="nav-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="getCompanyName(lead)" />
|
||||
</v-list-item-content>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</template>
|
||||
|
||||
<template #subtitle>
|
||||
<template v-if="selectedLead">
|
||||
<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() }}
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<template #actions>
|
||||
<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>
|
||||
</template>
|
||||
|
||||
<template #empty-state>
|
||||
Wähle einen Lead in der Navigation aus oder
|
||||
<v-button x-small @click="openCreateDrawer">registriere einen neuen Lead</v-button>.
|
||||
</template>
|
||||
|
||||
<div v-if="selectedLead" 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 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">
|
||||
<MintelStatCard label="Projekt-Modus" :value="selectedLead.ai_state.projectType || 'Unbekannt'" icon="category" />
|
||||
<MintelStatCard label="Seitenanzahl" :value="selectedLead.ai_state.sitemap?.length || '0'" icon="description" />
|
||||
</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>
|
||||
</private-view>
|
||||
|
||||
<!-- Drawer: New Lead -->
|
||||
<v-drawer
|
||||
v-model="drawerActive"
|
||||
title="Neuen Lead registrieren"
|
||||
icon="person_add"
|
||||
@cancel="drawerActive = false"
|
||||
>
|
||||
<div class="drawer-content">
|
||||
<div class="form-section">
|
||||
<div class="field">
|
||||
<span class="label">Organisation / Firma (Zentral)</span>
|
||||
<MintelSelect
|
||||
v-model="newLead.company"
|
||||
:items="companyOptions"
|
||||
placeholder="Bestehende Firma auswählen..."
|
||||
allow-add
|
||||
@add="openQuickAdd('company')"
|
||||
/>
|
||||
</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">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>
|
||||
<MintelSelect
|
||||
v-model="newLead.contact_person"
|
||||
:items="peopleOptions"
|
||||
placeholder="Person auswählen..."
|
||||
allow-add
|
||||
@add="openQuickAdd('person')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="drawer-actions">
|
||||
<v-button primary block :loading="savingLead" @click="saveLead">Lead speichern</v-button>
|
||||
</div>
|
||||
</div>
|
||||
</v-drawer>
|
||||
</MintelManagerLayout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// Logic will be added here
|
||||
import { ref, onMounted, computed } from 'vue';
|
||||
import { useApi } from '@directus/extensions-sdk';
|
||||
import { MintelManagerLayout, MintelSelect, MintelStatCard } from '@mintel/directus-extension-toolkit';
|
||||
|
||||
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 drawerActive = ref(false);
|
||||
const savingLead = ref(false);
|
||||
const notice = ref<{ type: string; message: string } | null>(null);
|
||||
|
||||
const newLead = ref({
|
||||
company: null,
|
||||
website_url: '',
|
||||
contact_person: null,
|
||||
briefing: '',
|
||||
status: 'new'
|
||||
});
|
||||
|
||||
const companies = ref<any[]>([]);
|
||||
const people = ref<any[]>([]);
|
||||
|
||||
const companyOptions = computed(() =>
|
||||
companies.value.map(c => ({
|
||||
text: c.name,
|
||||
value: c.id
|
||||
}))
|
||||
);
|
||||
|
||||
const peopleOptions = computed(() =>
|
||||
people.value.map(p => ({
|
||||
text: `${p.first_name} ${p.last_name}`,
|
||||
value: p.id
|
||||
}))
|
||||
);
|
||||
|
||||
function getCompanyName(lead: any) {
|
||||
if (!lead) return '';
|
||||
if (lead.company) {
|
||||
return typeof lead.company === 'object' ? lead.company.name : (companies.value.find(c => c.id === lead.company)?.name || 'Unbekannte Firma');
|
||||
}
|
||||
return 'Unbekannte Organisation';
|
||||
}
|
||||
|
||||
function getPersonName(id: string | any) {
|
||||
if (!id) return '';
|
||||
if (typeof id === 'object') return `${id.first_name} ${id.last_name}`;
|
||||
const person = people.value.find(p => p.id === id);
|
||||
return person ? `${person.first_name} ${person.last_name}` : id;
|
||||
}
|
||||
|
||||
function goToPerson(id: string) {
|
||||
notice.value = { type: 'info', message: `Navigiere zu Person: ${id}` };
|
||||
}
|
||||
|
||||
const selectedLead = computed(() => leads.value.find(l => l.id === selectedLeadId.value));
|
||||
|
||||
async function fetchData() {
|
||||
try {
|
||||
const [leadsResp, peopleResp, companiesResp] = await Promise.all([
|
||||
api.get('/items/leads', {
|
||||
params: {
|
||||
sort: '-date_created',
|
||||
fields: '*.*'
|
||||
}
|
||||
}),
|
||||
api.get('/items/people', { params: { sort: 'last_name' } }),
|
||||
api.get('/items/companies', { params: { sort: 'name' } })
|
||||
]);
|
||||
leads.value = leadsResp.data.data;
|
||||
people.value = peopleResp.data.data;
|
||||
companies.value = companiesResp.data.data;
|
||||
|
||||
if (!selectedLeadId.value && leads.value.length > 0) {
|
||||
selectedLeadId.value = leads.value[0].id;
|
||||
}
|
||||
} catch (e: any) {
|
||||
console.error('Fetch error:', e);
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchLeads() {
|
||||
await fetchData();
|
||||
}
|
||||
|
||||
function selectLead(id: string) {
|
||||
selectedLeadId.value = id;
|
||||
}
|
||||
|
||||
function openCreateDrawer() {
|
||||
newLead.value = {
|
||||
company: null,
|
||||
website_url: '',
|
||||
contact_person: null,
|
||||
briefing: '',
|
||||
status: 'new'
|
||||
};
|
||||
drawerActive.value = true;
|
||||
}
|
||||
|
||||
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) {
|
||||
notice.value = { type: 'danger', message: 'Organisation erforderlich.' };
|
||||
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!' };
|
||||
drawerActive.value = false;
|
||||
await fetchLeads();
|
||||
selectedLeadId.value = payload.id;
|
||||
} catch (e: any) {
|
||||
notice.value = { type: 'danger', message: `Fehler beim Speichern: ${e.message}` };
|
||||
} finally {
|
||||
savingLead.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function openQuickAdd(type: string) {
|
||||
notice.value = { type: 'info', message: `${type === 'company' ? 'Firma' : 'Person'} im jeweiligen Manager anlegen.` };
|
||||
}
|
||||
|
||||
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)';
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(fetchData);
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.acquisition-manager {
|
||||
padding: 20px;
|
||||
}
|
||||
.url-link { color: inherit; text-decoration: none; border-bottom: 1px solid transparent; }
|
||||
.url-link:hover { border-bottom-color: currentColor; }
|
||||
|
||||
.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: 24px; 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; }
|
||||
</style>
|
||||
|
||||
@@ -7,7 +7,7 @@ const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
||||
const entryPoint = resolve(__dirname, 'src/index.ts');
|
||||
const outfile = resolve(__dirname, 'index.js');
|
||||
const outfile = resolve(__dirname, 'dist/index.js');
|
||||
|
||||
try {
|
||||
mkdirSync(dirname(outfile), { recursive: true });
|
||||
@@ -21,20 +21,25 @@ build({
|
||||
platform: 'node',
|
||||
target: 'node18',
|
||||
outfile: outfile,
|
||||
format: 'esm',
|
||||
external: [],
|
||||
jsx: 'automatic',
|
||||
loader: {
|
||||
'.tsx': 'tsx',
|
||||
'.ts': 'ts',
|
||||
'.js': 'js',
|
||||
},
|
||||
external: ["@react-pdf/renderer", "react", "react-dom", "jsdom", "jsdom/*", "jquery", "jquery/*", "canvas", "fs", "path", "os", "http", "https", "zlib", "stream", "util", "url", "net", "tls", "crypto"],
|
||||
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.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) {
|
||||
build.onResolve({ filter: /^jsdom/ }, args => ({ path: args.path, namespace: 'mock-jsdom' }));
|
||||
build.onLoad({ filter: /.*/, namespace: 'mock-jsdom' }, () => ({ contents: 'export default {};', loader: 'js' }));
|
||||
}
|
||||
}]
|
||||
}).then(() => {
|
||||
console.log("Build succeeded!");
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,25 +1,27 @@
|
||||
{
|
||||
"name": "acquisition",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"directus:extension": {
|
||||
"type": "endpoint",
|
||||
"path": "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",
|
||||
"esbuild": "^0.25.0",
|
||||
"typescript": "^5.6.3"
|
||||
},
|
||||
"dependencies": {
|
||||
"jquery": "^3.7.1",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4"
|
||||
}
|
||||
}
|
||||
"name": "acquisition",
|
||||
"version": "1.8.10",
|
||||
"type": "module",
|
||||
"directus:extension": {
|
||||
"type": "endpoint",
|
||||
"path": "dist/index.js",
|
||||
"source": "src/index.ts",
|
||||
"host": "^11.0.0"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "node build.mjs",
|
||||
"dev": "node build.mjs --watch"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@directus/extensions-sdk": "11.0.2",
|
||||
"@mintel/pdf": "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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,176 @@
|
||||
import { defineEndpoint } from '@directus/extensions-sdk';
|
||||
import { defineEndpoint } from "@directus/extensions-sdk";
|
||||
import { AcquisitionService, PdfEngine } from "@mintel/pdf/server";
|
||||
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) => {
|
||||
router.get('/ping', (req, res) => res.send('pong'));
|
||||
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 { ItemsService, MailService } = services;
|
||||
const leadsService = new ItemsService("leads", { schema: req.schema, accountability: req.accountability });
|
||||
const peopleService = new ItemsService("people", { schema: req.schema, accountability: req.accountability });
|
||||
const companiesService = new ItemsService("companies", { schema: req.schema, accountability: req.accountability });
|
||||
const mailService = new MailService({ schema: req.schema, accountability: req.accountability });
|
||||
|
||||
try {
|
||||
const lead = await leadsService.readOne(id, { fields: ["*", "company.*", "contact_person.*"] });
|
||||
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 || lead.company_name;
|
||||
|
||||
if (lead.contact_person) {
|
||||
recipientEmail = lead.contact_person.email || recipientEmail;
|
||||
|
||||
if (lead.contact_person.company) {
|
||||
const personCompany = await companiesService.readOne(lead.contact_person.company);
|
||||
companyName = personCompany?.name || companyName;
|
||||
}
|
||||
}
|
||||
|
||||
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 companiesService = new ItemsService("companies", { schema: req.schema, accountability: req.accountability });
|
||||
const mailService = new MailService({ schema: req.schema, accountability: req.accountability });
|
||||
|
||||
try {
|
||||
const lead = await leadsService.readOne(id, { fields: ["*", "company.*", "contact_person.*"] });
|
||||
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 || lead.company_name;
|
||||
|
||||
if (lead.contact_person) {
|
||||
recipientEmail = lead.contact_person.email || recipientEmail;
|
||||
|
||||
if (lead.contact_person.company) {
|
||||
const personCompany = await companiesService.readOne(lead.contact_person.company);
|
||||
companyName = personCompany?.name || companyName;
|
||||
}
|
||||
}
|
||||
|
||||
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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@mintel/cli",
|
||||
"version": "1.7.3",
|
||||
"version": "1.8.10",
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
"registry": "https://npm.infra.mintel.me"
|
||||
|
||||
41
packages/cloner-library/build.mjs
Normal file
41
packages/cloner-library/build.mjs
Normal file
@@ -0,0 +1,41 @@
|
||||
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 (e) { }
|
||||
|
||||
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);
|
||||
});
|
||||
30
packages/cloner-library/package.json
Normal file
30
packages/cloner-library/package.json
Normal file
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"name": "@mintel/cloner",
|
||||
"version": "1.8.10",
|
||||
"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": {
|
||||
"esbuild": "^0.25.0",
|
||||
"typescript": "^5.6.3",
|
||||
"@types/node": "^22.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"playwright": "^1.40.0",
|
||||
"crawlee": "^3.7.0",
|
||||
"axios": "^1.6.0",
|
||||
"cheerio": "^1.0.0-rc.12"
|
||||
}
|
||||
}
|
||||
93
packages/cloner-library/src/AssetManager.ts
Normal file
93
packages/cloner-library/src/AssetManager.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
184
packages/cloner-library/src/PageCloner.ts
Normal file
184
packages/cloner-library/src/PageCloner.ts
Normal file
@@ -0,0 +1,184 @@
|
||||
import { chromium, Browser, BrowserContext, Page } 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);
|
||||
|
||||
let 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 { }
|
||||
}
|
||||
}
|
||||
|
||||
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 { }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
123
packages/cloner-library/src/WebsiteCloner.ts
Normal file
123
packages/cloner-library/src/WebsiteCloner.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
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) { }
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
3
packages/cloner-library/src/index.ts
Normal file
3
packages/cloner-library/src/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from "./AssetManager.js";
|
||||
export * from "./PageCloner.js";
|
||||
export * from "./WebsiteCloner.js";
|
||||
17
packages/cloner-library/tsconfig.json
Normal file
17
packages/cloner-library/tsconfig.json
Normal file
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"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/**/*"
|
||||
]
|
||||
}
|
||||
11
packages/cms-infra/Dockerfile
Normal file
11
packages/cms-infra/Dockerfile
Normal file
@@ -0,0 +1,11 @@
|
||||
FROM directus/directus:11
|
||||
|
||||
USER root
|
||||
# Install dependencies in a way that avoids metadata conflicts in the root
|
||||
RUN mkdir -p /directus/lib-dependencies && \
|
||||
cd /directus/lib-dependencies && \
|
||||
npm init -y && \
|
||||
npm install vue @vueuse/core vue-router
|
||||
# Ensure they are in the NODE_PATH
|
||||
ENV NODE_PATH="/directus/lib-dependencies/node_modules:${NODE_PATH}"
|
||||
USER node
|
||||
Binary file not shown.
@@ -1,6 +1,6 @@
|
||||
services:
|
||||
infra-cms:
|
||||
image: directus/directus:11
|
||||
image: directus/directus:11.15.2
|
||||
ports:
|
||||
- "8059:8055"
|
||||
networks:
|
||||
@@ -14,6 +14,7 @@ services:
|
||||
DB_CLIENT: "sqlite3"
|
||||
DB_FILENAME: "/directus/database/data.db"
|
||||
WEBSOCKETS_ENABLED: "true"
|
||||
PUBLIC_URL: "http://cms.localhost"
|
||||
EMAIL_TRANSPORT: "smtp"
|
||||
EMAIL_SMTP_HOST: "smtp.eu.mailgun.org"
|
||||
EMAIL_SMTP_PORT: "587"
|
||||
@@ -21,19 +22,29 @@ services:
|
||||
EMAIL_SMTP_PASSWORD: "4592fcb94599ee1a45b4ac2386fd0a64-102c75d8-ca2870e6"
|
||||
EMAIL_SMTP_SECURE: "false"
|
||||
EMAIL_FROM: "postmaster@mg.mintel.me"
|
||||
LOG_LEVEL: "debug"
|
||||
SERVE_APP: "true"
|
||||
EXTENSIONS_AUTO_RELOAD: "true"
|
||||
volumes:
|
||||
- ./database:/directus/database
|
||||
- ./uploads:/directus/uploads
|
||||
- ./schema:/directus/schema
|
||||
- ./extensions:/directus/extensions
|
||||
healthcheck:
|
||||
test: [ "CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://127.0.0.1:8055/server/health" ]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.http.routers.infra-cms.rule=Host(`cms.localhost`)"
|
||||
- "traefik.http.services.infra-cms.loadbalancer.server.port=8055"
|
||||
- "traefik.http.routers.at-mintel-infra-cms.rule=Host(`cms.localhost`)"
|
||||
- "traefik.http.services.at-mintel-infra-cms.loadbalancer.server.port=8055"
|
||||
- "traefik.http.services.at-mintel-infra-cms.loadbalancer.healthcheck.path=/server/health"
|
||||
- "traefik.docker.network=infra"
|
||||
|
||||
networks:
|
||||
default:
|
||||
name: mintel-infra-cms-internal
|
||||
name: at-mintel-cms-network
|
||||
infra:
|
||||
external: true
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -1,30 +1,28 @@
|
||||
{
|
||||
"name": "acquisition-manager",
|
||||
"description": "Custom High-Fidelity Acquisition Management for Directus",
|
||||
"icon": "account_balance_wallet",
|
||||
"version": "1.0.0",
|
||||
"description": "Custom High-Fidelity Management for Directus",
|
||||
"icon": "extension",
|
||||
"version": "1.8.2",
|
||||
"type": "module",
|
||||
"keywords": [
|
||||
"directus",
|
||||
"directus-extension",
|
||||
"directus-extension-module"
|
||||
],
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"directus:extension": {
|
||||
"type": "module",
|
||||
"path": "index.js",
|
||||
"source": "src/index.ts",
|
||||
"host": "*",
|
||||
"name": "Acquisition Manager"
|
||||
"host": "app",
|
||||
"name": "acquisition manager"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "directus-extension build && (cp -f dist/index.js index.js 2>/dev/null || true)",
|
||||
"build": "directus-extension build",
|
||||
"dev": "directus-extension build -w"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@directus/extensions-sdk": "11.0.2",
|
||||
"@mintel/directus-extension-toolkit": "workspace:*",
|
||||
"vue": "^3.4.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -1,25 +1,27 @@
|
||||
{
|
||||
"name": "acquisition",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"directus:extension": {
|
||||
"type": "endpoint",
|
||||
"path": "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",
|
||||
"esbuild": "^0.25.0",
|
||||
"typescript": "^5.6.3"
|
||||
},
|
||||
"dependencies": {
|
||||
"jquery": "^3.7.1",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4"
|
||||
}
|
||||
"name": "acquisition",
|
||||
"version": "1.8.2",
|
||||
"type": "module",
|
||||
"directus:extension": {
|
||||
"type": "endpoint",
|
||||
"path": "index.js",
|
||||
"source": "src/index.ts",
|
||||
"host": "^11.0.0"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "node build.mjs",
|
||||
"dev": "node build.mjs --watch"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@directus/extensions-sdk": "11.0.2",
|
||||
"@mintel/pdf": "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"
|
||||
}
|
||||
}
|
||||
1
packages/cms-infra/extensions/company-manager/index.js
Normal file
1
packages/cms-infra/extensions/company-manager/index.js
Normal file
File diff suppressed because one or more lines are too long
28
packages/cms-infra/extensions/company-manager/package.json
Normal file
28
packages/cms-infra/extensions/company-manager/package.json
Normal file
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"name": "company-manager",
|
||||
"description": "Custom High-Fidelity Management for Directus",
|
||||
"icon": "extension",
|
||||
"version": "1.8.2",
|
||||
"type": "module",
|
||||
"keywords": [
|
||||
"directus",
|
||||
"directus-extension",
|
||||
"directus-extension-module"
|
||||
],
|
||||
"directus:extension": {
|
||||
"type": "module",
|
||||
"path": "index.js",
|
||||
"source": "src/index.ts",
|
||||
"host": "app",
|
||||
"name": "company manager"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "directus-extension build",
|
||||
"dev": "directus-extension build -w"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@directus/extensions-sdk": "11.0.2",
|
||||
"@mintel/directus-extension-toolkit": "workspace:*",
|
||||
"vue": "^3.4.0"
|
||||
}
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -1,30 +1,28 @@
|
||||
{
|
||||
"name": "customer-manager",
|
||||
"description": "Custom High-Fidelity Customer & Company Management for Directus",
|
||||
"icon": "supervisor_account",
|
||||
"version": "1.7.3",
|
||||
"description": "Custom High-Fidelity Management for Directus",
|
||||
"icon": "extension",
|
||||
"version": "1.8.2",
|
||||
"type": "module",
|
||||
"keywords": [
|
||||
"directus",
|
||||
"directus-extension",
|
||||
"directus-extension-module"
|
||||
],
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"directus:extension": {
|
||||
"type": "module",
|
||||
"path": "index.js",
|
||||
"source": "src/index.ts",
|
||||
"host": "*",
|
||||
"name": "Customer Manager"
|
||||
"host": "app",
|
||||
"name": "customer manager"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "directus-extension build && (cp -f dist/index.js index.js 2>/dev/null || true)",
|
||||
"build": "directus-extension build",
|
||||
"dev": "directus-extension build -w"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@directus/extensions-sdk": "11.0.2",
|
||||
"@mintel/directus-extension-toolkit": "workspace:*",
|
||||
"vue": "^3.4.0"
|
||||
}
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -1,30 +1,27 @@
|
||||
{
|
||||
"name": "feedback-commander",
|
||||
"description": "Custom High-Fidelity Feedback Management Extension for Directus",
|
||||
"icon": "view_kanban",
|
||||
"version": "1.7.3",
|
||||
"description": "Custom High-Fidelity Management for Directus",
|
||||
"icon": "extension",
|
||||
"version": "1.8.2",
|
||||
"type": "module",
|
||||
"keywords": [
|
||||
"directus",
|
||||
"directus-extension",
|
||||
"directus-extension-module"
|
||||
],
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"directus:extension": {
|
||||
"type": "module",
|
||||
"path": "index.js",
|
||||
"source": "src/index.ts",
|
||||
"host": "*",
|
||||
"name": "Feedback Commander"
|
||||
"host": "app",
|
||||
"name": "feedback commander"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "directus-extension build && (cp -f dist/index.js index.js 2>/dev/null || true)",
|
||||
"build": "directus-extension build",
|
||||
"dev": "directus-extension build -w"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@directus/extensions-sdk": "11.0.2",
|
||||
"vue": "^3.4.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -1,30 +1,28 @@
|
||||
{
|
||||
"name": "people-manager",
|
||||
"description": "Custom High-Fidelity People Management for Directus",
|
||||
"icon": "person",
|
||||
"version": "1.0.0",
|
||||
"name": "people-manager",
|
||||
"description": "Custom High-Fidelity Management for Directus",
|
||||
"icon": "extension",
|
||||
"version": "1.8.2",
|
||||
"type": "module",
|
||||
"keywords": [
|
||||
"directus",
|
||||
"directus-extension",
|
||||
"directus-extension-module"
|
||||
],
|
||||
"directus:extension": {
|
||||
"type": "module",
|
||||
"keywords": [
|
||||
"directus",
|
||||
"directus-extension",
|
||||
"directus-extension-module"
|
||||
],
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"directus:extension": {
|
||||
"type": "module",
|
||||
"path": "index.js",
|
||||
"source": "src/index.ts",
|
||||
"host": "*",
|
||||
"name": "People Manager"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "directus-extension build && (cp -f dist/index.js index.js 2>/dev/null || true)",
|
||||
"dev": "directus-extension build -w"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@directus/extensions-sdk": "11.0.2",
|
||||
"vue": "^3.4.0"
|
||||
}
|
||||
"path": "index.js",
|
||||
"source": "src/index.ts",
|
||||
"host": "app",
|
||||
"name": "people manager"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "directus-extension build",
|
||||
"dev": "directus-extension build -w"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@directus/extensions-sdk": "11.0.2",
|
||||
"@mintel/directus-extension-toolkit": "workspace:*",
|
||||
"vue": "^3.4.0"
|
||||
}
|
||||
}
|
||||
1
packages/cms-infra/extensions/unified-dashboard/index.js
Normal file
1
packages/cms-infra/extensions/unified-dashboard/index.js
Normal file
File diff suppressed because one or more lines are too long
27
packages/cms-infra/extensions/unified-dashboard/package.json
Normal file
27
packages/cms-infra/extensions/unified-dashboard/package.json
Normal file
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"name": "unified-dashboard",
|
||||
"description": "Custom High-Fidelity Management for Directus",
|
||||
"icon": "extension",
|
||||
"version": "1.8.2",
|
||||
"type": "module",
|
||||
"keywords": [
|
||||
"directus",
|
||||
"directus-extension",
|
||||
"directus-extension-module"
|
||||
],
|
||||
"directus:extension": {
|
||||
"type": "module",
|
||||
"path": "index.js",
|
||||
"source": "src/index.ts",
|
||||
"host": "app",
|
||||
"name": "unified dashboard"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "directus-extension build",
|
||||
"dev": "directus-extension build -w"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@directus/extensions-sdk": "11.0.2",
|
||||
"vue": "^3.4.0"
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,17 @@
|
||||
{
|
||||
"name": "@mintel/cms-infra",
|
||||
"version": "1.7.3",
|
||||
"version": "1.8.10",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"up": "npm run build:extensions && docker compose up -d",
|
||||
"down": "docker compose down",
|
||||
"logs": "docker compose logs -f",
|
||||
"build:extensions": "../../scripts/sync-extensions.sh"
|
||||
"build:extensions": "../../scripts/sync-extensions.sh",
|
||||
"schema:apply:local": "../../scripts/cms-apply.sh local",
|
||||
"schema:apply:infra": "../../scripts/cms-apply.sh infra",
|
||||
"snapshot:local": "../../scripts/cms-snapshot.sh",
|
||||
"sync:push": "../../scripts/sync-directus.sh push infra",
|
||||
"sync:pull": "../../scripts/sync-directus.sh pull infra"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
1203
packages/cms-infra/schema/current.yaml
Normal file
1203
packages/cms-infra/schema/current.yaml
Normal file
File diff suppressed because it is too large
Load Diff
2046
packages/cms-infra/schema/current_v2.yaml
Normal file
2046
packages/cms-infra/schema/current_v2.yaml
Normal file
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1 +1 @@
|
||||
xmKX5
|
||||
--tVj
|
||||
28
packages/company-manager/package.json
Normal file
28
packages/company-manager/package.json
Normal file
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"name": "company-manager",
|
||||
"description": "Custom High-Fidelity Management for Directus",
|
||||
"icon": "extension",
|
||||
"version": "1.8.10",
|
||||
"type": "module",
|
||||
"keywords": [
|
||||
"directus",
|
||||
"directus-extension",
|
||||
"directus-extension-module"
|
||||
],
|
||||
"directus:extension": {
|
||||
"type": "module",
|
||||
"path": "dist/index.js",
|
||||
"source": "src/index.ts",
|
||||
"host": "app",
|
||||
"name": "company manager"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "directus-extension build",
|
||||
"dev": "directus-extension build -w"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@directus/extensions-sdk": "11.0.2",
|
||||
"@mintel/directus-extension-toolkit": "workspace:*",
|
||||
"vue": "^3.4.0"
|
||||
}
|
||||
}
|
||||
14
packages/company-manager/src/index.ts
Normal file
14
packages/company-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: 'company-manager',
|
||||
name: 'Company Manager',
|
||||
icon: 'business',
|
||||
routes: [
|
||||
{
|
||||
path: '',
|
||||
component: ModuleComponent,
|
||||
},
|
||||
],
|
||||
});
|
||||
217
packages/company-manager/src/module.vue
Normal file
217
packages/company-manager/src/module.vue
Normal file
@@ -0,0 +1,217 @@
|
||||
<template>
|
||||
<MintelManagerLayout
|
||||
title="Company Manager"
|
||||
:item-title="selectedCompany?.name || 'Firma wählen'"
|
||||
:is-empty="!selectedCompany"
|
||||
empty-title="Firma auswählen"
|
||||
empty-icon="business"
|
||||
:notice="feedback"
|
||||
@close-notice="feedback = null"
|
||||
>
|
||||
<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 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="nav-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 #subtitle>
|
||||
<template v-if="selectedCompany">
|
||||
{{ selectedCompany.domain || 'Keine Domain angegeben' }}
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<template #actions>
|
||||
<v-button secondary rounded icon v-tooltip.bottom="'Firma bearbeiten'" @click="openEditDrawer">
|
||||
<v-icon name="edit" />
|
||||
</v-button>
|
||||
<v-button danger rounded icon v-tooltip.bottom="'Firma löschen'" @click="deleteCompany">
|
||||
<v-icon name="delete" />
|
||||
</v-button>
|
||||
</template>
|
||||
|
||||
<template #empty-state>
|
||||
Wähle eine Firma in der Navigation aus oder
|
||||
<v-button x-small @click="openCreateDrawer">erstelle eine neue Firma</v-button>.
|
||||
</template>
|
||||
|
||||
<div v-if="selectedCompany" class="details-grid">
|
||||
<div class="detail-item full">
|
||||
<span class="label">Notizen / Adresse</span>
|
||||
<p class="value">{{ selectedCompany.notes || '---' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Create/Edit Drawer -->
|
||||
<v-drawer
|
||||
v-model="drawerActive"
|
||||
:title="isEditing ? 'Firma bearbeiten' : 'Neue Firma anlegen'"
|
||||
icon="business"
|
||||
@cancel="drawerActive = false"
|
||||
>
|
||||
<template #default>
|
||||
<div class="drawer-content">
|
||||
<div class="form-section">
|
||||
<div class="field">
|
||||
<span class="label">Firmenname</span>
|
||||
<v-input v-model="form.name" placeholder="z.B. Schmidt GmbH" autofocus />
|
||||
</div>
|
||||
<div class="field">
|
||||
<span class="label">Domain / Website</span>
|
||||
<v-input v-model="form.domain" placeholder="example.com" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<span class="label">Notizen / Adresse</span>
|
||||
<v-textarea v-model="form.notes" placeholder="z.B. Branche, Adresse, etc." />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="drawer-actions">
|
||||
<v-button primary block :loading="saving" @click="saveCompany">
|
||||
Firma speichern
|
||||
</v-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</v-drawer>
|
||||
</MintelManagerLayout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { useApi } from '@directus/extensions-sdk';
|
||||
import { MintelManagerLayout } from '@mintel/directus-extension-toolkit';
|
||||
|
||||
const api = useApi();
|
||||
const companies = ref([]);
|
||||
const selectedCompany = ref(null);
|
||||
const feedback = ref(null);
|
||||
const saving = ref(false);
|
||||
const drawerActive = ref(false);
|
||||
const isEditing = ref(false);
|
||||
|
||||
const form = ref({
|
||||
id: null,
|
||||
name: '',
|
||||
domain: '',
|
||||
notes: ''
|
||||
});
|
||||
|
||||
async function fetchData() {
|
||||
try {
|
||||
const resp = await api.get('/items/companies', {
|
||||
params: { sort: 'name' }
|
||||
});
|
||||
companies.value = resp.data.data;
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch companies:', error);
|
||||
}
|
||||
}
|
||||
|
||||
function selectCompany(company: any) {
|
||||
selectedCompany.value = company;
|
||||
}
|
||||
|
||||
function openCreateDrawer() {
|
||||
isEditing.value = false;
|
||||
form.value = {
|
||||
id: null,
|
||||
name: '',
|
||||
domain: '',
|
||||
notes: ''
|
||||
};
|
||||
drawerActive.value = true;
|
||||
}
|
||||
|
||||
function openEditDrawer() {
|
||||
isEditing.value = true;
|
||||
form.value = {
|
||||
id: selectedCompany.value.id,
|
||||
name: selectedCompany.value.name,
|
||||
domain: selectedCompany.value.domain,
|
||||
notes: selectedCompany.value.notes
|
||||
};
|
||||
drawerActive.value = true;
|
||||
}
|
||||
|
||||
async function saveCompany() {
|
||||
if (!form.value.name) {
|
||||
feedback.value = { type: 'danger', message: 'Firmenname ist erforderlich.' };
|
||||
return;
|
||||
}
|
||||
|
||||
saving.value = true;
|
||||
try {
|
||||
let updatedItem;
|
||||
if (isEditing.value) {
|
||||
const res = await api.patch(`/items/companies/${form.value.id}`, form.value);
|
||||
updatedItem = res.data.data;
|
||||
feedback.value = { type: 'success', message: 'Firma aktualisiert!' };
|
||||
} else {
|
||||
const res = await api.post('/items/companies', form.value);
|
||||
updatedItem = res.data.data;
|
||||
feedback.value = { type: 'success', message: 'Firma angelegt!' };
|
||||
}
|
||||
drawerActive.value = false;
|
||||
await fetchData();
|
||||
if (updatedItem) {
|
||||
selectedCompany.value = companies.value.find(c => c.id === updatedItem.id) || updatedItem;
|
||||
}
|
||||
} catch (error) {
|
||||
feedback.value = { type: 'danger', message: error.message };
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteCompany() {
|
||||
if (!confirm('Soll diese Firma wirklich gelöscht werden?')) return;
|
||||
|
||||
try {
|
||||
await api.delete(`/items/companies/${selectedCompany.value.id}`);
|
||||
feedback.value = { type: 'success', message: 'Firma gelöscht.' };
|
||||
selectedCompany.value = null;
|
||||
await fetchData();
|
||||
} catch (error) {
|
||||
feedback.value = { type: 'danger', message: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(fetchData);
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.details-grid { display: flex; flex-direction: column; gap: 24px; }
|
||||
.detail-item { display: flex; flex-direction: column; gap: 8px; }
|
||||
.detail-item.full { width: 100%; }
|
||||
.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>
|
||||
File diff suppressed because one or more lines are too long
@@ -1,30 +1,28 @@
|
||||
{
|
||||
"name": "customer-manager",
|
||||
"description": "Custom High-Fidelity Customer & Company Management for Directus",
|
||||
"icon": "supervisor_account",
|
||||
"version": "1.7.3",
|
||||
"description": "Custom High-Fidelity Management for Directus",
|
||||
"icon": "extension",
|
||||
"version": "1.8.10",
|
||||
"type": "module",
|
||||
"keywords": [
|
||||
"directus",
|
||||
"directus-extension",
|
||||
"directus-extension-module"
|
||||
],
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"directus:extension": {
|
||||
"type": "module",
|
||||
"path": "index.js",
|
||||
"path": "dist/index.js",
|
||||
"source": "src/index.ts",
|
||||
"host": "*",
|
||||
"name": "Customer Manager"
|
||||
"host": "app",
|
||||
"name": "customer manager"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "directus-extension build && (cp -f dist/index.js index.js 2>/dev/null || true)",
|
||||
"build": "directus-extension build",
|
||||
"dev": "directus-extension build -w"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@directus/extensions-sdk": "11.0.2",
|
||||
"@mintel/directus-extension-toolkit": "workspace:*",
|
||||
"vue": "^3.4.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,152 +1,191 @@
|
||||
<template>
|
||||
<private-view title="Customer Manager">
|
||||
<MintelManagerLayout
|
||||
title="Customer Manager"
|
||||
:item-title="selectedItem?.company?.name || 'Kunde wählen'"
|
||||
:is-empty="!selectedItem"
|
||||
empty-title="Kunde auswählen"
|
||||
empty-icon="handshake"
|
||||
:notice="notice"
|
||||
@close-notice="notice = null"
|
||||
>
|
||||
<template #navigation>
|
||||
<v-list nav>
|
||||
<v-list-item @click="openCreateCompany" clickable>
|
||||
<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 Firma anlegen" />
|
||||
<v-text-overflow text="Neuen Kunden verlinken" />
|
||||
</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"
|
||||
v-for="item in items"
|
||||
:key="item.id"
|
||||
:active="selectedItem?.id === item.id"
|
||||
class="nav-item"
|
||||
clickable
|
||||
@click="selectCompany(company)"
|
||||
@click="selectItem(item)"
|
||||
>
|
||||
<v-list-item-icon><v-icon name="business" /></v-list-item-icon>
|
||||
<v-list-item-content>
|
||||
<v-text-overflow :text="company.name" />
|
||||
<v-text-overflow :text="item.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 #subtitle>
|
||||
<template v-if="selectedItem">
|
||||
{{ clientUsers.length }} Portal-Nutzer · {{ selectedItem.company?.domain }}
|
||||
</template>
|
||||
</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 #actions>
|
||||
<v-button secondary rounded icon v-tooltip.bottom="'Kunden-Verlinkung bearbeiten'" @click="openEditDrawer">
|
||||
<v-icon name="edit" />
|
||||
</v-button>
|
||||
<v-button primary @click="openCreateClientUser">
|
||||
Portal-Nutzer hinzufügen
|
||||
</v-button>
|
||||
</template>
|
||||
|
||||
<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>
|
||||
<template #empty-state>
|
||||
Wähle einen Kunden aus der Liste oder
|
||||
<v-button x-small @click="openCreateDrawer">verlinke eine neue Firma</v-button>.
|
||||
</template>
|
||||
|
||||
<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"
|
||||
<!-- Main Content: Client Users Table -->
|
||||
<v-table
|
||||
:headers="tableHeaders"
|
||||
:items="clientUsers"
|
||||
:loading="loading"
|
||||
class="clickable-table"
|
||||
fixed-header
|
||||
@click:row="onRowClick"
|
||||
>
|
||||
<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>
|
||||
|
||||
<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>
|
||||
|
||||
<!-- Drawer: Customer (Link) Form -->
|
||||
<v-drawer
|
||||
v-model="drawerActive"
|
||||
:title="isEditing ? 'Kunden-Verlinkung bearbeiten' : 'Kunden verlinken'"
|
||||
icon="handshake"
|
||||
@cancel="drawerActive = false"
|
||||
>
|
||||
<div v-if="drawerActive" class="drawer-content">
|
||||
<div class="form-section">
|
||||
<div class="field">
|
||||
<span class="label">Organisation / Firma</span>
|
||||
<MintelSelect
|
||||
v-model="form.company"
|
||||
:items="companyOptions"
|
||||
placeholder="Firma auswählen..."
|
||||
allow-add
|
||||
@add="openQuickAdd('company')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<span class="label">Haupt-Ansprechpartner (optional)</span>
|
||||
<MintelSelect
|
||||
v-model="form.contact_person"
|
||||
:items="peopleOptions"
|
||||
placeholder="Person auswählen..."
|
||||
allow-add
|
||||
@add="openQuickAdd('person')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<span class="label">Status</span>
|
||||
<v-select
|
||||
v-model="form.status"
|
||||
:items="[
|
||||
{ text: 'Aktiv', value: 'active' },
|
||||
{ text: 'Inaktiv', value: 'inactive' }
|
||||
]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<span class="label">Notizen</span>
|
||||
<v-textarea v-model="form.notes" placeholder="Besonderheiten zu diesem Kunden..." />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="drawer-actions">
|
||||
<v-button primary block :loading="saving" @click="saveCompany">Speichern</v-button>
|
||||
<v-button primary block :loading="saving" @click="saveItem">Speichern</v-button>
|
||||
</div>
|
||||
</div>
|
||||
</v-drawer>
|
||||
|
||||
<!-- Drawer: Employee Form -->
|
||||
<!-- Drawer: Client User Form -->
|
||||
<v-drawer
|
||||
v-model="drawerEmployeeActive"
|
||||
:title="isEditingEmployee ? 'Mitarbeiter bearbeiten' : 'Neuen Mitarbeiter anlegen'"
|
||||
v-model="drawerUserActive"
|
||||
:title="isEditingUser ? 'Portal-Nutzer bearbeiten' : 'Neuen Portal-Nutzer anlegen'"
|
||||
icon="person"
|
||||
@cancel="drawerEmployeeActive = false"
|
||||
@cancel="drawerUserActive = 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 v-if="drawerUserActive" class="drawer-content">
|
||||
<div class="form-section">
|
||||
<div class="field">
|
||||
<span class="label">Vorname</span>
|
||||
<v-input v-model="userForm.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">Nachname</span>
|
||||
<v-input v-model="userForm.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">E-Mail</span>
|
||||
<v-input v-model="userForm.email" placeholder="E-Mail Adresse" type="email" />
|
||||
</div>
|
||||
|
||||
<v-divider v-if="isEditingEmployee" />
|
||||
<div class="field">
|
||||
<span class="label">Zentrale Person (Verknüpfung)</span>
|
||||
<v-select
|
||||
v-model="userForm.person"
|
||||
:items="peopleOptions"
|
||||
placeholder="Master-Person auswählen..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
<v-divider v-if="isEditingUser" />
|
||||
|
||||
<div v-if="isEditingUser" class="field">
|
||||
<span class="label">Temporäres Passwort</span>
|
||||
<v-input v-model="userForm.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>
|
||||
<v-button primary block :loading="saving" @click="saveClientUser">Daten speichern</v-button>
|
||||
|
||||
<template v-if="isEditingEmployee">
|
||||
<template v-if="isEditingUser">
|
||||
<v-divider />
|
||||
<v-button
|
||||
v-tooltip.bottom="'Generiert PW, speichert es und sendet E-Mail'"
|
||||
secondary
|
||||
block
|
||||
:loading="invitingId === employeeForm.id"
|
||||
@click="inviteUser(employeeForm)"
|
||||
:loading="invitingId === userForm.id"
|
||||
@click="inviteUser(userForm)"
|
||||
>
|
||||
<v-icon name="send" left /> Zugangsdaten senden
|
||||
</v-button>
|
||||
@@ -154,37 +193,34 @@
|
||||
</div>
|
||||
</div>
|
||||
</v-drawer>
|
||||
</private-view>
|
||||
</MintelManagerLayout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, nextTick } from 'vue';
|
||||
import { ref, onMounted, nextTick, computed } from 'vue';
|
||||
import { useApi } from '@directus/extensions-sdk';
|
||||
import { MintelManagerLayout, MintelSelect } from '@mintel/directus-extension-toolkit';
|
||||
|
||||
const api = useApi();
|
||||
|
||||
const companies = ref<any[]>([]);
|
||||
const selectedCompany = ref<any>(null);
|
||||
const employees = ref<any[]>([]);
|
||||
const items = ref<any[]>([]);
|
||||
const selectedItem = ref<any>(null);
|
||||
const clientUsers = 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);
|
||||
|
||||
// Forms State
|
||||
const drawerCompanyActive = ref(false);
|
||||
const isEditingCompany = ref(false);
|
||||
const companyForm = ref({ id: '', name: '' });
|
||||
const companies = ref<any[]>([]);
|
||||
const people = ref<any[]>([]);
|
||||
|
||||
const drawerEmployeeActive = ref(false);
|
||||
const isEditingEmployee = ref(false);
|
||||
const employeeForm = ref({
|
||||
id: '',
|
||||
first_name: '',
|
||||
last_name: '',
|
||||
email: '',
|
||||
temporary_password: ''
|
||||
});
|
||||
const drawerActive = ref(false);
|
||||
const isEditing = ref(false);
|
||||
const form = ref({ id: null, company: null, contact_person: null, status: 'active', notes: '' });
|
||||
|
||||
const drawerUserActive = ref(false);
|
||||
const isEditingUser = ref(false);
|
||||
const userForm = ref({ id: '', first_name: '', last_name: '', email: '', person: null, temporary_password: '' });
|
||||
|
||||
const tableHeaders = [
|
||||
{ text: 'Name', value: 'name', sortable: true },
|
||||
@@ -192,169 +228,159 @@ const tableHeaders = [
|
||||
{ text: 'Zuletzt eingeladen', value: 'last_invited', sortable: true }
|
||||
];
|
||||
|
||||
async function fetchCompanies() {
|
||||
const res = await api.get('/items/companies', {
|
||||
params: {
|
||||
fields: ['id', 'name'],
|
||||
sort: 'name',
|
||||
},
|
||||
});
|
||||
companies.value = res.data.data;
|
||||
const companyOptions = computed(() => companies.value.map(c => ({ text: c.name, value: c.id })));
|
||||
const peopleOptions = computed(() => people.value.map(p => ({ text: `${p.first_name} ${p.last_name} (${p.email})`, value: p.id })));
|
||||
|
||||
async function fetchData() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const [custResp, compResp, peopleResp] = await Promise.all([
|
||||
api.get('/items/customers', { params: { fields: ['*', 'company.*', 'contact_person.*'], sort: 'company.name' } }),
|
||||
api.get('/items/companies', { params: { sort: 'name' } }),
|
||||
api.get('/items/people', { params: { sort: 'last_name' } })
|
||||
]);
|
||||
items.value = custResp.data.data;
|
||||
companies.value = compResp.data.data;
|
||||
people.value = peopleResp.data.data;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
async function selectItem(item: any) {
|
||||
selectedItem.value = item;
|
||||
loading.value = true;
|
||||
try {
|
||||
const res = await api.get('/items/client_users', {
|
||||
params: {
|
||||
filter: { company: { _eq: item.company.id } },
|
||||
fields: ['*', 'person.*'],
|
||||
sort: 'first_name',
|
||||
},
|
||||
});
|
||||
clientUsers.value = res.data.data;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Company Actions
|
||||
function openCreateCompany() {
|
||||
isEditingCompany.value = false;
|
||||
companyForm.value = { id: '', name: '' };
|
||||
drawerCompanyActive.value = true;
|
||||
function openCreateDrawer() {
|
||||
isEditing.value = false;
|
||||
form.value = { id: null, company: null, contact_person: null, status: 'active', notes: '' };
|
||||
drawerActive.value = true;
|
||||
}
|
||||
|
||||
async function openEditCompany() {
|
||||
if (!selectedCompany.value) return;
|
||||
companyForm.value = {
|
||||
id: selectedCompany.value.id,
|
||||
name: selectedCompany.value.name
|
||||
function openEditDrawer() {
|
||||
if (!selectedItem.value) return;
|
||||
isEditing.value = true;
|
||||
form.value = {
|
||||
id: selectedItem.value.id,
|
||||
company: selectedItem.value.company?.id || selectedItem.value.company,
|
||||
contact_person: selectedItem.value.contact_person?.id || selectedItem.value.contact_person,
|
||||
status: selectedItem.value.status,
|
||||
notes: selectedItem.value.notes
|
||||
};
|
||||
isEditingCompany.value = true;
|
||||
await nextTick();
|
||||
drawerCompanyActive.value = true;
|
||||
drawerActive.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;
|
||||
}
|
||||
async function saveItem() {
|
||||
if (!form.value.company) return;
|
||||
saving.value = true;
|
||||
try {
|
||||
if (isEditing.value) {
|
||||
await api.patch(`/items/customers/${form.value.id}`, form.value);
|
||||
notice.value = { type: 'success', message: 'Kunde aktualisiert!' };
|
||||
} else {
|
||||
await api.post('/items/customers', form.value);
|
||||
notice.value = { type: 'success', message: 'Neuer Kunde verlinkt!' };
|
||||
}
|
||||
drawerActive.value = false;
|
||||
await fetchData();
|
||||
if (form.value.id) {
|
||||
const updated = items.value.find(i => i.id === form.value.id);
|
||||
if (updated) selectItem(updated);
|
||||
}
|
||||
} 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: '', 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 || '',
|
||||
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
|
||||
});
|
||||
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
|
||||
});
|
||||
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;
|
||||
}
|
||||
// Client User Actions
|
||||
function openCreateClientUser() {
|
||||
isEditingUser.value = false;
|
||||
userForm.value = { id: '', first_name: '', last_name: '', email: '', person: null, temporary_password: '' };
|
||||
drawerUserActive.value = true;
|
||||
}
|
||||
|
||||
function onRowClick(event: any) {
|
||||
const item = event?.item || event;
|
||||
if (item && item.id) {
|
||||
openEditEmployee(item);
|
||||
}
|
||||
const item = event?.item || event;
|
||||
if (item && item.id) {
|
||||
userForm.value = {
|
||||
id: item.id,
|
||||
first_name: item.first_name,
|
||||
last_name: item.last_name,
|
||||
email: item.email,
|
||||
person: item.person?.id || item.person,
|
||||
temporary_password: item.temporary_password
|
||||
};
|
||||
isEditingUser.value = true;
|
||||
drawerUserActive.value = true;
|
||||
}
|
||||
}
|
||||
|
||||
async function saveClientUser() {
|
||||
if (!userForm.value.email || !selectedItem.value) return;
|
||||
saving.value = true;
|
||||
try {
|
||||
const payload = {
|
||||
first_name: userForm.value.first_name,
|
||||
last_name: userForm.value.last_name,
|
||||
email: userForm.value.email,
|
||||
person: userForm.value.person,
|
||||
company: selectedItem.value.company.id
|
||||
};
|
||||
if (isEditingUser.value) {
|
||||
await api.patch(`/items/client_users/${userForm.value.id}`, payload);
|
||||
} else {
|
||||
await api.post('/items/client_users', payload);
|
||||
}
|
||||
drawerUserActive.value = false;
|
||||
await selectItem(selectedItem.value);
|
||||
} 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 versendet. 📧` };
|
||||
await selectItem(selectedItem.value);
|
||||
} finally {
|
||||
invitingId.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
function openQuickAdd(type: 'company' | 'person') {
|
||||
// Quick add logic can involve opening another drawer or navigating
|
||||
// For now, we'll just show a notice
|
||||
notice.value = { type: 'info', message: `${type === 'company' ? 'Firma' : 'Person'} im jeweiligen Manager anlegen.` };
|
||||
}
|
||||
|
||||
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'
|
||||
});
|
||||
return new Date(dateStr).toLocaleString('de-DE', {
|
||||
day: '2-digit', month: '2-digit', year: 'numeric',
|
||||
hour: '2-digit', minute: '2-digit'
|
||||
});
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchCompanies();
|
||||
});
|
||||
onMounted(fetchData);
|
||||
</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); }
|
||||
@@ -364,6 +390,7 @@ onMounted(() => {
|
||||
.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;
|
||||
@@ -373,5 +400,4 @@ onMounted(() => {
|
||||
|
||||
.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>
|
||||
|
||||
31
packages/directus-extension-toolkit/package.json
Normal file
31
packages/directus-extension-toolkit/package.json
Normal file
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"name": "@mintel/directus-extension-toolkit",
|
||||
"version": "1.8.10",
|
||||
"description": "Shared toolkit for Directus extensions in the Mintel ecosystem",
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/index.js",
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"exports": {
|
||||
".": {
|
||||
"import": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "vite build",
|
||||
"dev": "vite build --watch"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@directus/extensions-sdk": "11.0.2",
|
||||
"@vitejs/plugin-vue": "^6.0.4",
|
||||
"typescript": "^5.0.0",
|
||||
"vite": "^5.0.0",
|
||||
"vue": "^3.4.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@directus/extensions-sdk": "*",
|
||||
"vue": "^3.4.0"
|
||||
}
|
||||
}
|
||||
102
packages/directus-extension-toolkit/src/MintelManagerLayout.vue
Normal file
102
packages/directus-extension-toolkit/src/MintelManagerLayout.vue
Normal file
@@ -0,0 +1,102 @@
|
||||
<template>
|
||||
<private-view :title="title">
|
||||
<template #navigation>
|
||||
<slot name="navigation" />
|
||||
</template>
|
||||
|
||||
<template #title-outer:after>
|
||||
<v-notice v-if="notice" :type="notice.type" @close="$emit('close-notice')" dismissible>
|
||||
{{ notice.message }}
|
||||
</v-notice>
|
||||
</template>
|
||||
|
||||
<div class="mintel-manager-layout">
|
||||
<div v-if="isEmpty" class="empty-state">
|
||||
<v-info :title="emptyTitle" :icon="emptyIcon" center>
|
||||
<slot name="empty-state" />
|
||||
</v-info>
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<header class="mintel-header">
|
||||
<div class="header-left">
|
||||
<h1 class="mintel-title">{{ itemTitle }}</h1>
|
||||
<p class="mintel-subtitle">
|
||||
<slot name="subtitle" />
|
||||
</p>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<slot name="actions" />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<v-divider />
|
||||
|
||||
<div class="mintel-content">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</private-view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
title: string;
|
||||
itemTitle?: string;
|
||||
isEmpty?: boolean;
|
||||
emptyTitle?: string;
|
||||
emptyIcon?: string;
|
||||
notice?: { type: string; message: string } | null;
|
||||
}>();
|
||||
|
||||
defineEmits(['close-notice']);
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.mintel-manager-layout {
|
||||
padding: 32px;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.mintel-header {
|
||||
margin-bottom: 24px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.mintel-title {
|
||||
font-size: 24px;
|
||||
font-weight: 800;
|
||||
margin-bottom: 4px;
|
||||
color: var(--theme--foreground);
|
||||
}
|
||||
|
||||
.mintel-subtitle {
|
||||
color: var(--theme--foreground-subdued);
|
||||
font-size: 14px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.header-right {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.mintel-content {
|
||||
margin-top: 32px;
|
||||
}
|
||||
</style>
|
||||
62
packages/directus-extension-toolkit/src/MintelSelect.vue
Normal file
62
packages/directus-extension-toolkit/src/MintelSelect.vue
Normal file
@@ -0,0 +1,62 @@
|
||||
<template>
|
||||
<div class="mintel-select">
|
||||
<v-select
|
||||
:model-value="modelValue"
|
||||
:items="items"
|
||||
:placeholder="placeholder"
|
||||
:searchable="searchable"
|
||||
:show-deselect="showDeselect"
|
||||
@update:model-value="$emit('update:modelValue', $event)"
|
||||
/>
|
||||
<v-button v-if="allowAdd" secondary rounded icon x-small class="add-button" @click="$emit('add')">
|
||||
<v-icon name="add" />
|
||||
</v-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: [String, Number],
|
||||
default: null
|
||||
},
|
||||
items: {
|
||||
type: Array as () => Array<{ text: string; value: string | number }>,
|
||||
required: true
|
||||
},
|
||||
placeholder: {
|
||||
type: String,
|
||||
default: 'Auswählen...'
|
||||
},
|
||||
searchable: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
showDeselect: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
allowAdd: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
});
|
||||
|
||||
defineEmits(['update:modelValue', 'add']);
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.mintel-select {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.mintel-select :deep(.v-select) {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.add-button {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
</style>
|
||||
84
packages/directus-extension-toolkit/src/MintelStatCard.vue
Normal file
84
packages/directus-extension-toolkit/src/MintelStatCard.vue
Normal file
@@ -0,0 +1,84 @@
|
||||
<template>
|
||||
<div class="mintel-stat-card" @click="$emit('click')">
|
||||
<div class="stat-icon">
|
||||
<v-icon :name="icon" large />
|
||||
</div>
|
||||
<div class="stat-content">
|
||||
<span class="stat-label">{{ label }}</span>
|
||||
<span class="stat-value">{{ value }}</span>
|
||||
</div>
|
||||
<v-icon name="chevron_right" class="arrow" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
label: string;
|
||||
value: string | number;
|
||||
icon: string;
|
||||
}>();
|
||||
|
||||
defineEmits(['click']);
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.mintel-stat-card {
|
||||
background: var(--theme--background-normal);
|
||||
border: 1px solid var(--theme--border);
|
||||
padding: 24px;
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.mintel-stat-card:hover {
|
||||
border-color: var(--theme--primary);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 16px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.stat-icon {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
background: var(--theme--background-subdued);
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--theme--primary);
|
||||
}
|
||||
|
||||
.stat-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
color: var(--theme--foreground-subdued);
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 28px;
|
||||
font-weight: 800;
|
||||
color: var(--theme--foreground);
|
||||
}
|
||||
|
||||
.arrow {
|
||||
position: absolute;
|
||||
right: 24px;
|
||||
opacity: 0.2;
|
||||
}
|
||||
|
||||
.mintel-stat-card:hover .arrow {
|
||||
opacity: 1;
|
||||
color: var(--theme--primary);
|
||||
}
|
||||
</style>
|
||||
3
packages/directus-extension-toolkit/src/index.ts
Normal file
3
packages/directus-extension-toolkit/src/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { default as MintelSelect } from './MintelSelect.vue';
|
||||
export { default as MintelManagerLayout } from './MintelManagerLayout.vue';
|
||||
export { default as MintelStatCard } from './MintelStatCard.vue';
|
||||
24
packages/directus-extension-toolkit/vite.config.ts
Normal file
24
packages/directus-extension-toolkit/vite.config.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import vue from '@vitejs/plugin-vue';
|
||||
import { resolve } from 'path';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
build: {
|
||||
lib: {
|
||||
entry: resolve('src/index.ts'),
|
||||
name: 'MintelDirectusToolkit',
|
||||
fileName: 'index',
|
||||
formats: ['es']
|
||||
},
|
||||
rollupOptions: {
|
||||
external: ['vue', '@directus/extensions-sdk'],
|
||||
output: {
|
||||
globals: {
|
||||
vue: 'Vue',
|
||||
'@directus/extensions-sdk': 'DirectusExtensionsSDK'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -1,7 +1,17 @@
|
||||
import js from "@eslint/js";
|
||||
import tseslint from "typescript-eslint";
|
||||
import globals from "globals";
|
||||
|
||||
export default tseslint.config(
|
||||
{
|
||||
languageOptions: {
|
||||
globals: {
|
||||
...globals.browser,
|
||||
...globals.node,
|
||||
...globals.es2021,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
ignores: ["**/dist/**", "**/node_modules/**", "**/.next/**", "**/build/**"],
|
||||
},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@mintel/eslint-config",
|
||||
"version": "1.7.3",
|
||||
"version": "1.8.10",
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
"registry": "https://npm.infra.mintel.me"
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -1,26 +1,23 @@
|
||||
{
|
||||
"name": "feedback-commander",
|
||||
"description": "Custom High-Fidelity Feedback Management Extension for Directus",
|
||||
"icon": "view_kanban",
|
||||
"version": "1.7.3",
|
||||
"description": "Custom High-Fidelity Management for Directus",
|
||||
"icon": "extension",
|
||||
"version": "1.8.10",
|
||||
"type": "module",
|
||||
"keywords": [
|
||||
"directus",
|
||||
"directus-extension",
|
||||
"directus-extension-module"
|
||||
],
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"directus:extension": {
|
||||
"type": "module",
|
||||
"path": "index.js",
|
||||
"path": "dist/index.js",
|
||||
"source": "src/index.ts",
|
||||
"host": "*",
|
||||
"name": "Feedback Commander"
|
||||
"host": "app",
|
||||
"name": "feedback commander"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "directus-extension build && (cp -f dist/index.js index.js 2>/dev/null || true)",
|
||||
"build": "directus-extension build",
|
||||
"dev": "directus-extension build -w"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -78,7 +78,7 @@
|
||||
<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-chip x-small outline>{{ item.company?.name || 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 />
|
||||
@@ -142,7 +142,8 @@
|
||||
<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-user">{{ reply.user_name || 'System' }}</span>
|
||||
<span v-if="reply.person" class="reply-person">({{ reply.person.first_name }} {{ reply.person.last_name }})</span>
|
||||
<span class="reply-date">{{ formatDate(reply.date_created || reply.id) }}</span>
|
||||
</header>
|
||||
<div class="reply-text">{{ reply.text }}</div>
|
||||
@@ -168,8 +169,12 @@
|
||||
<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>
|
||||
<label><v-icon name="business" x-small /> Organisation / Firma</label>
|
||||
<strong>{{ selectedItem.company?.name || selectedItem.project }}</strong>
|
||||
</div>
|
||||
<div v-if="selectedItem.person" class="meta-item">
|
||||
<label><v-icon name="person" x-small /> Zentrale Person</label>
|
||||
<strong>{{ selectedItem.person.first_name }} {{ selectedItem.person.last_name }}</strong>
|
||||
</div>
|
||||
<div class="meta-item">
|
||||
<label><v-icon name="link" x-small /> Source Path</label>
|
||||
@@ -238,13 +243,14 @@ const statusOptions = [
|
||||
];
|
||||
|
||||
const projects = computed(() => {
|
||||
const projSet = new Set(items.value.map(i => i.project).filter(Boolean));
|
||||
const projSet = new Set(items.value.map(i => i.company?.name || i.project).filter(Boolean));
|
||||
return Array.from(projSet).sort();
|
||||
});
|
||||
|
||||
const filteredItems = computed(() => {
|
||||
return items.value.filter(item => {
|
||||
const matchProject = currentProject.value === 'all' || item.project === currentProject.value;
|
||||
const projectName = item.company?.name || item.project;
|
||||
const matchProject = currentProject.value === 'all' || projectName === currentProject.value;
|
||||
const status = item.status || 'open';
|
||||
const matchStatus = currentStatusFilter.value === 'all' || status === currentStatusFilter.value;
|
||||
return matchProject && matchStatus;
|
||||
@@ -258,7 +264,8 @@ async function fetchData() {
|
||||
const response = await api.get('/items/visual_feedback', {
|
||||
params: {
|
||||
sort: '-date_created,-id',
|
||||
limit: 300
|
||||
limit: 300,
|
||||
fields: ['*', 'company.*', 'person.*']
|
||||
}
|
||||
});
|
||||
items.value = response.data.data;
|
||||
@@ -278,7 +285,8 @@ async function selectItem(item) {
|
||||
const response = await api.get('/items/visual_feedback_comments', {
|
||||
params: {
|
||||
filter: { feedback_id: { _eq: item.id } },
|
||||
sort: '-date_created,-id'
|
||||
sort: '-date_created,-id',
|
||||
fields: ['*', 'person.*']
|
||||
}
|
||||
});
|
||||
comments.value = response.data.data;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@mintel/gatekeeper",
|
||||
"version": "1.7.3",
|
||||
"version": "1.8.10",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -11,6 +11,8 @@ export async function GET(req: NextRequest) {
|
||||
|
||||
// 1. URL Parameter Bypass (for automated tests/staging)
|
||||
const originalUrl = req.headers.get("x-forwarded-uri") || "/";
|
||||
|
||||
console.log(`[Verify] Check: ${originalUrl} | Cookie: ${session ? "Found" : "Missing"}`);
|
||||
const host =
|
||||
req.headers.get("x-forwarded-host") || req.headers.get("host") || "";
|
||||
const proto = req.headers.get("x-forwarded-proto") || "https";
|
||||
@@ -54,15 +56,17 @@ export async function GET(req: NextRequest) {
|
||||
if (session?.value) {
|
||||
if (session.value === password) {
|
||||
isAuthenticated = true;
|
||||
console.log(`[Verify] Legacy password match`);
|
||||
} else {
|
||||
try {
|
||||
const payload = JSON.parse(session.value);
|
||||
if (payload.identity) {
|
||||
isAuthenticated = true;
|
||||
identity = payload.identity;
|
||||
console.log(`[Verify] Identity verified: ${identity}`);
|
||||
}
|
||||
} catch (_e) {
|
||||
// Fallback or old format
|
||||
console.log(`[Verify] JSON Parse failed for cookie: ${session.value.substring(0, 10)}...`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,8 +17,8 @@ export default async function LoginPage({ searchParams }: LoginPageProps) {
|
||||
async function login(formData: FormData) {
|
||||
"use server";
|
||||
|
||||
const email = formData.get("email") as string;
|
||||
const password = formData.get("password") as string;
|
||||
const email = (formData.get("email") as string || "").trim();
|
||||
const password = (formData.get("password") as string || "").trim();
|
||||
|
||||
const expectedCode = process.env.GATEKEEPER_PASSWORD || "mintel";
|
||||
const adminEmail = process.env.DIRECTUS_ADMIN_EMAIL;
|
||||
@@ -31,19 +31,19 @@ export default async function LoginPage({ searchParams }: LoginPageProps) {
|
||||
let userIdentity = "";
|
||||
let userCompany: any = null;
|
||||
|
||||
// 1. Check Global Admin (from ENV)
|
||||
if (
|
||||
// 1. Check Generic Code (Guest) - High Priority to prevent autofill traps
|
||||
if (password === expectedCode) {
|
||||
userIdentity = "Guest";
|
||||
}
|
||||
// 2. Check Global Admin (from ENV)
|
||||
else if (
|
||||
adminEmail &&
|
||||
adminPassword &&
|
||||
email === adminEmail &&
|
||||
password === adminPassword
|
||||
email === adminEmail.trim() &&
|
||||
password === adminPassword.trim()
|
||||
) {
|
||||
userIdentity = "Admin";
|
||||
}
|
||||
// 2. Check Generic Code (Guest)
|
||||
else if (!email && password === expectedCode) {
|
||||
userIdentity = "Guest";
|
||||
}
|
||||
// 3. Check Lightweight Client Users (dedicated collection)
|
||||
if (email && password && process.env.INFRA_DIRECTUS_URL) {
|
||||
try {
|
||||
@@ -116,6 +116,7 @@ export default async function LoginPage({ searchParams }: LoginPageProps) {
|
||||
}
|
||||
|
||||
if (userIdentity) {
|
||||
console.log(`[Login] Success: ${userIdentity} | Redirect: ${targetRedirect}`);
|
||||
const cookieStore = await cookies();
|
||||
// Store identity in the cookie (simplified for now, ideally signed)
|
||||
const sessionValue = JSON.stringify({
|
||||
@@ -126,6 +127,8 @@ export default async function LoginPage({ searchParams }: LoginPageProps) {
|
||||
|
||||
const isDev = process.env.NODE_ENV === "development";
|
||||
|
||||
console.log(`[Login] Setting Cookie: ${authCookieName} | Domain: ${cookieDomain || "Default"}`);
|
||||
|
||||
cookieStore.set(authCookieName, sessionValue, {
|
||||
httpOnly: true,
|
||||
secure: !isDev,
|
||||
@@ -136,6 +139,7 @@ export default async function LoginPage({ searchParams }: LoginPageProps) {
|
||||
});
|
||||
redirect(targetRedirect);
|
||||
} else {
|
||||
console.log(`[Login] Failed for inputs. Redirecting back with error.`);
|
||||
redirect(`/login?error=1&redirect=${encodeURIComponent(targetRedirect)}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/* global module, require */
|
||||
import path from "node:path";
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
module.exports = {
|
||||
content: [
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@mintel/husky-config",
|
||||
"version": "1.7.3",
|
||||
"version": "1.8.10",
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
"registry": "https://npm.infra.mintel.me"
|
||||
|
||||
@@ -1,38 +1,13 @@
|
||||
# Step 1: Builder image
|
||||
FROM node:20-alpine AS builder
|
||||
# Step 1: Base image for Next.js builds
|
||||
FROM node:20-alpine
|
||||
RUN apk add --no-cache libc6-compat curl
|
||||
|
||||
# Enable pnpm
|
||||
RUN corepack enable pnpm && \
|
||||
corepack prepare pnpm@10.2.0 --activate
|
||||
|
||||
WORKDIR /app
|
||||
RUN corepack enable pnpm
|
||||
|
||||
# Step 2: Install dependencies
|
||||
ENV NPM_TOKEN=placeholder
|
||||
# Copy manifest files specifically for better layer caching
|
||||
COPY pnpm-lock.yaml pnpm-workspace.yaml package.json .npmrc ./
|
||||
# Copy package manifest files individually to preserve directory structure
|
||||
COPY packages/cli/package.json ./packages/cli/
|
||||
COPY packages/cms-infra/package.json ./packages/cms-infra/
|
||||
COPY packages/customer-manager/package.json ./packages/customer-manager/
|
||||
COPY packages/eslint-config/package.json ./packages/eslint-config/
|
||||
COPY packages/feedback-commander/package.json ./packages/feedback-commander/
|
||||
COPY packages/gatekeeper/package.json ./packages/gatekeeper/
|
||||
COPY packages/husky-config/package.json ./packages/husky-config/
|
||||
COPY packages/infra/package.json ./packages/infra/
|
||||
COPY packages/mail/package.json ./packages/mail/
|
||||
COPY packages/next-config/package.json ./packages/next-config/
|
||||
COPY packages/next-feedback/package.json ./packages/next-feedback/
|
||||
COPY packages/next-observability/package.json ./packages/next-observability/
|
||||
COPY packages/next-utils/package.json ./packages/next-utils/
|
||||
COPY packages/observability/package.json ./packages/observability/
|
||||
COPY packages/tsconfig/package.json ./packages/tsconfig/
|
||||
# packages/ui does not have a package.json
|
||||
|
||||
# Use a secret for NPM_TOKEN and a standardized 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) && \
|
||||
pnpm config set store-dir /pnpm/store && \
|
||||
pnpm i --frozen-lockfile
|
||||
|
||||
# Step 3: Build shared packages
|
||||
COPY . .
|
||||
RUN pnpm --filter "./packages/*" -r build
|
||||
# Final environment
|
||||
ENV NODE_ENV=production
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
|
||||
@@ -5,7 +5,7 @@ on:
|
||||
branches:
|
||||
- main
|
||||
tags:
|
||||
- 'v*'
|
||||
- '*'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
skip_long_checks:
|
||||
@@ -65,11 +65,6 @@ jobs:
|
||||
PRJ_ID="${{ github.event.repository.name }}"
|
||||
|
||||
if [[ "${{ github.ref_type }}" == "branch" && "$TAG" == "main" ]]; then
|
||||
if [[ "$COMMIT_MSG" =~ ^chore: ]]; then
|
||||
TARGET="skip"
|
||||
GOTIFY_TITLE="ℹ️ Skip Deploy (Chore)"
|
||||
GOTIFY_PRIORITY=2
|
||||
else
|
||||
TARGET="testing"
|
||||
IMAGE_TAG="main-${SHORT_SHA}"
|
||||
ENV_FILE=".env.testing"
|
||||
@@ -81,9 +76,8 @@ jobs:
|
||||
IS_PROD="false"
|
||||
GOTIFY_TITLE="🧪 Testing-Deploy"
|
||||
GOTIFY_PRIORITY=4
|
||||
fi
|
||||
elif [[ "${{ github.ref_type }}" == "tag" ]]; then
|
||||
if [[ "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||
if [[ "$TAG" =~ ^v?[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||
TARGET="production"
|
||||
IMAGE_TAG="$TAG"
|
||||
ENV_FILE=".env.prod"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@mintel/infra",
|
||||
"version": "1.7.3",
|
||||
"version": "1.8.10",
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
"registry": "https://npm.infra.mintel.me"
|
||||
|
||||
@@ -43,8 +43,15 @@ for repo_dir in "$REGISTRY_DATA/repositories/mintel/"*; do
|
||||
done
|
||||
|
||||
# 2. Run Garbage Collection
|
||||
echo "♻️ Running Registry Garbage Collection..."
|
||||
docker exec registry-registry-1 bin/registry garbage-collect /etc/docker/registry/config.yml --delete-untagged
|
||||
echo "♻️ Detecting Registry Container..."
|
||||
REGISTRY_CONTAINER=$(docker ps --format "{{.Names}}" | grep registry | head -1 || true)
|
||||
|
||||
if [ -n "$REGISTRY_CONTAINER" ]; then
|
||||
echo "♻️ Running Registry Garbage Collection on $REGISTRY_CONTAINER..."
|
||||
docker exec "$REGISTRY_CONTAINER" bin/registry garbage-collect /etc/docker/registry/config.yml --delete-untagged
|
||||
else
|
||||
echo "⚠️ Registry container not found. Skipping GC."
|
||||
fi
|
||||
|
||||
# 3. Prune Host Docker resources (Shorter window: 24h)
|
||||
echo "🧹 Pruning Host Docker resources..."
|
||||
|
||||
93
packages/infra/scripts/wait-for-upstream.sh
Executable file
93
packages/infra/scripts/wait-for-upstream.sh
Executable file
@@ -0,0 +1,93 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
# wait-for-upstream.sh
|
||||
# Usage: ./wait-for-upstream.sh <org/repo> <version_tag> [poll_interval_sec]
|
||||
|
||||
REPO=$1
|
||||
TAG=$2
|
||||
INTERVAL=${3:-30}
|
||||
MAX_RETRIES=40 # ~20 minutes default
|
||||
|
||||
if [[ -z "$REPO" || -z "$TAG" ]]; then
|
||||
echo "❌ Error: REPO and TAG are required."
|
||||
echo "Usage: $0 <org/repo> <version_tag>"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ -z "$GITEA_TOKEN" ]]; then
|
||||
echo "❌ Error: GITEA_TOKEN is not set."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
GITEA_API="https://git.infra.mintel.me/api/v1"
|
||||
|
||||
echo "🔎 Searching for upstream release $TAG in $REPO..."
|
||||
|
||||
# 1. Get the SHA of the tag to be more precise
|
||||
TAG_INFO=$(curl -s -H "Authorization: token $GITEA_TOKEN" "$GITEA_API/repos/$REPO/tags/$TAG")
|
||||
TARGET_SHA=$(echo "$TAG_INFO" | jq -r '.commit.sha // empty')
|
||||
|
||||
if [[ -z "$TARGET_SHA" || "$TARGET_SHA" == "null" ]]; then
|
||||
echo "⚠️ Warning: Tag $TAG not found yet. Upstream might be lagging."
|
||||
echo " Waiting 15s for tag to appear..."
|
||||
sleep 15
|
||||
TAG_INFO=$(curl -s -H "Authorization: token $GITEA_TOKEN" "$GITEA_API/repos/$REPO/tags/$TAG")
|
||||
TARGET_SHA=$(echo "$TAG_INFO" | jq -r '.commit.sha // empty')
|
||||
|
||||
if [[ -z "$TARGET_SHA" || "$TARGET_SHA" == "null" ]]; then
|
||||
echo "❌ Error: Tag $TAG does not exist in $REPO."
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "✅ Target SHA for $TAG is $TARGET_SHA"
|
||||
|
||||
# 2. Find the run for the specific SHA
|
||||
# We list recent runs and filter by head_sha
|
||||
RUN_QUERY=$(curl -s -H "Authorization: token $GITEA_TOKEN" "$GITEA_API/repos/$REPO/actions/runs?limit=30")
|
||||
RUN_ID=$(echo "$RUN_QUERY" | jq -r ".workflow_runs[] | select(.head_sha == \"$TARGET_SHA\") | .id" | head -n 1)
|
||||
|
||||
if [[ -z "$RUN_ID" || "$RUN_ID" == "null" ]]; then
|
||||
echo "ℹ️ No recent action run found for SHA $TARGET_SHA yet."
|
||||
echo " Checking if we should wait or if it was already successful..."
|
||||
|
||||
# Fallback: wait a bit more for new tags
|
||||
echo "⏳ waiting for run to appear..."
|
||||
sleep 20
|
||||
RUN_QUERY=$(curl -s -H "Authorization: token $GITEA_TOKEN" "$GITEA_API/repos/$REPO/actions/runs?limit=30")
|
||||
RUN_ID=$(echo "$RUN_QUERY" | jq -r ".workflow_runs[] | select(.head_sha == \"$TARGET_SHA\") | .id" | head -n 1)
|
||||
|
||||
if [[ -z "$RUN_ID" || "$RUN_ID" == "null" ]]; then
|
||||
echo "✅ No run found but Tag exists. Assuming manual release or already completed. Proceeding."
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "⏳ Waiting for upstream run $RUN_ID status..."
|
||||
|
||||
RETRY_COUNT=0
|
||||
while [ $RETRY_COUNT -lt $MAX_RETRIES ]; do
|
||||
STATUS_QUERY=$(curl -s -H "Authorization: token $GITEA_TOKEN" "$GITEA_API/repos/$REPO/actions/runs/$RUN_ID")
|
||||
STATUS=$(echo "$STATUS_QUERY" | jq -r '.status')
|
||||
CONCLUSION=$(echo "$STATUS_QUERY" | jq -r '.conclusion')
|
||||
|
||||
echo " - Current Status: $STATUS (Conclusion: $CONCLUSION)"
|
||||
|
||||
if [[ "$STATUS" == "success" || "$CONCLUSION" == "success" ]]; then
|
||||
echo "✅ Upstream release $TAG is READY."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [[ "$STATUS" == "failure" || "$CONCLUSION" == "failure" || "$CONCLUSION" == "cancelled" ]]; then
|
||||
echo "❌ Error: Upstream release $TAG FAILED or was CANCELLED."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo " - Still working... waiting $INTERVAL seconds (Attempt $((RETRY_COUNT+1))/$MAX_RETRIES)"
|
||||
sleep $INTERVAL
|
||||
RETRY_COUNT=$((RETRY_COUNT+1))
|
||||
done
|
||||
|
||||
echo "❌ Error: Timeout waiting for upstream release $TAG."
|
||||
exit 1
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@mintel/mail",
|
||||
"version": "1.7.3",
|
||||
"version": "1.8.10",
|
||||
"private": false,
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
|
||||
@@ -22,3 +22,6 @@ export * from "./layouts/ClientLayout";
|
||||
// Export Templates
|
||||
export * from "./templates/ContactFormNotification";
|
||||
export * from "./templates/ConfirmationMessage";
|
||||
export * from "./templates/FollowUpTemplate";
|
||||
export * from "./templates/ProjectEstimateTemplate";
|
||||
export * from "./templates/SiteAuditTemplate";
|
||||
|
||||
88
packages/mail/src/templates/FollowUpTemplate.tsx
Normal file
88
packages/mail/src/templates/FollowUpTemplate.tsx
Normal file
@@ -0,0 +1,88 @@
|
||||
import * as React from "react";
|
||||
import { Heading, Text, Button } from "@react-email/components";
|
||||
import { MintelLayout } from "../layouts/MintelLayout";
|
||||
|
||||
export interface FollowUpTemplateProps {
|
||||
companyName: string;
|
||||
}
|
||||
|
||||
export const FollowUpTemplate = ({
|
||||
companyName,
|
||||
}: FollowUpTemplateProps) => {
|
||||
const preview = `Kurzes Follow-up: ${companyName}`;
|
||||
|
||||
return (
|
||||
<MintelLayout preview={preview}>
|
||||
<Heading style={h1}>Kurzes Follow-up</Heading>
|
||||
<Text style={intro}>
|
||||
Hallo noch einmal,<br /><br />
|
||||
ich wollte mich nur kurz erkundigen, ob Sie bereits Zeit hatten,
|
||||
einen Blick auf das Audit Ihrer Website zu werfen, das ich Ihnen
|
||||
vor ein paar Tagen gesendet habe.
|
||||
</Text>
|
||||
|
||||
<Text style={bodyText}>
|
||||
Vielleicht passt es ja diese Woche für ein kurzes, unverbindliches
|
||||
Telefonat, um die Punkte gemeinsam durchzugehen?
|
||||
</Text>
|
||||
|
||||
<Button
|
||||
style={button}
|
||||
href="https://calendly.com/mintel-me/intro"
|
||||
>
|
||||
Treffen vereinbaren
|
||||
</Button>
|
||||
|
||||
<Text style={footerText}>
|
||||
Beste Grüße,<br />
|
||||
<strong>Marc Mintel</strong>
|
||||
</Text>
|
||||
</MintelLayout>
|
||||
);
|
||||
};
|
||||
|
||||
export default FollowUpTemplate;
|
||||
|
||||
const h1 = {
|
||||
fontSize: "28px",
|
||||
fontWeight: "900",
|
||||
margin: "0 0 24px",
|
||||
color: "#ffffff",
|
||||
letterSpacing: "-0.04em",
|
||||
};
|
||||
|
||||
const intro = {
|
||||
fontSize: "16px",
|
||||
lineHeight: "24px",
|
||||
color: "#cccccc",
|
||||
margin: "0 0 24px",
|
||||
};
|
||||
|
||||
const bodyText = {
|
||||
fontSize: "16px",
|
||||
lineHeight: "24px",
|
||||
color: "#888888",
|
||||
margin: "0 0 32px",
|
||||
};
|
||||
|
||||
const button = {
|
||||
backgroundColor: "#333333",
|
||||
borderRadius: "0",
|
||||
color: "#ffffff",
|
||||
fontSize: "13px",
|
||||
fontWeight: "bold",
|
||||
textDecoration: "none",
|
||||
textAlign: "center" as const,
|
||||
display: "inline-block",
|
||||
padding: "12px 24px",
|
||||
textTransform: "uppercase" as const,
|
||||
letterSpacing: "0.1em",
|
||||
border: "1px solid #444444",
|
||||
};
|
||||
|
||||
const footerText = {
|
||||
fontSize: "14px",
|
||||
color: "#666666",
|
||||
lineHeight: "20px",
|
||||
marginTop: "48px",
|
||||
};
|
||||
86
packages/mail/src/templates/ProjectEstimateTemplate.tsx
Normal file
86
packages/mail/src/templates/ProjectEstimateTemplate.tsx
Normal file
@@ -0,0 +1,86 @@
|
||||
import * as React from "react";
|
||||
import { Heading, Text, Section } from "@react-email/components";
|
||||
import { MintelLayout } from "../layouts/MintelLayout";
|
||||
|
||||
export interface ProjectEstimateTemplateProps {
|
||||
companyName: string;
|
||||
}
|
||||
|
||||
export const ProjectEstimateTemplate = ({
|
||||
companyName,
|
||||
}: ProjectEstimateTemplateProps) => {
|
||||
const preview = `Ihre personalisierte Projekt-Schätzung: ${companyName}`;
|
||||
|
||||
return (
|
||||
<MintelLayout preview={preview}>
|
||||
<Heading style={h1}>Ihre Projekt-Schätzung</Heading>
|
||||
<Text style={intro}>
|
||||
Hallo {companyName},<br /><br />
|
||||
vielen Dank für unser Gespräch. Wie versprochen sende ich Ihnen hiermit
|
||||
die detaillierte Schätzung für Ihre neue digitale Webpräsenz.
|
||||
</Text>
|
||||
|
||||
<Section style={infoBox}>
|
||||
<Text style={infoText}>
|
||||
Im Anhang finden Sie das PDF-Dokument mit allen Positionen,
|
||||
Umfängen und dem strategischen Ausblick.
|
||||
</Text>
|
||||
</Section>
|
||||
|
||||
<Text style={bodyText}>
|
||||
Ich freue mich auf Ihr Feedback und stehe für Rückfragen jederzeit
|
||||
zur Verfügung.
|
||||
</Text>
|
||||
|
||||
<Text style={footerText}>
|
||||
Beste Grüße,<br />
|
||||
<strong>Marc Mintel</strong>
|
||||
</Text>
|
||||
</MintelLayout>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProjectEstimateTemplate;
|
||||
|
||||
const h1 = {
|
||||
fontSize: "28px",
|
||||
fontWeight: "900",
|
||||
margin: "0 0 24px",
|
||||
color: "#ffffff",
|
||||
letterSpacing: "-0.04em",
|
||||
};
|
||||
|
||||
const intro = {
|
||||
fontSize: "16px",
|
||||
lineHeight: "24px",
|
||||
color: "#cccccc",
|
||||
margin: "0 0 24px",
|
||||
};
|
||||
|
||||
const infoBox = {
|
||||
backgroundColor: "#0f172a",
|
||||
padding: "24px",
|
||||
borderLeft: "4px solid #ffffff",
|
||||
marginBottom: "32px",
|
||||
};
|
||||
|
||||
const infoText = {
|
||||
fontSize: "15px",
|
||||
color: "#ffffff",
|
||||
margin: "0",
|
||||
lineHeight: "1.5",
|
||||
};
|
||||
|
||||
const bodyText = {
|
||||
fontSize: "16px",
|
||||
lineHeight: "24px",
|
||||
color: "#888888",
|
||||
margin: "0 0 32px",
|
||||
};
|
||||
|
||||
const footerText = {
|
||||
fontSize: "14px",
|
||||
color: "#666666",
|
||||
lineHeight: "20px",
|
||||
marginTop: "48px",
|
||||
};
|
||||
146
packages/mail/src/templates/SiteAuditTemplate.tsx
Normal file
146
packages/mail/src/templates/SiteAuditTemplate.tsx
Normal file
@@ -0,0 +1,146 @@
|
||||
import * as React from "react";
|
||||
import { Heading, Section, Text, Button } from "@react-email/components";
|
||||
import { MintelLayout } from "../layouts/MintelLayout";
|
||||
|
||||
export interface SiteAuditTemplateProps {
|
||||
companyName: string;
|
||||
auditHighlights: string[];
|
||||
websiteUrl: string;
|
||||
}
|
||||
|
||||
export const SiteAuditTemplate = ({
|
||||
companyName,
|
||||
auditHighlights,
|
||||
websiteUrl,
|
||||
}: SiteAuditTemplateProps) => {
|
||||
const preview = `Analyse Ihrer Webpräsenz: ${companyName}`;
|
||||
|
||||
return (
|
||||
<MintelLayout preview={preview}>
|
||||
<Heading style={h1}>Analyse Ihrer Webpräsenz</Heading>
|
||||
<Text style={intro}>
|
||||
Hallo {companyName},<br /><br />
|
||||
ich habe mir Ihre aktuelle Website ({websiteUrl}) im Detail angeschaut und
|
||||
einige technische sowie strategische Potenziale identifiziert.
|
||||
</Text>
|
||||
|
||||
<Section style={auditContainer}>
|
||||
<Heading as="h2" style={h2}>Audit Highlights</Heading>
|
||||
{auditHighlights.map((highlight, i) => (
|
||||
<div key={i} style={highlightRow}>
|
||||
<div style={bullet} />
|
||||
<Text style={highlightText}>{highlight}</Text>
|
||||
</div>
|
||||
))}
|
||||
</Section>
|
||||
|
||||
<Text style={bodyText}>
|
||||
In der heutigen digitalen Landschaft ist eine performante und strategisch
|
||||
ausgerichtete Website kein Luxus mehr, sondern das Fundament für
|
||||
nachhaltiges Wachstum.
|
||||
</Text>
|
||||
|
||||
<Section style={ctaSection}>
|
||||
<Button
|
||||
style={button}
|
||||
href={`mailto:marc@mintel.me?subject=Feedback zum Audit: ${companyName}`}
|
||||
>
|
||||
Audit gemeinsam besprechen
|
||||
</Button>
|
||||
</Section>
|
||||
|
||||
<Text style={footerText}>
|
||||
Beste Grüße,<br />
|
||||
<strong>Marc Mintel</strong><br />
|
||||
Digitaler Architekt
|
||||
</Text>
|
||||
</MintelLayout>
|
||||
);
|
||||
};
|
||||
|
||||
export default SiteAuditTemplate;
|
||||
|
||||
const h1 = {
|
||||
fontSize: "28px",
|
||||
fontWeight: "900",
|
||||
margin: "0 0 24px",
|
||||
color: "#ffffff",
|
||||
letterSpacing: "-0.04em",
|
||||
};
|
||||
|
||||
const h2 = {
|
||||
fontSize: "14px",
|
||||
fontWeight: "900",
|
||||
textTransform: "uppercase" as const,
|
||||
color: "#444444",
|
||||
margin: "0 0 16px",
|
||||
letterSpacing: "0.1em",
|
||||
};
|
||||
|
||||
const intro = {
|
||||
fontSize: "16px",
|
||||
lineHeight: "24px",
|
||||
color: "#cccccc",
|
||||
margin: "0 0 32px",
|
||||
};
|
||||
|
||||
const auditContainer = {
|
||||
backgroundColor: "#151515",
|
||||
padding: "32px",
|
||||
borderRadius: "8px",
|
||||
marginBottom: "32px",
|
||||
border: "1px solid #222222",
|
||||
};
|
||||
|
||||
const highlightRow = {
|
||||
display: "flex" as const,
|
||||
alignItems: "flex-start" as const,
|
||||
marginBottom: "12px",
|
||||
};
|
||||
|
||||
const bullet = {
|
||||
width: "6px",
|
||||
height: "6px",
|
||||
backgroundColor: "#4CAF50",
|
||||
marginTop: "8px",
|
||||
marginRight: "12px",
|
||||
flexShrink: 0,
|
||||
};
|
||||
|
||||
const highlightText = {
|
||||
fontSize: "15px",
|
||||
color: "#ffffff",
|
||||
margin: "0",
|
||||
};
|
||||
|
||||
const bodyText = {
|
||||
fontSize: "16px",
|
||||
lineHeight: "24px",
|
||||
color: "#888888",
|
||||
margin: "0 0 32px",
|
||||
};
|
||||
|
||||
const ctaSection = {
|
||||
textAlign: "center" as const,
|
||||
marginBottom: "48px",
|
||||
};
|
||||
|
||||
const button = {
|
||||
backgroundColor: "#ffffff",
|
||||
borderRadius: "0",
|
||||
color: "#000000",
|
||||
fontSize: "14px",
|
||||
fontWeight: "bold",
|
||||
textDecoration: "none",
|
||||
textAlign: "center" as const,
|
||||
display: "inline-block",
|
||||
padding: "16px 32px",
|
||||
textTransform: "uppercase" as const,
|
||||
letterSpacing: "0.1em",
|
||||
};
|
||||
|
||||
const footerText = {
|
||||
fontSize: "14px",
|
||||
color: "#666666",
|
||||
lineHeight: "20px",
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user