Compare commits

..

69 Commits

Author SHA1 Message Date
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
73b60f14a9 chore: release clean base image 1.7.10
All checks were successful
Monorepo Pipeline / ⚡ Prioritize Release (push) Successful in 1s
Monorepo Pipeline / 🧹 Lint (push) Successful in 1m3s
Monorepo Pipeline / 🧪 Test (push) Successful in 41s
Monorepo Pipeline / 🏗️ Build (push) Successful in 2m19s
Monorepo Pipeline / 🐳 Build Directus (Base) (push) Successful in 16s
Monorepo Pipeline / 🐳 Build Gatekeeper (Product) (push) Successful in 2m14s
Monorepo Pipeline / 🚀 Release (push) Successful in 2m43s
Monorepo Pipeline / 🐳 Build Build-Base (push) Successful in 21s
Monorepo Pipeline / 🐳 Build Production Runtime (push) Successful in 32s
2026-02-11 16:32:16 +01:00
b3f43c421f chore: manual version bump to 1.7.9
All checks were successful
Monorepo Pipeline / ⚡ Prioritize Release (push) Successful in 1s
Monorepo Pipeline / 🧹 Lint (push) Successful in 57s
Monorepo Pipeline / 🧪 Test (push) Successful in 50s
Monorepo Pipeline / 🏗️ Build (push) Successful in 1m49s
Monorepo Pipeline / 🐳 Build Directus (Base) (push) Successful in 57s
Monorepo Pipeline / 🚀 Release (push) Successful in 2m7s
Monorepo Pipeline / 🐳 Build Gatekeeper (Product) (push) Successful in 4m4s
Monorepo Pipeline / 🐳 Build Build-Base (push) Successful in 3m1s
Monorepo Pipeline / 🐳 Build Production Runtime (push) Successful in 34s
2026-02-11 16:02:51 +01:00
a2339f7106 fix: make directus extension build scripts more resilient
All checks were successful
Monorepo Pipeline / ⚡ Prioritize Release (push) Successful in 2s
Monorepo Pipeline / 🧹 Lint (push) Successful in 45s
Monorepo Pipeline / 🧪 Test (push) Successful in 52s
Monorepo Pipeline / 🏗️ Build (push) Successful in 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
2026-02-11 15:58:58 +01:00
e83a76f111 chore: trigger CI build after disk cleanup
Some checks failed
Monorepo Pipeline / ⚡ Prioritize Release (push) Successful in 1s
Monorepo Pipeline / 🧪 Test (push) Successful in 45s
Monorepo Pipeline / 🧹 Lint (push) Successful in 1m4s
Monorepo Pipeline / 🏗️ Build (push) Failing after 38s
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 15:56:16 +01:00
0096c18098 fix(infra): correct registry data path and enable untagged manifest deletion
Some checks failed
Monorepo Pipeline / ⚡ Prioritize Release (push) Successful in 27s
Monorepo Pipeline / 🧪 Test (push) Failing after 24s
Monorepo Pipeline / 🏗️ Build (push) Failing after 22s
Monorepo Pipeline / 🧹 Lint (push) Successful in 1m4s
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 12:39:50 +01:00
3284931f84 chore(next-utils): respect skip flags in refinements and publish v1.7.15
All checks were successful
Monorepo Pipeline / ⚡ Prioritize Release (push) Successful in 2s
Monorepo Pipeline / 🧹 Lint (push) Successful in 46s
Monorepo Pipeline / 🧪 Test (push) Successful in 55s
Monorepo Pipeline / 🏗️ Build (push) Successful in 1m48s
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 01:32:01 +01:00
28517a3558 chore(next-utils): introduce withMintelRefinements and publish v1.7.14
Some checks failed
Monorepo Pipeline / ⚡ Prioritize Release (push) Successful in 1s
Monorepo Pipeline / 🧹 Lint (push) Has started running
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 / 🧪 Test (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 01:31:16 +01:00
3b9f10ec98 chore(next-utils): convert to ESM-only and publish v1.7.13
Some checks failed
Monorepo Pipeline / ⚡ Prioritize Release (push) Successful in 1s
Monorepo Pipeline / 🧪 Test (push) Successful in 42s
Monorepo Pipeline / 🧹 Lint (push) Successful in 1m0s
Monorepo Pipeline / 🏗️ Build (push) Failing after 1m47s
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 01:26:53 +01:00
65fd248993 chore(next-utils): fix generic propagation in createMintelDirectusClient and publish v1.7.12
Some checks failed
Monorepo Pipeline / ⚡ Prioritize Release (push) Successful in 1s
Monorepo Pipeline / 🧹 Lint (push) Successful in 45s
Monorepo Pipeline / 🧪 Test (push) Successful in 54s
Monorepo Pipeline / 🏗️ Build (push) Failing after 1m46s
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 01:22:12 +01:00
ebd9ab132c chore(next-utils): add explicit return type to validateMintelEnv and publish v1.7.11
Some checks failed
Monorepo Pipeline / ⚡ Prioritize Release (push) Successful in 1s
Monorepo Pipeline / 🧹 Lint (push) Successful in 45s
Monorepo Pipeline / 🧪 Test (push) Successful in 58s
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 / 🏗️ Build (push) Has been cancelled
2026-02-11 01:20:57 +01:00
ddaeb2c3ca chore(next-utils): rebuild with generic types and publish v1.7.10
Some checks failed
Monorepo Pipeline / ⚡ Prioritize Release (push) Successful in 2s
Monorepo Pipeline / 🧪 Test (push) Successful in 42s
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 (push) Has been cancelled
Monorepo Pipeline / 🧹 Lint (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 01:20:03 +01:00
ad1a8c4fbf feat(next-utils): support generic schema in directus client
All checks were successful
Monorepo Pipeline / ⚡ Prioritize Release (push) Successful in 34s
Monorepo Pipeline / 🧪 Test (push) Successful in 43s
Monorepo Pipeline / 🧹 Lint (push) Successful in 1m6s
Monorepo Pipeline / 🏗️ Build (push) Successful in 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
2026-02-11 01:00:34 +01:00
013b0259b2 fix(pipeline): use POSIX sh compatible logic for release prioritization
Some checks failed
Monorepo Pipeline / ⚡ Prioritize Release (push) Successful in 1s
Monorepo Pipeline / 🧹 Lint (push) Successful in 44s
Monorepo Pipeline / 🧪 Test (push) Successful in 40s
Monorepo Pipeline / 🏗️ Build (push) Successful in 1m47s
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 00:33:06 +01:00
d5a9a3bce4 chore: refine release prioritization logic and bump v1.7.8
All checks were successful
Monorepo Pipeline / ⚡ Prioritize Release (push) Successful in 2s
Monorepo Pipeline / 🧹 Lint (push) Successful in 1m0s
Monorepo Pipeline / 🧪 Test (push) Successful in 39s
Monorepo Pipeline / 🏗️ Build (push) Successful in 2m23s
Monorepo Pipeline / 🐳 Build Directus (Base) (push) Successful in 19s
Monorepo Pipeline / 🚀 Release (push) Successful in 2m42s
Monorepo Pipeline / 🐳 Build Gatekeeper (Product) (push) Successful in 2m17s
Monorepo Pipeline / 🐳 Build Production Runtime (push) Successful in 16s
Monorepo Pipeline / 🐳 Build Build-Base (push) Successful in 5m29s
2026-02-11 00:31:03 +01:00
b9fd583ac4 chore: fix pipeline hang by disabling broken caching and using corepack
Some checks failed
Monorepo Pipeline / ⚡ Prioritize Release (push) Successful in 1s
Monorepo Pipeline / 🧹 Lint (push) Successful in 57s
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 / 🧪 Test (push) Has been cancelled
Monorepo Pipeline / 🏗️ Build (push) Has been cancelled
2026-02-11 00:29:10 +01:00
bfdbaba0d0 chore: implement release prioritization and streamline setup for speed
Some checks failed
Monorepo Pipeline / ⚡ Prioritize Release (push) Successful in 2s
Monorepo Pipeline / 🧹 Lint (push) Successful in 1m2s
Monorepo Pipeline / 🧪 Test (push) Successful in 58s
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 / 🏗️ Build (push) Has been cancelled
2026-02-11 00:22:31 +01:00
4ea9cbc551 fix(next-utils): use natural type inference for validateMintelEnv to fix unknown type errors
Some checks failed
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 / 🏗️ Build (push) Has been cancelled
Monorepo Pipeline / 🧹 Lint (push) Has been cancelled
Monorepo Pipeline / 🧪 Test (push) Has been cancelled
Monorepo Pipeline / 📦 Install & Sync (push) Has been cancelled
2026-02-11 00:17:46 +01:00
d8c1a38c0d chore: optimize pipeline for speed and parallelize QA jobs
Some checks failed
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
Monorepo Pipeline / 📦 Install & Sync (push) Has been cancelled
2026-02-11 00:14:55 +01:00
b65b9a7fb2 fix(next-utils): finalize type safety for validateMintelEnv and fix pre-push hook
Some checks failed
Monorepo Pipeline / 🧪 Quality Assurance (push) Failing after 2m7s
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 00:10:38 +01:00
858c7bbc39 fix(next-utils): use z.extend() for robust type inference in validateMintelEnv
Some checks failed
Monorepo Pipeline / 🧪 Quality Assurance (push) Successful in 2m33s
Monorepo Pipeline / 🐳 Build Directus (Base) (push) Successful in 33s
Monorepo Pipeline / 🐳 Build Build-Base (push) Has been cancelled
Monorepo Pipeline / 🐳 Build Production Runtime (push) Has been cancelled
Monorepo Pipeline / 🐳 Build Gatekeeper (Product) (push) Has been cancelled
Monorepo Pipeline / 🚀 Release (push) Has been cancelled
2026-02-11 00:04:14 +01:00
149123ef90 fix(next-utils): restore optional argument with robust types to satisfy linter
Some checks failed
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 / 🧪 Quality Assurance (push) Has been cancelled
2026-02-11 00:02:53 +01:00
6bc49d1c52 fix(next-utils): make validateMintelEnv generic for better type safety
Some checks failed
Monorepo Pipeline / 🧪 Quality Assurance (push) Failing after 2m58s
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-10 23:57:18 +01:00
52ffe49019 feat(next-utils): make directus client environment-aware and standardize base env schema
All checks were successful
Monorepo Pipeline / 🧪 Quality Assurance (push) Successful in 2m10s
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-10 23:44:27 +01:00
73fa292528 fix: remove klz from workspace 2026-02-10 21:39:48 +01:00
f2c0a4581c chore: sync versions
All checks were successful
Monorepo Pipeline / 🧪 Quality Assurance (push) Successful in 2m6s
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-10 00:39:34 +01:00
367c4d8404 fix: cms schema 2026-02-10 00:35:26 +01:00
587c88980f chore: release next-config v1.7.0
All checks were successful
Monorepo Pipeline / 🧪 Quality Assurance (push) Successful in 2m8s
Monorepo Pipeline / 🚀 Release (push) Successful in 1m51s
Monorepo Pipeline / 🐳 Build Directus (Base) (push) Successful in 17s
Monorepo Pipeline / 🐳 Build Gatekeeper (Product) (push) Successful in 2m18s
Monorepo Pipeline / 🐳 Build Production Runtime (push) Successful in 16s
Monorepo Pipeline / 🐳 Build Build-Base (push) Successful in 5m34s
2026-02-10 00:29:02 +01:00
fcdfdb4588 chore: release next-config v1.6.1
All checks were successful
Monorepo Pipeline / 🧪 Quality Assurance (push) Successful in 2m12s
Monorepo Pipeline / 🐳 Build Directus (Base) (push) Successful in 18s
Monorepo Pipeline / 🚀 Release (push) Successful in 2m17s
Monorepo Pipeline / 🐳 Build Gatekeeper (Product) (push) Successful in 2m13s
Monorepo Pipeline / 🐳 Build Production Runtime (push) Successful in 17s
Monorepo Pipeline / 🐳 Build Build-Base (push) Successful in 5m27s
2026-02-10 00:27:59 +01:00
6bbaa8d105 chore: cms sync 2026-02-10 00:26:13 +01:00
eccc084441 chore: cms sync commands 2026-02-10 00:13:42 +01:00
da6b8aba64 fix: cms sync
All checks were successful
Monorepo Pipeline / 🧪 Quality Assurance (push) Successful in 2m43s
Monorepo Pipeline / 🚀 Release (push) Has been skipped
Monorepo Pipeline / 🐳 Build Directus (Base) (push) Has been skipped
Monorepo Pipeline / 🐳 Build Build-Base (push) Has been skipped
Monorepo Pipeline / 🐳 Build Gatekeeper (Product) (push) Has been skipped
Monorepo Pipeline / 🐳 Build Production Runtime (push) Has been skipped
2026-02-10 00:03:27 +01:00
290097b4e6 chore: fix linter
Some checks failed
Monorepo Pipeline / 🧪 Quality Assurance (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-10 00:02:26 +01:00
45894cce34 chore: fix linter
Some checks failed
Monorepo Pipeline / 🧪 Quality Assurance (push) Failing after 57s
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-09 23:59:22 +01:00
7195906da0 chore: fix linter
Some checks failed
Monorepo Pipeline / 🧪 Quality Assurance (push) Failing after 42s
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-09 23:54:58 +01:00
dcb466f53b chore: fix husky
Some checks failed
Monorepo Pipeline / 🧪 Quality Assurance (push) Failing after 1m3s
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-09 23:44:34 +01:00
14089766ea feat: infra cms
Some checks failed
Monorepo Pipeline / 🧪 Quality Assurance (push) Failing after 1m5s
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-09 23:33:45 +01:00
6ecabe4a04 chore: sync lock file
Some checks failed
Monorepo Pipeline / 🧪 Quality Assurance (push) Failing after 9s
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-09 23:26:38 +01:00
b205220bde fix: cli compatibility with nextjs 16
Some checks failed
Monorepo Pipeline / 🧪 Quality Assurance (push) Failing after 14s
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-09 23:15:50 +01:00
3d5a802c6e chore: release packages
Some checks failed
Monorepo Pipeline / 🧪 Quality Assurance (push) Failing after 1m42s
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-09 23:05:15 +01:00
b5d1272f85 fix: customer manager
Some checks failed
Monorepo Pipeline / 🧪 Quality Assurance (push) Failing after 2m15s
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-09 23:04:21 +01:00
e152fb8171 fix: sync versions 2026-02-09 22:50:28 +01:00
d7cec1fa0e fix: docker images
All checks were successful
Monorepo Pipeline / 🧪 Quality Assurance (push) Successful in 4m4s
Monorepo Pipeline / 🐳 Build Directus (Base) (push) Successful in 29s
Monorepo Pipeline / 🚀 Release (push) Successful in 3m44s
Monorepo Pipeline / 🐳 Build Build-Base (push) Successful in 4m40s
Monorepo Pipeline / 🐳 Build Production Runtime (push) Successful in 17s
Monorepo Pipeline / 🐳 Build Gatekeeper (Product) (push) Successful in 7m15s
2026-02-09 22:37:19 +01:00
67c2af958a fix: docker images
Some checks failed
Monorepo Pipeline / 🧪 Quality Assurance (push) Successful in 4m1s
Monorepo Pipeline / 🐳 Build Directus (Base) (push) Successful in 33s
Monorepo Pipeline / 🐳 Build Gatekeeper (Product) (push) Failing after 21s
Monorepo Pipeline / 🐳 Build Build-Base (push) Failing after 45s
Monorepo Pipeline / 🐳 Build Production Runtime (push) Successful in 27s
Monorepo Pipeline / 🚀 Release (push) Successful in 3m43s
2026-02-09 22:26:16 +01:00
015e295370 fix: pipeline 2026-02-09 22:21:22 +01:00
c9952bfd1d fix: pipeline
Some checks failed
Monorepo Pipeline / 🧪 Quality Assurance (push) Successful in 6m14s
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 Production Runtime (push) Has been skipped
Monorepo Pipeline / 🐳 Build Build-Base (push) Has been cancelled
2026-02-09 22:16:07 +01:00
f9aaf3712e fix: pipeline
Some checks failed
Monorepo Pipeline / 🧪 Quality Assurance (push) Failing after 7m22s
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-09 22:02:58 +01:00
d2bbfe3b40 fix: pipeline 2026-02-09 21:58:04 +01:00
f3fafa8ea0 feat: cms feedback and customer management
Some checks failed
Monorepo Pipeline / 🧪 Quality Assurance (push) Failing after 6m39s
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-09 21:49:27 +01:00
625c58398c feat: cms feedback and customer management 2026-02-09 20:02:52 +01:00
a306d24f51 feat: integrate cms 2026-02-09 12:08:47 +01:00
59d3e97ef0 perf: implement registry-based build caching and next.js cache mounts
Some checks failed
Monorepo Pipeline / 🧪 Quality Assurance (push) Failing after 5m25s
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-08 15:01:42 +01:00
0c0d0caae6 fix: set CI=true in gatekeeper dockerfile
Some checks failed
Monorepo Pipeline / 🧪 Quality Assurance (push) Failing after 50s
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-08 14:54:19 +01:00
2c9f12623e fix: copy all package manifests for monorepo pnpm install
Some checks failed
Monorepo Pipeline / 🧪 Quality Assurance (push) Failing after 36s
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-08 14:38:37 +01:00
a55649c5f2 perf: optimize gatekeeper docker build with cache mounts and layer caching
Some checks failed
Monorepo Pipeline / 🧪 Quality Assurance (push) Failing after 36s
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-08 13:06:32 +01:00
0d7c588536 fix: set basePath to /gatekeeper for correct sub-path routing
Some checks failed
Monorepo Pipeline / 🧪 Quality Assurance (push) Failing after 5m30s
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-08 10:56:50 +01:00
b6debcbb59 fix: ensure node shebang is preserved in dev binary 2026-02-08 10:39:19 +01:00
5847bc5795 fix: nextjs eslint flat config compatibility 2026-02-07 16:38:14 +01:00
127 changed files with 15242 additions and 1469 deletions

View File

@@ -1,5 +0,0 @@
---
"@mintel/mail": minor
---
Initial release of the branded email system package.

View File

@@ -0,0 +1,7 @@
---
"@mintel/monorepo": patch
"acquisition-manager": patch
"feedback-commander": patch
---
fix: make directus extension build scripts more resilient

View File

@@ -1,7 +1,7 @@
node_modules
.next
.git
.npmrc
# .npmrc is allowed as it contains the registry template
dist
build
out

View File

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

2
.eslintignore Normal file
View File

@@ -0,0 +1,2 @@
**/index.js
**/dist/**

View File

@@ -0,0 +1,44 @@
name: 🏥 Server Maintenance
on:
schedule:
- cron: '0 3 * * *' # Every day at 3:00 AM
workflow_dispatch: # Allow manual trigger
jobs:
maintenance:
name: 🧹 Prune & Clean
runs-on: docker
container:
image: catthehacker/ubuntu:act-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: 🚀 Execute Maintenance via SSH
run: |
mkdir -p ~/.ssh
echo "${{ secrets.SSH_KEY }}" > ~/.ssh/id_ed25519
chmod 600 ~/.ssh/id_ed25519
ssh-keyscan -H ${{ secrets.SSH_HOST }} >> ~/.ssh/known_hosts 2>/dev/null
# Run the prune script on the host
# We transfer the script and execute it to ensure it matches the repo version
scp packages/infra/scripts/prune-registry.sh root@${{ secrets.SSH_HOST }}:/tmp/prune-registry.sh
ssh root@${{ secrets.SSH_HOST }} "bash /tmp/prune-registry.sh && rm /tmp/prune-registry.sh"
- name: 🔔 Notification - Success
if: success()
run: |
curl -s -k -X POST "${{ secrets.GOTIFY_URL }}/message?token=${{ secrets.GOTIFY_TOKEN }}" \
-F "title=🏥 Maintenance Complete" \
-F "message=Server-Wartung erfolgreich ausgeführt.\nRegistry & Docker Ressourcen bereinigt." \
-F "priority=2" || true
- name: 🔔 Notification - Failure
if: failure()
run: |
curl -s -k -X POST "${{ secrets.GOTIFY_URL }}/message?token=${{ secrets.GOTIFY_TOKEN }}" \
-F "title=❌ Maintenance FAILED" \
-F "message=Die automatische Server-Wartung ist fehlgeschlagen!\nBitte Logs prüfen." \
-F "priority=8" || true

View File

@@ -2,6 +2,8 @@ name: Monorepo Pipeline
on:
push:
branches:
- '**'
tags:
- 'v*'
@@ -10,43 +12,124 @@ concurrency:
cancel-in-progress: true
jobs:
qa:
name: 🧪 Quality Assurance
prioritize:
name: ⚡ Prioritize Release
runs-on: docker
container:
image: catthehacker/ubuntu:act-latest
steps:
- name: 🛑 Cancel Redundant Runs
env:
GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }}
REPO: ${{ github.repository }}
RUN_ID: ${{ github.run_id }}
REF: ${{ github.ref }}
REF_NAME: ${{ github.ref_name }}
EVENT: ${{ github.event_name }}
run: |
echo "🔎 Debug: Event=$EVENT, Ref=$REF, RefName=$REF_NAME, RunId=$RUN_ID"
case "$REF" in
refs/tags/v*)
echo "🚀 Release detected ($REF_NAME). Cancelling non-tag runs..."
# Fetch all runs
RUNS=$(curl -s -H "Authorization: token $GITEA_TOKEN" "https://git.infra.mintel.me/api/v1/repos/$REPO/actions/runs")
# Identify runs to cancel: in_progress/queued, NOT this run, and NOT a tag run
echo "$RUNS" | jq -c '.workflow_runs[] | select(.status == "in_progress" or .status == "queued") | select(.id | tostring != "'$RUN_ID'")' | while read -r run; do
ID=$(echo "$run" | jq -r '.id')
RUN_REF=$(echo "$run" | jq -r '.ref')
TITLE=$(echo "$run" | jq -r '.display_title')
case "$RUN_REF" in
refs/tags/v*)
echo "⏭️ Skipping parallel release run $ID ($TITLE) on $RUN_REF"
;;
*)
echo "🛑 Cancelling redundant branch run $ID ($TITLE) on $RUN_REF..."
curl -X POST -s -H "Authorization: token $GITEA_TOKEN" "https://git.infra.mintel.me/api/v1/repos/$REPO/actions/runs/$ID/cancel"
;;
esac
done
;;
*)
echo " Regular push. No prioritization needed."
;;
esac
lint:
name: 🧹 Lint
needs: prioritize
if: always() && !cancelled() && (needs.prioritize.result == 'success' || needs.prioritize.result == 'skipped')
runs-on: docker
container:
image: catthehacker/ubuntu:act-latest
env:
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Install pnpm
uses: pnpm/action-setup@v4
with:
version: 10
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node_version: 20
cache: 'pnpm'
- name: Enable pnpm
run: corepack enable && corepack prepare pnpm@10.2.0 --activate
- name: Install dependencies
run: pnpm install --frozen-lockfile
env:
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
run: pnpm install --frozen-lockfile --prefer-offline --ignore-scripts --no-color
- name: Lint
run: pnpm lint
test:
name: 🧪 Test
needs: prioritize
if: always() && !cancelled() && (needs.prioritize.result == 'success' || needs.prioritize.result == 'skipped')
runs-on: docker
container:
image: catthehacker/ubuntu:act-latest
env:
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node_version: 20
- name: Enable pnpm
run: corepack enable && corepack prepare pnpm@10.2.0 --activate
- name: Install dependencies
run: pnpm install --frozen-lockfile --prefer-offline --ignore-scripts --no-color
- name: Test
run: pnpm test
build:
name: 🏗️ Build
needs: prioritize
if: always() && !cancelled() && (needs.prioritize.result == 'success' || needs.prioritize.result == 'skipped')
runs-on: docker
container:
image: catthehacker/ubuntu:act-latest
env:
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node_version: 20
- name: Enable pnpm
run: corepack enable && corepack prepare pnpm@10.2.0 --activate
- name: Install dependencies
run: pnpm install --frozen-lockfile --prefer-offline --ignore-scripts --no-color
- name: Build
run: pnpm build
release:
name: 🚀 Release
needs: qa
needs: [lint, test, build]
if: startsWith(github.ref, 'refs/tags/v')
runs-on: docker
container:
@@ -59,30 +142,24 @@ jobs:
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Install pnpm
uses: pnpm/action-setup@v4
with:
version: 10
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node_version: 20
cache: 'pnpm'
- name: Enable pnpm
run: corepack enable && corepack prepare pnpm@10.2.0 --activate
- name: Install dependencies
run: pnpm install --frozen-lockfile
run: pnpm install --frozen-lockfile --prefer-offline --ignore-scripts --no-color
- name: 🏷️ Sync Versions (if Tagged)
run: pnpm sync-versions
- name: 🏷️ Release Packages (Tag-Driven)
run: |
echo "🏷️ Tag detected [${{ github.ref_name }}], performing sync release..."
pnpm sync-versions
pnpm release:tag
build-images:
name: 🐳 Build ${{ matrix.name }}
needs: qa
needs: [lint, test, build]
if: startsWith(github.ref, 'refs/tags/v')
runs-on: docker
container:
@@ -130,6 +207,6 @@ jobs:
tags: |
registry.infra.mintel.me/mintel/${{ matrix.image }}:${{ github.ref_name }}
registry.infra.mintel.me/mintel/${{ matrix.image }}:latest
cache-from: type=gha
cache-to: type=gha,mode=max
cache-from: type=registry,ref=registry.infra.mintel.me/mintel/${{ matrix.image }}:buildcache
cache-to: type=registry,ref=registry.infra.mintel.me/mintel/${{ matrix.image }}:buildcache,mode=max

16
.husky/pre-push Executable file
View File

@@ -0,0 +1,16 @@
# Check if we are pushing a tag
while read local_ref local_sha remote_ref remote_sha
do
if [[ "$remote_ref" == refs/tags/v* ]]; then
TAG=${remote_ref#refs/tags/}
echo "🏷️ Tag detected: $TAG, syncing versions..."
pnpm sync-versions "$TAG"
# Stage the changed files (excluding ignored files like .env)
git add package.json packages/*/package.json apps/*/package.json .env.example
echo "⚠️ package.json and .env files updated to match tag $TAG."
echo "⚠️ Note: You might need to push again if these changes were not already in your commit/tag."
fi
done

3
.npmrc
View File

@@ -2,3 +2,6 @@
registry=https://npm.infra.mintel.me/
//npm.infra.mintel.me/:_authToken=${NPM_TOKEN}
always-auth=true
public-hoist-pattern[]=*
shamefully-hoist=true

56
Dockerfile.template Normal file
View File

@@ -0,0 +1,56 @@
# Stage 1: Builder
FROM registry.infra.mintel.me/mintel/nextjs:latest AS builder
WORKDIR /app
# Clean the workspace in case the base image is dirty
RUN rm -rf ./*
# Arguments for build-time configuration
ARG NEXT_PUBLIC_BASE_URL
ARG NEXT_PUBLIC_TARGET
ARG DIRECTUS_URL
ARG NPM_TOKEN
# Environment variables for Next.js build
ENV NEXT_PUBLIC_BASE_URL=$NEXT_PUBLIC_BASE_URL
ENV NEXT_PUBLIC_TARGET=$NEXT_PUBLIC_TARGET
ENV DIRECTUS_URL=$DIRECTUS_URL
ENV SKIP_RUNTIME_ENV_VALIDATION=true
ENV CI=true
# Enable pnpm
RUN corepack enable
# Copy lockfile and manifest for dependency installation caching
COPY pnpm-lock.yaml package.json .npmrc* ./
# Install dependencies with cache mount
RUN --mount=type=cache,id=pnpm,target=/pnpm/store \
--mount=type=secret,id=NPM_TOKEN \
export NPM_TOKEN=$(cat /run/secrets/NPM_TOKEN 2>/dev/null || echo $NPM_TOKEN) && \
pnpm install --frozen-lockfile
# Copy source code
COPY . .
# Build application
RUN pnpm build
# Stage 2: Runner
FROM registry.infra.mintel.me/mintel/runtime:latest AS runner
WORKDIR /app
ENV HOSTNAME="0.0.0.0"
ENV PORT=3000
ENV NODE_ENV=production
# Copy standalone output and static files
# Adjust paths if using a monorepo structure (e.g., /app/apps/web/public)
COPY --from=builder --chown=nextjs:nodejs /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
COPY --from=builder --chown=nextjs:nodejs /app/.next/cache ./.next/cache
USER nextjs
CMD ["node", "server.js"]

View File

@@ -1,3 +0,0 @@
import { nextConfig } from "@mintel/eslint-config/next";
export default nextConfig;

View File

@@ -1,6 +1,6 @@
{
"name": "sample-website",
"version": "0.1.1",
"version": "1.7.12",
"private": true,
"type": "module",
"scripts": {
@@ -8,15 +8,9 @@
"dev:local": "mintel dev --local",
"build": "next build",
"start": "next start",
"lint": "next lint",
"lint": "eslint src/",
"typecheck": "tsc --noEmit",
"test": "vitest run --passWithNoTests",
"cms:bootstrap": "mintel directus bootstrap",
"cms:push:testing": "mintel directus sync push testing",
"cms:pull:testing": "mintel directus sync pull testing",
"cms:push:staging": "mintel directus sync push staging",
"cms:pull:staging": "mintel directus sync pull staging",
"cms:push:prod": "mintel directus sync push production",
"cms:pull:prod": "mintel directus sync pull production",
"pagespeed:test": "mintel pagespeed"
},
@@ -24,8 +18,8 @@
"@mintel/next-utils": "workspace:*",
"@mintel/observability": "workspace:*",
"@mintel/next-observability": "workspace:*",
"@sentry/nextjs": "^8.55.0",
"next": "15.1.6",
"@sentry/nextjs": "10.38.0",
"next": "16.1.6",
"next-intl": "^4.8.2",
"react": "^19.0.0",
"react-dom": "^19.0.0"

View File

@@ -0,0 +1,19 @@
version: 1
directus: 11.15.1
vendor: postgres
collections: []
fields: []
systemFields:
- collection: directus_activity
field: timestamp
schema:
is_indexed: true
- collection: directus_revisions
field: activity
schema:
is_indexed: true
- collection: directus_revisions
field: parent
schema:
is_indexed: true
relations: []

View File

View File

@@ -1,17 +1,18 @@
services:
app:
build:
context: .
context: ./apps/sample-website
dockerfile: Dockerfile
args:
NEXT_PUBLIC_BASE_URL: ${NEXT_PUBLIC_BASE_URL:-http://localhost:3000}
NEXT_PUBLIC_UMAMI_WEBSITE_ID: ${NEXT_PUBLIC_UMAMI_WEBSITE_ID}
NEXT_PUBLIC_UMAMI_SCRIPT_URL: ${NEXT_PUBLIC_UMAMI_SCRIPT_URL}
NEXT_PUBLIC_TARGET: ${TARGET:-development}
DIRECTUS_URL: ${DIRECTUS_URL:-http://directus:8055}
restart: always
networks:
- infra
environment:
- DIRECTUS_URL=${DIRECTUS_URL:-http://directus:8055}
env_file:
- .env
ports:
@@ -22,7 +23,7 @@ services:
- "traefik.http.services.sample-website.loadbalancer.server.port=3000"
directus:
image: registry.infra.mintel.me/mintel/directus:latest
image: registry.infra.mintel.me/mintel/directus:${IMAGE_TAG:-latest}
restart: always
networks:
- infra
@@ -46,6 +47,7 @@ services:
volumes:
- ./directus/uploads:/directus/uploads
- ./directus/extensions:/directus/extensions
- ./directus/schema:/directus/schema
labels:
- "traefik.enable=true"
- "traefik.http.routers.sample-website-directus.rule=Host(`${DIRECTUS_HOST:-cms.sample-website.localhost}`)"

View File

@@ -1,3 +1,26 @@
import baseConfig from "@mintel/eslint-config";
import { nextConfig } from "@mintel/eslint-config/next";
export default nextConfig;
export default [
{
ignores: [
"packages/cms-infra/extensions/**",
"packages/customer-manager/index.js",
"**/*.db",
"**/build/**",
"**/data/**",
"**/reference/**",
"**/dist/**",
"**/.next/**",
],
},
...baseConfig,
...nextConfig.map((config) => ({
...config,
files: [
"apps/sample-website/**/*.{ts,tsx}",
"packages/gatekeeper/**/*.{ts,tsx}",
"../klz-2026/**/*.{ts,tsx}",
],
})),
];

View File

@@ -5,11 +5,17 @@
"scripts": {
"build": "pnpm -r build",
"dev": "pnpm -r dev",
"lint": "pnpm -r lint",
"lint": "pnpm -r --filter='./packages/**' --filter='./apps/**' lint",
"test": "pnpm -r test",
"changeset": "changeset",
"version-packages": "changeset version",
"sync-versions": "tsx scripts/sync-versions.ts",
"sync-versions": "tsx scripts/sync-versions.ts --",
"cms:push:infra": "./scripts/sync-directus.sh push infra",
"cms:pull:infra": "./scripts/sync-directus.sh pull infra",
"cms:schema:snapshot": "./scripts/cms-snapshot.sh",
"cms:schema:apply": "./scripts/cms-apply.sh local",
"cms:schema:apply:infra": "./scripts/cms-apply.sh infra",
"dev:infra": "docker-compose up -d directus directus-db",
"release": "pnpm build && changeset publish",
"release:tag": "pnpm build && pnpm -r publish --no-git-checks --access public",
"prepare": "husky"
@@ -27,7 +33,7 @@
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.2",
"eslint": "^9.39.2",
"eslint-plugin-next": "^0.0.0",
"@next/eslint-plugin-next": "16.1.6",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^7.0.1",
"happy-dom": "^20.4.0",
@@ -39,5 +45,18 @@
"typescript": "^5.0.0",
"typescript-eslint": "^8.54.0",
"vitest": "^4.0.18"
},
"dependencies": {
"import-in-the-middle": "^3.0.0",
"pino": "^10.3.1",
"pino-pretty": "^13.1.3",
"require-in-the-middle": "^8.0.1"
},
"version": "1.7.12",
"pnpm": {
"overrides": {
"next": "16.1.6",
"@sentry/nextjs": "10.38.0"
}
}
}

View File

@@ -0,0 +1,44 @@
import { build } from 'esbuild';
import { resolve, dirname } from 'path';
import { mkdirSync } from 'fs';
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const entryPoint = resolve(__dirname, 'src/index.ts');
const outfile = resolve(__dirname, 'dist/index.js');
try {
mkdirSync(dirname(outfile), { recursive: true });
} catch (e) { }
console.log(`Building from ${entryPoint} to ${outfile}...`);
build({
entryPoints: [entryPoint],
bundle: true,
platform: 'node',
target: 'node18',
outfile: outfile,
format: 'esm',
external: [],
plugins: [{
name: 'mock-jquery',
setup(build) {
build.onResolve({ filter: /^jquery$/ }, args => ({ path: args.path, namespace: 'mock-jquery' }));
build.onLoad({ filter: /.*/, namespace: 'mock-jquery' }, () => ({ contents: 'export default {};', loader: 'js' }));
}
}, {
name: 'mock-canvas',
setup(build) {
build.onResolve({ filter: /^canvas$/ }, args => ({ path: args.path, namespace: 'mock-canvas' }));
build.onLoad({ filter: /.*/, namespace: 'mock-canvas' }, () => ({ contents: 'export default {};', loader: 'js' }));
}
}]
}).then(() => {
console.log("Build succeeded!");
}).catch((e) => {
console.error("Build failed:", e);
process.exit(1);
});

View File

@@ -0,0 +1,25 @@
{
"name": "@mintel/acquisition",
"version": "1.7.12",
"type": "module",
"main": "dist/index.js",
"module": "dist/index.js",
"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": {
"@mintel/mail": "workspace:*",
"axios": "^1.7.9",
"crawlee": "^3.12.2",
"cheerio": "^1.0.0",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"@react-pdf/renderer": "^4.3.0"
}
}

View File

@@ -0,0 +1,93 @@
"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;
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,401 @@
"use client";
import * as React from "react";
import {
View as PDFView,
Text as PDFText,
StyleSheet,
Image as PDFImage,
} from "@react-pdf/renderer";
// INDUSTRIAL DESIGN SYSTEM TOKENS
export const COLORS = {
CHARCOAL: "#0f172a", // Slate 900
TEXT_MAIN: "#334155", // Slate 700
TEXT_DIM: "#64748b", // Slate 500
TEXT_LIGHT: "#94a3b8", // Slate 400
DIVIDER: "#cbd5e1", // Slate 300
GRID: "#f1f5f9", // Slate 100
BLUEPRINT: "#e2e8f0", // Slate 200
WHITE: "#ffffff",
};
export const FONT_SIZES = {
HERO: 24, // Main Page Titles
HEADING: 14, // Section Headers
BODY: 11, // Standard Content
LABEL: 10, // Bold Labels / Keys
SMALL: 9, // Descriptions / Footnotes
TINY: 8, // Metadata / Unit prices
};
export const pdfStyles = StyleSheet.create({
page: {
paddingTop: 45, // DIN 5008
paddingLeft: 70, // ~25mm
paddingRight: 57, // ~20mm
paddingBottom: 80, // Safe buffer for absolute footer
backgroundColor: COLORS.WHITE,
fontFamily: "Helvetica",
fontSize: FONT_SIZES.BODY,
color: COLORS.CHARCOAL,
},
titlePage: {
width: "100%",
height: "100%",
backgroundColor: COLORS.WHITE,
fontFamily: "Helvetica",
color: COLORS.CHARCOAL,
padding: 0,
},
header: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "flex-start",
marginBottom: 20,
minHeight: 120,
},
addressBlock: {
width: "55%",
marginTop: 45,
},
senderLine: {
fontSize: FONT_SIZES.TINY,
textDecoration: "underline",
color: COLORS.TEXT_DIM,
marginBottom: 8,
},
recipientAddress: {
fontSize: FONT_SIZES.BODY,
lineHeight: 1.4,
},
brandLogoContainer: {
width: "40%",
alignItems: "flex-end",
},
brandIconContainer: {
width: 40,
height: 40,
backgroundColor: "#0f172a",
borderRadius: 8,
alignItems: "center",
justifyContent: "center",
marginBottom: 12,
},
brandIconText: {
color: COLORS.WHITE,
fontSize: 20,
fontWeight: "bold",
},
titleInfo: {
marginBottom: 24,
},
mainTitle: {
fontSize: FONT_SIZES.HEADING,
fontWeight: "bold",
marginBottom: 4,
color: COLORS.CHARCOAL,
letterSpacing: 0.5,
},
subTitle: {
fontSize: FONT_SIZES.BODY,
color: COLORS.TEXT_DIM,
marginTop: 2,
lineHeight: 1.4,
},
section: {
marginBottom: 32,
},
sectionTitle: {
fontSize: FONT_SIZES.LABEL,
fontWeight: "bold",
textTransform: "uppercase",
letterSpacing: 1,
color: COLORS.TEXT_LIGHT,
marginBottom: 8,
},
footer: {
position: "absolute",
bottom: 32,
left: 70,
right: 57,
borderTopWidth: 1,
borderTopColor: COLORS.GRID,
paddingTop: 16,
flexDirection: "row",
justifyContent: "space-between",
alignItems: "flex-start",
},
footerColumn: {
flex: 1,
alignItems: "flex-start",
},
footerLogo: {
height: 20,
width: "auto",
objectFit: "contain",
marginBottom: 8,
},
footerText: {
fontSize: FONT_SIZES.TINY,
color: COLORS.TEXT_LIGHT,
lineHeight: 1.4,
},
asymmetryContainer: {
flexDirection: "row",
gap: 32,
},
asymmetryLeft: {
width: "32%",
},
asymmetryRight: {
width: "63%",
},
specRow: {
flexDirection: "row",
justifyContent: "space-between",
paddingVertical: 6,
borderBottomWidth: 1,
borderBottomColor: COLORS.GRID,
},
specLabel: {
fontSize: FONT_SIZES.TINY,
fontWeight: "bold",
color: COLORS.TEXT_LIGHT,
textTransform: "uppercase",
letterSpacing: 0.5,
},
specValue: {
fontSize: FONT_SIZES.SMALL,
color: COLORS.CHARCOAL,
fontWeight: "bold",
},
blueprintBox: {
borderWidth: 1,
borderColor: COLORS.GRID,
padding: 16,
backgroundColor: "#fafafa",
},
footerLabel: {
fontWeight: "bold",
color: COLORS.TEXT_DIM,
},
pageNumber: {
fontSize: FONT_SIZES.TINY,
color: COLORS.DIVIDER,
fontWeight: "bold",
marginTop: 8,
textAlign: "right",
},
foldingMark: {
position: "absolute",
left: 20,
width: 10,
borderTopWidth: 0.5,
borderTopColor: COLORS.DIVIDER,
},
divider: {
width: "100%",
height: 1,
backgroundColor: COLORS.DIVIDER,
marginVertical: 12,
},
industrialListItem: {
flexDirection: "row",
alignItems: "flex-start",
marginBottom: 6,
},
industrialBulletBox: {
width: 6,
height: 6,
backgroundColor: COLORS.DIVIDER,
marginRight: 8,
marginTop: 5,
},
industrialTitle: {
fontSize: FONT_SIZES.HERO,
fontWeight: "bold",
color: COLORS.CHARCOAL,
marginBottom: 6,
letterSpacing: 0,
},
});
export const IndustrialListItem = ({
children,
}: {
children: React.ReactNode;
}) => (
<PDFView style={pdfStyles.industrialListItem}>
<PDFView style={pdfStyles.industrialBulletBox} />
{children}
</PDFView>
);
export const Divider = ({ style = {} }: { style?: any }) => (
<PDFView style={[pdfStyles.divider, style]} />
);
export const Footer = ({
logo,
companyData,
showDetails = true,
showPageNumber = true,
}: {
logo?: string;
companyData: any;
showDetails?: boolean;
showPageNumber?: boolean;
}) => (
<PDFView style={pdfStyles.footer}>
<PDFView style={pdfStyles.footerColumn}>
{logo ? (
<PDFImage src={logo} style={pdfStyles.footerLogo} />
) : (
<PDFText style={{ fontSize: 12, fontWeight: "bold", marginBottom: 8 }}>
marc mintel
</PDFText>
)}
</PDFView>
{showDetails && (
<>
<PDFView style={pdfStyles.footerColumn}>
<PDFText style={pdfStyles.footerText}>
<PDFText style={pdfStyles.footerLabel}>{companyData.name}</PDFText>
{"\n"}
{companyData.address1}
{"\n"}
{companyData.address2}
{"\n"}UST: {companyData.ustId}
</PDFText>
</PDFView>
<PDFView style={[pdfStyles.footerColumn, { alignItems: "flex-end" }]}>
{showPageNumber && (
<PDFText
style={pdfStyles.pageNumber}
render={({ pageNumber, totalPages }) =>
`${pageNumber} / ${totalPages}`
}
fixed
/>
)}
</PDFView>
</>
)}
</PDFView>
);
export const Header = ({
sender,
recipient,
icon,
showAddress = true,
}: {
sender?: string;
recipient?: {
title: string;
subtitle?: string;
email?: string;
address?: string;
phone?: string;
taxId?: string;
};
icon?: string;
showAddress?: boolean;
}) => (
<PDFView
style={[
pdfStyles.header,
showAddress ? {} : { minHeight: 40, marginBottom: 0 },
]}
>
<PDFView style={pdfStyles.addressBlock}>
{showAddress && sender && (
<>
<PDFText style={pdfStyles.senderLine}>{sender}</PDFText>
{recipient && (
<PDFView style={pdfStyles.recipientAddress}>
<PDFText style={{ fontWeight: "bold" }}>
{recipient.title}
</PDFText>
{recipient.subtitle && <PDFText>{recipient.subtitle}</PDFText>}
{recipient.address && <PDFText>{recipient.address}</PDFText>}
{recipient.phone && <PDFText>{recipient.phone}</PDFText>}
{recipient.email && <PDFText>{recipient.email}</PDFText>}
{recipient.taxId && <PDFText>USt-ID: {recipient.taxId}</PDFText>}
</PDFView>
)}
</>
)}
</PDFView>
<PDFView style={pdfStyles.brandLogoContainer}>
<PDFView style={pdfStyles.brandIconContainer}>
{icon ? (
<PDFImage src={icon} style={{ width: 24, height: 24 }} />
) : (
<PDFText style={pdfStyles.brandIconText}>M</PDFText>
)}
</PDFView>
</PDFView>
</PDFView>
);
export const DocumentTitle = ({
title,
subLines,
isHero = false,
}: {
title: string;
subLines?: string[];
isHero?: boolean;
}) => (
<PDFView style={pdfStyles.titleInfo}>
<PDFText
style={[
pdfStyles.mainTitle,
{ fontSize: isHero ? FONT_SIZES.HERO : FONT_SIZES.HEADING },
]}
>
{title}
</PDFText>
{subLines?.map((line, i) => (
<PDFText
key={i}
style={[
pdfStyles.subTitle,
i === 1 ? { fontWeight: "bold", color: COLORS.CHARCOAL } : {},
]}
>
{line}
</PDFText>
))}
</PDFView>
);
export const TechnicalSpec = ({
label,
value,
}: {
label: string;
value: string;
}) => (
<PDFView style={pdfStyles.specRow}>
<PDFText style={pdfStyles.specLabel}>{label}</PDFText>
<PDFText style={pdfStyles.specValue}>{value}</PDFText>
</PDFView>
);
export const AsymmetryView = ({
left,
right,
style = {},
}: {
left: React.ReactNode;
right: React.ReactNode;
style?: any;
}) => (
<PDFView style={[pdfStyles.asymmetryContainer, style]}>
<PDFView style={pdfStyles.asymmetryLeft}>{left}</PDFView>
<PDFView style={pdfStyles.asymmetryRight}>{right}</PDFView>
</PDFView>
);

View File

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

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.js";
const styles = StyleSheet.create({
section: { marginBottom: 24 },
sectionTitle: {
fontSize: FONT_SIZES.LABEL,
fontWeight: "bold",
marginBottom: 8,
color: COLORS.CHARCOAL,
},
visionText: {
fontSize: FONT_SIZES.BODY,
color: COLORS.TEXT_MAIN,
lineHeight: 1.4,
textAlign: "justify",
},
});
export const BriefingModule = ({ state }: any) => (
<>
<DocumentTitle title="Projektdetails" isHero={true} />
{state.briefingSummary && (
<PDFView style={styles.section}>
<PDFText style={styles.sectionTitle}>Briefing Analyse</PDFText>
<PDFText
style={{
fontSize: FONT_SIZES.BODY,
color: COLORS.TEXT_MAIN,
lineHeight: 1.6,
textAlign: "justify",
}}
>
{state.briefingSummary}
</PDFText>
</PDFView>
)}
{state.designVision && (
<PDFView
style={[
styles.section,
{
padding: 12,
borderLeftWidth: 2,
borderLeftColor: COLORS.DIVIDER,
backgroundColor: COLORS.GRID,
},
]}
>
<PDFText
style={[
styles.sectionTitle,
{ color: COLORS.CHARCOAL, marginBottom: 4 },
]}
>
Strategische Vision
</PDFText>
<PDFText style={styles.visionText}>{state.designVision}</PDFText>
</PDFView>
)}
</>
);

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,56 @@
"use client";
import * as React from "react";
import { View as PDFView, Text as PDFText, StyleSheet } from "@react-pdf/renderer";
import { DocumentTitle, COLORS, FONT_SIZES, IndustrialListItem } from "../SharedUI.js";
const styles = StyleSheet.create({
section: { marginBottom: 24 },
categoryBox: {
marginBottom: 20,
padding: 12,
backgroundColor: COLORS.GRID,
borderLeftWidth: 2,
borderLeftColor: COLORS.DIVIDER,
},
categoryTitle: {
fontSize: FONT_SIZES.TINY,
fontWeight: "bold",
color: COLORS.TEXT_LIGHT,
textTransform: "uppercase",
marginBottom: 10,
letterSpacing: 1,
},
pageTitle: {
fontSize: FONT_SIZES.LABEL,
fontWeight: "bold",
color: COLORS.CHARCOAL,
marginBottom: 2,
},
pageDesc: {
fontSize: FONT_SIZES.TINY,
color: COLORS.TEXT_DIM,
lineHeight: 1.4,
},
});
export const SitemapModule = ({ state }: any) => (
<>
<DocumentTitle title="Informations-Architektur" isHero={true} />
<PDFView style={styles.section}>
{state.sitemap?.map((cat: any, i: number) => (
<PDFView key={i} style={styles.categoryBox}>
<PDFText style={styles.categoryTitle}>{cat.category}</PDFText>
{cat.pages?.map((p: any, j: number) => (
<IndustrialListItem key={j}>
<PDFView style={{ marginBottom: 8 }}>
<PDFText style={styles.pageTitle}>{p.title}</PDFText>
<PDFText style={styles.pageDesc}>{p.desc}</PDFText>
</PDFView>
</IndustrialListItem>
))}
</PDFView>
))}
</PDFView>
</>
);

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,6 @@
export * from "./logic/pricing/types.js";
export * from "./logic/pricing/constants.js";
export * from "./logic/pricing/calculator.js";
export * from "./services/AcquisitionService.js";
export * from "./services/PdfEngine.js";
export * from "./components/EstimationPDF.js";

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,153 @@
import { CheerioCrawler } from "crawlee";
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,22 @@
import { fileURLToPath } from 'url';
import { dirname } from 'path';
import { createRequire } from 'module';
try {
const url = import.meta?.url;
// Hardcode fallback path for Directus Docker environment
const fallbackPath = '/directus/extensions/acquisition/dist/index.js';
const filename = url ? fileURLToPath(url) : fallbackPath;
const dir = dirname(filename);
// @ts-ignore
globalThis.__filename = filename;
// @ts-ignore
globalThis.__dirname = dir;
// @ts-ignore
globalThis.require = createRequire(url || `file://${fallbackPath}`);
console.log(`[Shim] Loaded. __dirname: ${dir}`);
} catch (e) {
console.warn("[Shim] Failed to shim __dirname/require", e);
}

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

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,30 @@
{
"name": "acquisition-manager",
"description": "Custom High-Fidelity Acquisition Management for Directus",
"icon": "account_balance_wallet",
"version": "1.7.12",
"type": "module",
"keywords": [
"directus",
"directus-extension",
"directus-extension-module"
],
"files": [
"dist"
],
"directus:extension": {
"type": "module",
"path": "index.js",
"source": "src/index.ts",
"host": "*",
"name": "Acquisition Manager"
},
"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

@@ -0,0 +1,19 @@
import { defineModule } from "@directus/extensions-sdk";
import ModuleComponent from "./module.vue";
export default defineModule({
id: "acquisition-manager",
name: "Acquisition",
icon: "auto_awesome",
routes: [
{
path: "",
component: ModuleComponent,
},
{
path: ":id",
component: ModuleComponent,
props: true
}
],
});

View File

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

View File

@@ -0,0 +1,50 @@
import { build } from 'esbuild';
import { resolve, dirname } from 'path';
import { mkdirSync } from 'fs';
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const entryPoint = resolve(__dirname, 'src/index.ts');
const outfile = resolve(__dirname, 'dist/index.js');
try {
mkdirSync(dirname(outfile), { recursive: true });
} catch (e) { }
console.log(`Building from ${entryPoint} to ${outfile}...`);
build({
entryPoints: [entryPoint],
bundle: true,
platform: 'node',
target: 'node18',
outfile: outfile,
format: 'esm',
// Bundle everything, including Directus SDK, to avoid resolution issues in Docker
external: [],
plugins: [{
name: 'mock-jquery',
setup(build) {
build.onResolve({ filter: /^jquery$/ }, args => ({ path: args.path, namespace: 'mock-jquery' }));
build.onLoad({ filter: /.*/, namespace: 'mock-jquery' }, () => ({ contents: 'export default {};', loader: 'js' }));
}
}, {
name: 'mock-canvas',
setup(build) {
build.onResolve({ filter: /^canvas$/ }, args => ({ path: args.path, namespace: 'mock-canvas' }));
build.onLoad({ filter: /.*/, namespace: 'mock-canvas' }, () => ({ contents: 'export default {};', loader: 'js' }));
}
}, {
name: 'mock-jsdom',
setup(build) {
return;
}
}]
}).then(() => {
console.log("Build succeeded!");
}).catch((e) => {
console.error("Build failed:", e);
process.exit(1);
});

View File

@@ -0,0 +1,27 @@
{
"name": "acquisition",
"version": "1.7.12",
"type": "module",
"directus:extension": {
"type": "endpoint",
"path": "dist/index.js",
"source": "src/index.ts",
"host": "^11.0.0"
},
"scripts": {
"build": "node build.js",
"dev": "node build.js --watch"
},
"devDependencies": {
"@directus/extensions-sdk": "11.0.2",
"@mintel/acquisition": "workspace:*",
"@mintel/mail": "workspace:*",
"esbuild": "^0.25.0",
"typescript": "^5.6.3"
},
"dependencies": {
"jquery": "^3.7.1",
"react": "^19.2.4",
"react-dom": "^19.2.4"
}
}

View File

@@ -0,0 +1,172 @@
import "./shim";
import { defineEndpoint } from "@directus/extensions-sdk";
import { AcquisitionService, PdfEngine } from "../../acquisition-library/src/index";
import { render, SiteAuditTemplate, ProjectEstimateTemplate } from "@mintel/mail";
import { createElement } from "react";
import * as path from "path";
import * as fs from "fs";
export default defineEndpoint((router, { services, env }) => {
const { ItemsService, MailService } = services;
router.get("/ping", (req, res) => res.send("pong"));
router.post("/audit/:id", async (req: any, res: any) => {
const { id } = req.params;
const leadsService = new ItemsService("leads", { schema: req.schema, accountability: req.accountability });
try {
const lead = await leadsService.readOne(id);
if (!lead) return res.status(404).send({ error: "Lead not found" });
await leadsService.updateOne(id, { status: "auditing" });
const acqService = new AcquisitionService(env.OPENROUTER_API_KEY);
const result = await acqService.runFullSequence(lead.website_url, lead.briefing, lead.comments);
await leadsService.updateOne(id, {
status: "audit_ready",
ai_state: result.state,
audit_context: JSON.stringify(result.usage),
});
res.send({ success: true, result });
} catch (error: any) {
console.error("Audit failed:", error);
await leadsService.updateOne(id, { status: "new", comments: `Audit failed: ${error.message}` });
res.status(500).send({ error: error.message });
}
});
router.post("/audit-email/:id", async (req: any, res: any) => {
const { id } = req.params;
const leadsService = new ItemsService("leads", { schema: req.schema, accountability: req.accountability });
const peopleService = new ItemsService("people", { schema: req.schema, accountability: req.accountability });
const mailService = new MailService({ schema: req.schema, accountability: req.accountability });
try {
const lead = await leadsService.readOne(id);
if (!lead || !lead.ai_state) return res.status(400).send({ error: "Lead or Audit not ready" });
let recipientEmail = lead.contact_email;
let companyName = lead.company_name;
if (lead.contact_person) {
const person = await peopleService.readOne(lead.contact_person);
if (person && person.email) {
recipientEmail = person.email;
companyName = person.company || lead.company_name;
}
}
if (!recipientEmail) return res.status(400).send({ error: "No recipient email found" });
const auditHighlights = [
`Projekt-Typ: ${lead.ai_state.projectType === "website" ? "Website" : "Web App"}`,
...(lead.ai_state.sitemap || []).slice(0, 3).map((item: any) => `Potenzial in: ${item.category}`),
];
const html = await render(createElement(SiteAuditTemplate, {
companyName: companyName,
websiteUrl: lead.website_url,
auditHighlights
}));
await mailService.send({
to: recipientEmail,
subject: `Analyse Ihrer Webpräsenz: ${companyName}`,
html
});
await leadsService.updateOne(id, {
status: "contacted",
last_contacted_at: new Date().toISOString(),
});
res.send({ success: true });
} catch (error: any) {
console.error("Audit Email failed:", error);
res.status(500).send({ error: error.message });
}
});
router.post("/estimate/:id", async (req: any, res: any) => {
const { id } = req.params;
const leadsService = new ItemsService("leads", { schema: req.schema, accountability: req.accountability });
try {
const lead = await leadsService.readOne(id);
if (!lead || !lead.ai_state) return res.status(400).send({ error: "Lead or AI state not found" });
const pdfEngine = new PdfEngine();
const filename = `estimate_${id}_${Date.now()}.pdf`;
const storageRoot = env.STORAGE_LOCAL_ROOT || "./storage";
const outputPath = path.join(storageRoot, filename);
await pdfEngine.generateEstimatePdf(lead.ai_state, outputPath);
await leadsService.updateOne(id, {
audit_pdf_path: filename,
});
res.send({ success: true, filename });
} catch (error: any) {
console.error("PDF Generation failed:", error);
res.status(500).send({ error: error.message });
}
});
router.post("/estimate-email/:id", async (req: any, res: any) => {
const { id } = req.params;
const leadsService = new ItemsService("leads", { schema: req.schema, accountability: req.accountability });
const peopleService = new ItemsService("people", { schema: req.schema, accountability: req.accountability });
const mailService = new MailService({ schema: req.schema, accountability: req.accountability });
try {
const lead = await leadsService.readOne(id);
if (!lead || !lead.audit_pdf_path) return res.status(400).send({ error: "PDF not generated" });
let recipientEmail = lead.contact_email;
let companyName = lead.company_name;
if (lead.contact_person) {
const person = await peopleService.readOne(lead.contact_person);
if (person && person.email) {
recipientEmail = person.email;
companyName = person.company || lead.company_name;
}
}
if (!recipientEmail) return res.status(400).send({ error: "No recipient email found" });
const html = await render(createElement(ProjectEstimateTemplate, {
companyName: companyName,
}));
const storageRoot = env.STORAGE_LOCAL_ROOT || "./storage";
const attachmentPath = path.join(storageRoot, lead.audit_pdf_path);
await mailService.send({
to: recipientEmail,
subject: `Ihre Projekt-Schätzung: ${companyName}`,
html,
attachments: [
{
filename: `Angebot_${companyName}.pdf`,
content: fs.readFileSync(attachmentPath)
}
]
});
await leadsService.updateOne(id, {
status: "contacted",
last_contacted_at: new Date().toISOString(),
});
res.send({ success: true });
} catch (error: any) {
console.error("Estimate Email failed:", error);
res.status(500).send({ error: error.message });
}
});
});

View File

@@ -0,0 +1,22 @@
import { fileURLToPath } from 'url';
import { dirname } from 'path';
import { createRequire } from 'module';
try {
const url = import.meta?.url;
// Hardcode fallback path for Directus Docker environment
const fallbackPath = '/directus/extensions/acquisition/dist/index.js';
const filename = url ? fileURLToPath(url) : fallbackPath;
const dir = dirname(filename);
// @ts-ignore
globalThis.__filename = filename;
// @ts-ignore
globalThis.__dirname = dir;
// @ts-ignore
globalThis.require = createRequire(url || `file://${fallbackPath}`);
console.log(`[Shim] Loaded. __dirname: ${dir}`);
} catch (e) {
console.warn("[Shim] Failed to shim __dirname/require", e);
}

View File

@@ -1,6 +1,6 @@
{
"name": "@mintel/cli",
"version": "1.0.1",
"version": "1.7.12",
"publishConfig": {
"access": "public",
"registry": "https://npm.infra.mintel.me"
@@ -10,7 +10,7 @@
"mintel": "./dist/index.js"
},
"scripts": {
"build": "tsup src/index.ts --format esm --target es2020",
"build": "tsup",
"start": "node dist/index.js",
"dev": "tsup src/index.ts --format esm --watch --target es2020",
"test": "vitest run"

View File

@@ -25,24 +25,25 @@ program
console.log(chalk.cyan("Running Next.js locally..."));
execSync("next dev", { stdio: "inherit" });
} else {
console.log(chalk.cyan("Starting Docker stack (App, Directus, DB)..."));
// Ensure network exists
try {
execSync("docker network create infra", { stdio: "ignore" });
} catch (e) {}
console.log(chalk.cyan("Starting Docker stack (App, Directus, DB)..."));
// Ensure network exists
} catch (_e) {
// Network already exists or docker is not running
}
}
console.log(
chalk.yellow(`
console.log(
chalk.yellow(`
📱 App: http://localhost:3000
🗄️ CMS: http://localhost:8055/admin
🚦 Traefik: http://localhost:8080
`),
);
execSync(
"docker-compose down --remove-orphans && docker-compose up app directus directus-db",
{ stdio: "inherit" },
);
}
);
execSync(
"docker-compose down --remove-orphans && docker-compose up app directus directus-db",
{ stdio: "inherit" },
);
});
const directus = program
@@ -60,6 +61,115 @@ directus
});
});
directus
.command("bootstrap-feedback")
.description("Setup Directus collections and flows for Feedback")
.action(async () => {
const { execSync } = await import("child_process");
console.log(chalk.blue("📧 Bootstrapping Visual Feedback System..."));
// Use the logic from setup-feedback-hardened.ts
const bootstrapScript = `
import { createDirectus, rest, authentication, createCollection, createDashboard, createPanel, createItems, createPermission, readPolicies, readRoles, readUsers } from '@directus/sdk';
async function setup() {
const url = process.env.DIRECTUS_URL || 'http://localhost:8055';
const email = process.env.DIRECTUS_ADMIN_EMAIL;
const password = process.env.DIRECTUS_ADMIN_PASSWORD;
if (!email || !password) {
console.error('❌ DIRECTUS_ADMIN_EMAIL or DIRECTUS_ADMIN_PASSWORD not set');
process.exit(1);
}
const client = createDirectus(url).with(authentication('json')).with(rest());
try {
console.log('🔑 Authenticating...');
await client.login(email, password);
const roles = await client.request(readRoles());
const adminRole = roles.find(r => r.name === 'Administrator');
const policies = await client.request(readPolicies());
const adminPolicy = policies.find(p => p.name === 'Administrator');
console.log('🏗️ Creating Collection "visual_feedback"...');
try {
await client.request(createCollection({
collection: 'visual_feedback',
meta: { icon: 'feedback', display_template: '{{user_name}}: {{text}}' },
fields: [
{ field: 'id', type: 'uuid', schema: { is_primary_key: true } },
{ field: 'status', type: 'string', schema: { default_value: 'open' }, meta: { interface: 'select-dropdown' } },
{ field: 'url', type: 'string' },
{ field: 'selector', type: 'string' },
{ field: 'x', type: 'float' },
{ field: 'y', type: 'float' },
{ field: 'type', type: 'string' },
{ field: 'text', type: 'text' },
{ field: 'user_name', type: 'string' },
{ field: 'user_identity', type: 'string' },
{ field: 'screenshot', type: 'uuid', meta: { interface: 'file' } },
{ field: 'date_created', type: 'timestamp', schema: { default_value: 'NOW()' } }
]
} as any));
} catch (_e) { console.log(' (Collection might already exist)'); }
try {
await client.request(createCollection({
collection: 'visual_feedback_comments',
meta: { icon: 'comment' },
fields: [
{ field: 'id', type: 'integer', schema: { is_primary_key: true, has_auto_increment: true } },
{ field: 'feedback_id', type: 'uuid', meta: { interface: 'select-dropdown' } },
{ field: 'user_name', type: 'string' },
{ field: 'text', type: 'text' },
{ field: 'date_created', type: 'timestamp', schema: { default_value: 'NOW()' } }
]
} as any));
} catch (e) { }
if (adminPolicy) {
console.log('🔐 Granting ALL permissions to Administrator Policy...');
for (const coll of ['visual_feedback', 'visual_feedback_comments']) {
for (const action of ['create', 'read', 'update', 'delete']) {
try {
await client.request(createPermission({
collection: coll,
action,
fields: ['*'],
policy: adminPolicy.id
} as any));
} catch (_e) { }
}
}
}
console.log('📊 Creating Dashboard...');
try {
const dash = await client.request(createDashboard({ name: 'Visual Feedback', icon: 'feedback', color: '#6366f1' }));
await client.request(createPanel({
dashboard: dash.id,
name: 'Total Feedbacks',
type: 'metric',
width: 12, height: 6, position_x: 1, position_y: 1,
options: { collection: 'visual_feedback', function: 'count', field: 'id' }
} as any));
} catch (e) { }
console.log('✨ FEEDBACK BOOTSTRAP DONE.');
} catch (e) { console.error('❌ FAILURE:', e); }
}
setup();
`;
const tempFile = path.join(process.cwd(), "temp-bootstrap-feedback.ts");
await fs.writeFile(tempFile, bootstrapScript);
try {
execSync("npx tsx --env-file=.env " + tempFile, { stdio: "inherit" });
} finally {
await fs.remove(tempFile);
}
});
directus
.command("sync <action> <env>")
.description("Sync Directus data (push/pull) for a specific environment")
@@ -121,7 +231,7 @@ program
"pagespeed:test": "mintel pagespeed",
},
dependencies: {
next: "15.1.6",
next: "16.1.6",
react: "^19.0.0",
"react-dom": "^19.0.0",
"@mintel/next-utils": "workspace:*",

View File

@@ -0,0 +1,11 @@
import { defineConfig } from 'tsup';
export default defineConfig({
entry: ['src/index.ts'],
format: ['esm'],
target: 'es2020',
clean: true,
banner: {
js: '#!/usr/bin/env node',
},
});

View File

Binary file not shown.

View File

@@ -0,0 +1,39 @@
services:
infra-cms:
image: directus/directus:11
ports:
- "8059:8055"
networks:
- default
- infra
environment:
KEY: "infra-cms-key"
SECRET: "infra-cms-secret"
ADMIN_EMAIL: "marc@mintel.me"
ADMIN_PASSWORD: "Tim300493."
DB_CLIENT: "sqlite3"
DB_FILENAME: "/directus/database/data.db"
WEBSOCKETS_ENABLED: "true"
EMAIL_TRANSPORT: "smtp"
EMAIL_SMTP_HOST: "smtp.eu.mailgun.org"
EMAIL_SMTP_PORT: "587"
EMAIL_SMTP_USER: "postmaster@mg.mintel.me"
EMAIL_SMTP_PASSWORD: "4592fcb94599ee1a45b4ac2386fd0a64-102c75d8-ca2870e6"
EMAIL_SMTP_SECURE: "false"
EMAIL_FROM: "postmaster@mg.mintel.me"
volumes:
- ./database:/directus/database
- ./uploads:/directus/uploads
- ./schema:/directus/schema
- ./extensions:/directus/extensions
labels:
- "traefik.enable=true"
- "traefik.http.routers.infra-cms.rule=Host(`cms.localhost`)"
- "traefik.http.services.infra-cms.loadbalancer.server.port=8055"
- "traefik.docker.network=infra"
networks:
default:
name: mintel-infra-cms-internal
infra:
external: true

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,30 @@
{
"name": "acquisition-manager",
"description": "Custom High-Fidelity Acquisition Management for Directus",
"icon": "account_balance_wallet",
"version": "1.7.12",
"type": "module",
"keywords": [
"directus",
"directus-extension",
"directus-extension-module"
],
"files": [
"dist"
],
"directus:extension": {
"type": "module",
"path": "index.js",
"source": "src/index.ts",
"host": "*",
"name": "Acquisition Manager"
},
"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"
}
}

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,30 @@
{
"name": "customer-manager",
"description": "Custom High-Fidelity Customer & Company Management for Directus",
"icon": "supervisor_account",
"version": "1.7.12",
"type": "module",
"keywords": [
"directus",
"directus-extension",
"directus-extension-module"
],
"files": [
"dist"
],
"directus:extension": {
"type": "module",
"path": "index.js",
"source": "src/index.ts",
"host": "*",
"name": "Customer Manager"
},
"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"
}
}

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,30 @@
{
"name": "feedback-commander",
"description": "Custom High-Fidelity Feedback Management Extension for Directus",
"icon": "view_kanban",
"version": "1.7.12",
"type": "module",
"keywords": [
"directus",
"directus-extension",
"directus-extension-module"
],
"files": [
"dist"
],
"directus:extension": {
"type": "module",
"path": "index.js",
"source": "src/index.ts",
"host": "*",
"name": "Feedback Commander"
},
"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"
}
}

File diff suppressed because one or more lines are too long

View File

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

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

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1 @@
xmKX5

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,30 @@
{
"name": "customer-manager",
"description": "Custom High-Fidelity Customer & Company Management for Directus",
"icon": "supervisor_account",
"version": "1.7.12",
"type": "module",
"keywords": [
"directus",
"directus-extension",
"directus-extension-module"
],
"files": [
"dist"
],
"directus:extension": {
"type": "module",
"path": "index.js",
"source": "src/index.ts",
"host": "*",
"name": "Customer Manager"
},
"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

@@ -0,0 +1,14 @@
import { defineModule } from '@directus/extensions-sdk';
import ModuleComponent from './module.vue';
export default defineModule({
id: 'customer-manager',
name: 'Customer Manager',
icon: 'supervisor_account',
routes: [
{
path: '',
component: ModuleComponent,
},
],
});

View File

@@ -0,0 +1,377 @@
<template>
<private-view title="Customer Manager">
<template #navigation>
<v-list nav>
<v-list-item @click="openCreateCompany" clickable>
<v-list-item-icon><v-icon name="add" color="var(--theme--primary)" /></v-list-item-icon>
<v-list-item-content>
<v-text-overflow text="Neue Firma anlegen" />
</v-list-item-content>
</v-list-item>
<v-divider />
<v-list-item
v-for="company in companies"
:key="company.id"
:active="selectedCompany?.id === company.id"
class="company-item"
clickable
@click="selectCompany(company)"
>
<v-list-item-icon><v-icon name="business" /></v-list-item-icon>
<v-list-item-content>
<v-text-overflow :text="company.name" />
</v-list-item-content>
</v-list-item>
</v-list>
</template>
<template #title-outer:after>
<v-notice v-if="notice" :type="notice.type" @close="notice = null" dismissible>
{{ notice.message }}
</v-notice>
</template>
<div class="content-wrapper">
<div v-if="!selectedCompany" class="empty-state">
<v-info title="Firmen auswählen" icon="business" center>
Wähle eine Firma in der Navigation aus oder
<v-button x-small @click="openCreateCompany">erstelle eine neue Firma</v-button>.
</v-info>
</div>
<template v-else>
<header class="header">
<div class="header-left">
<h1 class="title">{{ selectedCompany.name }}</h1>
<p class="subtitle">{{ employees.length }} Kunden-Mitarbeiter</p>
</div>
<div class="header-right">
<v-button secondary rounded icon v-tooltip.bottom="'Firma bearbeiten'" @click="openEditCompany">
<v-icon name="edit" />
</v-button>
<v-button primary @click="openCreateEmployee">
Mitarbeiter hinzufügen
</v-button>
</div>
</header>
<v-table
:headers="tableHeaders"
:items="employees"
:loading="loading"
class="clickable-table"
fixed-header
@click:row="onRowClick"
>
<template #[`item.name`]="{ item }">
<div class="user-cell">
<v-avatar :name="item.first_name" x-small />
<span class="user-name">{{ item.first_name }} {{ item.last_name }}</span>
</div>
</template>
<template #[`item.last_invited`]="{ item }">
<span v-if="item.last_invited" class="status-date">
{{ formatDate(item.last_invited) }}
</span>
<v-chip v-else x-small>Noch nie</v-chip>
</template>
</v-table>
</template>
</div>
<!-- Drawer: Company Form -->
<v-drawer
v-model="drawerCompanyActive"
:title="isEditingCompany ? 'Firma bearbeiten' : 'Neue Firma anlegen'"
icon="business"
@cancel="drawerCompanyActive = false"
>
<div v-if="drawerCompanyActive" class="drawer-content">
<div class="form-section">
<div class="field">
<span class="label">Firmenname</span>
<v-input v-model="companyForm.name" placeholder="z.B. KLZ Cables" autofocus />
</div>
</div>
<div class="drawer-actions">
<v-button primary block :loading="saving" @click="saveCompany">Speichern</v-button>
</div>
</div>
</v-drawer>
<!-- Drawer: Employee Form -->
<v-drawer
v-model="drawerEmployeeActive"
:title="isEditingEmployee ? 'Mitarbeiter bearbeiten' : 'Neuen Mitarbeiter anlegen'"
icon="person"
@cancel="drawerEmployeeActive = false"
>
<div v-if="drawerEmployeeActive" class="drawer-content">
<div class="form-section">
<div class="field">
<span class="label">Vorname</span>
<v-input v-model="employeeForm.first_name" placeholder="Vorname" autofocus />
</div>
<div class="field">
<span class="label">Nachname</span>
<v-input v-model="employeeForm.last_name" placeholder="Nachname" />
</div>
<div class="field">
<span class="label">E-Mail</span>
<v-input v-model="employeeForm.email" placeholder="E-Mail Adresse" type="email" />
</div>
<v-divider v-if="isEditingEmployee" />
<div v-if="isEditingEmployee" class="field">
<span class="label">Temporäres Passwort</span>
<v-input v-model="employeeForm.temporary_password" readonly class="password-input" />
<p class="field-note">Wird beim Senden der Zugangsdaten automatisch generiert.</p>
</div>
</div>
<div class="drawer-actions">
<v-button primary block :loading="saving" @click="saveEmployee">Daten speichern</v-button>
<template v-if="isEditingEmployee">
<v-divider />
<v-button
v-tooltip.bottom="'Generiert PW, speichert es und sendet E-Mail'"
secondary
block
:loading="invitingId === employeeForm.id"
@click="inviteUser(employeeForm)"
>
<v-icon name="send" left /> Zugangsdaten senden
</v-button>
</template>
</div>
</div>
</v-drawer>
</private-view>
</template>
<script setup lang="ts">
import { ref, onMounted, nextTick } from 'vue';
import { useApi } from '@directus/extensions-sdk';
const api = useApi();
const companies = ref<any[]>([]);
const selectedCompany = ref<any>(null);
const employees = ref<any[]>([]);
const loading = ref(false);
const saving = ref(false);
const invitingId = ref<string | null>(null);
const notice = ref<{ type: string; message: string } | null>(null);
// Forms State
const drawerCompanyActive = ref(false);
const isEditingCompany = ref(false);
const companyForm = ref({ id: '', name: '' });
const drawerEmployeeActive = ref(false);
const isEditingEmployee = ref(false);
const employeeForm = ref({
id: '',
first_name: '',
last_name: '',
email: '',
temporary_password: ''
});
const tableHeaders = [
{ text: 'Name', value: 'name', sortable: true },
{ text: 'E-Mail', value: 'email', sortable: true },
{ text: 'Zuletzt eingeladen', value: 'last_invited', sortable: true }
];
async function fetchCompanies() {
const res = await api.get('/items/companies', {
params: {
fields: ['id', 'name'],
sort: 'name',
},
});
companies.value = res.data.data;
}
async function selectCompany(company: any) {
selectedCompany.value = company;
loading.value = true;
try {
const res = await api.get('/items/client_users', {
params: {
filter: { company: { _eq: company.id } },
fields: ['*'],
sort: 'first_name',
},
});
employees.value = res.data.data;
} finally {
loading.value = false;
}
}
// Company Actions
function openCreateCompany() {
isEditingCompany.value = false;
companyForm.value = { id: '', name: '' };
drawerCompanyActive.value = true;
}
async function openEditCompany() {
if (!selectedCompany.value) return;
companyForm.value = {
id: selectedCompany.value.id,
name: selectedCompany.value.name
};
isEditingCompany.value = true;
await nextTick();
drawerCompanyActive.value = true;
}
async function saveCompany() {
if (!companyForm.value.name) return;
saving.value = true;
try {
if (isEditingCompany.value) {
await api.patch(`/items/companies/${companyForm.value.id}`, { name: companyForm.value.name });
notice.value = { type: 'success', message: 'Firma aktualisiert!' };
} else {
await api.post('/items/companies', { name: companyForm.value.name });
notice.value = { type: 'success', message: 'Firma angelegt!' };
}
drawerCompanyActive.value = false;
await fetchCompanies();
if (selectedCompany.value?.id === companyForm.value.id) {
selectedCompany.value.name = companyForm.value.name;
}
} catch (e: any) {
notice.value = { type: 'danger', message: e.message };
} finally {
saving.value = false;
}
}
// Employee Actions
function openCreateEmployee() {
isEditingEmployee.value = false;
employeeForm.value = { id: '', first_name: '', last_name: '', email: '', temporary_password: '' };
drawerEmployeeActive.value = true;
}
async function openEditEmployee(item: any) {
employeeForm.value = {
id: item.id || '',
first_name: item.first_name || '',
last_name: item.last_name || '',
email: item.email || '',
temporary_password: item.temporary_password || ''
};
isEditingEmployee.value = true;
await nextTick();
drawerEmployeeActive.value = true;
}
async function saveEmployee() {
if (!employeeForm.value.email || !selectedCompany.value) return;
saving.value = true;
try {
if (isEditingEmployee.value) {
await api.patch(`/items/client_users/${employeeForm.value.id}`, {
first_name: employeeForm.value.first_name,
last_name: employeeForm.value.last_name,
email: employeeForm.value.email
});
notice.value = { type: 'success', message: 'Mitarbeiter aktualisiert!' };
} else {
await api.post('/items/client_users', {
first_name: employeeForm.value.first_name,
last_name: employeeForm.value.last_name,
email: employeeForm.value.email,
company: selectedCompany.value.id
});
notice.value = { type: 'success', message: 'Mitarbeiter angelegt!' };
}
drawerEmployeeActive.value = false;
await selectCompany(selectedCompany.value);
} catch (e: any) {
notice.value = { type: 'danger', message: e.message };
} finally {
saving.value = false;
}
}
async function inviteUser(user: any) {
invitingId.value = user.id;
try {
await api.post(`/flows/trigger/33443f6b-cec7-4668-9607-f33ea674d501`, [user.id]);
notice.value = { type: 'success', message: `Zugangsdaten für ${user.first_name} versendet. 📧` };
await selectCompany(selectedCompany.value);
if (drawerEmployeeActive.value && employeeForm.value.id === user.id) {
const updated = employees.value.find(e => e.id === user.id);
if (updated) {
employeeForm.value.temporary_password = updated.temporary_password;
}
}
} catch (e: any) {
notice.value = { type: 'danger', message: `Fehler: ${e.message}` };
} finally {
invitingId.value = null;
}
}
function onRowClick(event: any) {
const item = event?.item || event;
if (item && item.id) {
openEditEmployee(item);
}
}
function formatDate(dateStr: string) {
return new Date(dateStr).toLocaleString('de-DE', {
day: '2-digit', month: '2-digit', year: 'numeric',
hour: '2-digit', minute: '2-digit'
});
}
onMounted(() => {
fetchCompanies();
});
</script>
<style scoped>
.content-wrapper { padding: 32px; height: 100%; display: flex; flex-direction: column; }
.company-item { cursor: pointer; }
.header { margin-bottom: 24px; display: flex; justify-content: space-between; align-items: flex-end; }
.header-right { display: flex; gap: 12px; }
.title { font-size: 24px; font-weight: 800; margin-bottom: 4px; }
.subtitle { color: var(--theme--foreground-subdued); font-size: 14px; }
.empty-state { height: 100%; display: flex; align-items: center; justify-content: center; }
.user-cell { display: flex; align-items: center; gap: 12px; }
.user-name { font-weight: 600; }
.status-date { font-size: 12px; color: var(--theme--foreground-subdued); }
.drawer-content { padding: 24px; display: flex; flex-direction: column; gap: 32px; }
.form-section { display: flex; flex-direction: column; gap: 20px; }
.field { display: flex; flex-direction: column; gap: 8px; }
.label { font-size: 12px; font-weight: 700; text-transform: uppercase; color: var(--theme--foreground-subdued); letter-spacing: 0.5px; }
.field-note { font-size: 11px; color: var(--theme--foreground-subdued); margin-top: 4px; }
.drawer-actions { margin-top: 24px; display: flex; flex-direction: column; gap: 12px; }
.password-input :deep(textarea) {
font-family: var(--family-monospace);
font-weight: 800;
color: var(--theme--primary) !important;
background: var(--theme--background-subdued) !important;
}
.clickable-table :deep(tbody tr) { cursor: pointer; transition: background-color 0.2s ease; }
.clickable-table :deep(tbody tr:hover) { background-color: var(--theme--background-subdued) !important; }
:deep(.v-list-item) { cursor: pointer !important; }
</style>

View File

@@ -3,13 +3,21 @@ import tseslint from "typescript-eslint";
export default tseslint.config(
{
ignores: ["**/dist/**", "**/node_modules/**", "**/.next/**"],
ignores: ["**/dist/**", "**/node_modules/**", "**/.next/**", "**/build/**"],
},
js.configs.recommended,
...tseslint.configs.recommended,
{
rules: {
"no-unused-vars": "warn",
"no-unused-vars": "off",
"@typescript-eslint/no-unused-vars": [
"warn",
{
argsIgnorePattern: "^_",
varsIgnorePattern: "^_",
caughtErrorsIgnorePattern: "^_",
},
],
"no-console": "off",
"@typescript-eslint/no-explicit-any": "off",
},

View File

@@ -1,40 +1,41 @@
import { dirname } from "path";
import { fileURLToPath } from "url";
import { FlatCompat } from "@eslint/eslintrc";
import nextPlugin from "@next/eslint-plugin-next";
import reactPlugin from "eslint-plugin-react";
import hooksPlugin from "eslint-plugin-react-hooks";
import tseslint from "typescript-eslint";
import js from "@eslint/js";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const compat = new FlatCompat({
baseDirectory: __dirname,
});
export const nextConfig = [
{
ignores: [
"**/dist/**",
"**/build/**",
"**/out/**",
"**/coverage/**",
"**/.next/**",
"**/node_modules/**",
"**/.gitea/**",
"**/.changeset/**",
"**/.vercel/**",
],
},
...compat.extends("next/core-web-vitals", "next/typescript"),
/**
* Mintel Next.js ESLint Configuration (Flat Config)
*
* This configuration replaces the legacy 'eslint-config-next' which
* relies on @rushstack/eslint-patch and causes issues in ESLint 9.
*/
export const nextConfig = tseslint.config(
{
plugins: {
"react": reactPlugin,
"react-hooks": hooksPlugin,
"@next/next": nextPlugin,
},
languageOptions: {
globals: {
// Add common browser/node globals if needed,
// though usually handled by base configs
},
},
rules: {
"@typescript-eslint/no-explicit-any": "off",
"@typescript-eslint/no-unused-vars": [
"warn",
{ argsIgnorePattern: "^_" },
],
"@typescript-eslint/no-require-imports": "off",
"prefer-const": "warn",
...reactPlugin.configs.recommended.rules,
...hooksPlugin.configs.recommended.rules,
...nextPlugin.configs.recommended.rules,
...nextPlugin.configs["core-web-vitals"].rules,
"react/react-in-jsx-scope": "off",
"react/no-unescaped-entities": "off",
"@next/next/no-img-element": "warn",
},
},
];
settings: {
react: {
version: "detect",
},
},
}
);

View File

@@ -1,6 +1,6 @@
{
"name": "@mintel/eslint-config",
"version": "1.0.1",
"version": "1.7.12",
"publishConfig": {
"access": "public",
"registry": "https://npm.infra.mintel.me"
@@ -20,8 +20,10 @@
"dependencies": {
"@eslint/eslintrc": "^3.0.0",
"@eslint/js": "^9.39.2",
"@next/eslint-plugin-next": "15.1.6",
"eslint-config-next": "15.1.6",
"@next/eslint-plugin-next": "16.1.6",
"eslint-config-next": "16.1.6",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^7.0.1",
"typescript-eslint": "^8.54.0"
}
}

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,30 @@
{
"name": "feedback-commander",
"description": "Custom High-Fidelity Feedback Management Extension for Directus",
"icon": "view_kanban",
"version": "1.7.12",
"type": "module",
"keywords": [
"directus",
"directus-extension",
"directus-extension-module"
],
"files": [
"dist"
],
"directus:extension": {
"type": "module",
"path": "index.js",
"source": "src/index.ts",
"host": "*",
"name": "Feedback Commander"
},
"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

@@ -0,0 +1,14 @@
import { defineModule } from '@directus/extensions-sdk';
import ModuleComponent from './module.vue';
export default defineModule({
id: 'feedback-commander',
name: 'Feedback Commander',
icon: 'view_kanban',
routes: [
{
path: '',
component: ModuleComponent,
},
],
});

View File

@@ -0,0 +1,723 @@
<template>
<private-view title="Feedback Commander">
<template #headline>
<v-breadcrumb :items="[{ name: 'Feedback', to: '/feedback-commander' }]" />
</template>
<template #title-outer:after>
<v-chip v-if="loading" label color="blue" small>Loading...</v-chip>
<v-chip v-else-if="fetchError" label color="red" small>Fetch Error</v-chip>
<v-chip v-else label color="green" small>{{ items.length }} Items</v-chip>
</template>
<template #navigation>
<div class="sidebar-header">
<v-text-overflow text="Websites" class="header-text" />
</div>
<v-list nav>
<v-list-item
:active="currentProject === 'all'"
@click="currentProject = 'all'"
clickable
>
<v-list-item-icon><v-icon name="language" /></v-list-item-icon>
<v-list-item-content><v-text-overflow text="All Projects" /></v-list-item-content>
</v-list-item>
<v-list-item
v-for="project in projects"
:key="project"
:active="currentProject === project"
@click="currentProject = project"
clickable
>
<v-list-item-icon><v-icon name="public" color="var(--primary)" /></v-list-item-icon>
<v-list-item-content><v-text-overflow :text="project || 'Unknown'" /></v-list-item-content>
</v-list-item>
</v-list>
</template>
<div class="feedback-container">
<div v-if="!items.length && !loading && !fetchError" class="empty-state">
<v-info icon="inbox" title="Clean Inbox" center>
All feedback has been processed. Great job!
</v-info>
</div>
<div v-if="fetchError" class="empty-state">
<v-info icon="error" title="Fetch Failed" :description="fetchError" center />
<v-button @click="fetchData" secondary small>Retry</v-button>
</div>
<div class="operational-layout" v-else-if="items.length">
<!-- Detailed Triage Lane -->
<aside class="triage-lane">
<div class="lane-header">
<v-select
v-model="currentStatusFilter"
:items="statusOptions"
small
placeholder="Status Filter"
/>
</div>
<div class="lane-content scrollbar">
<TransitionGroup name="list">
<div
v-for="item in filteredItems"
:key="item.id"
class="feedback-card"
:class="{ active: selectedItem?.id === item.id }"
@click="selectItem(item)"
>
<div class="card-status-bar" :style="{ background: getStatusColor(item.status || 'open') }"></div>
<div class="card-body">
<header class="card-header">
<span class="card-user">{{ item.user_name }}</span>
<span class="card-date">{{ formatDate(item.date_created || item.id) }}</span>
</header>
<div class="card-text">{{ item.text }}</div>
<footer class="card-footer">
<div class="meta-tags">
<v-chip x-small outline>{{ item.project }}</v-chip>
<v-icon :name="item.type === 'bug' ? 'bug_report' : 'lightbulb'" :color="item.type === 'bug' ? '#E91E63' : '#FFC107'" small />
</div>
<v-icon v-if="selectedItem?.id === item.id" name="chevron_right" small />
</footer>
</div>
</div>
</TransitionGroup>
</div>
</aside>
<!-- Elaborated Master-Detail Desk -->
<main class="processing-desk scrollbar">
<Transition name="fade" mode="out-in">
<div v-if="selectedItem" :key="selectedItem.id" class="desk-content">
<header class="desk-header">
<div class="headline-group">
<div class="status-indicator">
<div class="status-dot" :style="{ background: getStatusColor(selectedItem.status || 'open') }"></div>
<span class="status-text">{{ capitalize(selectedItem.status || 'open') }}</span>
</div>
<h2>{{ selectedItem.user_name }}'s Submission</h2>
</div>
<div class="header-actions">
<v-button primary @click="openDeepLink(selectedItem)">
<v-icon name="open_in_new" left /> Open & Highlight
</v-button>
<v-select
v-model="selectedItem.status"
:items="statuses"
inline
@update:model-value="updateStatus"
/>
</div>
</header>
<div class="desk-grid">
<!-- Message Container -->
<div class="main-column">
<v-card class="content-card">
<v-card-title>
<v-icon name="format_quote" left />
Feedback Content
</v-card-title>
<v-card-text class="feedback-body">
<div v-if="selectedItem.screenshot" class="visual-proof">
<label class="proof-label"><v-icon name="photo" x-small /> Element Snapshot</label>
<img :src="getAssetUrl(selectedItem.screenshot)" class="screenshot-img" />
</div>
<div class="main-text">{{ selectedItem.text }}</div>
</v-card-text>
</v-card>
<section class="reply-section">
<div class="section-divider">
<v-divider />
<span class="divider-label">Internal Communication</span>
<v-divider />
</div>
<div class="thread">
<TransitionGroup name="thread-list">
<div v-for="reply in comments" :key="reply.id" class="reply-bubble">
<header class="reply-header">
<span class="reply-user">{{ reply.user_name }}</span>
<span class="reply-date">{{ formatDate(reply.date_created || reply.id) }}</span>
</header>
<div class="reply-text">{{ reply.text }}</div>
</div>
</TransitionGroup>
<div v-if="!comments.length" class="empty-state-mini">
<v-icon name="auto_awesome" small /> No replies yet. Start the thread.
</div>
</div>
<div class="composer">
<v-textarea v-model="replyText" placeholder="Compose internal response..." auto-grow />
<div class="composer-actions">
<v-button secondary :loading="sending" @click="sendReply">Post Reply</v-button>
</div>
</div>
</section>
</div>
<!-- Technical Sidebar -->
<aside class="meta-column">
<v-card class="meta-card">
<v-card-title>Context</v-card-title>
<v-card-text class="meta-list">
<div class="meta-item">
<label><v-icon name="public" x-small /> Website</label>
<strong>{{ selectedItem.project }}</strong>
</div>
<div class="meta-item">
<label><v-icon name="link" x-small /> Source Path</label>
<span class="truncate-path" :title="selectedItem.url">{{ formatUrl(selectedItem.url) }}</span>
<v-button icon small @click="openExternal(selectedItem.url)"><v-icon name="launch" /></v-button>
</div>
<v-divider />
<div class="meta-item">
<label><v-icon name="layers" x-small /> Element Trace</label>
<code class="trace-code">{{ selectedItem.selector || 'Body' }}</code>
</div>
<div class="meta-item">
<label><v-icon name="location_searching" x-small /> Precise Mark</label>
<span class="coords">X: {{ Math.round(selectedItem.x) }}px / Y: {{ Math.round(selectedItem.y) }}px</span>
</div>
<div class="meta-item">
<label><v-icon name="fingerprint" x-small /> Reference ID</label>
<code class="id-code">{{ selectedItem.id }}</code>
</div>
</v-card-text>
</v-card>
<div class="help-box">
<v-icon name="help_outline" x-small />
<span>Click "Open & Highlight" to jump directly to this element on the live site.</span>
</div>
</aside>
</div>
</div>
<div v-else class="no-selection-desk">
<v-info icon="touch_app" title="Select Feedback" center>
Choose an entry from the triage list to view details and process.
</v-info>
</div>
</Transition>
</main>
</div>
</div>
</private-view>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue';
import { useApi } from '@directus/extensions-sdk';
const api = useApi();
const items = ref([]);
const comments = ref([]);
const loading = ref(true);
const fetchError = ref(null);
const sending = ref(false);
const selectedItem = ref(null);
const currentProject = ref('all');
const currentStatusFilter = ref('open');
const replyText = ref('');
const statuses = [
{ text: 'Open', value: 'open', icon: 'warning', color: '#E91E63' },
{ text: 'In Progress', value: 'in_progress', icon: 'play_arrow', color: '#2196F3' },
{ text: 'Resolved', value: 'resolved', icon: 'check_circle', color: '#4CAF50' }
];
const statusOptions = [
{ text: 'All Statuses', value: 'all' },
...statuses
];
const projects = computed(() => {
const projSet = new Set(items.value.map(i => i.project).filter(Boolean));
return Array.from(projSet).sort();
});
const filteredItems = computed(() => {
return items.value.filter(item => {
const matchProject = currentProject.value === 'all' || item.project === currentProject.value;
const status = item.status || 'open';
const matchStatus = currentStatusFilter.value === 'all' || status === currentStatusFilter.value;
return matchProject && matchStatus;
});
});
async function fetchData() {
loading.value = true;
fetchError.value = null;
try {
const response = await api.get('/items/visual_feedback', {
params: {
sort: '-date_created,-id',
limit: 300
}
});
items.value = response.data.data;
} catch (e: any) {
fetchError.value = e.message;
} finally {
loading.value = false;
}
}
async function selectItem(item) {
selectedItem.value = null;
setTimeout(async () => {
selectedItem.value = item;
comments.value = [];
try {
const response = await api.get('/items/visual_feedback_comments', {
params: {
filter: { feedback_id: { _eq: item.id } },
sort: '-date_created,-id'
}
});
comments.value = response.data.data;
} catch (e) {
console.error(e);
}
}, 10);
}
async function updateStatus(val) {
if (!selectedItem.value) return;
try {
await api.patch(`/items/visual_feedback/${selectedItem.value.id}`, {
status: val
});
fetchData();
} catch (e) {
console.error(e);
}
}
async function sendReply() {
if (!replyText.value.trim() || !selectedItem.value) return;
sending.value = true;
try {
const response = await api.post('/items/visual_feedback_comments', {
feedback_id: selectedItem.value.id,
user_name: 'Operator',
text: replyText.value
});
comments.value.unshift(response.data.data);
replyText.value = '';
} catch (e) {
console.error(e);
} finally {
sending.value = false;
}
}
function formatDate(dateStr) {
if (!dateStr || typeof dateStr === 'number') return 'Legacy';
return new Date(dateStr).toLocaleDateString() + ' ' + new Date(dateStr).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
}
function formatUrl(url) {
if (!url) return '';
return url.replace(/^https?:\/\//, '');
}
function capitalize(s) {
return s.charAt(0).toUpperCase() + s.slice(1).replace('_', ' ');
}
function getDeepLinkUrl(item) {
if (!item || !item.url) return '';
try {
const url = new URL(item.url);
url.searchParams.set('fb_id', item.id);
return url.toString();
} catch (e) {
return item.url + '?fb_id=' + item.id;
}
}
function openDeepLink(item) {
const url = getDeepLinkUrl(item);
if (url) window.open(url, '_blank');
}
function openExternal(url) {
if (url) window.open(url, '_blank');
}
function getAssetUrl(id) {
if (!id) return '';
return `/assets/${id}`;
}
function getStatusColor(status) {
const s = statuses.find(st => st.value === status);
return s ? s.color : 'var(--foreground-subdued)';
}
onMounted(() => {
fetchData();
});
</script>
<style scoped>
.feedback-container {
height: calc(100vh - 64px);
display: flex;
flex-direction: column;
background: var(--background-subdued);
}
.operational-layout {
display: flex;
height: 100%;
}
/* Triage Lane Polish */
.triage-lane {
width: 360px;
height: 100%;
display: flex;
flex-direction: column;
background: var(--background-normal);
border-right: 1px solid var(--border-normal);
box-shadow: 2px 0 8px rgba(0,0,0,0.02);
}
.lane-header {
padding: 16px;
background: var(--background-normal);
border-bottom: 1px solid var(--border-normal);
}
.lane-content {
flex: 1;
overflow-y: auto;
padding: 12px;
display: flex;
flex-direction: column;
gap: 12px;
}
.feedback-card {
background: var(--background-normal);
border: 1px solid var(--border-subdued);
border-radius: 8px;
display: flex;
overflow: hidden;
cursor: pointer;
transition: all var(--transition);
}
.feedback-card:hover {
border-color: var(--border-normal);
background: var(--background-subdued);
transform: translateY(-1px);
box-shadow: 0 4px 8px rgba(0,0,0,0.04);
}
.feedback-card.active {
border-color: var(--primary);
background: var(--background-accent);
box-shadow: 0 4px 12px rgba(var(--primary-rgb), 0.1);
}
.card-status-bar {
width: 4px;
}
.card-body {
flex: 1;
padding: 12px;
display: flex;
flex-direction: column;
gap: 8px;
}
.card-header {
display: flex;
justify-content: space-between;
font-size: 11px;
}
.card-user { font-weight: bold; color: var(--foreground-normal); }
.card-date { color: var(--foreground-subdued); }
.card-text {
font-size: 13px;
line-height: 1.5;
color: var(--foreground-normal);
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.card-footer {
display: flex;
justify-content: space-between;
align-items: center;
}
.meta-tags {
display: flex;
gap: 8px;
align-items: center;
}
/* Processing Desk Refinement */
.processing-desk {
flex: 1;
height: 100%;
overflow-y: auto;
padding: 32px;
}
.desk-content {
max-width: 1100px;
margin: 0 auto;
}
.desk-header {
display: flex;
justify-content: space-between;
align-items: flex-end;
margin-bottom: 32px;
border-bottom: 2px solid var(--border-normal);
padding-bottom: 20px;
}
.headline-group {
display: flex;
flex-direction: column;
gap: 8px;
}
.status-indicator {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
font-weight: bold;
text-transform: uppercase;
color: var(--foreground-subdued);
}
.status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
}
.status-text { letter-spacing: 0.5px; }
.header-actions {
display: flex;
gap: 16px;
align-items: center;
}
.desk-grid {
display: grid;
grid-template-columns: 1fr 300px;
gap: 24px;
align-items: start;
}
.content-card {
border-radius: 12px;
overflow: hidden;
}
.feedback-body {
font-size: 18px;
line-height: 1.6;
padding: 24px;
color: var(--foreground-normal);
display: flex;
flex-direction: column;
gap: 20px;
}
.visual-proof {
display: flex;
flex-direction: column;
gap: 8px;
}
.proof-label {
font-size: 10px;
text-transform: uppercase;
font-weight: 800;
color: var(--foreground-subdued);
letter-spacing: 0.5px;
}
.screenshot-img {
width: 100%;
border-radius: 8px;
border: 1px solid var(--border-normal);
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
background: var(--background-subdued);
}
.main-text {
white-space: pre-wrap;
}
.reply-section {
margin-top: 40px;
}
.section-divider {
display: flex;
align-items: center;
gap: 16px;
margin-bottom: 24px;
}
.divider-label {
font-size: 11px;
text-transform: uppercase;
font-weight: 800;
color: var(--foreground-subdued);
white-space: nowrap;
letter-spacing: 1px;
}
.thread {
display: flex;
flex-direction: column;
gap: 16px;
margin-bottom: 32px;
}
.reply-bubble {
padding: 16px;
border-radius: 12px;
background: var(--background-normal);
border: 1px solid var(--border-subdued);
}
.reply-header {
display: flex;
justify-content: space-between;
font-size: 11px;
margin-bottom: 8px;
}
.reply-user { font-weight: 800; color: var(--primary); }
.reply-date { color: var(--foreground-subdued); }
.reply-text { font-size: 14px; line-height: 1.5; }
.composer {
background: var(--background-normal);
border: 1px solid var(--border-normal);
border-radius: 12px;
padding: 16px;
}
.composer-actions {
display: flex;
justify-content: flex-end;
margin-top: 12px;
}
.meta-card {
border-radius: 12px;
}
.meta-list {
display: flex;
flex-direction: column;
gap: 16px;
padding: 16px;
}
.meta-item {
display: flex;
flex-direction: column;
gap: 4px;
font-size: 13px;
}
.meta-item label {
font-size: 10px;
text-transform: uppercase;
font-weight: bold;
color: var(--foreground-subdued);
display: flex;
align-items: center;
gap: 4px;
}
.truncate-path {
color: var(--primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.trace-code, .id-code {
background: var(--background-subdued);
padding: 4px 8px;
border-radius: 4px;
font-size: 11px;
word-break: break-all;
}
.coords { font-weight: bold; font-family: var(--family-monospace); }
.help-box {
margin-top: 20px;
padding: 16px;
background: rgba(var(--primary-rgb), 0.05);
border-radius: 12px;
font-size: 12px;
color: var(--primary);
display: flex;
gap: 8px;
line-height: 1.4;
}
.no-selection-desk {
height: 100%;
display: flex;
align-items: center;
justify-content: center;
}
.empty-state-mini {
text-align: center;
padding: 24px;
font-size: 12px;
color: var(--foreground-subdued);
background: var(--background-subdued);
border-radius: 12px;
border: 1px dashed var(--border-normal);
}
/* Animations */
.list-enter-active, .list-leave-active { transition: all 0.3s ease; }
.list-enter-from, .list-leave-to { opacity: 0; transform: translateX(-20px); }
.fade-enter-active, .fade-leave-active { transition: opacity 0.2s ease, transform 0.2s ease; }
.fade-enter-from { opacity: 0; transform: translateY(10px); }
.fade-leave-to { opacity: 0; transform: translateY(-10px); }
.thread-list-enter-active { transition: all 0.4s ease; transform-origin: top; }
.thread-list-enter-from { opacity: 0; transform: scaleY(0.9); }
.scrollbar::-webkit-scrollbar { width: 6px; }
.scrollbar::-webkit-scrollbar-track { background: transparent; }
.scrollbar::-webkit-scrollbar-thumb { background: var(--border-subdued); border-radius: 3px; }
.scrollbar::-webkit-scrollbar-thumb:hover { background: var(--border-normal); }
</style>

View File

@@ -2,7 +2,7 @@ import mintelNextConfig from "@mintel/next-config";
import { NextConfig } from "next";
const nextConfig: NextConfig = {
// Gatekeeper specific overrides
basePath: '/gatekeeper',
};
export default mintelNextConfig(nextConfig);

View File

@@ -1,20 +1,20 @@
{
"name": "@mintel/gatekeeper",
"version": "1.0.0",
"version": "1.7.12",
"private": true,
"type": "module",
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint",
"lint": "eslint src/",
"test": "vitest run"
},
"dependencies": {
"@mintel/next-utils": "workspace:*",
"clsx": "^2.1.1",
"lucide-react": "^0.474.0",
"next": "15.1.6",
"next": "16.1.6",
"next-intl": "^4.8.2",
"react": "^19.0.0",
"react-dom": "^19.0.0",

View File

@@ -1,3 +1,4 @@
/* global module */
module.exports = {
plugins: {
tailwindcss: {},

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";
@@ -44,7 +46,7 @@ export async function GET(req: NextRequest) {
return response;
}
} catch (e) {
} catch (_e) {
// URL parsing failed, proceed with normal logic
}
@@ -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
} catch (_e) {
console.log(`[Verify] JSON Parse failed for cookie: ${session.value.substring(0, 10)}...`);
}
}
}

View File

@@ -1,7 +1,7 @@
import { NextRequest, NextResponse } from "next/server";
import { cookies } from "next/headers";
export async function GET(req: NextRequest) {
export async function GET(_req: NextRequest) {
const cookieStore = await cookies();
const authCookieName =
process.env.AUTH_COOKIE_NAME || "mintel_gatekeeper_session";
@@ -12,15 +12,18 @@ export async function GET(req: NextRequest) {
}
let identity = "Guest";
let company = null;
try {
const payload = JSON.parse(session.value);
identity = payload.identity || "Guest";
} catch (e) {
company = payload.company || null;
} catch (_e) {
// Old format probably just the password
}
return NextResponse.json({
authenticated: true,
identity: identity,
company: company,
});
}

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;
@@ -29,22 +29,59 @@ export default async function LoginPage({ searchParams }: LoginPageProps) {
const cookieDomain = process.env.COOKIE_DOMAIN;
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 {
const clientUsersRes = await fetch(
`${process.env.INFRA_DIRECTUS_URL}/items/client_users?filter[email][_eq]=${encodeURIComponent(
email,
)}&fields=*,company.*`,
{
headers: {
Authorization: `Bearer ${process.env.INFRA_DIRECTUS_TOKEN}`,
},
},
);
if (clientUsersRes.ok) {
const { data: users } = await clientUsersRes.json();
const clientUser = users[0];
// ⚠️ NOTE: Plain text check for demo/dev, should use argon2 in production
if (
clientUser &&
(clientUser.password === password ||
clientUser.temporary_password === password)
) {
userIdentity = clientUser.first_name || clientUser.email;
userCompany = {
id: clientUser.company?.id,
name: clientUser.company?.name,
};
}
}
} catch (e) {
console.error("Client User Auth Error:", e);
}
}
// 3. Check Directus if email is provided
if (email && password && process.env.DIRECTUS_URL) {
// 4. Fallback to Directus Staff Auth if still not identified
if (!userIdentity && email && password && process.env.DIRECTUS_URL) {
try {
const loginRes = await fetch(`${process.env.DIRECTUS_URL}/auth/login`, {
method: "POST",
@@ -56,14 +93,21 @@ export default async function LoginPage({ searchParams }: LoginPageProps) {
const { data } = await loginRes.json();
const accessToken = data.access_token;
// Fetch user info to get a nice display name
const userRes = await fetch(`${process.env.DIRECTUS_URL}/users/me`, {
headers: { Authorization: `Bearer ${accessToken}` },
});
// Fetch user info with company depth
const userRes = await fetch(
`${process.env.DIRECTUS_URL}/users/me?fields=*,company.*`,
{
headers: { Authorization: `Bearer ${accessToken}` },
},
);
if (userRes.ok) {
const { data: user } = await userRes.json();
userIdentity = user.first_name || user.email;
userCompany = {
id: user.company?.id,
name: user.company?.name,
};
}
}
} catch (e) {
@@ -72,15 +116,19 @@ 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({
identity: userIdentity,
company: userCompany,
timestamp: Date.now(),
});
const isDev = process.env.NODE_ENV === "development";
console.log(`[Login] Setting Cookie: ${authCookieName} | Domain: ${cookieDomain || "Default"}`);
cookieStore.set(authCookieName, sessionValue, {
httpOnly: true,
secure: !isDev,
@@ -91,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,3 +1,4 @@
/* global module, require */
/** @type {import('tailwindcss').Config} */
module.exports = {
content: [
@@ -55,5 +56,6 @@ module.exports = {
},
},
},
// eslint-disable-next-line @typescript-eslint/no-require-imports
plugins: [require("@tailwindcss/typography")],
};

View File

@@ -1,4 +1,5 @@
import path from "path";
/* global process */
import path from "node:path";
const buildLintCommand = (filenames) => {
const isNext =
@@ -11,7 +12,7 @@ const buildLintCommand = (filenames) => {
.join(" --file ")}`;
}
return "eslint --fix";
return "eslint --fix --no-warn-ignored";
};
const config = {

View File

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

View File

@@ -3,18 +3,36 @@ FROM node:20-alpine AS builder
RUN apk add --no-cache libc6-compat curl
WORKDIR /app
RUN corepack enable pnpm
ENV CI=true
# Copy source (honoring .dockerignore)
COPY . .
# Copy manifest files specifically for better layer caching
COPY pnpm-lock.yaml pnpm-workspace.yaml package.json .npmrc ./
COPY packages/gatekeeper/package.json ./packages/gatekeeper/package.json
COPY packages/next-utils/package.json ./packages/next-utils/package.json
COPY packages/eslint-config/package.json ./packages/eslint-config/package.json
COPY packages/next-config/package.json ./packages/next-config/package.json
COPY packages/tsconfig/package.json ./packages/tsconfig/package.json
COPY packages/infra/package.json ./packages/infra/package.json
COPY packages/cms-infra/package.json ./packages/cms-infra/package.json
COPY packages/mail/package.json ./packages/mail/package.json
COPY packages/cli/package.json ./packages/cli/package.json
COPY packages/observability/package.json ./packages/observability/package.json
COPY packages/next-observability/package.json ./packages/next-observability/package.json
COPY packages/husky-config/package.json ./packages/husky-config/package.json
# Use a secret for NPM_TOKEN to authenticate with private registry
RUN --mount=type=cache,target=/root/.local/share/pnpm/store/v3 \
# Use a secret for NPM_TOKEN and a cache mount for the pnpm store
RUN --mount=type=cache,id=pnpm,target=/pnpm/store \
--mount=type=secret,id=NPM_TOKEN \
export NPM_TOKEN=$(cat /run/secrets/NPM_TOKEN) && \
pnpm config set store-dir /pnpm/store && \
pnpm i --frozen-lockfile
# Copy the rest of the source
COPY . .
# Build Gatekeeper and its dependencies
RUN pnpm --filter @mintel/gatekeeper... build
RUN --mount=type=cache,target=/app/packages/gatekeeper/.next/cache \
pnpm --filter @mintel/gatekeeper... build
RUN mkdir -p packages/gatekeeper/public
# Step 2: Runner stage

View File

@@ -1,19 +1,13 @@
# Step 1: Builder image
FROM node:20-alpine AS builder
# Step 1: Base image for Next.js builds
FROM node:20-alpine
RUN apk add --no-cache libc6-compat curl
# Enable pnpm
RUN corepack enable pnpm && \
corepack prepare pnpm@10.2.0 --activate
WORKDIR /app
RUN corepack enable pnpm
# Step 2: Install dependencies
# We copy everything first because we have a .dockerignore
# and we need the workspace structure for pnpm to work correctly
COPY . .
# Use a secret for NPM_TOKEN to authenticate with private registry
RUN --mount=type=cache,target=/root/.local/share/pnpm/store/v3 \
--mount=type=secret,id=NPM_TOKEN \
export NPM_TOKEN=$(cat /run/secrets/NPM_TOKEN) && \
pnpm i --frozen-lockfile
# Step 3: Build shared packages
RUN pnpm --filter "./packages/*" -r build
# Final environment
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1

View File

@@ -1,19 +1,22 @@
FROM node:20-alpine
FROM node:20-alpine AS runner
RUN apk add --no-cache libc6-compat curl
# Install essential production utilities
RUN apk add --no-cache curl libc6-compat
WORKDIR /app
# Set standard production environment
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"
WORKDIR /app
# Create non-root user for security
RUN addgroup --system --gid 1001 nodejs && \
adduser --system --uid 1001 nextjs
# Expose the default Next.js port
# Set correct permissions
RUN chown -R nextjs:nodejs /app
USER nextjs
EXPOSE 3000
CMD ["node", "server.js"]

View File

@@ -53,7 +53,7 @@ services:
- "traefik.http.services.${PROJECT_NAME}-gatekeeper.loadbalancer.server.port=3000"
directus:
image: registry.infra.mintel.me/mintel/directus:latest
image: registry.infra.mintel.me/mintel/directus:${IMAGE_TAG:-latest}
restart: always
networks:
- infra

View File

@@ -275,6 +275,10 @@ jobs:
docker system prune -f --filter "until=24h"
EOF
- name: 🧹 Post-Deploy Cleanup (Runner)
if: always()
run: docker builder prune -f --filter "until=1h"
# ──────────────────────────────────────────────────────────────────────────────
# JOB 5: Notifications
# ──────────────────────────────────────────────────────────────────────────────

View File

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

View File

@@ -2,7 +2,7 @@
set -e
# Configuration
REGISTRY_DATA="/opt/infra/registry/data/docker/registry/v2"
REGISTRY_DATA="/mnt/HC_Volume_104575103/registry-data/docker/registry/v2"
KEEP_TAGS=3
echo "🏥 Starting Aggressive Registry & Docker Maintenance..."
@@ -15,31 +15,26 @@ for repo_dir in "$REGISTRY_DATA/repositories/mintel/"*; do
if [ -d "$tags_dir" ]; then
echo "🔍 Processing repository: mintel/$repo_name"
# Prune main-* tags
echo " 📦 Pruning main tags..."
main_tags=$(ls -dt "$tags_dir"/main-* 2>/dev/null || true)
count=0
for tag_path in $main_tags; do
((++count))
if [ $count -gt $KEEP_TAGS ]; then
echo " 🗑️ Deleting old main tag: $(basename "$tag_path")"
rm -rf "$tag_path"
fi
# Prune various tag patterns
PATTERNS=("main-*" "testing-*" "branch-*" "v*" "rc*" "[0-9a-f]*")
for pattern in "${PATTERNS[@]}"; do
echo " 📦 Pruning $pattern tags..."
tags=$(ls -dt "$tags_dir"/${pattern} 2>/dev/null || true)
count=0
for tag_path in $tags; do
tag_name=$(basename "$tag_path")
if [[ "$tag_name" == "latest" ]]; then continue; fi
((++count))
if [ $count -gt $KEEP_TAGS ]; then
echo " 🗑️ Deleting old tag: $tag_name"
rm -rf "$tag_path"
fi
done
done
# Prune version tags (v* and rc*)
echo " 🏷️ Pruning version tags..."
version_tags=$(ls -dt "$tags_dir"/v1* 2>/dev/null || true)
count=0
for tag_path in $version_tags; do
((++count))
if [ $count -gt $KEEP_TAGS ]; then
echo " 🗑️ Deleting old version tag: $(basename "$tag_path")"
rm -rf "$tag_path"
fi
done
# Always prune buildcache (as it rebuilds quickly)
# Always prune buildcache
if [ -d "$tags_dir/buildcache" ]; then
echo " 🧹 Deleting buildcache tag"
rm -rf "$tags_dir/buildcache"
@@ -48,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
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

@@ -0,0 +1,7 @@
# @mintel/mail
## 1.7.0
### Minor Changes
- 96ec2c7: Initial release of the branded email system package.

View File

@@ -1,6 +1,6 @@
{
"name": "@mintel/mail",
"version": "1.2.0",
"version": "1.7.12",
"private": false,
"publishConfig": {
"access": "public",
@@ -38,6 +38,7 @@
"@mintel/tsconfig": "workspace:*",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"prettier": "^3.8.1",
"tsup": "^8.3.5",
"typescript": "^5.0.0",
"vitest": "^3.0.4"

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

@@ -14,11 +14,7 @@ export interface BaseLayoutProps {
brandColor?: string;
}
export const BaseLayout = ({
preview,
children,
brandColor = "#82ed20",
}: BaseLayoutProps) => {
export const BaseLayout = ({ preview, children }: BaseLayoutProps) => {
return (
<Html>
<Head />

View File

@@ -10,7 +10,7 @@ export interface MintelLayoutProps {
export const MintelLayout = ({ preview, children }: MintelLayoutProps) => {
return (
<BaseLayout preview={preview} brandColor="#82ed20">
<BaseLayout preview={preview}>
<Section style={header}>
<MintelLogo />
</Section>

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

@@ -0,0 +1,23 @@
import { defineConfig } from "vitest/config";
import path from "path";
export default defineConfig({
test: {
environment: "node",
alias: {
"prettier/plugins/html": path.resolve(
process.cwd(),
"../../node_modules/prettier/plugins/html.js",
),
"prettier/parser-html": path.resolve(
process.cwd(),
"../../node_modules/prettier/plugins/html.js",
),
},
server: {
deps: {
inline: [/@react-email/],
},
},
},
});

View File

@@ -1,5 +1,17 @@
# @mintel/next-config
## 1.6.1
### Patch Changes
- Add `turbopack: {}` to support Next.js 16 default Turbopack behavior when a webpack config is present.
## 1.6.1
### Patch Changes
- Add `turbopack: {}` to support Next.js 16 default Turbopack behavior when a webpack config is present.
## 1.0.1
### Patch Changes

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