Compare commits

...

33 Commits

Author SHA1 Message Date
23358fc708 fix: temporary trigger test
Some checks failed
Monorepo Pipeline / ⚡ Prioritize Release (push) Successful in 2s
Monorepo Pipeline / 🧪 Test (push) Successful in 8m49s
Monorepo Pipeline / 🧹 Lint (push) Successful in 9m13s
Monorepo Pipeline / 🐳 Build Gatekeeper (Product) (push) Failing after 51s
Monorepo Pipeline / 🐳 Build Build-Base (push) Successful in 1m34s
Monorepo Pipeline / 🏗️ Build (push) Successful in 6m58s
Monorepo Pipeline / 🐳 Build Directus (Base) (push) Successful in 20s
Monorepo Pipeline / 🐳 Build Production Runtime (push) Successful in 18s
Monorepo Pipeline / 🚀 Release (push) Successful in 3m58s
2026-02-13 14:38:01 +01:00
73ea958655 chore: remove [skip ci] from version sync and update image tag 2026-02-13 14:31:30 +01:00
f2035d79dd chore: automate re-push in pre-push hook
All checks were successful
Monorepo Pipeline / ⚡ Prioritize Release (push) Successful in 2s
Monorepo Pipeline / 🧪 Test (push) Successful in 56s
Monorepo Pipeline / 🧹 Lint (push) Successful in 2m9s
Monorepo Pipeline / 🏗️ Build (push) Successful in 3m59s
Monorepo Pipeline / 🚀 Release (push) Has been skipped
Monorepo Pipeline / 🐳 Build Directus (Base) (push) Has been skipped
Monorepo Pipeline / 🐳 Build Gatekeeper (Product) (push) Has been skipped
Monorepo Pipeline / 🐳 Build Build-Base (push) Has been skipped
Monorepo Pipeline / 🐳 Build Production Runtime (push) Has been skipped
2026-02-13 14:28:55 +01:00
f514349ccf chore: sync versions to v1.8.2 [skip ci] 2026-02-13 14:27:22 +01:00
a71f86560b chore: fix @mintel/directus-extension-toolkit build and update eslint ignores
All checks were successful
Monorepo Pipeline / ⚡ Prioritize Release (push) Successful in 27s
Monorepo Pipeline / 🧪 Test (push) Successful in 57s
Monorepo Pipeline / 🧹 Lint (push) Successful in 2m1s
Monorepo Pipeline / 🏗️ Build (push) Successful in 3m35s
Monorepo Pipeline / 🚀 Release (push) Has been skipped
Monorepo Pipeline / 🐳 Build Directus (Base) (push) Has been skipped
Monorepo Pipeline / 🐳 Build Gatekeeper (Product) (push) Has been skipped
Monorepo Pipeline / 🐳 Build Build-Base (push) Has been skipped
Monorepo Pipeline / 🐳 Build Production Runtime (push) Has been skipped
2026-02-13 14:21:39 +01:00
de8314732d chore: fix remaining build script syntax errors in extensions
Some checks failed
Monorepo Pipeline / ⚡ Prioritize Release (push) Successful in 1s
Monorepo Pipeline / 🧹 Lint (push) Successful in 1m5s
Monorepo Pipeline / 🏗️ Build (push) Failing after 2m49s
Monorepo Pipeline / 🧪 Test (push) Successful in 3m0s
Monorepo Pipeline / 🚀 Release (push) Has been skipped
Monorepo Pipeline / 🐳 Build Directus (Base) (push) Has been skipped
Monorepo Pipeline / 🐳 Build Gatekeeper (Product) (push) Has been skipped
Monorepo Pipeline / 🐳 Build Build-Base (push) Has been skipped
Monorepo Pipeline / 🐳 Build Production Runtime (push) Has been skipped
2026-02-13 12:15:30 +01:00
bdf7773310 chore: finalize 'meaningful' sync hook and pipeline stabilization
Some checks failed
Monorepo Pipeline / ⚡ Prioritize Release (push) Successful in 2s
Monorepo Pipeline / 🚀 Release (push) Has been cancelled
Monorepo Pipeline / 🧹 Lint (push) Has been cancelled
Monorepo Pipeline / 🏗️ Build (push) Has been cancelled
Monorepo Pipeline / 🐳 Build Directus (Base) (push) Has been cancelled
Monorepo Pipeline / 🐳 Build Gatekeeper (Product) (push) Has been cancelled
Monorepo Pipeline / 🐳 Build Build-Base (push) Has been cancelled
Monorepo Pipeline / 🧪 Test (push) Has been cancelled
Monorepo Pipeline / 🐳 Build Production Runtime (push) Has been cancelled
2026-02-13 12:15:01 +01:00
a25e4aa1d4 chore: stabilize pipeline, fix extension build scripts, and finalize version sync hook
Some checks failed
Monorepo Pipeline / ⚡ Prioritize Release (push) Successful in 3s
Monorepo Pipeline / 🧹 Lint (push) Has started running
Monorepo Pipeline / 🚀 Release (push) Has been cancelled
Monorepo Pipeline / 🧪 Test (push) Has been cancelled
Monorepo Pipeline / 🐳 Build Directus (Base) (push) Has been cancelled
Monorepo Pipeline / 🐳 Build Gatekeeper (Product) (push) Has been cancelled
Monorepo Pipeline / 🐳 Build Build-Base (push) Has been cancelled
Monorepo Pipeline / 🏗️ Build (push) Has been cancelled
Monorepo Pipeline / 🐳 Build Production Runtime (push) Has been cancelled
2026-02-13 12:14:27 +01:00
ecc2163b8e chore: remove redundant version sync from pre-push hook
Some checks failed
Monorepo Pipeline / ⚡ Prioritize Release (push) Successful in 2s
Monorepo Pipeline / 🧹 Lint (push) Successful in 47s
Monorepo Pipeline / 🏗️ Build (push) Failing after 3m27s
Monorepo Pipeline / 🧪 Test (push) Successful in 4m33s
Monorepo Pipeline / 🚀 Release (push) Has been skipped
Monorepo Pipeline / 🐳 Build Directus (Base) (push) Has been skipped
Monorepo Pipeline / 🐳 Build Gatekeeper (Product) (push) Has been skipped
Monorepo Pipeline / 🐳 Build Build-Base (push) Has been skipped
Monorepo Pipeline / 🐳 Build Production Runtime (push) Has been skipped
2026-02-13 12:08:58 +01:00
af02378d29 chore: sync versions
Some checks failed
Monorepo Pipeline / ⚡ Prioritize Release (push) Successful in 4s
Monorepo Pipeline / 🏗️ Build (push) Failing after 1m10s
Monorepo Pipeline / 🚀 Release (push) Has been cancelled
Monorepo Pipeline / 🧪 Test (push) Has been cancelled
Monorepo Pipeline / 🐳 Build Directus (Base) (push) Has been cancelled
Monorepo Pipeline / 🐳 Build Gatekeeper (Product) (push) Has been cancelled
Monorepo Pipeline / 🐳 Build Build-Base (push) Has been cancelled
Monorepo Pipeline / 🐳 Build Production Runtime (push) Has been cancelled
Monorepo Pipeline / 🧹 Lint (push) Has been cancelled
2026-02-13 12:05:14 +01:00
f8847a7a10 feat(next-feedback): refine selector filters for tailwind and dynamic classes
All checks were successful
Monorepo Pipeline / ⚡ Prioritize Release (push) Successful in 8s
Monorepo Pipeline / 🏗️ Build (push) Successful in 1m59s
Monorepo Pipeline / 🧪 Test (push) Successful in 4m5s
Monorepo Pipeline / 🧹 Lint (push) Successful in 5m8s
Monorepo Pipeline / 🐳 Build Directus (Base) (push) Successful in 20s
Monorepo Pipeline / 🐳 Build Build-Base (push) Successful in 18s
Monorepo Pipeline / 🐳 Build Production Runtime (push) Successful in 20s
Monorepo Pipeline / 🚀 Release (push) Successful in 8m3s
Monorepo Pipeline / 🐳 Build Gatekeeper (Product) (push) Successful in 10m25s
2026-02-13 12:03:33 +01:00
117b23db1e feat(next-feedback): improve selector precision with @medv/finder and fix client/server boundary 2026-02-13 12:03:11 +01:00
d6f9a24823 chore: sync versions to 1.8.0 2026-02-12 22:05:20 +01:00
422e4fccba feat(cloner): add cloner-library and finalize pdf-library rename 2026-02-12 22:04:40 +01:00
57ec4d7544 chore: bump versions 2026-02-12 21:47:55 +01:00
a4d021c658 feat(pdf): rename acquisition-library to pdf-library and update package name to @mintel/pdf
Some checks failed
Monorepo Pipeline / ⚡ Prioritize Release (push) Successful in 1s
Monorepo Pipeline / 🧹 Lint (push) Failing after 13s
Monorepo Pipeline / 🏗️ Build (push) Failing after 11s
Monorepo Pipeline / 🧪 Test (push) Failing after 25s
Monorepo Pipeline / 🚀 Release (push) Has been skipped
Monorepo Pipeline / 🐳 Build Directus (Base) (push) Has been skipped
Monorepo Pipeline / 🐳 Build Gatekeeper (Product) (push) Has been skipped
Monorepo Pipeline / 🐳 Build Build-Base (push) Has been skipped
Monorepo Pipeline / 🐳 Build Production Runtime (push) Has been skipped
2026-02-12 21:46:45 +01:00
269d19bbef fix(acquisition): finalize extension build and components
- Fixed IndustrialCard export in SharedUI.
- Successfully built all extensions including acquisition-library.
- Verified sitemap and briefing module updates.
2026-02-12 21:27:39 +01:00
30ff08c66d fix(acquisition): standardize bundling and externalize React/PDF dependencies
- Added JSX support and correctly externalized react/pdf dependencies in esbuild.
- Fixed acquisition-library exports by removing missing DINLayout reference.
- Standardized extension entry points across all modules.
2026-02-12 21:26:30 +01:00
81deaf447f fix(acquisition): standardize bundling and externalize React/PDF dependencies
- Added JSX support to esbuild configuration.
- Externalized react, react-dom, and @react-pdf/renderer to avoid redundant bundling.
- Updated acquisition-library exports for modular PDF generation.
2026-02-12 21:24:15 +01:00
a0ebc58d6d fix(directus): resolve extension visibility and registration failures
- Corrected module_bar settings to restore custom extension visibility in UI.
- Fixed 'fs' dynamic require in acquisition endpoint by externalizing Node.js built-ins.
- Standardized local environment branding to AT Mintel.
2026-02-12 21:20:28 +01:00
7498c24c9a fix(directus): resolve login failures and standardize project branding
- Fixed project isolation bypass (identity shadowing) by prefixing database service name.
- Standardized health check paths and protocols in docker-compose.yml.
- Resolved extension SyntaxError caused by duplicate banner injections in build scripts.
- Migrated extension build system to clean esbuild-based bundles (removing shims).
- Updated sync-directus.sh for project-prefixed service name.
- Synchronized latest production data and branding (AT Mintel).
2026-02-12 19:21:53 +01:00
efba82337c refactor(acquisition): change build output to dist/ directory
Some checks failed
Monorepo Pipeline / ⚡ Prioritize Release (push) Successful in 2s
Monorepo Pipeline / 🧹 Lint (push) Successful in 59s
Monorepo Pipeline / 🧪 Test (push) Successful in 1m38s
Monorepo Pipeline / 🏗️ Build (push) Successful in 3m22s
Monorepo Pipeline / 🚀 Release (push) Has been skipped
Monorepo Pipeline / 🐳 Build Directus (Base) (push) Has been skipped
Monorepo Pipeline / 🐳 Build Gatekeeper (Product) (push) Has been skipped
Monorepo Pipeline / 🐳 Build Build-Base (push) Has been skipped
Monorepo Pipeline / 🐳 Build Production Runtime (push) Has been skipped
🏥 Server Maintenance / 🧹 Prune & Clean (push) Failing after 4s
2026-02-12 12:47:06 +01:00
c083b309fb fix(acquisition): add missing dependencies to acquisition-library and fix build failure
All checks were successful
Monorepo Pipeline / ⚡ Prioritize Release (push) Successful in 1s
Monorepo Pipeline / 🧪 Test (push) Successful in 1m0s
Monorepo Pipeline / 🧹 Lint (push) Successful in 2m6s
Monorepo Pipeline / 🏗️ Build (push) Successful in 3m23s
Monorepo Pipeline / 🐳 Build Directus (Base) (push) Has been skipped
Monorepo Pipeline / 🚀 Release (push) Has been skipped
Monorepo Pipeline / 🐳 Build Gatekeeper (Product) (push) Has been skipped
Monorepo Pipeline / 🐳 Build Build-Base (push) Has been skipped
Monorepo Pipeline / 🐳 Build Production Runtime (push) Has been skipped
2026-02-12 12:15:43 +01:00
eb8bf60408 fix(infra): use dynamic container detection for registry maintenance
Some checks failed
Monorepo Pipeline / ⚡ Prioritize Release (push) Successful in 20s
Monorepo Pipeline / 🧪 Test (push) Successful in 49s
Monorepo Pipeline / 🧹 Lint (push) Successful in 1m55s
Monorepo Pipeline / 🏗️ Build (push) Failing after 2m1s
Monorepo Pipeline / 🚀 Release (push) Has been skipped
Monorepo Pipeline / 🐳 Build Directus (Base) (push) Has been skipped
Monorepo Pipeline / 🐳 Build Gatekeeper (Product) (push) Has been skipped
Monorepo Pipeline / 🐳 Build Build-Base (push) Has been skipped
Monorepo Pipeline / 🐳 Build Production Runtime (push) Has been skipped
2026-02-12 11:16:28 +01:00
a3819490ac chore(package): standardize formatting and cleanup temporary logs
Some checks failed
Monorepo Pipeline / ⚡ Prioritize Release (push) Successful in 26s
Monorepo Pipeline / 🧪 Test (push) Successful in 52s
Monorepo Pipeline / 🏗️ Build (push) Has started running
Monorepo Pipeline / 🚀 Release (push) Has been cancelled
Monorepo Pipeline / 🐳 Build Directus (Base) (push) Has been cancelled
Monorepo Pipeline / 🐳 Build Gatekeeper (Product) (push) Has been cancelled
Monorepo Pipeline / 🐳 Build Build-Base (push) Has been cancelled
Monorepo Pipeline / 🐳 Build Production Runtime (push) Has been cancelled
Monorepo Pipeline / 🧹 Lint (push) Has been cancelled
2026-02-12 11:14:58 +01:00
1127954fea feat(cms): final restoration of extension logic and monorepo stabilization
Some checks failed
Monorepo Pipeline / ⚡ Prioritize Release (push) Successful in 1s
Monorepo Pipeline / 🧹 Lint (push) Successful in 55s
Monorepo Pipeline / 🧪 Test (push) Successful in 1m32s
Monorepo Pipeline / 🏗️ Build (push) Failing after 1m50s
Monorepo Pipeline / 🚀 Release (push) Has been skipped
Monorepo Pipeline / 🐳 Build Directus (Base) (push) Has been skipped
Monorepo Pipeline / 🐳 Build Gatekeeper (Product) (push) Has been skipped
Monorepo Pipeline / 🐳 Build Build-Base (push) Has been skipped
Monorepo Pipeline / 🐳 Build Production Runtime (push) Has been skipped
🏥 Server Maintenance / 🧹 Prune & Clean (push) Failing after 4s
2026-02-12 02:11:53 +01:00
fa0b133012 feat(cms): restore extension logic and stabilize build pipeline
Some checks failed
Monorepo Pipeline / ⚡ Prioritize Release (push) Successful in 1s
Monorepo Pipeline / 🧹 Lint (push) Successful in 45s
Monorepo Pipeline / 🏗️ Build (push) Failing after 1m47s
Monorepo Pipeline / 🧪 Test (push) Successful in 1m57s
Monorepo Pipeline / 🚀 Release (push) Has been skipped
Monorepo Pipeline / 🐳 Build Directus (Base) (push) Has been skipped
Monorepo Pipeline / 🐳 Build Gatekeeper (Product) (push) Has been skipped
Monorepo Pipeline / 🐳 Build Build-Base (push) Has been skipped
Monorepo Pipeline / 🐳 Build Production Runtime (push) Has been skipped
- restored People Manager and Acquisition Manager logic from git history
- standardized build scripts for directus extensions
- added mmintel user and enabled extensions in cms settings
- updated sync script for robust artifact distribution
2026-02-12 01:14:13 +01:00
1b40baebd4 fix(infra): use SHA detection and better logging in wait-for-upstream.sh
All checks were successful
Monorepo Pipeline / ⚡ Prioritize Release (push) Successful in 1s
Monorepo Pipeline / 🧹 Lint (push) Successful in 44s
Monorepo Pipeline / 🧪 Test (push) Successful in 2m24s
Monorepo Pipeline / 🏗️ Build (push) Successful in 3m43s
Monorepo Pipeline / 🚀 Release (push) Has been skipped
Monorepo Pipeline / 🐳 Build Directus (Base) (push) Has been skipped
Monorepo Pipeline / 🐳 Build Gatekeeper (Product) (push) Has been skipped
Monorepo Pipeline / 🐳 Build Build-Base (push) Has been skipped
Monorepo Pipeline / 🐳 Build Production Runtime (push) Has been skipped
2026-02-11 23:25:47 +01:00
316c03869a fix(gatekeeper): enhance logging and stabilize upstream polling
All checks were successful
Monorepo Pipeline / ⚡ Prioritize Release (push) Successful in 2s
Monorepo Pipeline / 🧪 Test (push) Successful in 6m38s
Monorepo Pipeline / 🧹 Lint (push) Successful in 7m14s
Monorepo Pipeline / 🏗️ Build (push) Successful in 10m24s
Monorepo Pipeline / 🐳 Build Directus (Base) (push) Successful in 1m39s
Monorepo Pipeline / 🐳 Build Build-Base (push) Successful in 2m7s
Monorepo Pipeline / 🐳 Build Production Runtime (push) Successful in 2m8s
Monorepo Pipeline / 🚀 Release (push) Successful in 2m18s
Monorepo Pipeline / 🐳 Build Gatekeeper (Product) (push) Successful in 6m58s
2026-02-11 22:49:16 +01:00
63d2acfab5 feat(infra): add wait-for-upstream script for smart dependencies
Some checks failed
Monorepo Pipeline / ⚡ Prioritize Release (push) Successful in 2s
Monorepo Pipeline / 🧹 Lint (push) Successful in 47s
Monorepo Pipeline / 🧪 Test (push) Successful in 39s
Monorepo Pipeline / 🏗️ Build (push) Successful in 1m49s
Monorepo Pipeline / 🚀 Release (push) Has been cancelled
Monorepo Pipeline / 🐳 Build Directus (Base) (push) Has been cancelled
Monorepo Pipeline / 🐳 Build Gatekeeper (Product) (push) Has been cancelled
Monorepo Pipeline / 🐳 Build Build-Base (push) Has been cancelled
Monorepo Pipeline / 🐳 Build Production Runtime (push) Has been cancelled
2026-02-11 22:41:47 +01:00
bdeae0aca6 chore(gatekeeper): bump to 1.7.11 for fix
Some checks failed
Monorepo Pipeline / ⚡ Prioritize Release (push) Successful in 1s
Monorepo Pipeline / 🧹 Lint (push) Successful in 47s
Monorepo Pipeline / 🧪 Test (push) Successful in 40s
Monorepo Pipeline / 🏗️ Build (push) Successful in 1m51s
Monorepo Pipeline / 🚀 Release (push) Has been cancelled
Monorepo Pipeline / 🐳 Build Directus (Base) (push) Has been cancelled
Monorepo Pipeline / 🐳 Build Gatekeeper (Product) (push) Has been cancelled
Monorepo Pipeline / 🐳 Build Build-Base (push) Has been cancelled
Monorepo Pipeline / 🐳 Build Production Runtime (push) Has been cancelled
2026-02-11 22:35:04 +01:00
47c70a16f1 fix(gatekeeper): trim auth inputs and prioritize access code to prevent autofill traps
Some checks failed
Monorepo Pipeline / ⚡ Prioritize Release (push) Successful in 2s
Monorepo Pipeline / 🧹 Lint (push) Has been cancelled
Monorepo Pipeline / 🧪 Test (push) Has been cancelled
Monorepo Pipeline / 🏗️ Build (push) Has been cancelled
Monorepo Pipeline / 🚀 Release (push) Has been cancelled
Monorepo Pipeline / 🐳 Build Directus (Base) (push) Has been cancelled
Monorepo Pipeline / 🐳 Build Gatekeeper (Product) (push) Has been cancelled
Monorepo Pipeline / 🐳 Build Build-Base (push) Has been cancelled
Monorepo Pipeline / 🐳 Build Production Runtime (push) Has been cancelled
2026-02-11 22:32:17 +01:00
b96d44bf6d chore: finalize version updates for v1.7.10
All checks were successful
Monorepo Pipeline / ⚡ Prioritize Release (push) Successful in 3s
Monorepo Pipeline / 🧪 Test (push) Successful in 45s
Monorepo Pipeline / 🧹 Lint (push) Successful in 1m12s
Monorepo Pipeline / 🏗️ Build (push) Successful in 1m52s
Monorepo Pipeline / 🚀 Release (push) Has been skipped
Monorepo Pipeline / 🐳 Build Directus (Base) (push) Has been skipped
Monorepo Pipeline / 🐳 Build Gatekeeper (Product) (push) Has been skipped
Monorepo Pipeline / 🐳 Build Build-Base (push) Has been skipped
Monorepo Pipeline / 🐳 Build Production Runtime (push) Has been skipped
2026-02-11 16:55:17 +01:00
105 changed files with 12080 additions and 7974 deletions

View 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`).

36
.env Normal file
View File

@@ -0,0 +1,36 @@
# Project
IMAGE_TAG=v1.8.2
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.localhost
# Next.js
NEXT_PUBLIC_BASE_URL=http://at-mintel.localhost
# Directus
DIRECTUS_URL=http://cms.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

View File

@@ -1,5 +1,5 @@
# Project
IMAGE_TAG=v1.7.9
IMAGE_TAG=v1.8.2
PROJECT_NAME=sample-website
PROJECT_COLOR=#82ed20

4
.eslintignore Normal file
View File

@@ -0,0 +1,4 @@
**/index.js
**/dist/**
packages/cms-infra/extensions/**
packages/cms-infra/extensions/**

View File

@@ -1,16 +1,37 @@
# Check if we are pushing a tag
while read local_ref local_sha remote_ref remote_sha
do
if [[ "$remote_ref" == refs/tags/v* ]]; then
TAG=${remote_ref#refs/tags/}
echo "🏷️ Tag detected: $TAG, syncing versions..."
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

View File

@@ -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.

View File

@@ -1,6 +1,6 @@
{
"name": "sample-website",
"version": "1.7.9",
"version": "1.8.2",
"private": true,
"type": "module",
"scripts": {

View File

@@ -0,0 +1 @@
S9WsV

View File

@@ -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:

View File

@@ -5,7 +5,7 @@ export default [
{
ignores: [
"packages/cms-infra/extensions/**",
"packages/customer-manager/index.js",
"**/index.js",
"**/*.db",
"**/build/**",
"**/data/**",

View File

@@ -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": "./scripts/sync-extensions.sh && 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",
@@ -52,7 +55,7 @@
"pino-pretty": "^13.1.3",
"require-in-the-middle": "^8.0.1"
},
"version": "1.7.10",
"version": "1.8.2",
"pnpm": {
"overrides": {
"next": "16.1.6",

File diff suppressed because one or more lines are too long

View File

@@ -2,16 +2,13 @@
"name": "acquisition-manager",
"description": "Custom High-Fidelity Acquisition Management for Directus",
"icon": "account_balance_wallet",
"version": "1.7.9",
"version": "1.8.2",
"type": "module",
"keywords": [
"directus",
"directus-extension",
"directus-extension-module"
],
"files": [
"dist"
],
"directus:extension": {
"type": "module",
"path": "index.js",
@@ -20,7 +17,7 @@
"name": "Acquisition Manager"
},
"scripts": {
"build": "directus-extension build && (cp -f dist/index.js index.js 2>/dev/null || true)",
"build": "directus-extension build && cp -f dist/index.js index.js 2>/dev/null || true",
"dev": "directus-extension build -w"
},
"devDependencies": {

View File

@@ -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
}
],
});

View File

@@ -1,18 +1,445 @@
<template>
<private-view title="Acquisition Manager">
<div class="acquisition-manager">
<h1>Acquisition Manager</h1>
<p>Modern Industrial Acquisition Management Interface</p>
<template #navigation>
<v-list nav>
<v-list-item @click="showAddLead = true" clickable>
<v-list-item-icon><v-icon name="add" color="var(--theme--primary)" /></v-list-item-icon>
<v-list-item-content>
<v-text-overflow text="Neuen Lead anlegen" />
</v-list-item-content>
</v-list-item>
<v-divider />
<v-list-item
v-for="lead in leads"
:key="lead.id"
:active="selectedLeadId === lead.id"
class="lead-item"
clickable
@click="selectLead(lead.id)"
>
<v-list-item-icon>
<v-icon :name="getStatusIcon(lead.status)" :color="getStatusColor(lead.status)" />
</v-list-item-icon>
<v-list-item-content>
<v-text-overflow :text="getCompanyName(lead)" />
</v-list-item-content>
</v-list-item>
</v-list>
</template>
<template #title-outer:after>
<v-notice v-if="notice" :type="notice.type" @close="notice = null" dismissible>
{{ notice.message }}
</v-notice>
</template>
<div class="content-wrapper">
<v-notice type="success" style="margin-bottom: 16px;">
DEBUG: Module Version 1.1.0 - Native Build - {{ new Date().toISOString() }}
</v-notice>
<div v-if="!selectedLead" class="empty-state">
<v-info title="Lead auswählen" icon="auto_awesome" center>
Wähle einen Lead in der Navigation aus oder
<v-button x-small @click="showAddLead = true">registriere einen neuen Lead</v-button>.
</v-info>
</div>
<template v-else>
<header class="header">
<div class="header-left">
<h1 class="title">{{ getCompanyName(selectedLead) }}</h1>
<p class="subtitle">
<v-icon name="language" x-small />
<a :href="selectedLead.website_url" target="_blank" class="url-link">
{{ selectedLead.website_url.replace(/^https?:\/\//, '') }}
</a>
&middot; Status: {{ selectedLead.status.toUpperCase() }}
</p>
</div>
<div class="header-right">
<v-button
v-if="selectedLead.status === 'new'"
secondary
:loading="loadingAudit"
@click="runAudit"
>
<v-icon name="settings_suggest" left />
Audit starten
</v-button>
<template v-if="selectedLead.status === 'audit_ready'">
<v-button secondary :loading="loadingEmail" @click="sendAuditEmail">
<v-icon name="mail" left />
Audit E-Mail
</v-button>
<v-button :loading="loadingPdf" @click="generatePdf">
<v-icon name="picture_as_pdf" left />
PDF Erstellen
</v-button>
</template>
<v-button v-if="selectedLead.audit_pdf_path" secondary icon v-tooltip.bottom="'PDF öffnen'" @click="openPdf">
<v-icon name="open_in_new" />
</v-button>
<v-button
v-if="selectedLead.audit_pdf_path"
primary
:loading="loadingEmail"
@click="sendEstimateEmail"
>
<v-icon name="send" left />
Angebot senden
</v-button>
</div>
</header>
<div class="sections">
<div class="main-info">
<div class="form-grid">
<div class="field">
<span class="label">Kontaktperson</span>
<div v-if="selectedLead.contact_person" class="value person-link" @click="goToPerson(selectedLead.contact_person)">
{{ getPersonName(selectedLead.contact_person) }}
</div>
<div v-else class="value text-subdued">Keine Person verknüpft</div>
</div>
<div class="field">
<span class="label">E-Mail (Legacy)</span>
<div class="value">{{ selectedLead.contact_email || '—' }}</div>
</div>
<div class="field full">
<span class="label">Briefing / Fokus</span>
<div class="value text-block">{{ selectedLead.briefing || 'Kein Briefing hinterlegt.' }}</div>
</div>
</div>
</div>
<v-divider />
<div v-if="selectedLead.ai_state" class="ai-observations">
<h3 class="section-title">AI Observations & Estimation</h3>
<div class="metrics">
<v-info label="Projekt-Modus" :value="selectedLead.ai_state.projectType || 'Unbekannt'" />
<v-info label="Seitenanzahl" :value="selectedLead.ai_state.sitemap?.length || '0'" />
</div>
<v-table
v-if="selectedLead.ai_state.sitemap"
:headers="[ { text: 'Seite', value: 'title' }, { text: 'URL', value: 'url' } ]"
:items="selectedLead.ai_state.sitemap"
class="observation-table"
>
<template #[`item.title`]="{ item }">
<span class="page-title">{{ item.title }}</span>
</template>
<template #[`item.url`]="{ item }">
<span class="page-url">{{ item.url }}</span>
</template>
</v-table>
</div>
</div>
</template>
</div>
<!-- Drawer: New Lead -->
<v-drawer
v-model="showAddLead"
title="Neuen Lead registrieren"
icon="person_add"
@cancel="showAddLead = false"
>
<div class="drawer-content">
<div class="form-section">
<div class="field">
<span class="label">Organisation / Firma (Zentral)</span>
<v-select
v-model="newLead.company"
:items="companyOptions"
placeholder="Bestehende Firma auswählen..."
/>
</div>
<div class="field">
<span class="label">Organisation / Firma (Legacy / Neu)</span>
<v-input v-model="newLead.company_name" placeholder="z.B. Schmidt GmbH" autofocus />
</div>
<div class="field">
<span class="label">Website URL</span>
<v-input v-model="newLead.website_url" placeholder="https://..." />
</div>
<div class="field">
<span class="label">Ansprechpartner</span>
<v-input v-model="newLead.contact_name" placeholder="Vorname Nachname" />
</div>
<div class="field">
<span class="label">E-Mail Adresse</span>
<v-input v-model="newLead.contact_email" placeholder="email@beispiel.de" type="email" />
</div>
<div class="field">
<span class="label">Briefing / Fokus</span>
<v-textarea v-model="newLead.briefing" placeholder="Besonderheiten für das Audit..." />
</div>
<div class="field">
<span class="label">Kontaktperson (Optional)</span>
<v-select
v-model="newLead.contact_person"
:items="peopleOptions"
placeholder="Person auswählen..."
/>
</div>
</div>
<div class="drawer-actions">
<v-button primary block :loading="savingLead" @click="saveLead">Lead speichern</v-button>
</div>
</div>
</v-drawer>
</private-view>
</template>
<script setup lang="ts">
// Logic will be added here
import { ref, onMounted, computed } from 'vue';
import { useApi } from '@directus/extensions-sdk';
const api = useApi();
const leads = ref<any[]>([]);
const selectedLeadId = ref<string | null>(null);
const loadingAudit = ref(false);
const loadingPdf = ref(false);
const loadingEmail = ref(false);
const showAddLead = ref(false);
const savingLead = ref(false);
const notice = ref<{ type: string; message: string } | null>(null);
const newLead = ref({
company_name: '', // Legacy
company: null,
website_url: '',
contact_name: '', // Legacy
contact_email: '', // Legacy
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.company) {
return typeof lead.company === 'object' ? lead.company.name : (companies.value.find(c => c.id === lead.company)?.name || lead.company_name);
}
return lead.company_name;
}
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) {
// Logic to navigate to people manager or open details
notice.value = { type: 'info', message: `Navigiere zu Person: ${id}` };
}
const selectedLead = computed(() => leads.value.find(l => l.id === selectedLeadId.value));
onMounted(fetchData);
async function fetchData() {
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;
}
}
async function fetchLeads() {
await fetchData();
}
function selectLead(id: string) {
selectedLeadId.value = id;
}
async function runAudit() {
if (!selectedLeadId.value) return;
loadingAudit.value = true;
try {
await api.post(`/acquisition/audit/${selectedLeadId.value}`);
notice.value = { type: 'success', message: 'Audit erfolgreich gestartet!' };
await fetchLeads();
} catch (e: any) {
notice.value = { type: 'danger', message: `Fehler beim Audit: ${e.message}` };
} finally {
loadingAudit.value = false;
}
}
async function sendAuditEmail() {
if (!selectedLeadId.value) return;
loadingEmail.value = true;
try {
await api.post(`/acquisition/audit-email/${selectedLeadId.value}`);
notice.value = { type: 'success', message: 'Audit E-Mail versendet!' };
await fetchLeads();
} catch (e: any) {
notice.value = { type: 'danger', message: `Fehler beim Versenden: ${e.message}` };
} finally {
loadingEmail.value = false;
}
}
async function generatePdf() {
if (!selectedLeadId.value) return;
loadingPdf.value = true;
try {
await api.post(`/acquisition/estimate/${selectedLeadId.value}`);
notice.value = { type: 'success', message: 'Angebot (PDF) wurde generiert!' };
await fetchLeads();
} catch (e: any) {
notice.value = { type: 'danger', message: `Fehler bei PDF Generierung: ${e.message}` };
} finally {
loadingPdf.value = false;
}
}
async function sendEstimateEmail() {
if (!selectedLeadId.value) return;
loadingEmail.value = true;
try {
await api.post(`/acquisition/estimate-email/${selectedLeadId.value}`);
notice.value = { type: 'success', message: 'Angebot erfolgreich versendet!' };
await fetchLeads();
} catch (e: any) {
notice.value = { type: 'danger', message: `Fehler beim Versenden: ${e.message}` };
} finally {
loadingEmail.value = false;
}
}
function openPdf() {
if (!selectedLead.value?.audit_pdf_path) return;
window.open(`${window.location.origin}/assets/${selectedLead.value.audit_pdf_path}`, '_blank');
}
async function saveLead() {
if (!newLead.value.company_name && !newLead.value.company) {
notice.value = { type: 'danger', message: 'Firma oder Firmenname 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!' };
showAddLead.value = false;
await fetchLeads();
selectedLeadId.value = payload.id;
newLead.value = {
company_name: '',
company: null,
website_url: '',
contact_name: '',
contact_email: '',
contact_person: null,
briefing: '',
status: 'new'
};
} catch (e: any) {
notice.value = { type: 'danger', message: `Fehler beim Speichern: ${e.message}` };
} finally {
savingLead.value = false;
}
}
function getStatusIcon(status: string) {
switch(status) {
case 'new': return 'fiber_new';
case 'auditing': return 'hourglass_empty';
case 'audit_ready': return 'check_circle';
case 'contacted': return 'mail_outline';
default: return 'help_outline';
}
}
function getStatusColor(status: string) {
switch(status) {
case 'new': return 'var(--theme--primary)';
case 'auditing': return 'var(--theme--warning)';
case 'audit_ready': return 'var(--theme--success)';
case 'contacted': return 'var(--theme--secondary)';
default: return 'var(--theme--foreground-subdued)';
}
}
</script>
<style scoped>
.acquisition-manager {
padding: 20px;
}
.content-wrapper { padding: 32px; height: 100%; display: flex; flex-direction: column; overflow-y: auto; }
.lead-item { cursor: pointer; }
.header { margin-bottom: 24px; display: flex; justify-content: space-between; align-items: flex-end; }
.header-right { display: flex; gap: 12px; }
.title { font-size: 24px; font-weight: 800; margin-bottom: 4px; color: var(--theme--foreground); }
.subtitle { color: var(--theme--foreground-subdued); font-size: 14px; display: flex; align-items: center; gap: 8px; }
.url-link { color: inherit; text-decoration: none; border-bottom: 1px solid transparent; }
.url-link:hover { border-bottom-color: currentColor; }
.empty-state { height: 100%; display: flex; align-items: center; justify-content: center; }
.sections { display: flex; flex-direction: column; gap: 32px; }
.form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 24px; }
.field { display: flex; flex-direction: column; gap: 8px; }
.field.full { grid-column: span 2; }
.label { font-size: 11px; font-weight: 700; text-transform: uppercase; color: var(--theme--foreground-subdued); letter-spacing: 0.5px; }
.value { font-size: 15px; color: var(--theme--foreground); }
.text-block { line-height: 1.6; white-space: pre-wrap; background: var(--theme--background-subdued); padding: 16px; border-radius: 8px; }
.ai-observations { display: flex; flex-direction: column; gap: 16px; }
.section-title { font-size: 16px; font-weight: 700; color: var(--theme--foreground); margin-bottom: 8px; }
.metrics { display: flex; gap: 32px; margin-bottom: 16px; }
.observation-table { border: 1px solid var(--theme--border); border-radius: 8px; overflow: hidden; }
.page-title { font-weight: 600; }
.page-url { font-family: var(--family-monospace); font-size: 12px; color: var(--theme--foreground-subdued); }
.drawer-content { padding: 24px; display: flex; flex-direction: column; gap: 32px; }
.form-section { display: flex; flex-direction: column; gap: 20px; }
.drawer-actions { margin-top: 24px; display: flex; flex-direction: column; gap: 12px; }
:deep(.v-list-item) { cursor: pointer !important; }
</style>

View File

@@ -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

View File

@@ -1,19 +1,21 @@
{
"name": "acquisition",
"version": "1.7.9",
"version": "1.8.2",
"type": "module",
"directus:extension": {
"type": "endpoint",
"path": "index.js",
"path": "dist/index.js",
"source": "src/index.ts",
"host": "^11.0.0"
},
"scripts": {
"build": "node build.js",
"dev": "node build.js --watch"
"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"
},

View File

@@ -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 });
}
});
});

View File

@@ -1,6 +1,6 @@
{
"name": "@mintel/cli",
"version": "1.7.9",
"version": "1.8.2",
"publishConfig": {
"access": "public",
"registry": "https://npm.infra.mintel.me"

View 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);
});

View File

@@ -0,0 +1,30 @@
{
"name": "@mintel/cloner",
"version": "1.8.2",
"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"
}
}

View 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;
}
}

View 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();
}
}
}

View 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;
}
}

View File

@@ -0,0 +1,3 @@
export * from "./AssetManager.js";
export * from "./PageCloner.js";
export * from "./WebsiteCloner.js";

View 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/**/*"
]
}

BIN
packages/cms-infra/database/data.db Normal file → Executable file

Binary file not shown.

File diff suppressed because one or more lines are too long

View File

@@ -2,7 +2,7 @@
"name": "acquisition-manager",
"description": "Custom High-Fidelity Acquisition Management for Directus",
"icon": "account_balance_wallet",
"version": "1.0.0",
"version": "1.7.12",
"type": "module",
"keywords": [
"directus",

File diff suppressed because it is too large Load Diff

View File

@@ -1,25 +0,0 @@
{
"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"
}
}

View File

@@ -2,7 +2,7 @@
"name": "customer-manager",
"description": "Custom High-Fidelity Customer & Company Management for Directus",
"icon": "supervisor_account",
"version": "1.7.3",
"version": "1.7.12",
"type": "module",
"keywords": [
"directus",
@@ -27,4 +27,4 @@
"@directus/extensions-sdk": "11.0.2",
"vue": "^3.4.0"
}
}
}

View File

@@ -2,7 +2,7 @@
"name": "feedback-commander",
"description": "Custom High-Fidelity Feedback Management Extension for Directus",
"icon": "view_kanban",
"version": "1.7.3",
"version": "1.7.12",
"type": "module",
"keywords": [
"directus",

File diff suppressed because one or more lines are too long

View File

@@ -1,30 +1,30 @@
{
"name": "people-manager",
"description": "Custom High-Fidelity People Management for Directus",
"icon": "person",
"version": "1.0.0",
"name": "people-manager",
"description": "Custom High-Fidelity People Management for Directus",
"icon": "person",
"version": "1.7.12",
"type": "module",
"keywords": [
"directus",
"directus-extension",
"directus-extension-module"
],
"files": [
"dist"
],
"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": "*",
"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"
}
}

View File

@@ -1,12 +1,17 @@
{
"name": "@mintel/cms-infra",
"version": "1.7.9",
"version": "1.8.2",
"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"
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,27 @@
{
"name": "company-manager",
"description": "Central Company Management for Directus",
"icon": "business",
"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": "*",
"name": "Company 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"
}
}

View File

@@ -2,16 +2,13 @@
"name": "customer-manager",
"description": "Custom High-Fidelity Customer & Company Management for Directus",
"icon": "supervisor_account",
"version": "1.7.9",
"version": "1.8.2",
"type": "module",
"keywords": [
"directus",
"directus-extension",
"directus-extension-module"
],
"files": [
"dist"
],
"directus:extension": {
"type": "module",
"path": "index.js",
@@ -20,7 +17,7 @@
"name": "Customer Manager"
},
"scripts": {
"build": "directus-extension build && (cp -f dist/index.js index.js 2>/dev/null || true)",
"build": "directus-extension build && cp -f dist/index.js index.js 2>/dev/null || true",
"dev": "directus-extension build -w"
},
"devDependencies": {

View File

@@ -127,6 +127,15 @@
<v-input v-model="employeeForm.email" placeholder="E-Mail Adresse" type="email" />
</div>
<div class="field">
<span class="label">Zentrale Person (Verknüpfung)</span>
<v-select
v-model="employeeForm.person"
:items="peopleOptions"
placeholder="Person aus dem People Manager auswählen..."
/>
</div>
<v-divider v-if="isEditingEmployee" />
<div v-if="isEditingEmployee" class="field">
@@ -158,7 +167,7 @@
</template>
<script setup lang="ts">
import { ref, onMounted, nextTick } from 'vue';
import { ref, onMounted, nextTick, computed } from 'vue';
import { useApi } from '@directus/extensions-sdk';
const api = useApi();
@@ -183,6 +192,7 @@ const employeeForm = ref({
first_name: '',
last_name: '',
email: '',
person: null,
temporary_password: ''
});
@@ -192,14 +202,22 @@ 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 people = ref<any[]>([]);
const peopleOptions = computed(() =>
people.value.map(p => ({
text: `${p.first_name} ${p.last_name} (${p.email})`,
value: p.id
}))
);
async function fetchData() {
const [companiesResp, peopleResp] = await Promise.all([
api.get('/items/companies', { params: { sort: 'name', fields: ['id', 'name'] } }),
api.get('/items/people', { params: { sort: 'last_name' } })
]);
companies.value = companiesResp.data.data;
people.value = peopleResp.data.data;
}
async function selectCompany(company: any) {
@@ -209,7 +227,7 @@ async function selectCompany(company: any) {
const res = await api.get('/items/client_users', {
params: {
filter: { company: { _eq: company.id } },
fields: ['*'],
fields: ['*', 'person.*'],
sort: 'first_name',
},
});
@@ -273,6 +291,7 @@ async function openEditEmployee(item: any) {
first_name: item.first_name || '',
last_name: item.last_name || '',
email: item.email || '',
person: item.person?.id || item.person || null,
temporary_password: item.temporary_password || ''
};
isEditingEmployee.value = true;
@@ -288,7 +307,8 @@ async function saveEmployee() {
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
email: employeeForm.value.email,
person: employeeForm.value.person
});
notice.value = { type: 'success', message: 'Mitarbeiter aktualisiert!' };
} else {
@@ -296,7 +316,8 @@ async function saveEmployee() {
first_name: employeeForm.value.first_name,
last_name: employeeForm.value.last_name,
email: employeeForm.value.email,
company: selectedCompany.value.id
company: selectedCompany.value.id,
person: employeeForm.value.person
});
notice.value = { type: 'success', message: 'Mitarbeiter angelegt!' };
}
@@ -343,7 +364,7 @@ function formatDate(dateStr: string) {
}
onMounted(() => {
fetchCompanies();
fetchData();
});
</script>

View File

@@ -0,0 +1,31 @@
{
"name": "@mintel/directus-extension-toolkit",
"version": "1.8.2",
"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"
}
}

View 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>

View 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>

View 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>

View 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';

View 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'
}
}
}
}
});

View File

@@ -1,6 +1,6 @@
{
"name": "@mintel/eslint-config",
"version": "1.7.9",
"version": "1.8.2",
"publishConfig": {
"access": "public",
"registry": "https://npm.infra.mintel.me"

View File

@@ -2,16 +2,13 @@
"name": "feedback-commander",
"description": "Custom High-Fidelity Feedback Management Extension for Directus",
"icon": "view_kanban",
"version": "1.7.9",
"version": "1.8.2",
"type": "module",
"keywords": [
"directus",
"directus-extension",
"directus-extension-module"
],
"files": [
"dist"
],
"directus:extension": {
"type": "module",
"path": "index.js",
@@ -20,7 +17,7 @@
"name": "Feedback Commander"
},
"scripts": {
"build": "directus-extension build && (cp -f dist/index.js index.js 2>/dev/null || true)",
"build": "directus-extension build && cp -f dist/index.js index.js 2>/dev/null || true",
"dev": "directus-extension build -w"
},
"devDependencies": {

View File

@@ -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;

View File

@@ -1,6 +1,6 @@
{
"name": "@mintel/gatekeeper",
"version": "1.7.9",
"version": "1.8.2",
"private": true,
"type": "module",
"scripts": {

View File

@@ -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)}...`);
}
}
}

View File

@@ -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)}`);
}
}

View File

@@ -1,6 +1,6 @@
{
"name": "@mintel/husky-config",
"version": "1.7.9",
"version": "1.8.2",
"publishConfig": {
"access": "public",
"registry": "https://npm.infra.mintel.me"

View File

@@ -1,6 +1,6 @@
{
"name": "@mintel/infra",
"version": "1.7.9",
"version": "1.8.2",
"publishConfig": {
"access": "public",
"registry": "https://npm.infra.mintel.me"

View File

@@ -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..."

View 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

View File

@@ -1,6 +1,6 @@
{
"name": "@mintel/mail",
"version": "1.7.9",
"version": "1.8.2",
"private": false,
"publishConfig": {
"access": "public",

View File

@@ -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";

View 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",
};

View 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",
};

View File

@@ -0,0 +1,146 @@
import * as React from "react";
import { Heading, Section, Text, Button, Link } 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",
};

View File

@@ -1,6 +1,6 @@
{
"name": "@mintel/next-config",
"version": "1.7.9",
"version": "1.8.2",
"publishConfig": {
"access": "public",
"registry": "https://npm.infra.mintel.me"

View File

@@ -1,6 +1,6 @@
{
"name": "@mintel/next-feedback",
"version": "1.7.9",
"version": "1.8.2",
"publishConfig": {
"access": "public",
"registry": "https://npm.infra.mintel.me"
@@ -27,6 +27,7 @@
},
"dependencies": {
"@directus/sdk": "^21.0.0",
"@medv/finder": "^4.0.2",
"clsx": "^2.1.1",
"framer-motion": "^11.5.4",
"html2canvas": "^1.4.1",

View File

@@ -6,6 +6,7 @@ import { MessageSquare, X, Check, Plus, List, Send, User } from "lucide-react";
import { clsx } from "clsx";
import { twMerge } from "tailwind-merge";
import html2canvas from "html2canvas";
import { finder } from "@medv/finder";
function cn(...inputs: any[]) {
return twMerge(clsx(inputs));
@@ -107,15 +108,16 @@ export function FeedbackOverlay() {
}, []);
const getSelector = (el: HTMLElement): string => {
if (el.id) return `#${el.id}`;
const path = [];
let curr: HTMLElement | null = el;
while (curr && curr.parentElement) {
const index = Array.from(curr.parentElement.children).indexOf(curr) + 1;
path.unshift(`${curr.tagName.toLowerCase()}:nth-child(${index})`);
curr = curr.parentElement;
}
return path.join(" > ");
return finder(el, {
root: document.body,
className: (name) =>
!name.startsWith('record-mode-') &&
!name.startsWith('feedback-') &&
!name.includes('[') &&
!name.includes('/') &&
!name.match(/^[a-z]-[0-9]/),
idName: (name) => !name.startsWith('__next') && !name.includes(':'),
});
};
useEffect(() => {

View File

@@ -6,7 +6,4 @@ export default defineConfig({
dts: true,
clean: true,
sourcemap: true,
banner: {
js: "'use client';",
},
});

View File

@@ -1,6 +1,6 @@
{
"name": "@mintel/next-observability",
"version": "1.7.9",
"version": "1.8.2",
"publishConfig": {
"access": "public",
"registry": "https://npm.infra.mintel.me"

View File

@@ -1,6 +1,6 @@
{
"name": "@mintel/next-utils",
"version": "1.7.9",
"version": "1.8.2",
"publishConfig": {
"access": "public",
"registry": "https://npm.infra.mintel.me"

View File

@@ -1,6 +1,6 @@
{
"name": "@mintel/observability",
"version": "1.7.9",
"version": "1.8.2",
"publishConfig": {
"access": "public",
"registry": "https://npm.infra.mintel.me"

View File

@@ -0,0 +1,57 @@
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'),
resolve(__dirname, 'src/server.ts')
];
try {
mkdirSync(resolve(__dirname, 'dist'), { recursive: true });
} catch (e) { }
console.log(`Building entry points...`);
build({
entryPoints: entryPoints,
bundle: true,
platform: 'node',
target: 'node18',
outdir: resolve(__dirname, 'dist'),
format: 'esm',
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-canvas',
setup(build) {
build.onResolve({ filter: /^canvas/ }, args => ({ path: args.path, namespace: 'mock-canvas' }));
build.onLoad({ filter: /.*/, namespace: 'mock-canvas' }, () => ({ contents: 'export default {};', loader: 'js' }));
}
}, {
name: 'mock-jsdom',
setup(build) {
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!");
}).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);
});

View File

@@ -0,0 +1,38 @@
{
"name": "@mintel/pdf",
"version": "1.8.2",
"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"
},
"./server": {
"types": "./dist/server.d.ts",
"import": "./dist/server.js",
"default": "./dist/server.js"
}
},
"scripts": {
"build": "node build.mjs",
"dev": "node build.mjs --watch"
},
"devDependencies": {
"@directus/extensions-sdk": "11.0.2",
"esbuild": "^0.25.0",
"typescript": "^5.6.3"
},
"dependencies": {
"@crawlee/cheerio": "^3.16.0",
"@mintel/mail": "workspace:*",
"@react-pdf/renderer": "^4.3.0",
"axios": "^1.7.9",
"cheerio": "^1.0.0",
"react": "^19.2.4",
"react-dom": "^19.2.4"
}
}

View File

@@ -0,0 +1,241 @@
"use client";
import * as React from "react";
import {
Page as PDFPage,
Text as PDFText,
View as PDFView,
StyleSheet as PDFStyleSheet,
} from "@react-pdf/renderer";
import {
pdfStyles,
Header,
Footer,
FoldingMarks,
DocumentTitle,
} from "./pdf/SharedUI.js";
import { SimpleLayout } from "./pdf/SimpleLayout.js";
const localStyles = PDFStyleSheet.create({
sectionContainer: {
marginTop: 0,
},
agbSection: {
marginBottom: 20,
},
labelRow: {
flexDirection: "row",
alignItems: "baseline",
marginBottom: 6,
},
monoNumber: {
fontSize: 7,
fontWeight: "bold",
color: "#94a3b8",
letterSpacing: 2,
width: 25,
},
sectionTitle: {
fontSize: 9,
fontWeight: "bold",
color: "#000000",
textTransform: "uppercase",
letterSpacing: 0.5,
},
officialText: {
fontSize: 8,
lineHeight: 1.5,
color: "#334155",
textAlign: "justify",
paddingLeft: 25,
},
});
const AGBSection = ({
index,
title,
children,
}: {
index: string;
title: string;
children: React.ReactNode;
}) => (
<PDFView style={localStyles.agbSection} wrap={false}>
<PDFView style={localStyles.labelRow}>
<PDFText style={localStyles.monoNumber}>{index}</PDFText>
<PDFText style={localStyles.sectionTitle}>{title}</PDFText>
</PDFView>
<PDFText style={localStyles.officialText}>{children}</PDFText>
</PDFView>
);
interface AgbsPDFProps {
headerIcon?: string;
footerLogo?: string;
mode?: "estimation" | "full";
}
export const AgbsPDF = ({
headerIcon,
footerLogo,
mode = "full",
}: AgbsPDFProps) => {
const date = new Date().toLocaleDateString("de-DE", {
year: "numeric",
month: "long",
day: "numeric",
});
const companyData = {
name: "Marc Mintel",
address1: "Georg-Meistermann-Straße 7",
address2: "54586 Schüller",
ustId: "DE367588065",
};
const bankData = {
name: "N26",
bic: "NTSBDEB1XXX",
iban: "DE50 1001 1001 2620 4328 65",
};
const content = (
<>
<DocumentTitle
title="Allgemeine Geschäftsbedingungen"
subLines={[`Stand: ${date}`]}
/>
<PDFView style={localStyles.sectionContainer}>
<AGBSection index="01" title="Geltungsbereich">
Diese Allgemeinen Geschäftsbedingungen gelten für alle Verträge
zwischen Marc Mintel (nachfolgend Auftragnehmer) und dem jeweiligen
Kunden (nachfolgend Auftraggeber). Abweichende oder ergänzende
Bedingungen des Auftraggebers werden nicht Vertragsbestandteil, auch
wenn ihrer Geltung nicht ausdrücklich widersprochen wird.
</AGBSection>
<AGBSection index="02" title="Vertragsgegenstand">
Der Auftragnehmer erbringt Dienstleistungen im Bereich:
Webentwicklung, technische Umsetzung digitaler Systeme, Funktionen,
Schnittstellen und Automatisierungen sowie Hosting, Betrieb und
Wartung, sofern ausdrücklich vereinbard. Der Auftragnehmer schuldet
ausschließlich die vereinbarte technische Leistung, nicht jedoch einen
wirtschaftlichen Erfolg, bestimmte Umsätze, Conversions, Reichweiten,
Suchmaschinen-Rankings oder rechtliche Ergebnisse.
</AGBSection>
<AGBSection index="03" title="Mitwirkungspflichten des Auftraggebers">
Der Auftraggeber verpflichtet sich, alle zur Leistungserbringung
erforderlichen Inhalte, Informationen, Zugänge und Entscheidungen
rechtzeitig, vollständig und korrekt bereitzustellen. Hierzu zählen
insbesondere Texte, Bilder, Videos, Produktdaten, Freigaben, Feedback,
Zugangsdaten sowie rechtlich erforderliche Inhalte (z. B. Impressum,
DSGVO). Verzögerungen oder Unterlassungen führen zu Verschiebungen
aller Termine ohne Schadensersatzanspruch.
</AGBSection>
<AGBSection index="04" title="Ausführungs- und Bearbeitungszeiten">
Angegebene Bearbeitungszeiten sind unverbindliche Schätzungen, keine
garantierten Fristen. Fixe Termine oder Deadlines gelten nur, wenn sie
ausdrücklich schriftlich als verbindlich vereinbart wurden.
</AGBSection>
<AGBSection index="05" title="Abnahme">
Die Leistung gilt als abgenommen, wenn der Auftraggeber sie produktiv
nutzt oder innerhalb von 7 Tagen nach Bereitstellung keine
wesentlichen Mängel angezeigt werden. Optische Abweichungen,
Geschmacksfragen oder subjektive Einschätzungen stellen keine Mängel
dar.
</AGBSection>
<AGBSection index="06" title="Haftung">
Der Auftragnehmer haftet nur für Schäden, die auf vorsätzlicher oder
grob fahrlässiger Pflichtverletzung beruhen. Eine Haftung für
entgangenen Gewinn, Umsatzausfälle, Datenverlust,
Betriebsunterbrechungen, mittelbare oder Folgeschäden ist
ausgeschlossen, soweit gesetzlich zulässig.
</AGBSection>
<AGBSection index="07" title="Verfügbarkeit & Betrieb">
Bei vereinbartem Hosting oder Betrieb schuldet der Auftragnehmer keine
permanente Verfügbarkeit. Wartungsarbeiten, Updates,
Sicherheitsmaßnahmen oder externe Störungen können zu zeitweisen
Einschränkungen führen und begründen keine Haftungsansprüche.
</AGBSection>
<AGBSection index="07a" title="Betriebs- und Pflegeleistung">
Die Betriebs- und Pflegeleistung umfasst ausschließlich die
Sicherstellung des technischen Betriebs, Wartung, Updates,
Fehlerbehebung der bestehenden Systeme sowie Pflege bestehender
Datensätze ohne Strukturänderung. Nicht Bestandteil sind die
Erstellung neuer Inhalte (Blogartikel, News, Produkte), redaktionelle
Tätigkeiten, strategische Planung oder der Aufbau neuer
Features/Datenmodelle. Leistungen darüber hinaus gelten als
Neuentwicklung.
</AGBSection>
<AGBSection index="08" title="Drittanbieter & externe Systeme">
Der Auftragnehmer übernimmt keine Verantwortung für Leistungen,
Ausfälle oder Änderungen externer Dienste, APIs, Schnittstellen oder
Plattformen Dritter. Eine Funktionsfähigkeit kann nur im Rahmen der
jeweils aktuellen externen Schnittstellen gewährleistet werden.
</AGBSection>
<AGBSection index="09" title="Inhalte & Rechtliches">
Der Auftraggeber ist allein verantwortlich für Inhalte, rechtliche
Konformität (DSGVO, Urheberrecht etc.) sowie bereitgestellte Daten.
Der Auftragnehmer übernimmt keine rechtliche Prüfung.
</AGBSection>
<AGBSection index="10" title="Vergütung & Zahlungsverzug">
Alle Preise netto zzgl. MwSt. Rechnungen sind innerhalb von 7 Tagen
fällig. Bei Zahlungsverzug ist der Auftragnehmer berechtigt,
Leistungen auszusetzen, Systeme offline zu nehmen oder laufende
Arbeiten zu stoppen.
</AGBSection>
<AGBSection index="11" title="Kündigung laufender Leistungen">
Laufende Leistungen (z. B. Hosting & Betrieb) können mit einer Frist
von 4 Wochen zum Monatsende gekündigt werden, sofern nichts anderes
vereinbart ist.
</AGBSection>
<AGBSection index="12" title="Schlussbestimmungen">
Es gilt das Recht der Bundesrepublik Deutschland. Gerichtsstand ist
der Sitz des Auftragnehmers. Sollte eine Bestimmung unwirksam sein,
bleibt die Wirksamkeit der übrigen Regelungen unberührt.
</AGBSection>
</PDFView>
</>
);
if (mode === "full") {
return (
<SimpleLayout
companyData={companyData}
bankData={bankData}
footerLogo={footerLogo}
icon={headerIcon}
pageNumber="10"
showPageNumber={false}
>
{content}
</SimpleLayout>
);
}
return (
<PDFPage size="A4" style={pdfStyles.page}>
<FoldingMarks />
<Header icon={headerIcon} showAddress={false} />
{content}
<Footer
logo={footerLogo}
companyData={companyData}
bankData={bankData}
showDetails={false}
showPageNumber={false}
/>
</PDFPage>
);
};

View File

@@ -0,0 +1,79 @@
"use client";
import * as React from "react";
import { Document as PDFDocument } from "@react-pdf/renderer";
import { EstimationPDF } from "./EstimationPDF.js";
import { AgbsPDF } from "./AgbsPDF.js";
import { SimpleLayout } from "./pdf/SimpleLayout.js";
import { ClosingModule } from "./pdf/modules/CommonModules.js";
interface CombinedProps {
estimationProps: any;
showAgbs?: boolean;
techDetails?: any[];
principles?: any[];
maintenanceDetails?: any[];
standardsDetails?: any[];
}
export const CombinedQuotePDF = ({
estimationProps,
showAgbs = true,
techDetails,
principles,
maintenanceDetails,
standardsDetails,
mode = "full",
}: CombinedProps & { mode?: "estimation" | "full" }) => {
const date = new Date().toLocaleDateString("de-DE", {
year: "numeric",
month: "long",
day: "numeric",
});
const companyData = {
name: "Marc Mintel",
address1: "Georg-Meistermann-Straße 7",
address2: "54586 Schüller",
ustId: "DE367588065",
};
const bankData = {
name: "N26",
bic: "NTSBDEB1XXX",
iban: "DE50 1001 1001 2620 4328 65",
};
const layoutProps = {
date,
icon: estimationProps.headerIcon,
footerLogo: estimationProps.footerLogo,
companyData,
bankData,
};
return (
<PDFDocument
title={`Mintel - ${estimationProps.state.companyName || estimationProps.state.name}`}
>
<EstimationPDF
{...estimationProps}
mode={mode}
techDetails={techDetails}
principles={principles}
maintenanceDetails={maintenanceDetails}
standardsDetails={standardsDetails}
/>
{showAgbs && (
<AgbsPDF
mode={mode}
headerIcon={estimationProps.headerIcon}
footerLogo={estimationProps.footerLogo}
/>
)}
<SimpleLayout {...layoutProps} pageNumber="END" showPageNumber={false}>
<ClosingModule />
</SimpleLayout>
</PDFDocument>
);
};

View File

@@ -0,0 +1,95 @@
"use client";
import * as React from "react";
import { Page as PDFPage, Document as PDFDocument } from "@react-pdf/renderer";
import { pdfStyles } from "./pdf/SharedUI.js";
import { SimpleLayout } from "./pdf/SimpleLayout.js";
// Modules
import { FrontPageModule } from "./pdf/modules/FrontPageModule.js";
import { BriefingModule } from "./pdf/modules/BriefingModule.js";
import { SitemapModule } from "./pdf/modules/SitemapModule.js";
import { EstimationModule } from "./pdf/modules/EstimationModule.js";
import { TransparenzModule } from "./pdf/modules/TransparenzModule.js";
import { ClosingModule } from "./pdf/modules/CommonModules.js";
import { calculatePositions } from "../logic/pricing/calculator.js";
interface PDFProps {
state: any;
totalPrice: number;
monthlyPrice?: number;
totalPagesCount?: number;
pricing: any;
headerIcon?: string;
footerLogo?: string;
}
export const EstimationPDF = ({
state,
totalPrice,
pricing,
headerIcon,
footerLogo,
}: PDFProps) => {
const date = new Date().toLocaleDateString("de-DE", {
year: "numeric",
month: "long",
day: "numeric",
});
const positions = calculatePositions(state, pricing);
const companyData = {
name: "Marc Mintel",
address1: "Georg-Meistermann-Straße 7",
address2: "54586 Schüller",
ustId: "DE367588065",
};
const commonProps = {
state,
date,
icon: headerIcon,
footerLogo,
companyData,
};
let pageCounter = 1;
const getPageNum = () => (pageCounter++).toString().padStart(2, "0");
return (
<PDFDocument title={`Angebot - ${state.companyName || "Projekt"}`}>
<PDFPage size="A4" style={pdfStyles.titlePage}>
<FrontPageModule state={state} headerIcon={headerIcon} date={date} />
</PDFPage>
<SimpleLayout {...commonProps} pageNumber={getPageNum()}>
<BriefingModule state={state} />
</SimpleLayout>
{state.sitemap && state.sitemap.length > 0 && (
<SimpleLayout {...commonProps} pageNumber={getPageNum()}>
<SitemapModule state={state} />
</SimpleLayout>
)}
<SimpleLayout {...commonProps} pageNumber={getPageNum()}>
<EstimationModule
state={state}
positions={positions}
totalPrice={totalPrice}
date={date}
/>
</SimpleLayout>
<SimpleLayout {...commonProps} pageNumber={getPageNum()}>
<TransparenzModule pricing={pricing} />
</SimpleLayout>
<SimpleLayout {...commonProps} pageNumber={getPageNum()}>
<ClosingModule />
</SimpleLayout>
</PDFDocument>
);
};

View File

@@ -0,0 +1,55 @@
'use client';
import * as React from 'react';
import { Page as PDFPage } from '@react-pdf/renderer';
import { FoldingMarks, Header, Footer, pdfStyles } from './SharedUI';
interface DINLayoutProps {
children: React.ReactNode;
sender?: string;
recipient?: {
title: string;
subtitle?: string;
address?: string;
phone?: string;
email?: string;
taxId?: string;
};
icon?: string;
footerLogo?: string;
companyData: any;
bankData: any;
showAddress?: boolean;
showFooterDetails?: boolean;
}
export const DINLayout = ({
children,
sender,
recipient,
icon,
footerLogo,
companyData,
bankData,
showAddress = true,
showFooterDetails = true
}: DINLayoutProps) => {
return (
<PDFPage size="A4" style={pdfStyles.page}>
<FoldingMarks />
<Header
sender={sender}
recipient={recipient}
icon={icon}
showAddress={showAddress}
/>
{children}
<Footer
logo={footerLogo}
companyData={companyData}
bankData={bankData}
showDetails={showFooterDetails}
/>
</PDFPage>
);
};

View File

@@ -0,0 +1,728 @@
"use client";
import * as React from "react";
import {
View as PDFView,
Text as PDFText,
StyleSheet,
Image as PDFImage,
} from "@react-pdf/renderer";
// INDUSTRIAL DESIGN SYSTEM TOKENS
export const COLORS = {
CHARCOAL: "#0f172a", // Slate 900
TEXT_MAIN: "#334155", // Slate 700
TEXT_DIM: "#64748b", // Slate 500
TEXT_LIGHT: "#94a3b8", // Slate 400
DIVIDER: "#cbd5e1", // Slate 300
GRID: "#f1f5f9", // Slate 100
BLUEPRINT: "#e2e8f0", // Slate 200
WHITE: "#ffffff",
};
export const FONT_SIZES = {
HERO: 24, // Main Page Titles
HEADING: 14, // Section Headers
BODY: 11, // Standard Content
LABEL: 10, // Bold Labels / Keys
SMALL: 9, // Descriptions / Footnotes
TINY: 8, // Metadata / Unit prices
};
// Mintel Industrial Glyphs (strictly 1px stroke, 12x12px grid)
export const IndustrialGlyph = ({
type,
color = COLORS.TEXT_LIGHT,
size = 12,
}: {
type: string;
color?: string;
size?: number;
}) => {
const stroke = 1;
const scale = size / 12;
switch (type) {
case "base": // Skeletal cube base
return (
<PDFView style={{ width: size, height: size, position: "relative" }}>
<PDFView
style={{
position: "absolute",
top: 2 * scale,
left: 2 * scale,
width: 8 * scale,
height: 8 * scale,
borderWidth: stroke,
borderColor: color,
}}
/>
<PDFView
style={{
position: "absolute",
top: 0,
left: 0,
width: 4 * scale,
height: 4 * scale,
borderWidth: stroke,
borderColor: color,
backgroundColor: "white",
}}
/>
</PDFView>
);
case "pages": // Layered rectangles
return (
<PDFView style={{ width: size, height: size, position: "relative" }}>
<PDFView
style={{
position: "absolute",
top: 3 * scale,
left: 3 * scale,
width: 6 * scale,
height: 8 * scale,
borderWidth: stroke,
borderColor: color,
}}
/>
<PDFView
style={{
position: "absolute",
top: 0,
left: 0,
width: 6 * scale,
height: 8 * scale,
borderWidth: stroke,
borderColor: color,
backgroundColor: "white",
}}
/>
</PDFView>
);
case "modules": // Four small squares grid
return (
<PDFView
style={{
width: size,
height: size,
flexDirection: "row",
flexWrap: "wrap",
gap: 2 * scale,
}}
>
<PDFView
style={{
width: 4 * scale,
height: 4 * scale,
borderWidth: stroke,
borderColor: color,
}}
/>
<PDFView
style={{
width: 4 * scale,
height: 4 * scale,
borderWidth: stroke,
borderColor: color,
}}
/>
<PDFView
style={{
width: 4 * scale,
height: 4 * scale,
borderWidth: stroke,
borderColor: color,
}}
/>
<PDFView
style={{
width: 4 * scale,
height: 4 * scale,
borderWidth: stroke,
borderColor: color,
}}
/>
</PDFView>
);
case "logic": // Diamond with center point
return (
<PDFView
style={{
width: size,
height: size,
alignItems: "center",
justifyContent: "center",
}}
>
<PDFView
style={{
width: 8 * scale,
height: 8 * scale,
borderWidth: stroke,
borderColor: color,
transform: "rotate(45deg)",
}}
/>
<PDFView
style={{
width: 2 * scale,
height: 2 * scale,
backgroundColor: color,
position: "absolute",
}}
/>
</PDFView>
);
case "interface": // Three horizontal lines of varying length
return (
<PDFView
style={{
width: size,
height: size,
justifyContent: "center",
gap: 2 * scale,
}}
>
<PDFView
style={{
width: 10 * scale,
height: stroke,
backgroundColor: color,
}}
/>
<PDFView
style={{ width: 6 * scale, height: stroke, backgroundColor: color }}
/>
<PDFView
style={{
width: 10 * scale,
height: stroke,
backgroundColor: color,
}}
/>
</PDFView>
);
case "management": // Framed grid
return (
<PDFView
style={{
width: size,
height: size,
borderWidth: stroke,
borderColor: color,
padding: 1 * scale,
}}
>
<PDFView
style={{
width: "100%",
height: 2 * scale,
backgroundColor: color,
marginBottom: 1 * scale,
}}
/>
<PDFView
style={{
width: "100%",
height: 2 * scale,
backgroundColor: color,
marginBottom: 1 * scale,
}}
/>
<PDFView
style={{ width: "100%", height: 2 * scale, backgroundColor: color }}
/>
</PDFView>
);
case "reveal": // Ascending bars
return (
<PDFView
style={{
width: size,
height: size,
flexDirection: "row",
alignItems: "flex-end",
gap: 1 * scale,
}}
>
<PDFView
style={{
width: 2 * scale,
height: 4 * scale,
backgroundColor: color,
opacity: 0.4,
}}
/>
<PDFView
style={{
width: 2 * scale,
height: 7 * scale,
backgroundColor: color,
opacity: 0.7,
}}
/>
<PDFView
style={{
width: 2 * scale,
height: 10 * scale,
backgroundColor: color,
}}
/>
</PDFView>
);
case "maintenance": // Circle with vertical notch
return (
<PDFView
style={{
width: size,
height: size,
borderRadius: 6 * scale,
borderWidth: stroke,
borderColor: color,
alignItems: "center",
}}
>
<PDFView
style={{
width: stroke,
height: 4 * scale,
backgroundColor: color,
marginTop: 1 * scale,
}}
/>
</PDFView>
);
default:
return (
<PDFView
style={{
width: size,
height: size,
borderWidth: stroke,
borderColor: COLORS.BLUEPRINT,
borderStyle: "dashed",
}}
/>
);
}
};
export const pdfStyles = StyleSheet.create({
page: {
paddingTop: 45, // DIN 5008
paddingLeft: 70, // ~25mm
paddingRight: 57, // ~20mm
paddingBottom: 80, // Safe buffer for absolute footer
backgroundColor: COLORS.WHITE,
fontFamily: "Helvetica",
fontSize: FONT_SIZES.BODY,
color: COLORS.CHARCOAL,
},
titlePage: {
width: "100%",
height: "100%",
backgroundColor: COLORS.WHITE,
fontFamily: "Helvetica",
color: COLORS.CHARCOAL,
padding: 0,
},
header: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "flex-start",
marginBottom: 20,
minHeight: 120,
},
addressBlock: {
width: "55%",
marginTop: 45,
},
senderLine: {
fontSize: FONT_SIZES.TINY,
textDecoration: "underline",
color: COLORS.TEXT_DIM,
marginBottom: 8,
},
recipientAddress: {
fontSize: FONT_SIZES.BODY,
lineHeight: 1.4,
},
brandLogoContainer: {
width: "40%",
alignItems: "flex-end",
},
brandIconContainer: {
width: 40,
height: 40,
backgroundColor: "#0f172a",
borderRadius: 8,
alignItems: "center",
justifyContent: "center",
marginBottom: 12,
},
brandIconText: {
color: COLORS.WHITE,
fontSize: 20,
fontWeight: "bold",
},
titleInfo: {
marginBottom: 24,
},
mainTitle: {
fontSize: FONT_SIZES.HEADING,
fontWeight: "bold",
marginBottom: 4,
color: COLORS.CHARCOAL,
letterSpacing: 0.5,
},
subTitle: {
fontSize: FONT_SIZES.BODY,
color: COLORS.TEXT_DIM,
marginTop: 2,
lineHeight: 1.4,
},
section: {
marginBottom: 32,
},
sectionTitle: {
fontSize: FONT_SIZES.LABEL,
fontWeight: "bold",
textTransform: "uppercase",
letterSpacing: 1,
color: COLORS.TEXT_LIGHT,
marginBottom: 8,
},
footer: {
position: "absolute",
bottom: 32,
left: 70,
right: 57,
borderTopWidth: 1,
borderTopColor: COLORS.GRID,
paddingTop: 16,
flexDirection: "row",
justifyContent: "space-between",
alignItems: "flex-start",
},
footerColumn: {
flex: 1,
alignItems: "flex-start",
},
footerLogo: {
height: 20,
width: "auto",
objectFit: "contain",
marginBottom: 8,
},
footerText: {
fontSize: FONT_SIZES.TINY,
color: COLORS.TEXT_LIGHT,
lineHeight: 1.4,
},
asymmetryContainer: {
flexDirection: "row",
gap: 32,
},
asymmetryLeft: {
width: "32%",
},
asymmetryRight: {
width: "63%",
},
specRow: {
flexDirection: "row",
justifyContent: "space-between",
paddingVertical: 6,
borderBottomWidth: 1,
borderBottomColor: COLORS.GRID,
},
specLabel: {
fontSize: FONT_SIZES.TINY,
fontWeight: "bold",
color: COLORS.TEXT_LIGHT,
textTransform: "uppercase",
letterSpacing: 0.5,
},
specValue: {
fontSize: FONT_SIZES.SMALL,
color: COLORS.CHARCOAL,
fontWeight: "bold",
},
blueprintBox: {
borderWidth: 1,
borderColor: COLORS.GRID,
padding: 16,
backgroundColor: "#fafafa",
},
footerLabel: {
fontWeight: "bold",
color: COLORS.TEXT_DIM,
},
pageNumber: {
fontSize: FONT_SIZES.TINY,
color: COLORS.DIVIDER,
fontWeight: "bold",
marginTop: 8,
textAlign: "right",
},
foldingMark: {
position: "absolute",
left: 20,
width: 10,
borderTopWidth: 0.5,
borderTopColor: COLORS.DIVIDER,
},
divider: {
width: "100%",
height: 1,
backgroundColor: COLORS.DIVIDER,
marginVertical: 12,
},
industrialListItem: {
flexDirection: "row",
alignItems: "flex-start",
marginBottom: 6,
},
industrialBulletBox: {
width: 6,
height: 6,
backgroundColor: COLORS.DIVIDER,
marginRight: 8,
marginTop: 5,
},
industrialTitle: {
fontSize: FONT_SIZES.HERO,
fontWeight: "bold",
color: COLORS.CHARCOAL,
marginBottom: 6,
letterSpacing: 0,
},
});
export const IndustrialListItem = ({
children,
}: {
children: React.ReactNode;
}) => (
<PDFView style={pdfStyles.industrialListItem}>
<PDFView style={pdfStyles.industrialBulletBox} />
{children}
</PDFView>
);
export const Divider = ({ style = {} }: { style?: any }) => (
<PDFView style={[pdfStyles.divider, style]} />
);
export const FoldingMarks = () => (
<>
<PDFView style={[pdfStyles.foldingMark, { top: 297.6 }]} fixed />
<PDFView style={[pdfStyles.foldingMark, { top: 420.9, width: 15 }]} fixed />
<PDFView style={[pdfStyles.foldingMark, { top: 595.3 }]} fixed />
</>
);
export const Footer = ({
logo,
companyData,
bankData,
showDetails = true,
showPageNumber = true,
}: {
logo?: string;
companyData: any;
bankData?: any;
showDetails?: boolean;
showPageNumber?: boolean;
}) => (
<PDFView style={pdfStyles.footer}>
<PDFView style={pdfStyles.footerColumn}>
{logo ? (
<PDFImage src={logo} style={pdfStyles.footerLogo} />
) : (
<PDFText style={{ fontSize: 12, fontWeight: "bold", marginBottom: 8 }}>
marc mintel
</PDFText>
)}
</PDFView>
{showDetails && (
<>
<PDFView style={pdfStyles.footerColumn}>
<PDFText style={pdfStyles.footerText}>
<PDFText style={pdfStyles.footerLabel}>{companyData.name}</PDFText>
{"\n"}
{companyData.address1}
{"\n"}
{companyData.address2}
{"\n"}UST: {companyData.ustId}
</PDFText>
</PDFView>
<PDFView style={[pdfStyles.footerColumn, { alignItems: "flex-end" }]}>
{showPageNumber && (
<PDFText
style={pdfStyles.pageNumber}
render={({ pageNumber, totalPages }) =>
`${pageNumber} / ${totalPages}`
}
fixed
/>
)}
</PDFView>
</>
)}
{!showDetails && (
<PDFView style={[pdfStyles.footerColumn, { alignItems: "flex-end" }]}>
{showPageNumber && (
<PDFText
style={pdfStyles.pageNumber}
render={({ pageNumber, totalPages }) =>
`${pageNumber} / ${totalPages}`
}
fixed
/>
)}
</PDFView>
)}
</PDFView>
);
export const Header = ({
sender,
recipient,
icon,
showAddress = true,
}: {
sender?: string;
recipient?: {
title: string;
subtitle?: string;
email?: string;
address?: string;
phone?: string;
taxId?: string;
};
icon?: string;
showAddress?: boolean;
}) => (
<PDFView
style={[
pdfStyles.header,
showAddress ? {} : { minHeight: 40, marginBottom: 0 },
]}
>
<PDFView style={pdfStyles.addressBlock}>
{showAddress && sender && (
<>
<PDFText style={pdfStyles.senderLine}>{sender}</PDFText>
{recipient && (
<PDFView style={pdfStyles.recipientAddress}>
<PDFText style={{ fontWeight: "bold" }}>
{recipient.title}
</PDFText>
{recipient.subtitle && <PDFText>{recipient.subtitle}</PDFText>}
{recipient.address && <PDFText>{recipient.address}</PDFText>}
{recipient.phone && <PDFText>{recipient.phone}</PDFText>}
{recipient.email && <PDFText>{recipient.email}</PDFText>}
{recipient.taxId && <PDFText>USt-ID: {recipient.taxId}</PDFText>}
</PDFView>
)}
</>
)}
</PDFView>
<PDFView style={pdfStyles.brandLogoContainer}>
<PDFView style={pdfStyles.brandIconContainer}>
{icon ? (
<PDFImage src={icon} style={{ width: 24, height: 24 }} />
) : (
<PDFText style={pdfStyles.brandIconText}>M</PDFText>
)}
</PDFView>
</PDFView>
</PDFView>
);
export const DocumentTitle = ({
title,
subLines,
isHero = false,
}: {
title: string;
subLines?: string[];
isHero?: boolean;
}) => (
<PDFView style={pdfStyles.titleInfo}>
<PDFText
style={[
pdfStyles.mainTitle,
{ fontSize: isHero ? FONT_SIZES.HERO : FONT_SIZES.HEADING },
]}
>
{title}
</PDFText>
{subLines?.map((line, i) => (
<PDFText
key={i}
style={[
pdfStyles.subTitle,
i === 1 ? { fontWeight: "bold", color: COLORS.CHARCOAL } : {},
]}
>
{line}
</PDFText>
))}
</PDFView>
);
export const TechnicalSpec = ({
label,
value,
}: {
label: string;
value: string;
}) => (
<PDFView style={pdfStyles.specRow}>
<PDFText style={pdfStyles.specLabel}>{label}</PDFText>
<PDFText style={pdfStyles.specValue}>{value}</PDFText>
</PDFView>
);
export const AsymmetryView = ({
left,
right,
style = {},
}: {
left: React.ReactNode;
right: React.ReactNode;
style?: any;
}) => (
<PDFView style={[pdfStyles.asymmetryContainer, style]}>
<PDFView style={pdfStyles.asymmetryLeft}>{left}</PDFView>
<PDFView style={pdfStyles.asymmetryRight}>{right}</PDFView>
</PDFView>
);
export const IndustrialCard = ({
title,
children,
style = {},
}: {
title: string;
children: React.ReactNode;
style?: any;
}) => (
<PDFView style={[pdfStyles.blueprintBox, { marginBottom: 12 }, style]}>
<PDFText
style={{
fontSize: FONT_SIZES.TINY,
fontWeight: "bold",
color: COLORS.TEXT_LIGHT,
letterSpacing: 1,
marginBottom: 6,
textTransform: "uppercase",
}}
>
{title}
</PDFText>
{children}
</PDFView>
);

View File

@@ -0,0 +1,67 @@
'use client';
import * as React from 'react';
import { Page as PDFPage, View as PDFView, Text as PDFText, StyleSheet } from '@react-pdf/renderer';
import { Header, Footer, pdfStyles } from './SharedUI.js';
const simpleStyles = StyleSheet.create({
industrialPage: {
padding: 30,
paddingTop: 20,
backgroundColor: '#ffffff',
},
industrialNumber: {
fontSize: 60,
fontWeight: 'bold',
color: '#f1f5f9',
position: 'absolute',
top: -10,
right: 0,
zIndex: -1,
},
industrialSection: {
marginTop: 16,
paddingTop: 12,
flexDirection: 'row',
position: 'relative',
},
});
interface SimpleLayoutProps {
children: React.ReactNode;
pageNumber?: string;
icon?: string;
footerLogo?: string;
companyData: any;
bankData?: any;
showPageNumber?: boolean;
}
export const SimpleLayout = ({
children,
pageNumber,
icon,
footerLogo,
companyData,
bankData,
showPageNumber = true
}: SimpleLayoutProps) => {
return (
<PDFPage size="A4" style={[pdfStyles.page, simpleStyles.industrialPage]}>
<Header icon={icon} showAddress={false} />
{pageNumber && <PDFText style={simpleStyles.industrialNumber}>{pageNumber}</PDFText>}
<PDFView style={simpleStyles.industrialSection}>
<PDFView style={{ width: '100%' }}>
{children}
</PDFView>
</PDFView>
<Footer
logo={footerLogo}
companyData={companyData}
bankData={bankData}
showDetails={false}
showPageNumber={showPageNumber}
/>
</PDFPage>
);
};

View File

@@ -0,0 +1,218 @@
"use client";
import * as React from "react";
import {
View as PDFView,
Text as PDFText,
StyleSheet,
} from "@react-pdf/renderer";
import {
DocumentTitle,
IndustrialListItem,
IndustrialCard,
Divider,
COLORS,
FONT_SIZES,
} from "../SharedUI";
const styles = StyleSheet.create({
industrialTextLead: {
fontSize: FONT_SIZES.BODY,
color: COLORS.TEXT_MAIN,
lineHeight: 1.4,
marginBottom: 16,
},
industrialText: {
fontSize: FONT_SIZES.BODY,
color: COLORS.TEXT_DIM,
lineHeight: 1.4,
marginBottom: 12,
},
industrialGrid2: { flexDirection: "row" },
industrialCol: { width: "46%" },
});
export const AboutModule = () => (
<>
<DocumentTitle
title="Expertise & Profil"
subLines={["Entwicklung & Technischer Partner für den Mittelstand"]}
isHero={true}
/>
<Divider style={{ marginVertical: 16, backgroundColor: COLORS.GRID }} />
<PDFView style={{ marginTop: 24 }}>
<PDFText style={styles.industrialTextLead}>
Begleitung mittelständischer Unternehmen und Agenturen bei der
Realisierung anspruchsvoller Web-Projekte. Als Senior Software Developer
mit over 15 Jahren Erfahrung wird das gesamte technische Spektrum
abgedeckt von der Architektur bis zum fertigen Produkt.
</PDFText>
<PDFView style={[styles.industrialGrid2, { marginTop: 20 }]}>
<PDFView style={[styles.industrialCol, { marginRight: "8%" }]}>
<PDFText
style={[
styles.industrialText,
{ fontWeight: "bold", color: COLORS.CHARCOAL, marginBottom: 8 },
]}
>
Erfahrung & Substanz
</PDFText>
<PDFText style={styles.industrialText}>
Der Werdegang umfasst alle Ebenen der Webentwicklung: von der
Teamleitung in Kreativagenturen bis zur Softwareentwicklung für
internationale Konzerne.
</PDFText>
<PDFText style={styles.industrialText}>
Die Kenntnis komplexer Enterprise-Systeme wird mit der Agilität
kombiniert, die im Mittelstand gefordert ist. Dieses Wissen
ermöglicht den Bau von Lösungen, die technologisch auf Augenhöhe mit
Konzern-Standards sind, jedoch ohne unnötigen bürokratischen
Overhead auskommen.
</PDFText>
</PDFView>
<PDFView style={styles.industrialCol}>
<PDFText
style={[
styles.industrialText,
{ fontWeight: "bold", color: COLORS.CHARCOAL, marginBottom: 8 },
]}
>
Fokus Einzelentwicklung
</PDFText>
<PDFText style={styles.industrialText}>
Die Umsetzung erfolgt bewusst als spezialisierter Einzelentwickler.
Dies garantiert maximale Geschwindigkeit, direkte Kommunikationswege
und volle technologische Verantwortung.
</PDFText>
<PDFText style={styles.industrialText}>
Als direkter technischer Sparringspartner bleibt die Codebasis von
der ersten bis zur letzten Zeile transparent und wartbar. Diese
Unmittelbarkeit stellt sicher, dass Ergebnisse sowohl technisch
sauber als auch wirtschaftlich sinnvoll realisiert werden.
</PDFText>
</PDFView>
</PDFView>
<PDFView
style={{
marginTop: 32,
paddingVertical: 16,
borderTopWidth: 1,
borderTopColor: COLORS.GRID,
}}
>
<PDFText
style={[
styles.industrialText,
{ fontWeight: "bold", color: COLORS.CHARCOAL, marginBottom: 4 },
]}
>
Infrastruktur & Souveränität
</PDFText>
<PDFText style={styles.industrialText}>
Es wird keine instabile Prototyp-Software geliefert, sondern
produktionsreife Systeme, die technisch skalierbar bleiben. Die
Codebasis folgt modernen Standards bei wachsenden Ansprüchen oder
dem Wechsel zu einer Agentur kann der Quellcode jederzeit nahtlos
übernommen und weitergeführt werden.
</PDFText>
</PDFView>
</PDFView>
</>
);
export const CrossSellModule = ({ state }: any) => {
const isWebsite = state.projectType === "website";
const title = isWebsite ? "Weitere Potenziale" : "Websites & Ökosysteme";
const subtitle = isWebsite
? "Automatisierung und Prozessoptimierung"
: "Technische Infrastruktur ohne Kompromisse";
return (
<>
<DocumentTitle title={title} subLines={[subtitle]} isHero={true} />
<Divider style={{ marginVertical: 16, backgroundColor: COLORS.GRID }} />
<PDFView style={[styles.industrialGrid2, { marginTop: 16 }]}>
{isWebsite ? (
<>
<PDFView style={[styles.industrialCol, { marginRight: "8%" }]}>
<PDFText style={styles.industrialTextLead}>
Über die klassische Webpräsenz hinaus werden maßgeschneiderte
Lösungen zur Automatisierung von Routine-Prozessen angeboten.
Dies ermöglicht eine signifikante Effizienzsteigerung im
Tagesgeschäft.
</PDFText>
<PDFText style={[styles.industrialText, { fontWeight: "bold" }]}>
Keine Abos. Keine komplexen neuen Systeme. Gezielte
Zeitersparnis.
</PDFText>
<PDFView
style={{
marginTop: 24,
padding: 16,
backgroundColor: "#f8fafc",
borderLeftWidth: 2,
borderLeftColor: COLORS.GRID,
}}
>
<PDFText
style={[
styles.industrialText,
{
fontWeight: "bold",
color: COLORS.CHARCOAL,
marginBottom: 4,
},
]}
>
Individuelle Analyse
</PDFText>
<PDFText style={styles.industrialText}>
Spezifische Prozesse werden auf technisches
Automatisierungspotenzial untersucht. Das Ergebnis liefert
Klarheit über die wirtschaftliche Sinnhaftigkeit einer
Umsetzung.
</PDFText>
</PDFView>
</PDFView>
<PDFView style={styles.industrialCol}>
<IndustrialCard title="DOKUMENT-AUTOMATION">
<PDFText style={styles.industrialText}>
Erstellung von PDF-Angeboten, Berichten oder Protokollen in
Sekunden statt Stunden.
</PDFText>
</IndustrialCard>
<IndustrialCard title="EXCEL-LOGIK">
<PDFText style={styles.industrialText}>
Intelligente Tabellen und automatisierte Auswertungen
bestehender Datensätze.
</PDFText>
</IndustrialCard>
<IndustrialCard title="KI-ASSISTENZ">
<PDFText style={styles.industrialText}>
Effiziente Verarbeitung von analogen Dokumenten oder
handschriftlichen Notizen mittels KI.
</PDFText>
</IndustrialCard>
</PDFView>
</>
) : (
<PDFView style={{ width: "100%" }}>
<PDFText style={styles.industrialTextLead}>
Bereitstellung einer stabilen technischen Basis ohne
Abhängigkeiten von Baukasten-Systemen oder Agenturen.
</PDFText>
<PDFText style={styles.industrialText}>
Entwicklung performanter Frontends und skalierbarer Backends. Die
Auslieferung erfolgt als kontrollierbarer und nachhaltiger
Quellcode.
</PDFText>
</PDFView>
)}
</PDFView>
</>
);
};

View File

@@ -0,0 +1,69 @@
"use client";
import * as React from "react";
import {
View as PDFView,
Text as PDFText,
StyleSheet,
} from "@react-pdf/renderer";
import { DocumentTitle, COLORS, FONT_SIZES } from "../SharedUI";
const styles = StyleSheet.create({
section: { marginBottom: 24 },
sectionTitle: {
fontSize: FONT_SIZES.LABEL,
fontWeight: "bold",
marginBottom: 8,
color: COLORS.CHARCOAL,
},
visionText: {
fontSize: FONT_SIZES.BODY,
color: COLORS.TEXT_MAIN,
lineHeight: 1.4,
textAlign: "justify",
},
});
export const BriefingModule = ({ state }: any) => (
<>
<DocumentTitle title="Projektdetails" isHero={true} />
{state.briefingSummary && (
<PDFView style={styles.section}>
<PDFText style={styles.sectionTitle}>Briefing Analyse</PDFText>
<PDFText
style={{
fontSize: FONT_SIZES.BODY,
color: COLORS.TEXT_MAIN,
lineHeight: 1.6,
textAlign: "justify",
}}
>
{state.briefingSummary}
</PDFText>
</PDFView>
)}
{state.designVision && (
<PDFView
style={[
styles.section,
{
padding: 12,
borderLeftWidth: 2,
borderLeftColor: COLORS.DIVIDER,
backgroundColor: COLORS.GRID,
},
]}
>
<PDFText
style={[
styles.sectionTitle,
{ color: COLORS.CHARCOAL, marginBottom: 4 },
]}
>
Strategische Vision
</PDFText>
<PDFText style={styles.visionText}>{state.designVision}</PDFText>
</PDFView>
)}
</>
);

View File

@@ -0,0 +1,97 @@
"use client";
import * as React from "react";
import {
View as PDFView,
Text as PDFText,
StyleSheet,
} from "@react-pdf/renderer";
import {
DocumentTitle,
COLORS,
FONT_SIZES,
} from "../SharedUI.js";
const styles = StyleSheet.create({
section: { marginBottom: 24 },
moduleLabel: {
fontSize: FONT_SIZES.LABEL,
fontWeight: "bold",
color: COLORS.CHARCOAL,
letterSpacing: 0.5,
marginBottom: 4,
},
moduleDesc: {
fontSize: FONT_SIZES.SMALL,
color: COLORS.TEXT_DIM,
lineHeight: 1.5,
},
ledgerRow: {
paddingVertical: 14,
borderBottomWidth: 1,
borderBottomColor: COLORS.GRID,
flexDirection: "row",
alignItems: "flex-start",
},
ledgerPrice: {
fontSize: FONT_SIZES.BODY,
fontWeight: "bold",
color: COLORS.CHARCOAL,
},
ledgerUnit: {
fontSize: FONT_SIZES.TINY,
color: COLORS.TEXT_LIGHT,
marginLeft: 2,
},
});
export const ClosingModule = () => (
<>
<DocumentTitle title="Abschluss & Kontakt" isHero={true} />
<PDFView style={styles.section}>
<PDFText
style={[
styles.moduleLabel,
{ fontSize: FONT_SIZES.HEADING, marginBottom: 12 },
]}
>
Vielen Dank für Ihr Interesse!
</PDFText>
<PDFText style={styles.moduleDesc}>
Die aufgeführten Positionen stellen eine detaillierte Schätzung auf
Basis unseres aktuellen Stands dar. Sollten sich Anforderungen ändern
oder Sie Fragen zu einzelnen Details haben, lassen Sie uns die
Positionen gerne gemeinsam besprechen.
</PDFText>
<PDFView
style={{
marginTop: 24,
padding: 16,
backgroundColor: COLORS.GRID,
borderLeftWidth: 2,
borderLeftColor: COLORS.DIVIDER,
}}
>
<PDFText style={[styles.moduleLabel, { marginBottom: 8 }]}>
Haben Sie Fragen?
</PDFText>
<PDFText style={styles.moduleDesc}>
Ich erkläre Ihnen gerne noch einmal persönlich, was die technische
Umsetzung für Ihr Projekt bedeutet und wie wir die nächsten Schritte
gemeinsam gehen können.
</PDFText>
<PDFView style={{ marginTop: 16 }}>
<PDFText style={styles.moduleLabel}>Kontakt:</PDFText>
<PDFText
style={[
styles.moduleDesc,
{ color: COLORS.CHARCOAL, fontWeight: "bold" },
]}
>
Marc Mintel marc@mintel.me
</PDFText>
</PDFView>
</PDFView>
</PDFView>
</>
);

View File

@@ -0,0 +1,159 @@
"use client";
import * as React from "react";
import {
View as PDFView,
Text as PDFText,
StyleSheet,
} from "@react-pdf/renderer";
import { DocumentTitle, COLORS, FONT_SIZES } from "../SharedUI.js";
const styles = StyleSheet.create({
table: { marginTop: 12 },
tableHeader: {
flexDirection: "row",
paddingBottom: 8,
borderBottomWidth: 1,
borderBottomColor: COLORS.CHARCOAL,
marginBottom: 12,
},
tableRow: {
flexDirection: "row",
paddingVertical: 10,
borderBottomWidth: 1,
borderBottomColor: COLORS.GRID,
alignItems: "flex-start",
},
colPos: { width: "8%" },
colDesc: { width: "62%" },
colQty: { width: "10%", textAlign: "center" },
colPrice: { width: "20%", textAlign: "right" },
headerText: {
fontSize: FONT_SIZES.TINY,
fontWeight: "bold",
textTransform: "uppercase",
letterSpacing: 1,
color: COLORS.TEXT_DIM,
},
posText: { fontSize: FONT_SIZES.TINY, color: COLORS.TEXT_LIGHT },
itemTitle: {
fontSize: FONT_SIZES.LABEL,
fontWeight: "bold",
color: COLORS.CHARCOAL,
marginBottom: 4,
},
itemDesc: {
fontSize: FONT_SIZES.SMALL,
color: COLORS.TEXT_DIM,
lineHeight: 1.4,
},
priceText: {
fontSize: FONT_SIZES.BODY,
fontWeight: "bold",
color: COLORS.CHARCOAL,
},
summaryContainer: {
borderTopWidth: 1,
borderTopColor: COLORS.CHARCOAL,
paddingTop: 8,
},
summaryRow: {
flexDirection: "row",
justifyContent: "flex-end",
paddingVertical: 4,
alignItems: "baseline",
},
summaryLabel: {
fontSize: FONT_SIZES.TINY,
color: COLORS.TEXT_DIM,
textTransform: "uppercase",
letterSpacing: 1,
fontWeight: "bold",
marginRight: 12,
},
summaryValue: {
fontSize: FONT_SIZES.BODY,
fontWeight: "bold",
width: 100,
textAlign: "right",
color: COLORS.CHARCOAL,
},
totalRow: {
flexDirection: "row",
justifyContent: "flex-end",
paddingTop: 12,
marginTop: 8,
borderTopWidth: 2,
borderTopColor: COLORS.CHARCOAL,
alignItems: "baseline",
},
});
export const EstimationModule = ({
state,
positions,
totalPrice,
date,
}: any) => (
<>
<DocumentTitle
title="Kostenschätzung"
subLines={[
`Datum: ${date}`,
`Projekt: ${state.projectType === "website" ? "Website" : "Web App"}`,
]}
isHero={true}
/>
<PDFView style={styles.table}>
<PDFView style={styles.tableHeader}>
<PDFText style={[styles.headerText, styles.colPos]}>Pos</PDFText>
<PDFText style={[styles.headerText, styles.colDesc]}>
Beschreibung
</PDFText>
<PDFText style={[styles.headerText, styles.colQty]}>Menge</PDFText>
<PDFText style={[styles.headerText, styles.colPrice]}>Betrag</PDFText>
</PDFView>
{positions.map((item: any, i: number) => (
<PDFView key={i} style={styles.tableRow} wrap={false}>
<PDFText style={[styles.posText, styles.colPos]}>
{item.pos.toString().padStart(2, "0")}
</PDFText>
<PDFView style={styles.colDesc}>
<PDFText style={styles.itemTitle}>{item.title}</PDFText>
<PDFText style={styles.itemDesc}>
{state.positionDescriptions?.[item.title] || item.desc}
</PDFText>
</PDFView>
<PDFText style={[styles.posText, styles.colQty]}>{item.qty}</PDFText>
<PDFText style={[styles.priceText, styles.colPrice]}>
{item.price > 0
? `${item.price.toLocaleString("de-DE")}`
: "n. A."}
</PDFText>
</PDFView>
))}
</PDFView>
<PDFView style={styles.summaryContainer} wrap={false}>
<PDFView style={styles.summaryRow}>
<PDFText style={styles.summaryLabel}>Nettobetrag</PDFText>
<PDFText style={styles.summaryValue}>
{totalPrice.toLocaleString("de-DE")}
</PDFText>
</PDFView>
<PDFView style={styles.summaryRow}>
<PDFText style={styles.summaryLabel}>Umsatzsteuer (19%)</PDFText>
<PDFText style={styles.summaryValue}>
{(totalPrice * 0.19).toLocaleString("de-DE")}
</PDFText>
</PDFView>
<PDFView style={styles.totalRow}>
<PDFText style={styles.summaryLabel}>Gesamtbetrag (Brutto)</PDFText>
<PDFText
style={[styles.summaryValue, { fontSize: FONT_SIZES.HEADING }]}
>
{(totalPrice * 1.19).toLocaleString("de-DE")}
</PDFText>
</PDFView>
</PDFView>
</>
);

View File

@@ -0,0 +1,70 @@
"use client";
import * as React from "react";
import {
View as PDFView,
Text as PDFText,
Image as PDFImage,
StyleSheet,
} from "@react-pdf/renderer";
import { COLORS, FONT_SIZES } from "../SharedUI.js";
const styles = StyleSheet.create({
titlePage: {
flex: 1,
padding: 60,
justifyContent: "center",
alignItems: "center",
backgroundColor: COLORS.WHITE,
},
titleBrandIcon: {
width: 80,
height: 80,
backgroundColor: COLORS.CHARCOAL,
borderRadius: 16,
alignItems: "center",
justifyContent: "center",
marginBottom: 40,
},
brandIconText: {
fontSize: 40,
color: COLORS.WHITE,
fontWeight: "bold",
},
titleProjectName: {
fontSize: FONT_SIZES.HERO,
fontWeight: "bold",
color: COLORS.CHARCOAL,
marginBottom: 16,
textAlign: "center",
maxWidth: "85%",
lineHeight: 1.2,
},
titleDate: {
fontSize: FONT_SIZES.BODY,
color: COLORS.TEXT_LIGHT,
marginTop: 40,
},
});
export const FrontPageModule = ({ state, headerIcon, date }: any) => {
const fullTitle = `Digitale Webpräsenz für\n${state.companyName || "Ihr Projekt"}`;
const fontSize = fullTitle.length > 60 ? 14 : fullTitle.length > 40 ? 18 : 22;
return (
<PDFView style={styles.titlePage}>
<PDFView style={styles.titleBrandIcon}>
{headerIcon ? (
<PDFImage src={headerIcon} style={{ width: 40, height: 40 }} />
) : (
<PDFText style={styles.brandIconText}>M</PDFText>
)}
</PDFView>
<PDFText style={[styles.titleProjectName, { fontSize }]}>
{fullTitle}
</PDFText>
<PDFView style={{ marginBottom: 40 }} />
<PDFText style={styles.titleDate}>{date} | Marc Mintel</PDFText>
</PDFView>
);
};

View File

@@ -0,0 +1,125 @@
"use client";
import * as React from "react";
import {
View as PDFView,
Text as PDFText,
StyleSheet,
} from "@react-pdf/renderer";
import { DocumentTitle, COLORS, FONT_SIZES } from "../SharedUI";
const styles = StyleSheet.create({
section: { marginBottom: 32 },
intro: {
fontSize: FONT_SIZES.BODY,
color: COLORS.TEXT_DIM,
lineHeight: 1.4,
marginBottom: 24,
textAlign: "justify",
},
sitemapTree: { marginTop: 8 },
rootNode: {
padding: 12,
backgroundColor: COLORS.GRID,
marginBottom: 20,
borderLeftWidth: 2,
borderLeftColor: COLORS.CHARCOAL,
},
rootTitle: {
fontSize: FONT_SIZES.HEADING,
fontWeight: "bold",
color: COLORS.CHARCOAL,
letterSpacing: 0.5,
},
categorySection: { marginBottom: 20 },
categoryHeader: {
flexDirection: "row",
alignItems: "center",
paddingBottom: 6,
borderBottomWidth: 1,
borderBottomColor: COLORS.BLUEPRINT,
marginBottom: 10,
},
categoryIcon: {
width: 8,
height: 8,
backgroundColor: COLORS.GRID,
borderInlineWidth: 1,
borderColor: COLORS.DIVIDER,
marginRight: 10,
},
categoryTitle: {
fontSize: FONT_SIZES.BODY,
fontWeight: "bold",
color: COLORS.CHARCOAL,
textTransform: "uppercase",
letterSpacing: 1,
},
pagesGrid: { flexDirection: "row", flexWrap: "wrap" },
pageCard: {
width: "48%",
marginRight: "2%",
marginBottom: 12,
padding: 10,
borderWidth: 1,
borderColor: COLORS.GRID,
backgroundColor: "#fafafa",
},
pageTitle: {
fontSize: FONT_SIZES.BODY,
fontWeight: "bold",
color: COLORS.TEXT_MAIN,
marginBottom: 4,
},
pageDesc: {
fontSize: FONT_SIZES.TINY,
color: COLORS.TEXT_DIM,
lineHeight: 1.3,
},
});
export const SitemapModule = ({ state }: any) => (
<>
<DocumentTitle title="Informationsarchitektur" isHero={true} />
<PDFView style={styles.section}>
<PDFText style={styles.intro}>
Die folgende Struktur definiert die logische Hierarchie und
Benutzerführung. Sie dient als Bauplan für die technische Umsetzung und
stellt sicher, dass alle relevanten Geschäftsbereiche intuitiv
auffindbar sind.
</PDFText>
<PDFView style={styles.sitemapTree}>
<PDFView style={styles.rootNode}>
<PDFText style={styles.rootTitle}>Seitenstruktur</PDFText>
</PDFView>
{state.sitemap?.map((cat: any, i: number) => (
<PDFView key={i} style={styles.categorySection} wrap={false}>
<PDFView style={styles.categoryHeader}>
<PDFView style={styles.categoryIcon} />
<PDFText style={styles.categoryTitle}>{cat.category}</PDFText>
</PDFView>
<PDFView style={styles.pagesGrid}>
{cat.pages.map((p: any, j: number) => (
<PDFView
key={j}
style={[
styles.pageCard,
j % 2 === 1 ? { marginRight: 0 } : {},
]}
>
<PDFText style={styles.pageTitle}>{p.title}</PDFText>
{p.desc && (
<PDFText style={styles.pageDesc}>{p.desc}</PDFText>
)}
</PDFView>
))}
</PDFView>
</PDFView>
))}
</PDFView>
</PDFView>
</>
);

View File

@@ -0,0 +1,125 @@
"use client";
import * as React from "react";
import {
View as PDFView,
Text as PDFText,
StyleSheet,
} from "@react-pdf/renderer";
import { DocumentTitle, COLORS, FONT_SIZES } from "../SharedUI.js";
const styles = StyleSheet.create({
section: { marginBottom: 24 },
ledgerRow: {
paddingVertical: 14,
borderBottomWidth: 1,
borderBottomColor: COLORS.GRID,
flexDirection: "row",
alignItems: "flex-start",
},
moduleLabel: {
fontSize: FONT_SIZES.LABEL,
fontWeight: "bold",
color: COLORS.CHARCOAL,
letterSpacing: 0.5,
marginBottom: 4,
},
moduleDesc: {
fontSize: FONT_SIZES.SMALL,
color: COLORS.TEXT_DIM,
lineHeight: 1.5,
},
ledgerPrice: {
fontSize: FONT_SIZES.BODY,
fontWeight: "bold",
color: COLORS.CHARCOAL,
},
ledgerUnit: {
fontSize: FONT_SIZES.TINY,
color: COLORS.TEXT_LIGHT,
marginLeft: 2,
},
});
export const TransparenzModule = ({ pricing }: any) => {
const sorglosPrice = (pricing.HOSTING_MONTHLY || 250) * 12;
return (
<>
<DocumentTitle title="Preis-Transparenz & Modell" isHero={true} />
<PDFView style={styles.section}>
<PDFView style={{ borderTopWidth: 1, borderTopColor: COLORS.CHARCOAL }}>
{[
{
l: "Fundament",
d: "Bereitstellung der techn. Infrastruktur & System-Umgebung.",
p: pricing.BASE_WEBSITE,
},
{
l: "Einzelseiten",
d: "Individuelle Gestaltung, Layout & responsive Struktur.",
p: pricing.PAGE,
unit: "/ Stk",
},
{
l: "Core Features",
d: "Geschlossene Datensysteme mit eigener Datenstruktur.",
p: pricing.FEATURE,
unit: "/ Stk",
},
{
l: "Logik & Funktionen",
d: "Interaktive Funktions-Bausteine & Prozess-Logik.",
p: pricing.FUNCTION,
unit: "/ Stk",
},
{
l: "Schnittstellen",
d: "Synchronisation mit externen Zielsystemen.",
p: pricing.API_INTEGRATION,
unit: "/ Stk",
},
{
l: "Sprachversionen",
d: "Skalierung der System-Architektur auf Zweit-Sprachen.",
p: "+20%",
},
{
l: "Initial-Pflege",
d: "Konvertierung & Aufbereitung von Bestandsdaten.",
p: pricing.NEW_DATASET,
unit: "/ Stk",
},
{
l: "Sorglos Betrieb",
d: "Hosting, Instandhaltung, Security & techn. Support.",
p: sorglosPrice,
unit: "/ Jahr",
},
].map((item: any, i: number) => (
<PDFView key={i} style={styles.ledgerRow}>
<PDFView style={{ width: "25%" }}>
<PDFText style={styles.moduleLabel}>
{item.l.toUpperCase()}
</PDFText>
</PDFView>
<PDFView style={{ width: "50%" }}>
<PDFText style={styles.moduleDesc}>{item.d}</PDFText>
</PDFView>
<PDFView style={{ width: "25%", alignItems: "flex-end" }}>
<PDFText style={styles.ledgerPrice}>
{typeof item.p === "number"
? `${item.p.toLocaleString("de-DE")}`
: item.p}
{item.unit && (
<PDFText style={styles.ledgerUnit}> {item.unit}</PDFText>
)}
</PDFText>
</PDFView>
</PDFView>
))}
</PDFView>
</PDFView>
</>
);
};

View File

@@ -0,0 +1,15 @@
export * from "./logic/pricing/types.js";
export * from "./logic/pricing/constants.js";
export * from "./logic/pricing/calculator.js";
export * from "./components/EstimationPDF.js";
export * from "./components/pdf/SimpleLayout.js";
export * from "./components/pdf/SharedUI.js";
export * from "./components/pdf/modules/FrontPageModule.js";
export * from "./components/pdf/modules/BriefingModule.js";
export * from "./components/pdf/modules/SitemapModule.js";
export * from "./components/pdf/modules/EstimationModule.js";
export * from "./components/pdf/modules/CommonModules.js";
export * from "./components/pdf/modules/BrandingModules.js";
export * from "./components/pdf/modules/TransparenzModule.js";
export * from "./components/AgbsPDF.js";
export * from "./components/CombinedQuotePDF.js";

View File

@@ -0,0 +1,224 @@
import { FormState, Position, Totals } from "./types.js";
import {
FEATURE_LABELS,
FUNCTION_LABELS,
API_LABELS,
PAGE_LABELS,
} from "./constants.js";
export function calculateTotals(state: FormState, pricing: any): Totals {
if (state.projectType !== "website") {
return {
totalPrice: 0,
monthlyPrice: 0,
totalPagesCount: 0,
totalFeatures: 0,
totalFunctions: 0,
totalApis: 0,
languagesCount: 0,
};
}
const sitemapPagesCount =
state.sitemap?.reduce(
(acc: number, cat: any) => acc + (cat.pages?.length || 0),
0,
) || 0;
const totalPagesCount = Math.max(
(state.selectedPages?.length || 0) +
(state.otherPages?.length || 0) +
(state.otherPagesCount || 0),
sitemapPagesCount,
);
const totalFeatures =
(state.features?.length || 0) +
(state.otherFeatures?.length || 0) +
(state.otherFeaturesCount || 0);
const totalFunctions =
(state.functions?.length || 0) +
(state.otherFunctions?.length || 0) +
(state.otherFunctionsCount || 0);
const totalApis =
(state.apiSystems?.length || 0) +
(state.otherTech?.length || 0) +
(state.otherTechCount || 0);
let total = pricing.BASE_WEBSITE;
total += totalPagesCount * pricing.PAGE;
total += totalFeatures * pricing.FEATURE;
total += totalFunctions * pricing.FUNCTION;
total += totalApis * pricing.API_INTEGRATION;
total += (state.newDatasets || 0) * pricing.NEW_DATASET;
if (state.cmsSetup) {
total += Math.max(1, totalFeatures) * pricing.CMS_CONNECTION_PER_FEATURE;
}
const languagesCount = state.languagesList?.length || 1;
if (languagesCount > 1) {
total *= 1 + (languagesCount - 1) * 0.2;
}
const monthlyPrice =
pricing.HOSTING_MONTHLY +
(state.storageExpansion || 0) * pricing.STORAGE_EXPANSION_MONTHLY;
return {
totalPrice: Math.round(total),
monthlyPrice: Math.round(monthlyPrice),
totalPagesCount,
totalFeatures,
totalFunctions,
totalApis,
languagesCount,
};
}
export function calculatePositions(state: FormState, pricing: any): Position[] {
const positions: Position[] = [];
let pos = 1;
if (state.projectType === "website") {
positions.push({
pos: pos++,
title: "Das technische Fundament",
desc: "Projekt-Setup, Infrastruktur, Hosting-Bereitstellung, Grundstruktur & Design-Vorlage, technisches SEO-Basics, Analytics.",
qty: 1,
price: pricing.BASE_WEBSITE,
});
const sitemapPagesCount =
state.sitemap?.reduce(
(acc: number, cat: any) => acc + (cat.pages?.length || 0),
0,
) || 0;
const totalPagesCount = Math.max(
(state.selectedPages?.length || 0) +
(state.otherPages?.length || 0) +
(state.otherPagesCount || 0),
sitemapPagesCount,
);
const allPages = [
...(state.selectedPages || []).map((p: string) => PAGE_LABELS[p] || p),
...(state.otherPages || []),
...(state.sitemap?.flatMap((cat: any) =>
cat.pages?.map((p: any) => p.title),
) || []),
];
// Deduplicate labels
const uniquePages = Array.from(new Set(allPages));
positions.push({
pos: pos++,
title: "Individuelle Seiten",
desc: `Gestaltung und Umsetzung von ${totalPagesCount} individuellen Seiten-Layouts (${uniquePages.join(", ")}).`,
qty: totalPagesCount,
price: totalPagesCount * pricing.PAGE,
});
if ((state.features?.length || 0) > 0 || (state.otherFeatures?.length || 0) > 0) {
const allFeatures = [
...(state.features || []).map((f: string) => FEATURE_LABELS[f] || f),
...(state.otherFeatures || []),
];
positions.push({
pos: pos++,
title: "System-Module (Features)",
desc: `Implementierung funktionaler Bereiche: ${allFeatures.join(", ")}. Inklusive Datenstruktur und Darstellung.`,
qty: allFeatures.length,
price: allFeatures.length * pricing.FEATURE,
});
}
if ((state.functions?.length || 0) > 0 || (state.otherFunctions?.length || 0) > 0) {
const allFunctions = [
...(state.functions || []).map((f: string) => FUNCTION_LABELS[f] || f),
...(state.otherFunctions || []),
];
positions.push({
pos: pos++,
title: "Logik-Funktionen",
desc: `Implementierung technischer Logik: ${allFunctions.join(", ")}.`,
qty: allFunctions.length,
price: allFunctions.length * pricing.FUNCTION,
});
}
if ((state.apiSystems?.length || 0) > 0 || (state.otherTech?.length || 0) > 0) {
const allApis = [
...(state.apiSystems || []).map((a: string) => API_LABELS[a] || a),
...(state.otherTech || []),
];
positions.push({
pos: pos++,
title: "Schnittstellen (API)",
desc: `Anbindung externer Systeme zur Datensynchronisation: ${allApis.join(", ")}.`,
qty: allApis.length,
price: allApis.length * pricing.API_INTEGRATION,
});
}
if (state.cmsSetup) {
const totalFeatures =
(state.features?.length || 0) +
(state.otherFeatures?.length || 0) +
(state.otherFeaturesCount || 0);
const qty = Math.max(1, totalFeatures);
positions.push({
pos: pos++,
title: "Inhalts-Verwaltung",
desc: "Anbindung der System-Module an das Redaktions-System zur eigenständigen Pflege von Inhalten.",
qty: qty,
price: qty * pricing.CMS_CONNECTION_PER_FEATURE,
});
}
if (state.newDatasets > 0) {
positions.push({
pos: pos++,
title: "Inhaltliche Initial-Pflege",
desc: `Manuelle Übernahme und Aufbereitung von ${state.newDatasets} Datensätzen (Produkte, Artikel) in das Zielsystem.`,
qty: state.newDatasets,
price: state.newDatasets * pricing.NEW_DATASET,
});
}
const languagesCount = state.languagesList?.length || 1;
if (languagesCount > 1) {
const subtotal = positions.reduce((sum, p) => sum + p.price, 0);
const factorPrice = subtotal * ((languagesCount - 1) * 0.2);
positions.push({
pos: pos++,
title: "Mehrsprachigkeit",
desc: `Erweiterung des Systems auf ${languagesCount} Sprachen (Struktur & Logik).`,
qty: languagesCount,
price: Math.round(factorPrice),
});
}
const monthlyRate =
pricing.HOSTING_MONTHLY +
state.storageExpansion * pricing.STORAGE_EXPANSION_MONTHLY;
positions.push({
pos: pos++,
title: "Sorglos Betrieb (1 Jahr)",
desc: `Inklusive 1 Jahr Sicherung des technischen Betriebs, Hosting, Instandhaltung, Sicherheits-Updates und techn. Support gemäß AGB Punkt 7a.`,
qty: 1,
price: monthlyRate * 12,
});
} else {
positions.push({
pos: pos++,
title: "Web App / Software Entwicklung",
desc: "Individuelle Software-Entwicklung nach Aufwand. Abrechnung erfolgt auf Stundenbasis.",
qty: 1,
price: 0,
});
}
return positions;
}

View File

@@ -0,0 +1,332 @@
import { FormState } from "./types.js";
export const PRICING = {
BASE_WEBSITE: 5440, // Updated to match AI prompt requirement in Pass 1
PAGE: 600,
FEATURE: 1500,
FUNCTION: 800,
NEW_DATASET: 450,
HOSTING_MONTHLY: 250,
STORAGE_EXPANSION_MONTHLY: 10,
CMS_SETUP: 1500,
CMS_CONNECTION_PER_FEATURE: 1500,
API_INTEGRATION: 800,
APP_HOURLY: 120,
};
export const initialState: FormState = {
projectType: "website",
// Company
companyName: "",
employeeCount: "",
// Existing Presence
existingWebsite: "",
socialMedia: [],
socialMediaUrls: {},
existingDomain: "",
wishedDomain: "",
// Project
websiteTopic: "",
selectedPages: ["Home"],
otherPages: [],
otherPagesCount: 0,
features: [],
otherFeatures: [],
otherFeaturesCount: 0,
functions: [],
otherFunctions: [],
otherFunctionsCount: 0,
apiSystems: [],
otherTech: [],
otherTechCount: 0,
assets: [],
otherAssets: [],
otherAssetsCount: 0,
newDatasets: 0,
cmsSetup: false,
storageExpansion: 0,
name: "",
email: "",
role: "",
message: "",
sitemapFile: null,
contactFiles: [],
// Design
designVibe: "minimal",
colorScheme: ["#ffffff", "#f8fafc", "#0f172a"],
references: [],
designWishes: "",
// Maintenance
expectedAdjustments: "low",
languagesList: ["Deutsch"],
personName: "",
// Timeline
deadline: "flexible",
// Web App specific
targetAudience: "internal",
userRoles: [],
dataSensitivity: "standard",
platformType: "web-only",
// Meta
dontKnows: [],
visualStaging: "standard",
complexInteractions: "standard",
// AI generated / Post-processed
briefingSummary: "",
designVision: "",
positionDescriptions: {},
taxId: "",
sitemap: [],
};
export const PAGE_SAMPLES = [
{ id: "Home", label: "Startseite", desc: "Der erste Eindruck Ihrer Marke." },
{ id: "About", label: "Über uns", desc: "Ihre Geschichte und Ihr Team." },
{ id: "Services", label: "Leistungen", desc: "Übersicht Ihres Angebots." },
{ id: "Contact", label: "Kontakt", desc: "Anlaufstelle für Ihre Kunden." },
{
id: "Landing",
label: "Landingpage",
desc: "Optimiert für Marketing-Kampagnen.",
},
{ id: "Legal", label: "Rechtliches", desc: "Impressum & Datenschutz." },
];
export const FEATURE_OPTIONS = [
{
id: "blog_news",
label: "Blog / News",
desc: "Ein Bereich für aktuelle Beiträge und Neuigkeiten.",
},
{
id: "products",
label: "Produktbereich",
desc: "Katalog Ihrer Leistungen oder Produkte.",
},
{
id: "jobs",
label: "Karriere / Jobs",
desc: "Stellenanzeigen und Bewerbungsoptionen.",
},
{
id: "refs",
label: "Referenzen / Cases",
desc: "Präsentation Ihrer Projekte.",
},
{ id: "events", label: "Events / Termine", desc: "Veranstaltungskalender." },
];
export const FUNCTION_OPTIONS = [
{ id: "search", label: "Suche", desc: "Volltextsuche über alle Inhalte." },
{
id: "filter",
label: "Filter-Systeme",
desc: "Kategorisierung und Sortierung.",
},
{ id: "pdf", label: "PDF-Export", desc: "Automatisierte PDF-Erstellung." },
{
id: "forms",
label: "Individuelle Formular-Logik",
desc: "Smarte Validierung & mehrstufige Prozesse.",
},
];
export const API_OPTIONS = [
{
id: "crm",
label: "CRM System",
desc: "HubSpot, Salesforce, Pipedrive etc.",
},
{
id: "erp",
label: "ERP / Warenwirtschaft",
desc: "SAP, Microsoft Dynamics, Xentral etc.",
},
{
id: "stripe",
label: "Stripe / Payment",
desc: "Zahlungsabwicklung und Abonnements.",
},
{
id: "newsletter",
label: "Newsletter / Marketing",
desc: "Mailchimp, Brevo, ActiveCampaign etc.",
},
{
id: "ecommerce",
label: "E-Commerce / Shop",
desc: "Shopify, WooCommerce, Shopware Sync.",
},
{
id: "hr",
label: "HR / Recruiting",
desc: "Personio, Workday, Recruitee etc.",
},
{
id: "realestate",
label: "Immobilien",
desc: "OpenImmo, FlowFact, Immowelt Sync.",
},
{
id: "calendar",
label: "Termine / Booking",
desc: "Calendly, Shore, Doctolib etc.",
},
{
id: "social",
label: "Social Media Sync",
desc: "Automatisierte Posts oder Feeds.",
},
{
id: "maps",
label: "Google Maps / Places",
desc: "Standortsuche und Kartenintegration.",
},
{
id: "analytics",
label: "Custom Analytics",
desc: "Anbindung an spezialisierte Tracking-Tools.",
},
];
export const ASSET_OPTIONS = [
{
id: "existing_website",
label: "Bestehende Website",
desc: "Inhalte oder Struktur können übernommen werden.",
},
{ id: "logo", label: "Logo", desc: "Vektordatei Ihres Logos." },
{
id: "styleguide",
label: "Styleguide",
desc: "Farben, Schriften, Design-Vorgaben.",
},
{
id: "content_concept",
label: "Inhalts-Konzept",
desc: "Struktur und Texte sind bereits geplant.",
},
{
id: "media",
label: "Bild/Video-Material",
desc: "Professionelles Bildmaterial vorhanden.",
},
{ id: "icons", label: "Icons", desc: "Eigene Icon-Sets vorhanden." },
{
id: "illustrations",
label: "Illustrationen",
desc: "Eigene Illustrationen vorhanden.",
},
{
id: "fonts",
label: "Fonts",
desc: "Lizenzen für Hausschriften vorhanden.",
},
];
export const DESIGN_OPTIONS = [
{
id: "minimal",
label: "Minimalistisch",
desc: "Viel Weißraum, klare Typografie.",
},
{
id: "bold",
label: "Mutig & Laut",
desc: "Starke Kontraste, große Schriften.",
},
{
id: "nature",
label: "Natürlich",
desc: "Sanfte Erdtöne, organische Formen.",
},
{ id: "tech", label: "Technisch", desc: "Präzise Linien, dunkle Akzente." },
];
export const EMPLOYEE_OPTIONS = [
{ id: "1-5", label: "1-5 Mitarbeiter" },
{ id: "6-20", label: "6-20 Mitarbeiter" },
{ id: "21-100", label: "21-100 Mitarbeiter" },
{ id: "100+", label: "100+ Mitarbeiter" },
];
export const SOCIAL_MEDIA_OPTIONS = [
{ id: "instagram", label: "Instagram" },
{ id: "linkedin", label: "LinkedIn" },
{ id: "facebook", label: "Facebook" },
{ id: "twitter", label: "Twitter / X" },
{ id: "tiktok", label: "TikTok" },
{ id: "youtube", label: "YouTube" },
];
export const VIBE_LABELS: Record<string, string> = {
minimal: "Minimalistisch",
bold: "Mutig & Laut",
nature: "Natürlich",
tech: "Technisch",
};
export const DEADLINE_LABELS: Record<string, string> = {
asap: "So schnell wie möglich",
"2-3-months": "In 2-3 Monaten",
"3-6-months": "In 3-6 Monaten",
flexible: "Flexibel",
};
export const ASSET_LABELS: Record<string, string> = {
existing_website: "Bestehende Website",
logo: "Logo",
styleguide: "Styleguide",
content_concept: "Inhalts-Konzept",
media: "Bild/Video-Material",
icons: "Icons",
illustrations: "Illustrationen",
fonts: "Fonts",
};
export const FEATURE_LABELS: Record<string, string> = {
blog_news: "Blog / News",
products: "Produktbereich",
jobs: "Karriere / Jobs",
refs: "Referenzen / Cases",
events: "Events / Termine",
};
export const FUNCTION_LABELS: Record<string, string> = {
search: "Suche",
filter: "Filter-Systeme",
pdf: "PDF-Export",
forms: "Individuelle Formular-Logik",
members: "Mitgliederbereich",
calendar: "Event-Kalender",
multilang: "Mehrsprachigkeit",
chat: "Echtzeit-Chat",
};
export const API_LABELS: Record<string, string> = {
crm_erp: "CRM / ERP",
payment: "Payment",
marketing: "Marketing",
ecommerce: "E-Commerce",
maps: "Google Maps / Places",
social: "Social Media Sync",
analytics: "Custom Analytics",
};
export const SOCIAL_LABELS: Record<string, string> = {
instagram: "Instagram",
linkedin: "LinkedIn",
facebook: "Facebook",
twitter: "Twitter / X",
tiktok: "TikTok",
youtube: "YouTube",
};
export const PAGE_LABELS: Record<string, string> = {
Home: "Startseite",
About: "Über uns",
Services: "Leistungen",
Contact: "Kontakt",
Landing: "Landingpage",
Legal: "Impressum & Datenschutz",
};

View File

@@ -0,0 +1,89 @@
export type ProjectType = 'website' | 'web-app';
export interface FormState {
projectType: ProjectType;
// Company
companyName: string;
employeeCount: string;
// Existing Presence
existingWebsite: string;
socialMedia: string[];
socialMediaUrls: Record<string, string>;
existingDomain: string;
wishedDomain: string;
// Project
websiteTopic: string;
selectedPages: string[];
otherPages: string[];
otherPagesCount: number;
features: string[];
otherFeatures: string[];
otherFeaturesCount: number;
functions: string[];
otherFunctions: string[];
otherFunctionsCount: number;
apiSystems: string[];
otherTech: string[];
otherTechCount: number;
assets: string[];
otherAssets: string[];
otherAssetsCount: number;
newDatasets: number;
cmsSetup: boolean;
storageExpansion: number;
name: string;
email: string;
role: string;
message: string;
sitemapFile: any;
contactFiles: any[];
// Design
designVibe: string;
colorScheme: string[];
references: string[];
designWishes: string;
// Maintenance
expectedAdjustments: string;
languagesList: string[];
// Timeline
deadline: string;
// Web App specific
targetAudience: string;
userRoles: string[];
dataSensitivity: string;
platformType: string;
// Meta
dontKnows: string[];
visualStaging: string;
complexInteractions: string;
gridDontKnows?: Record<string, string>;
briefingSummary?: string;
companyAddress?: string;
companyPhone?: string;
personName?: string;
taxId?: string;
designVision?: string;
positionDescriptions?: Record<string, string>;
sitemap?: {
category: string;
pages: { title: string; desc: string }[];
}[];
}
export interface Position {
pos: number;
title: string;
desc: string;
qty: number;
price: number;
isRecurring?: boolean;
}
export interface Totals {
totalPrice: number;
monthlyPrice: number;
totalPagesCount: number;
totalFeatures: number;
totalFunctions: number;
totalApis: number;
languagesCount: number;
}

View File

@@ -0,0 +1,3 @@
export * from "./index.js";
export * from "./services/AcquisitionService.js";
export * from "./services/PdfEngine.js";

View File

@@ -0,0 +1,153 @@
import { CheerioCrawler } from "@crawlee/cheerio";
import axios from "axios";
import { FileCacheAdapter } from "../utils/cache/FileCacheAdapter.js";
import { initialState } from "../logic/pricing/constants.js";
import { FormState } from "../logic/pricing/types.js";
export interface AcquisitionResult {
state: FormState;
usage: {
prompt: number;
completion: number;
cost: number;
};
}
export class AcquisitionService {
private cache: FileCacheAdapter;
private openRouterKey: string;
constructor(openRouterKey: string) {
this.openRouterKey = openRouterKey;
this.cache = new FileCacheAdapter({ prefix: "acq_" });
}
async runFullSequence(url: string, briefing: string, comments?: string): Promise<AcquisitionResult> {
console.log(`🚀 Starting Acquisition Sequence for: ${url}`);
// 1. Crawl
const crawlData = await this.performCrawl(url);
// 2. Distill
const distilledContext = await this.distillCrawlContext(crawlData);
// 3. AI Estimation (using parts of the original ai-estimate logic)
// For brevity in this initial port, I'll implement a combined prompt strategy
// or keep the multi-pass if needed.
const result = await this.getAiEstimation(briefing, distilledContext, comments || null);
return result;
}
private async performCrawl(url: string): Promise<string> {
const pages: any[] = [];
const origin = new URL(url).origin;
const crawler = new CheerioCrawler({
maxRequestsPerCrawl: 15,
async requestHandler({ $, request, enqueueLinks }) {
const title = $("title").text();
const bodyText = $("body").text().replace(/\s+/g, " ").substring(0, 10000);
pages.push({
url: request.url,
content: `Title: ${title}\nText: ${bodyText}`,
});
await enqueueLinks({
limit: 10,
transformRequestFunction: (req) => {
try {
const reqUrl = new URL(req.url);
if (reqUrl.origin !== origin) return false;
if (reqUrl.pathname.match(/\.(pdf|zip|jpg|png|svg|webp)$/i)) return false;
return req;
} catch (_error) {
// Ignored - malformed URL in enqueueLinks
return false;
}
},
});
},
});
await crawler.run([url]);
return pages.map((p) => `--- PAGE: ${p.url} ---\n${p.content}`).join("\n\n");
}
private async distillCrawlContext(rawCrawl: string): Promise<string> {
const systemPrompt = `
You are a context distiller. Extract the "Company DNA" in 5-8 bullet points (GERMAN).
Focus on: Services, USPs, Target Audience, Tone.
`;
const resp = await axios.post(
"https://openrouter.ai/api/v1/chat/completions",
{
model: "google/gemini-3-flash-preview",
messages: [
{ role: "system", content: systemPrompt },
{ role: "user", content: `RAW_CRAWL:\n${rawCrawl.substring(0, 20000)}` },
],
},
{
headers: { Authorization: `Bearer ${this.openRouterKey}` },
}
);
return resp.data.choices[0].message.content;
}
private async getAiEstimation(briefing: string, context: string, comments: string | null): Promise<AcquisitionResult> {
// Porting a simplified version of Pass 1 and Pass 3 together for the "Audit"
const systemPrompt = `
You are a Digital Architect. Analyze the briefing and crawl context.
Generate a JSON state for a project estimation.
Language: GERMAN.
Format: ROOT LEVEL JSON matching FormState interface.
### PRICING RULES:
- Base: 5440 €
- Page: 600 €
- Feature: 1500 €
- Function/API: 800 €
Return ONLY the JSON.
`;
const userPrompt = `BRIEFING: ${briefing}\n\nCONTEXT: ${context}\n\nCOMMENTS: ${comments}`;
const resp = await axios.post(
"https://openrouter.ai/api/v1/chat/completions",
{
model: "google/gemini-3-flash-preview",
messages: [
{ role: "system", content: systemPrompt },
{ role: "user", content: userPrompt },
],
response_format: { type: "json_object" },
},
{
headers: { Authorization: `Bearer ${this.openRouterKey}` },
}
);
let state: FormState;
try {
state = JSON.parse(resp.data.choices[0].message.content);
} catch (_error) {
console.error("Failed to parse AI estimation JSON, returning initial state.");
state = initialState;
}
// Ensure it matches FormState defaults
const finalState = { ...initialState, ...state };
return {
state: finalState,
usage: {
prompt: resp.data.usage?.prompt_tokens || 0,
completion: resp.data.usage?.completion_tokens || 0,
cost: resp.data.usage?.cost || 0,
},
};
}
}

View File

@@ -0,0 +1,24 @@
import { renderToFile } from "@react-pdf/renderer";
import { createElement } from "react";
import { EstimationPDF } from "../components/EstimationPDF.js";
import { PRICING } from "../logic/pricing/constants.js";
import { calculateTotals } from "../logic/pricing/calculator.js";
export class PdfEngine {
constructor() { }
async generateEstimatePdf(state: any, outputPath: string): Promise<string> {
const totals = calculateTotals(state, PRICING);
await renderToFile(
createElement(EstimationPDF as any, {
state,
totalPrice: totals.totalPrice,
pricing: PRICING,
} as any) as any,
outputPath
);
return outputPath;
}
}

View File

@@ -0,0 +1,78 @@
import * as fs from 'node:fs/promises';
import * as path from 'node:path';
import { existsSync } from 'node:fs';
import * as crypto from 'node:crypto';
export class FileCacheAdapter {
private cacheDir: string;
private prefix: string;
private defaultTTL: number;
constructor(config?: { cacheDir?: string; prefix?: string; defaultTTL?: number }) {
this.cacheDir = config?.cacheDir || path.resolve(process.cwd(), '.cache');
this.prefix = config?.prefix || '';
this.defaultTTL = config?.defaultTTL || 3600;
if (!existsSync(this.cacheDir)) {
fs.mkdir(this.cacheDir, { recursive: true }).catch(() => { });
}
}
private sanitize(key: string): string {
const clean = key.replace(/[^a-z0-9]/gi, '_');
if (clean.length > 64) {
return crypto.createHash('md5').update(key).digest('hex');
}
return clean;
}
private getFilePath(key: string): string {
const safeKey = this.sanitize(`${this.prefix}${key}`).toLowerCase();
return path.join(this.cacheDir, `${safeKey}.json`);
}
async get<T>(key: string): Promise<T | null> {
const filePath = this.getFilePath(key);
try {
if (!existsSync(filePath)) return null;
const content = await fs.readFile(filePath, 'utf8');
const data = JSON.parse(content);
if (data.expiry && Date.now() > data.expiry) {
await this.del(key);
return null;
}
return data.value;
} catch (_error) {
return null; // Keeping original return type Promise<T | null>
}
}
async set<T>(key: string, value: T, ttl?: number): Promise<void> {
const filePath = this.getFilePath(key);
const effectiveTTL = ttl !== undefined ? ttl : this.defaultTTL;
const data = {
value,
expiry: effectiveTTL > 0 ? Date.now() + effectiveTTL * 1000 : null,
updatedAt: new Date().toISOString()
};
try {
await fs.writeFile(filePath, JSON.stringify(data, null, 2), 'utf8');
} catch (_error) {
// Ignore
}
}
async del(key: string): Promise<void> {
const filePath = this.getFilePath(key);
try {
if (existsSync(filePath)) {
await fs.unlink(filePath);
}
} catch (_error) {
// Ignored - best effort cleanup
}
}
}

View File

@@ -0,0 +1,24 @@
{
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "node",
"jsx": "react-jsx",
"declaration": true,
"declarationDir": "dist",
"emitDeclarationOnly": true,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"outDir": "dist"
},
"include": [
"src/**/*"
],
"exclude": [
"node_modules",
"dist",
"build.mjs"
]
}

File diff suppressed because one or more lines are too long

View File

@@ -1,18 +1,17 @@
{
"name": "people-manager",
"id": "people-manager",
"description": "Custom High-Fidelity People Management for Directus",
"icon": "person",
"version": "1.7.9",
"version": "1.8.2",
"type": "module",
"keywords": [
"directus",
"directus-extension",
"directus-extension-module"
],
"files": [
"dist"
],
"directus:extension": {
"id": "people-manager",
"type": "module",
"path": "index.js",
"source": "src/index.ts",
@@ -20,7 +19,7 @@
"name": "People Manager"
},
"scripts": {
"build": "directus-extension build && (cp -f dist/index.js index.js 2>/dev/null || true)",
"build": "directus-extension build && cp -f dist/index.js index.js 2>/dev/null || true",
"dev": "directus-extension build -w"
},
"devDependencies": {

View File

@@ -1,18 +1,356 @@
<template>
<private-view title="People Manager">
<div class="people-manager">
<h1>People Manager</h1>
<p>Modern Industrial People Management Interface</p>
<template #navigation>
<v-list nav>
<v-list-item @click="openCreateDrawer" clickable>
<v-list-item-icon>
<v-icon name="add" color="var(--theme--primary)" />
</v-list-item-icon>
<v-list-item-content>
<v-text-overflow text="Neue Person anlegen" />
</v-list-item-content>
</v-list-item>
<v-divider />
<v-list-item
v-for="person in people"
:key="person.id"
:active="selectedPerson?.id === person.id"
class="person-item"
clickable
@click="selectPerson(person)"
>
<v-list-item-icon>
<v-icon name="person" />
</v-list-item-icon>
<v-list-item-content>
<v-text-overflow :text="`${person.first_name} ${person.last_name}`" />
</v-list-item-content>
</v-list-item>
</v-list>
</template>
<div class="content-wrapper">
<v-notice v-if="feedback" :type="feedback.type" @close="feedback = null" dismissible>
{{ feedback.message }}
</v-notice>
<div v-if="!selectedPerson" class="empty-state">
<v-info title="Person auswählen" icon="person" center>
Wähle eine Person in der Navigation aus oder
<v-button x-small @click="openCreateDrawer">erstelle eine neue Person</v-button>.
</v-info>
</div>
<div v-else>
<header class="header">
<div class="header-left">
<h1 class="title">{{ selectedPerson.first_name }} {{ selectedPerson.last_name }}</h1>
<p class="subtitle">{{ selectedPerson.email || 'Keine E-Mail angegeben' }}</p>
</div>
<div class="header-right">
<v-button secondary rounded icon v-tooltip="'Person bearbeiten'" @click="openEditDrawer">
<v-icon name="edit" />
</v-button>
<v-button danger rounded icon v-tooltip="'Person löschen'" @click="deletePerson">
<v-icon name="delete" />
</v-button>
</div>
</header>
<v-divider />
<div class="details-grid">
<div class="detail-item">
<span class="label">Organisation / Firma</span>
<p class="value">{{ getCompanyName(selectedPerson) }}</p>
</div>
<div class="detail-item">
<span class="label">Telefon</span>
<p class="value">{{ selectedPerson.phone || '---' }}</p>
</div>
</div>
</div>
</div>
<!-- Create/Edit Drawer -->
<v-drawer
v-model="drawerActive"
:title="isEditing ? 'Person bearbeiten' : 'Neue Person anlegen'"
icon="person"
@cancel="drawerActive = false"
>
<template #default>
<div class="drawer-content">
<div class="form-section">
<div class="field">
<span class="label">Vorname</span>
<v-input v-model="form.first_name" placeholder="Vorname" autofocus />
</div>
<div class="field">
<span class="label">Nachname</span>
<v-input v-model="form.last_name" placeholder="Nachname" />
</div>
<div class="field">
<span class="label">E-Mail</span>
<v-input v-model="form.email" placeholder="email@beispiel.de" type="email" />
</div>
<div class="field">
<span class="label">Zentrale Firma</span>
<v-select
v-model="form.company"
:items="companyOptions"
placeholder="Bestehende Firma auswählen..."
/>
</div>
<div class="field">
<span class="label">Firma (Legacy / Neu)</span>
<v-input v-model="form.company_name" placeholder="z.B. Mintel" />
</div>
<div class="field">
<span class="label">Telefon</span>
<v-input v-model="form.phone" placeholder="+49 ..." />
</div>
</div>
<div class="drawer-actions">
<v-button primary block :loading="saving" @click="savePerson">
Person speichern
</v-button>
</div>
</div>
</template>
</v-drawer>
</private-view>
</template>
<script setup lang="ts">
// Logic will be added here
import { ref, onMounted, computed } from 'vue';
import { useApi } from '@directus/extensions-sdk';
const api = useApi();
const people = ref([]);
const companies = ref([]);
const selectedPerson = ref(null);
const feedback = ref(null);
const saving = ref(false);
const drawerActive = ref(false);
const isEditing = ref(false);
const form = ref({
id: null,
first_name: '',
last_name: '',
email: '',
company: null,
company_name: '',
phone: ''
});
const companyOptions = computed(() =>
companies.value.map(c => ({
text: c.name,
value: c.id
}))
);
function getCompanyName(person: any) {
if (!person) return '---';
if (person.company) {
return typeof person.company === 'object' ? person.company.name : (companies.value.find(c => c.id === person.company)?.name || person.company_name);
}
return person.company_name || '---';
}
async function fetchData() {
try {
const [peopleResp, companiesResp] = await Promise.all([
api.get('/items/people', {
params: {
sort: 'last_name',
fields: '*.*'
}
}),
api.get('/items/companies', {
params: { sort: 'name' }
})
]);
people.value = peopleResp.data.data;
companies.value = companiesResp.data.data;
} catch (error) {
console.error('Failed to fetch data:', error);
}
}
function selectPerson(person) {
selectedPerson.value = person;
}
function openCreateDrawer() {
isEditing.value = false;
form.value = {
id: null,
first_name: '',
last_name: '',
email: '',
company: null,
company_name: '',
phone: ''
};
drawerActive.value = true;
}
function openEditDrawer() {
isEditing.value = true;
const person = selectedPerson.value;
let companyId = null;
let companyName = person.company_name || '';
if (person.company) {
if (typeof person.company === 'object') {
companyId = person.company.id;
} else if (person.company.length === 36) { // Assume UUID
companyId = person.company;
} else {
companyName = person.company;
}
}
form.value = {
...person,
company: companyId,
company_name: companyName
};
drawerActive.value = true;
}
async function savePerson() {
if (!form.value.first_name || !form.value.last_name) {
feedback.value = { type: 'danger', message: 'Vor- und Nachname sind erforderlich.' };
return;
}
saving.value = true;
try {
if (isEditing.value) {
await api.patch(`/items/people/${form.value.id}`, form.value);
feedback.value = { type: 'success', message: 'Person aktualisiert!' };
} else {
await api.post('/items/people', form.value);
feedback.value = { type: 'success', message: 'Person angelegt!' };
}
drawerActive.value = false;
await fetchData();
if (isEditing.value) {
selectedPerson.value = people.value.find(p => p.id === form.value.id);
}
} catch (error) {
feedback.value = { type: 'danger', message: error.message };
} finally {
saving.value = false;
}
}
async function deletePerson() {
if (!confirm('Soll diese Person wirklich gelöscht werden?')) return;
try {
await api.delete(`/items/people/${selectedPerson.value.id}`);
feedback.value = { type: 'success', message: 'Person gelöscht.' };
selectedPerson.value = null;
await fetchData();
} catch (error) {
feedback.value = { type: 'danger', message: error.message };
}
}
onMounted(fetchData);
</script>
<style scoped>
.people-manager {
padding: 20px;
.content-wrapper {
padding: 32px;
height: 100%;
}
.header {
margin-bottom: 24px;
display: flex;
justify-content: space-between;
align-items: flex-end;
}
.title {
font-size: 24px;
font-weight: 800;
margin-bottom: 4px;
}
.subtitle {
color: var(--theme--foreground-subdued);
font-size: 14px;
}
.header-right {
display: flex;
gap: 12px;
}
.empty-state {
height: 100%;
display: flex;
align-items: center;
justify-content: center;
}
.details-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 32px;
margin-top: 32px;
}
.detail-item {
display: flex;
flex-direction: column;
gap: 8px;
}
.label {
font-size: 12px;
font-weight: 700;
text-transform: uppercase;
color: var(--theme--foreground-subdued);
letter-spacing: 0.5px;
}
.value {
font-size: 16px;
font-weight: 500;
}
.drawer-content {
padding: 24px;
display: flex;
flex-direction: column;
gap: 32px;
}
.form-section {
display: flex;
flex-direction: column;
gap: 20px;
}
.field {
display: flex;
flex-direction: column;
gap: 8px;
}
.drawer-actions {
margin-top: 24px;
}
</style>

View File

@@ -1,6 +1,6 @@
{
"name": "@mintel/tsconfig",
"version": "1.7.9",
"version": "1.8.2",
"publishConfig": {
"access": "public",
"registry": "https://npm.infra.mintel.me"

View File

@@ -0,0 +1,27 @@
{
"name": "unified-dashboard",
"description": "Unified Infrastructure Dashboard for Directus",
"icon": "dashboard",
"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": "*",
"name": "Unified Dashboard"
},
"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"
}
}

Some files were not shown because too many files have changed in this diff Show More