Compare commits

...

67 Commits

Author SHA1 Message Date
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
e662415137 feat: add gk_bypass
All checks were successful
Monorepo Pipeline / 🧪 Quality Assurance (push) Successful in 14m32s
Monorepo Pipeline / 🐳 Build Directus (Base) (push) Successful in 17s
Monorepo Pipeline / 🐳 Build Gatekeeper (Product) (push) Successful in 3m17s
Monorepo Pipeline / 🐳 Build Build-Base (push) Successful in 2m38s
Monorepo Pipeline / 🐳 Build Production Runtime (push) Successful in 21s
Monorepo Pipeline / 🚀 Release (push) Successful in 14m53s
2026-02-07 16:02:52 +01:00
580b087e8a chore: improve pipeline 2026-02-07 15:29:45 +01:00
ac3c405cb2 chore: make cookie secure flag conditional for development and add pnpm store to gitignore 2026-02-07 15:11:16 +01:00
a594affdfa feat: add auth to gatekeeper
All checks were successful
Monorepo Pipeline / 🧪 Quality Assurance (push) Successful in 5m0s
Monorepo Pipeline / 🚀 Release (push) Successful in 3m43s
Monorepo Pipeline / 🐳 Build & Push Images (push) Successful in 5m16s
2026-02-07 14:48:39 +01:00
61e78ea672 feat: integrate observability
All checks were successful
Monorepo Pipeline / 🧪 Quality Assurance (push) Successful in 3m50s
Monorepo Pipeline / 🚀 Release (push) Successful in 3m38s
Monorepo Pipeline / 🐳 Build & Push Images (push) Successful in 7m6s
2026-02-07 10:23:51 +01:00
6501eac38a fix: gatekeeper access error 2026-02-07 09:46:31 +01:00
7f9206ae77 feat: Remove hardcoded /gatekeeper base path, update image paths, and introduce configurable base URL and cookie domain for improved routing and session management.
All checks were successful
Monorepo Pipeline / 🧪 Quality Assurance (push) Successful in 3m4s
Monorepo Pipeline / 🚀 Release (push) Successful in 3m3s
Monorepo Pipeline / 🐳 Build & Push Images (push) Successful in 5m18s
2026-02-06 13:54:47 +01:00
6229f8e886 feat: Add Next.js basePath and relocate the login page to /login. 2026-02-06 13:39:38 +01:00
8ac090aff3 feat: Set up new Tailwind CSS configuration, global styling, and initial application pages.
All checks were successful
Monorepo Pipeline / 🧪 Quality Assurance (push) Successful in 2m36s
Monorepo Pipeline / 🚀 Release (push) Successful in 2m35s
Monorepo Pipeline / 🐳 Build & Push Images (push) Successful in 5m18s
2026-02-06 00:20:55 +01:00
696f9d361d feat: Configure package entry points and exports for better module resolution, update build scripts to include templates, and add React peer dependencies.
All checks were successful
Monorepo Pipeline / 🧪 Quality Assurance (push) Successful in 3m26s
Monorepo Pipeline / 🚀 Release (push) Successful in 3m8s
Monorepo Pipeline / 🐳 Build & Push Images (push) Successful in 5m1s
2026-02-05 13:00:34 +01:00
31840da9e7 feat: Enable global CLI linking and direct mintel command execution, updating installation instructions and project path resolution. 2026-02-05 12:12:56 +01:00
96ec2c7d8d feat: Publish initial version of the branded email system package.
All checks were successful
Monorepo Pipeline / 🧪 Quality Assurance (push) Successful in 2m58s
Monorepo Pipeline / 🚀 Release (push) Successful in 2m30s
Monorepo Pipeline / 🐳 Build & Push Images (push) Successful in 6m48s
2026-02-05 01:46:36 +01:00
9029375247 test: add rendering tests for MintelLogo and ContactFormNotification mail components.
All checks were successful
Monorepo Pipeline / 🧪 Quality Assurance (push) Successful in 2m36s
Monorepo Pipeline / 🚀 Release (push) Successful in 3m35s
Monorepo Pipeline / 🐳 Build & Push Images (push) Successful in 4m55s
2026-02-05 01:27:47 +01:00
95d0a1622f feat: Introduce a new mail package for email templates and update the gatekeeper login page with new logo assets.
Some checks failed
Monorepo Pipeline / 🧪 Quality Assurance (push) Failing after 42s
Monorepo Pipeline / 🚀 Release (push) Has been skipped
Monorepo Pipeline / 🐳 Build & Push Images (push) Has been skipped
2026-02-05 01:05:49 +01:00
646d615e76 fix(gatekeeper): ensure public directory exists in Dockerfile to prevent COPY failure
All checks were successful
Monorepo Pipeline / 🧪 Quality Assurance (push) Successful in 2m29s
Monorepo Pipeline / 🚀 Release (push) Successful in 2m28s
Monorepo Pipeline / 🐳 Build & Push Images (push) Successful in 7m4s
2026-02-04 22:15:44 +01:00
51409099fc feat: Increase registry pruning aggressiveness by reducing tag retention, adding version and buildcache tag deletion, and shortening Docker system prune duration. (includes chore: relax header-max-length)
Some checks failed
Monorepo Pipeline / 🧪 Quality Assurance (push) Successful in 2m33s
Monorepo Pipeline / 🚀 Release (push) Successful in 2m30s
Monorepo Pipeline / 🐳 Build & Push Images (push) Failing after 6m38s
2026-02-04 18:43:33 +01:00
22cd20e639 fix: build workspace dependencies before gatekeeper in Docker 2026-02-04 18:39:12 +01:00
e7cc1c8ca5 fix: stabilize Docker builds by standardizing on ARM64 and explicit stage naming (klz-2026 pattern)
Some checks failed
Monorepo Pipeline / 🧪 Quality Assurance (push) Successful in 2m42s
Monorepo Pipeline / 🚀 Release (push) Successful in 2m29s
Monorepo Pipeline / 🐳 Build & Push Images (push) Failing after 4m9s
2026-02-04 18:13:54 +01:00
0ccb15a929 refactor: restructure Dockerfile.gatekeeper into base, builder, and runner stages for improved multi-stage build. 2026-02-04 12:10:51 +01:00
a94ddcfbb2 refactor: rename Dockerfile build stage from base to builder
Some checks failed
Monorepo Pipeline / 🧪 Quality Assurance (push) Successful in 2m28s
Monorepo Pipeline / 🚀 Release (push) Successful in 2m49s
Monorepo Pipeline / 🐳 Build & Push Images (push) Failing after 23m39s
2026-02-04 01:35:45 +01:00
d3a9af140c feat: add script to prune Docker registry tags, perform garbage collection, and prune host Docker resources. 2026-02-04 01:01:00 +01:00
0dc3ba0da4 chore: Reorder pnpm build arguments in Dockerfile.nextjs.
Some checks failed
Monorepo Pipeline / 🧪 Quality Assurance (push) Successful in 2m30s
Monorepo Pipeline / 🚀 Release (push) Successful in 2m31s
Monorepo Pipeline / 🐳 Build & Push Images (push) Failing after 25m10s
2026-02-03 22:40:10 +01:00
1a94465dba feat: streamline Docker builds with .dockerignore and pass NPM_TOKEN as a build secret for pnpm install.
Some checks failed
Monorepo Pipeline / 🧪 Quality Assurance (push) Successful in 2m34s
Monorepo Pipeline / 🚀 Release (push) Successful in 2m40s
Monorepo Pipeline / 🐳 Build & Push Images (push) Failing after 3m6s
2026-02-03 22:13:34 +01:00
7e256025ea refactor: remove pnpm cache configuration from Node.js setup actions in pipeline workflows. 2026-02-03 19:41:31 +01:00
e843de42da ci: Add catthehacker/ubuntu:act-latest container image to Gitea workflow jobs.
Some checks failed
Monorepo Pipeline / 🧪 Quality Assurance (push) Successful in 12m11s
Monorepo Pipeline / 🐳 Build & Push Images (push) Failing after 49s
Monorepo Pipeline / 🚀 Release (push) Successful in 2m32s
2026-02-03 19:28:35 +01:00
4d1b2231e3 refactor: update pipeline to use Docker actions for registry login, build, and push.
Some checks failed
Monorepo Pipeline / 🧪 Quality Assurance (push) Successful in 2m35s
Monorepo Pipeline / 🐳 Build & Push Images (push) Failing after 2m21s
Monorepo Pipeline / 🚀 Release (push) Successful in 2m35s
2026-02-03 19:10:35 +01:00
71f47f9037 refactor: extract language utilities to lang.ts and adjust CI pipeline triggers.
Some checks failed
Monorepo Pipeline / 🧪 Quality Assurance (push) Successful in 12m31s
Monorepo Pipeline / 🐳 Build & Push Images (push) Failing after 10s
Monorepo Pipeline / 🚀 Release (push) Successful in 2m43s
2026-02-03 18:51:25 +01:00
79d41b6a73 feat: conditionally apply next-intl plugin and fix shared eslint ignore patterns
Some checks failed
Monorepo Pipeline / 🧪 Quality Assurance (push) Failing after 41s
Monorepo Pipeline / 🚀 Release (push) Has been skipped
Monorepo Pipeline / 🐳 Build & Push Images (push) Has been skipped
2026-02-03 16:55:20 +01:00
6b7236ba97 fix: add refined .gitignore and exclude Directus volume data
Some checks failed
Monorepo Pipeline / 🧪 Quality Assurance (push) Successful in 2m33s
Monorepo Pipeline / 🚀 Release (push) Successful in 2m31s
Monorepo Pipeline / 🐳 Build & Push Images (push) Failing after 9m31s
2026-02-03 12:05:01 +01:00
40a95b5353 fix: implement Lean Docker strategy with mintel/runtime and remove explicit container_name fields
Some checks failed
Monorepo Pipeline / 🚀 Release (push) Has been cancelled
Monorepo Pipeline / 🐳 Build & Push Images (push) Has been cancelled
Monorepo Pipeline / 🧪 Quality Assurance (push) Has been cancelled
2026-02-03 11:59:44 +01:00
7329e00125 fix: remove image tags from application compose files to avoid registry ambiguity
Some checks failed
Monorepo Pipeline / 🚀 Release (push) Has been cancelled
Monorepo Pipeline / 🐳 Build & Push Images (push) Has been cancelled
Monorepo Pipeline / 🧪 Quality Assurance (push) Has been cancelled
2026-02-03 11:53:01 +01:00
94be60ba4e fix: correct Docker registry strategy, add custom Directus Dockerfile, and revert app tagging 2026-02-03 11:51:45 +01:00
a8bc039c02 feat: implement centralized Docker base-image strategy and automate registry pushes
All checks were successful
Monorepo Pipeline / 🧪 Quality Assurance (push) Successful in 2m33s
Monorepo Pipeline / 🚀 Release (push) Has been skipped
Monorepo Pipeline / 🐳 Build & Push Images (push) Has been skipped
2026-02-03 11:50:17 +01:00
653deb7995 feat: adapt klz-2026 high-performance Docker setup and environment handling
All checks were successful
Monorepo Pipeline / 🧪 Quality Assurance (push) Successful in 2m33s
Monorepo Pipeline / 🚀 Release (push) Has been skipped
2026-02-03 11:44:16 +01:00
61f65107f2 feat: standardize NPM scripts across monorepo via @mintel/cli enhancement
Some checks failed
Monorepo Pipeline / 🚀 Release (push) Has been cancelled
Monorepo Pipeline / 🧪 Quality Assurance (push) Has been cancelled
2026-02-03 11:31:50 +01:00
664f165234 ci: make release job strictly tag-exclusive
All checks were successful
Monorepo Pipeline / 🧪 Quality Assurance (push) Successful in 2m31s
Monorepo Pipeline / 🚀 Release (push) Successful in 12m34s
2026-02-03 02:31:51 +01:00
f07e44016a ci: consolidate workflows into pipeline.yml to fix double triggers
All checks were successful
Monorepo Pipeline / 🧪 Quality Assurance (push) Successful in 2m30s
Monorepo Pipeline / 🚀 Release (push) Successful in 2m28s
2026-02-03 01:35:47 +01:00
cc3ec8f0c2 feat: extract Directus sync/branding and optimize Gitea CI with Next.js lint support
Some checks failed
Code Quality / lint-and-build (push) Has been cancelled
Release Packages / release (push) Has been cancelled
2026-02-03 01:28:36 +01:00
c9db75c945 fix(sample-website): add missing i18n configuration to fix build failure
All checks were successful
Code Quality / lint-and-build (push) Successful in 2m44s
Release Packages / release (push) Successful in 2m33s
2026-02-02 13:17:55 +01:00
bb8665212a fix(gatekeeper): add missing i18n configuration to fix build failure
Some checks failed
Code Quality / lint-and-build (push) Failing after 50s
Release Packages / release (push) Failing after 37s
2026-02-02 11:37:13 +01:00
143 changed files with 20006 additions and 1716 deletions

12
.dockerignore Normal file
View File

@@ -0,0 +1,12 @@
node_modules
.next
.git
# .npmrc is allowed as it contains the registry template
dist
build
out
coverage
.vercel
.turbo
*.log
.DS_Store

31
.env.example Normal file
View File

@@ -0,0 +1,31 @@
# Project
PROJECT_NAME=sample-website
PROJECT_COLOR=#82ed20
# Authentication
GATEKEEPER_PASSWORD=mintel
AUTH_COOKIE_NAME=mintel_gatekeeper_session
# Host Config (Local)
TRAEFIK_HOST=sample-website.localhost
DIRECTUS_HOST=cms.sample-website.localhost
# Next.js
NEXT_PUBLIC_BASE_URL=http://sample-website.localhost
# Directus
DIRECTUS_URL=http://localhost:8055
DIRECTUS_KEY=sample-key-123
DIRECTUS_SECRET=sample-secret-123
DIRECTUS_ADMIN_EMAIL=admin@mintel.me
DIRECTUS_ADMIN_PASSWORD=mintel-admin-pass
DIRECTUS_DB_NAME=directus
DIRECTUS_DB_USER=directus
DIRECTUS_DB_PASSWORD=mintel-db-pass
# Sentry / Glitchtip
SENTRY_DSN=
# Analytics (Umami)
NEXT_PUBLIC_UMAMI_WEBSITE_ID=
NEXT_PUBLIC_UMAMI_SCRIPT_URL=https://analytics.infra.mintel.me/script.js

View File

@@ -1,39 +0,0 @@
name: Code Quality
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
lint-and-build:
runs-on: docker
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: Install dependencies
run: pnpm install --frozen-lockfile
env:
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
- name: Lint
run: pnpm lint
- name: Test
run: pnpm test
- name: Build
run: pnpm build

View File

@@ -0,0 +1,138 @@
name: Monorepo Pipeline
on:
push:
branches:
- '**'
tags:
- 'v*'
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
qa:
name: 🧪 Quality Assurance
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
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: 🏷️ Sync Versions (if Tagged)
if: startsWith(github.ref, 'refs/tags/v')
run: pnpm sync-versions
- name: Lint
run: pnpm lint
- name: Test
run: pnpm test
- name: Build
run: pnpm build
release:
name: 🚀 Release
needs: qa
if: startsWith(github.ref, 'refs/tags/v')
runs-on: docker
container:
image: catthehacker/ubuntu:act-latest
env:
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Install pnpm
uses: pnpm/action-setup@v4
with:
version: 10
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node_version: 20
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: 🏷️ Release Packages (Tag-Driven)
run: |
echo "🏷️ Tag detected [${{ github.ref_name }}], performing sync release..."
pnpm release:tag
build-images:
name: 🐳 Build ${{ matrix.name }}
needs: qa
if: startsWith(github.ref, 'refs/tags/v')
runs-on: docker
container:
image: catthehacker/ubuntu:act-latest
strategy:
fail-fast: false
matrix:
include:
- image: nextjs
file: packages/infra/docker/Dockerfile.nextjs
name: Build-Base
- image: runtime
file: packages/infra/docker/Dockerfile.runtime
name: Production Runtime
- image: gatekeeper
file: packages/infra/docker/Dockerfile.gatekeeper
name: Gatekeeper (Product)
- image: directus
file: packages/infra/docker/Dockerfile.directus
name: Directus (Base)
steps:
- name: Checkout
uses: actions/checkout@v4
- name: 🐳 Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: 🔐 Registry Login
uses: docker/login-action@v3
with:
registry: registry.infra.mintel.me
username: ${{ secrets.REGISTRY_USER }}
password: ${{ secrets.REGISTRY_PASS }}
- name: 🏗️ Build & Push ${{ matrix.name }}
uses: docker/build-push-action@v5
with:
context: .
file: ${{ matrix.file }}
platforms: linux/arm64
pull: true
push: true
secrets: |
NPM_TOKEN=${{ secrets.NPM_TOKEN }}
tags: |
registry.infra.mintel.me/mintel/${{ matrix.image }}:${{ github.ref_name }}
registry.infra.mintel.me/mintel/${{ matrix.image }}:latest
cache-from: type=registry,ref=registry.infra.mintel.me/mintel/${{ matrix.image }}:buildcache
cache-to: type=registry,ref=registry.infra.mintel.me/mintel/${{ matrix.image }}:buildcache,mode=max

View File

@@ -1,45 +0,0 @@
name: Release Packages
on:
push:
branches:
- main
tags:
- 'v*'
jobs:
release:
runs-on: docker
env:
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
steps:
- name: Checkout
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: Install dependencies
run: pnpm install --frozen-lockfile
- name: Release Packages
run: |
if [[ "${{ github.ref_type }}" == "tag" ]]; then
echo "🏷️ Tag detected, performing sync release..."
pnpm sync-versions
pnpm release:tag
else
echo "🚀 Push detected, looking for changesets..."
pnpm release
fi

1
.gitignore vendored
View File

@@ -1,6 +1,7 @@
# dependencies
node_modules
.pnpm-debug.log*
.pnpm-store/
# next.js
.next/

13
.husky/pre-push Executable file
View File

@@ -0,0 +1,13 @@
# Check if we are pushing a tag
if echo "$*" | grep -q "refs/tags/v"; then
echo "🏷️ Tag detected in push, syncing versions..."
pnpm sync-versions
# Stage the changed package.json files
git add "package.json" "packages/*/package.json" "apps/*/package.json"
# Amend the tag if it's on the current commit, but this is complex in pre-push.
# Better: Just warn the user that they might need to update the tag if package.json changed.
echo "⚠️ package.json files updated to match tag. Please ensure these changes are part of your tag/commit."
fi

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

34
apps/sample-website/.gitignore vendored Normal file
View File

@@ -0,0 +1,34 @@
# dependencies
/node_modules
/.pnpm-debug.log*
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# local env files
.env*.local
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
# directus
/directus/uploads
/directus/extensions
/.env

View File

@@ -0,0 +1,38 @@
# Start from the pre-built Nextjs Base image
FROM registry.infra.mintel.me/mintel/nextjs:latest AS builder
WORKDIR /app
# Build-time environment variables for Next.js
ARG NEXT_PUBLIC_BASE_URL
ARG NEXT_PUBLIC_UMAMI_WEBSITE_ID
ARG NEXT_PUBLIC_UMAMI_SCRIPT_URL
ARG NEXT_PUBLIC_TARGET
ARG DIRECTUS_URL
ENV NEXT_PUBLIC_BASE_URL=$NEXT_PUBLIC_BASE_URL
ENV NEXT_PUBLIC_UMAMI_WEBSITE_ID=$NEXT_PUBLIC_UMAMI_WEBSITE_ID
ENV NEXT_PUBLIC_UMAMI_SCRIPT_URL=$NEXT_PUBLIC_UMAMI_SCRIPT_URL
ENV NEXT_PUBLIC_TARGET=$NEXT_PUBLIC_TARGET
ENV DIRECTUS_URL=$DIRECTUS_URL
# Build the specific application
RUN pnpm --filter sample-website build
# Production runner image
FROM registry.infra.mintel.me/mintel/runtime:latest AS runner
WORKDIR /app
COPY --from=builder /app/apps/sample-website/public ./apps/sample-website/public
# Set the correct permission for prerender cache
RUN mkdir -p apps/sample-website/.next && chown nextjs:nodejs apps/sample-website/.next
# Copy standalone output and static files from the monorepo path
COPY --from=builder --chown=nextjs:nodejs /app/apps/sample-website/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/apps/sample-website/.next/static ./apps/sample-website/.next/static
USER nextjs
# server.js in monorepo standalone is created for each app
CMD ["node", "apps/sample-website/server.js"]

View File

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

View File

@@ -0,0 +1,5 @@
{
"Index": {
"title": "Welcome"
}
}

View File

@@ -1,28 +1,36 @@
{
"name": "sample-website",
"version": "0.1.1",
"version": "1.6.0",
"private": true,
"type": "module",
"scripts": {
"dev": "next dev",
"dev": "mintel dev",
"dev:local": "mintel dev --local",
"build": "next build",
"start": "next start",
"lint": "next lint",
"test": "vitest run"
"lint": "eslint src/",
"typecheck": "tsc --noEmit",
"test": "vitest run --passWithNoTests",
"cms:pull:prod": "mintel directus sync pull production",
"pagespeed:test": "mintel pagespeed"
},
"dependencies": {
"next": "15.1.6",
"@mintel/next-utils": "workspace:*",
"@mintel/observability": "workspace:*",
"@mintel/next-observability": "workspace:*",
"@sentry/nextjs": "10.38.0",
"next": "16.1.6",
"next-intl": "^4.8.2",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"@mintel/next-utils": "workspace:*"
"react-dom": "^19.0.0"
},
"devDependencies": {
"@mintel/eslint-config": "workspace:*",
"@mintel/next-config": "workspace:*",
"@mintel/tsconfig": "workspace:*",
"@types/node": "^20.0.0",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"typescript": "^5.0.0",
"@mintel/tsconfig": "workspace:*",
"@mintel/eslint-config": "workspace:*",
"@mintel/next-config": "workspace:*"
"typescript": "^5.0.0"
}
}

View File

@@ -0,0 +1,9 @@
import { initSentry } from "@mintel/next-observability";
initSentry({
// Use a placeholder DSN on the client if you want to bypass ad-blockers via tunnel
// Or just use the real DSN if you don't care about ad-blockers for errors.
// The Mintel standard is to use the relay.
dsn: "https://public@errors.infra.mintel.me/1", // Placeholder for relay
tunnel: "/errors/api/relay",
});

View File

@@ -0,0 +1,8 @@
import { initSentry } from "@mintel/next-observability";
import { validateMintelEnv } from "@mintel/next-utils";
const env = validateMintelEnv();
initSentry({
dsn: env.SENTRY_DSN,
});

View File

@@ -0,0 +1,8 @@
import { initSentry } from "@mintel/next-observability";
import { validateMintelEnv } from "@mintel/next-utils";
const env = validateMintelEnv();
initSentry({
dsn: env.SENTRY_DSN,
});

View File

@@ -0,0 +1,6 @@
import { createSentryRelayHandler } from "@mintel/next-observability";
import { validateMintelEnv } from "@mintel/next-utils";
export const POST = createSentryRelayHandler({
dsn: validateMintelEnv().SENTRY_DSN,
});

View File

@@ -1,5 +1,11 @@
import type { Metadata } from "next";
import { Suspense } from "react";
import "./globals.css";
import {
AnalyticsContextProvider,
AnalyticsAutoTracker,
} from "@mintel/next-observability/client";
import { getAnalyticsConfig } from "@/lib/observability";
export const metadata: Metadata = {
title: "Sample Website",
@@ -11,9 +17,18 @@ export default function RootLayout({
}: Readonly<{
children: React.ReactNode;
}>) {
const analyticsConfig = getAnalyticsConfig();
return (
<html lang="en">
<body>{children}</body>
<body>
<AnalyticsContextProvider config={analyticsConfig}>
<Suspense fallback={null}>
<AnalyticsAutoTracker />
</Suspense>
{children}
</AnalyticsContextProvider>
</body>
</html>
);
}

View File

@@ -0,0 +1,7 @@
import { createUmamiProxyHandler } from "@mintel/next-observability";
import { validateMintelEnv } from "@mintel/next-utils";
export const POST = createUmamiProxyHandler({
websiteId: validateMintelEnv().UMAMI_WEBSITE_ID,
apiEndpoint: validateMintelEnv().UMAMI_API_ENDPOINT,
});

View File

@@ -0,0 +1,7 @@
import { createMintelI18nRequestConfig } from "@mintel/next-utils";
export default createMintelI18nRequestConfig(
["en"],
"en",
(locale) => import(`../../messages/${locale}.json`),
);

View File

@@ -0,0 +1,13 @@
import * as Sentry from "@sentry/nextjs";
export async function register() {
if (process.env.NEXT_RUNTIME === "nodejs") {
await import("../sentry.server.config");
}
if (process.env.NEXT_RUNTIME === "edge") {
await import("../sentry.edge.config");
}
}
export const onRequestError = Sentry.captureRequestError;

View File

@@ -0,0 +1,54 @@
import {
UmamiAnalyticsService,
GotifyNotificationService,
NoopNotificationService,
} from "@mintel/observability";
import { validateMintelEnv } from "@mintel/next-utils";
let analyticsService: any = null;
let notificationService: any = null;
export function getAnalyticsConfig() {
const isClient = typeof window !== "undefined";
if (isClient) {
return {
enabled: true,
apiEndpoint: "/stats",
};
}
const env = validateMintelEnv();
return {
enabled: Boolean(env.UMAMI_WEBSITE_ID),
websiteId: env.UMAMI_WEBSITE_ID,
apiEndpoint: env.UMAMI_API_ENDPOINT,
};
}
export function getAnalyticsService() {
if (analyticsService) return analyticsService;
const config = getAnalyticsConfig();
analyticsService = new UmamiAnalyticsService(config);
return analyticsService;
}
export function getNotificationService() {
if (notificationService) return notificationService;
if (typeof window === "undefined") {
const env = validateMintelEnv();
notificationService = new GotifyNotificationService({
enabled: Boolean(env.GOTIFY_URL && env.GOTIFY_TOKEN),
url: env.GOTIFY_URL || "",
token: env.GOTIFY_TOKEN || "",
});
} else {
// Notifications are typically server-side only to protect tokens
notificationService = new NoopNotificationService();
}
return notificationService;
}

0
directus/schema/.gitkeep Normal file
View File

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

73
docker-compose.yml Normal file
View File

@@ -0,0 +1,73 @@
services:
app:
build:
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}
restart: always
networks:
- infra
environment:
- DIRECTUS_URL=${DIRECTUS_URL:-http://directus:8055}
env_file:
- .env
ports:
- "3000:3000"
labels:
- "traefik.enable=true"
- "traefik.http.routers.sample-website.rule=Host(`${TRAEFIK_HOST:-sample-website.localhost}`)"
- "traefik.http.services.sample-website.loadbalancer.server.port=3000"
directus:
image: registry.infra.mintel.me/mintel/directus:latest
restart: always
networks:
- infra
env_file:
- .env
environment:
KEY: ${DIRECTUS_KEY:-mintel-key}
SECRET: ${DIRECTUS_SECRET:-mintel-secret}
ADMIN_EMAIL: ${DIRECTUS_ADMIN_EMAIL:-admin@mintel.me}
ADMIN_PASSWORD: ${DIRECTUS_ADMIN_PASSWORD:-mintel-admin}
DB_CLIENT: 'pg'
DB_HOST: 'directus-db'
DB_PORT: '5432'
DB_DATABASE: ${DIRECTUS_DB_NAME:-directus}
DB_USER: ${DIRECTUS_DB_USER:-directus}
DB_PASSWORD: ${DIRECTUS_DB_PASSWORD:-mintel-db-pass}
WEBSOCKETS_ENABLED: 'true'
PUBLIC_URL: ${DIRECTUS_URL:-http://localhost:8055}
ports:
- "8055:8055"
volumes:
- ./directus/uploads:/directus/uploads
- ./directus/extensions:/directus/extensions
- ./directus/schema:/directus/schema
labels:
- "traefik.enable=true"
- "traefik.http.routers.sample-website-directus.rule=Host(`${DIRECTUS_HOST:-cms.sample-website.localhost}`)"
- "traefik.http.services.sample-website-directus.loadbalancer.server.port=8055"
directus-db:
image: postgres:15-alpine
restart: always
networks:
- infra
environment:
POSTGRES_DB: ${DIRECTUS_DB_NAME:-directus}
POSTGRES_USER: ${DIRECTUS_DB_USER:-directus}
POSTGRES_PASSWORD: ${DIRECTUS_DB_PASSWORD:-mintel-db-pass}
volumes:
- directus-db-data:/var/lib/postgresql/data
networks:
infra:
external: true
volumes:
directus-db-data:

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",
"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:snapshot:infra": "./scripts/cms-snapshot.sh infra",
"cms:schema:apply": "./scripts/cms-apply.sh",
"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"
@@ -22,11 +28,12 @@
"@mintel/husky-config": "workspace:*",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@types/node": "^20.17.16",
"@types/react": "^19.2.10",
"@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",
@@ -38,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.6.0",
"pnpm": {
"overrides": {
"next": "16.1.6",
"@sentry/nextjs": "10.38.0"
}
}
}

View File

@@ -4,10 +4,30 @@ The Mintel CLI is the primary automation tool for managing the monorepo and ensu
## 🚀 Installation
The CLI is intended to be used within the monorepo:
### Using npx (Recommended)
Run the CLI without installing it globally. This always uses the latest version from the registry:
```bash
pnpm install
npx @mintel/cli init apps/my-new-website.com
```
### Global Installation
Install the CLI globally from the Mintel registry:
```bash
npm install -g @mintel/cli
```
### Development (Local Link)
If you are contributing to the CLI, you can link it locally:
```bash
cd packages/cli
pnpm build
npm link
```
## 🛠 Commands
@@ -17,10 +37,11 @@ pnpm install
Scaffolds a new, production-ready client website in the specified path.
```bash
pnpm --filter @mintel/cli start init apps/my-new-website.com
mintel init apps/my-new-website.com
```
#### What it does:
1. **Project Structure**: Creates a modern Next.js directory layout.
2. **Shared Configs**: Generates `package.json`, `tsconfig.json`, and `eslint.config.mjs` that extend the `@mintel` shared packages.
3. **Localization**: Sets up a localized routing structure (`src/app/[locale]`) with `next-intl` pre-configured.

View File

@@ -1,6 +1,6 @@
{
"name": "@mintel/cli",
"version": "1.0.1",
"version": "1.6.0",
"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

@@ -1,3 +1,4 @@
#!/usr/bin/env node
import { Command } from "commander";
import fs from "fs-extra";
import path from "path";
@@ -10,7 +11,186 @@ const program = new Command();
program
.name("mintel")
.description("CLI for Mintel monorepo management")
.version("1.0.0");
.version("1.0.1");
program
.command("dev")
.description("Start the development environment (Docker stack)")
.option("-l, --local", "Run Next.js locally instead of in Docker")
.action(async (options) => {
const { execSync } = await import("child_process");
console.log(chalk.blue("🚀 Starting Development Environment..."));
if (options.local) {
console.log(chalk.cyan("Running Next.js locally..."));
execSync("next dev", { stdio: "inherit" });
} else {
try {
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(`
📱 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" },
);
});
const directus = program
.command("directus")
.description("Directus management commands");
directus
.command("bootstrap")
.description("Setup Directus branding and settings")
.action(async () => {
const { execSync } = await import("child_process");
console.log(chalk.blue("🎨 Bootstrapping Directus..."));
execSync("npx tsx --env-file=.env scripts/setup-directus.ts", {
stdio: "inherit",
});
});
directus
.command("bootstrap-feedback")
.description("Setup Directus collections and flows for Feedback")
.action(async () => {
const { execSync } = await import("child_process");
console.log(chalk.blue("📧 Bootstrapping Visual Feedback System..."));
// Use the logic from setup-feedback-hardened.ts
const bootstrapScript = `
import { createDirectus, rest, authentication, createCollection, createDashboard, createPanel, createItems, createPermission, readPolicies, readRoles, readUsers } from '@directus/sdk';
async function setup() {
const url = process.env.DIRECTUS_URL || 'http://localhost:8055';
const email = process.env.DIRECTUS_ADMIN_EMAIL;
const password = process.env.DIRECTUS_ADMIN_PASSWORD;
if (!email || !password) {
console.error('❌ DIRECTUS_ADMIN_EMAIL or DIRECTUS_ADMIN_PASSWORD not set');
process.exit(1);
}
const client = createDirectus(url).with(authentication('json')).with(rest());
try {
console.log('🔑 Authenticating...');
await client.login(email, password);
const roles = await client.request(readRoles());
const adminRole = roles.find(r => r.name === 'Administrator');
const policies = await client.request(readPolicies());
const adminPolicy = policies.find(p => p.name === 'Administrator');
console.log('🏗️ Creating Collection "visual_feedback"...');
try {
await client.request(createCollection({
collection: 'visual_feedback',
meta: { icon: 'feedback', display_template: '{{user_name}}: {{text}}' },
fields: [
{ field: 'id', type: 'uuid', schema: { is_primary_key: true } },
{ field: 'status', type: 'string', schema: { default_value: 'open' }, meta: { interface: 'select-dropdown' } },
{ field: 'url', type: 'string' },
{ field: 'selector', type: 'string' },
{ field: 'x', type: 'float' },
{ field: 'y', type: 'float' },
{ field: 'type', type: 'string' },
{ field: 'text', type: 'text' },
{ field: 'user_name', type: 'string' },
{ field: 'user_identity', type: 'string' },
{ field: 'screenshot', type: 'uuid', meta: { interface: 'file' } },
{ field: 'date_created', type: 'timestamp', schema: { default_value: 'NOW()' } }
]
} as any));
} catch (_e) { console.log(' (Collection might already exist)'); }
try {
await client.request(createCollection({
collection: 'visual_feedback_comments',
meta: { icon: 'comment' },
fields: [
{ field: 'id', type: 'integer', schema: { is_primary_key: true, has_auto_increment: true } },
{ field: 'feedback_id', type: 'uuid', meta: { interface: 'select-dropdown' } },
{ field: 'user_name', type: 'string' },
{ field: 'text', type: 'text' },
{ field: 'date_created', type: 'timestamp', schema: { default_value: 'NOW()' } }
]
} as any));
} catch (e) { }
if (adminPolicy) {
console.log('🔐 Granting ALL permissions to Administrator Policy...');
for (const coll of ['visual_feedback', 'visual_feedback_comments']) {
for (const action of ['create', 'read', 'update', 'delete']) {
try {
await client.request(createPermission({
collection: coll,
action,
fields: ['*'],
policy: adminPolicy.id
} as any));
} catch (_e) { }
}
}
}
console.log('📊 Creating Dashboard...');
try {
const dash = await client.request(createDashboard({ name: 'Visual Feedback', icon: 'feedback', color: '#6366f1' }));
await client.request(createPanel({
dashboard: dash.id,
name: 'Total Feedbacks',
type: 'metric',
width: 12, height: 6, position_x: 1, position_y: 1,
options: { collection: 'visual_feedback', function: 'count', field: 'id' }
} as any));
} catch (e) { }
console.log('✨ FEEDBACK BOOTSTRAP DONE.');
} catch (e) { console.error('❌ FAILURE:', e); }
}
setup();
`;
const tempFile = path.join(process.cwd(), "temp-bootstrap-feedback.ts");
await fs.writeFile(tempFile, bootstrapScript);
try {
execSync("npx tsx --env-file=.env " + tempFile, { stdio: "inherit" });
} finally {
await fs.remove(tempFile);
}
});
directus
.command("sync <action> <env>")
.description("Sync Directus data (push/pull) for a specific environment")
.action(async (action, env) => {
const { execSync } = await import("child_process");
console.log(
chalk.blue(`📥 Executing Directus sync: ${action} -> ${env}...`),
);
execSync(`./scripts/sync-directus.sh ${action} ${env}`, {
stdio: "inherit",
});
});
program
.command("pagespeed")
.description("Run PageSpeed (Lighthouse) tests")
.action(async () => {
const { execSync } = await import("child_process");
console.log(chalk.blue("⚡ Running PageSpeed tests..."));
execSync("npx tsx ./scripts/pagespeed-sitemap.ts", { stdio: "inherit" });
});
program
.command("init <path>")
@@ -18,7 +198,7 @@ program
.action(async (projectPath) => {
const fullPath = path.isAbsolute(projectPath)
? projectPath
: path.resolve(process.cwd(), "../../", projectPath);
: path.resolve(process.cwd(), projectPath);
const projectName = path.basename(fullPath);
console.log(chalk.blue(`Initializing new project: ${projectName}...`));
@@ -34,16 +214,28 @@ program
private: true,
type: "module",
scripts: {
dev: "next dev",
dev: "mintel dev",
"dev:local": "mintel dev --local",
build: "next build",
start: "next start",
lint: "next lint",
typecheck: "tsc --noEmit",
test: "vitest run --passWithNoTests",
"directus:bootstrap": "mintel directus bootstrap",
"directus:push:testing": "mintel directus sync push testing",
"directus:pull:testing": "mintel directus sync pull testing",
"directus:push:staging": "mintel directus sync push staging",
"directus:pull:staging": "mintel directus sync pull staging",
"directus:push:prod": "mintel directus sync push production",
"directus:pull:prod": "mintel directus sync pull production",
"pagespeed:test": "mintel pagespeed",
},
dependencies: {
next: "15.1.6",
next: "16.1.6",
react: "^19.0.0",
"react-dom": "^19.0.0",
"@mintel/next-utils": "workspace:*",
"@mintel/next-observability": "workspace:*",
"@directus/sdk": "^21.0.0",
},
devDependencies: {
@@ -182,11 +374,15 @@ export default createMintelI18nRequestConfig(
// Create instrumentation.ts
await fs.writeFile(
path.join(fullPath, "src/instrumentation.ts"),
`import * as Sentry from '@sentry/nextjs';
`import { Sentry } from '@mintel/next-observability';
export async function register() {
if (process.env.NEXT_RUNTIME === 'nodejs') {
// Server-side initialization
await import('./sentry.server.config');
}
if (process.env.NEXT_RUNTIME === 'edge') {
await import('./sentry.edge.config');
}
}
@@ -238,34 +434,56 @@ export default function Home() {
// Copy infra templates
const infraPath = path.resolve(__dirname, "../../infra");
if (await fs.pathExists(infraPath)) {
await fs.copy(
path.join(infraPath, "docker/Dockerfile.nextjs"),
path.join(fullPath, "Dockerfile"),
// Setup Dockerfile from template
const templatePath = path.join(
infraPath,
"docker/Dockerfile.app-template",
);
await fs.copy(
path.join(infraPath, "docker/docker-compose.template.yml"),
path.join(fullPath, "docker-compose.yml"),
if (await fs.pathExists(templatePath)) {
let dockerfile = await fs.readFile(templatePath, "utf8");
dockerfile = dockerfile.replace(/\$\{APP_NAME:-app\}/g, projectName);
await fs.writeFile(path.join(fullPath, "Dockerfile"), dockerfile);
}
// Setup docker-compose from template
const composeTemplatePath = path.join(
infraPath,
"docker/docker-compose.template.yml",
);
if (await fs.pathExists(composeTemplatePath)) {
let compose = await fs.readFile(composeTemplatePath, "utf8");
compose = compose.replace(/\$\{APP_NAME:-app\}/g, projectName);
compose = compose.replace(/\$\{PROJECT_NAME:-app\}/g, projectName);
await fs.writeFile(
path.join(fullPath, "docker-compose.yml"),
compose,
);
}
await fs.ensureDir(path.join(fullPath, ".gitea/workflows"));
await fs.copy(
path.join(infraPath, "gitea/deploy-action.yml"),
path.join(fullPath, ".gitea/workflows/deploy.yml"),
const deployActionPath = path.join(
infraPath,
"gitea/deploy-action.yml",
);
if (await fs.pathExists(deployActionPath)) {
await fs.copy(
deployActionPath,
path.join(fullPath, ".gitea/workflows/deploy.yml"),
);
}
}
// Create Directus structure
await fs.ensureDir(path.join(fullPath, "directus/uploads"));
await fs.ensureDir(path.join(fullPath, "directus/extensions"));
await fs.writeFile(
path.join(fullPath, "directus/uploads/.gitkeep"),
"",
);
await fs.writeFile(
path.join(fullPath, "directus/extensions/.gitkeep"),
"",
);
// Create Directus structure
await fs.ensureDir(path.join(fullPath, "directus/uploads"));
await fs.ensureDir(path.join(fullPath, "directus/extensions"));
await fs.writeFile(path.join(fullPath, "directus/uploads/.gitkeep"), "");
await fs.writeFile(
path.join(fullPath, "directus/extensions/.gitkeep"),
"",
);
// Create .env.example
const envExample = `# Project
// Create .env.example
const envExample = `# Project
PROJECT_NAME=${projectName}
PROJECT_COLOR=#82ed20
@@ -294,17 +512,20 @@ DIRECTUS_DB_PASSWORD=mintel-db-pass
SENTRY_DSN=
# Analytics (Umami)
NEXT_PUBLIC_UMAMI_WEBSITE_ID=
NEXT_PUBLIC_UMAMI_SCRIPT_URL=https://analytics.infra.mintel.me/script.js
`;
await fs.writeFile(path.join(fullPath, ".env.example"), envExample);
UMAMI_WEBSITE_ID=
UMAMI_API_ENDPOINT=https://analytics.infra.mintel.me
// Copy premium templates (globals.css, lib/directus.ts, scripts/setup-directus.ts)
const templatePath = path.join(infraPath, "templates/website");
if (await fs.pathExists(templatePath)) {
console.log(chalk.blue("Applying premium templates..."));
await fs.copy(templatePath, fullPath, { overwrite: true });
}
# Notifications (Gotify)
GOTIFY_URL=
GOTIFY_TOKEN=
`;
await fs.writeFile(path.join(fullPath, ".env.example"), envExample);
// Copy premium templates (globals.css, lib/directus.ts, scripts/setup-directus.ts)
const templatePath = path.join(infraPath, "templates/website");
if (await fs.pathExists(templatePath)) {
console.log(chalk.blue("Applying premium templates..."));
await fs.copy(templatePath, fullPath, { overwrite: true });
}
console.log(

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

View File

@@ -0,0 +1,851 @@
import { useApi as e, defineModule as a } from "@directus/extensions-sdk";
import {
defineComponent as t,
ref as l,
onMounted as n,
resolveComponent as i,
resolveDirective as s,
openBlock as d,
createBlock as r,
withCtx as u,
createVNode as o,
createElementBlock as m,
Fragment as c,
renderList as v,
createTextVNode as p,
toDisplayString as f,
createCommentVNode as g,
createElementVNode as y,
withDirectives as b,
nextTick as _,
} from "vue";
const h = { class: "content-wrapper" },
x = { key: 0, class: "empty-state" },
w = { class: "header" },
k = { class: "header-left" },
V = { class: "title" },
C = { class: "subtitle" },
M = { class: "header-right" },
F = { class: "user-cell" },
N = { class: "user-name" },
z = { key: 0, class: "status-date" },
E = { key: 0, class: "drawer-content" },
U = { class: "form-section" },
S = { class: "field" },
A = { class: "drawer-actions" },
T = { key: 0, class: "drawer-content" },
Z = { class: "form-section" },
j = { class: "field" },
$ = { class: "field" },
D = { class: "field" },
O = { key: 1, class: "field" },
W = { class: "drawer-actions" };
var q = t({
__name: "module",
setup(a) {
const t = e(),
q = l([]),
B = l(null),
K = l([]),
L = l(!1),
P = l(!1),
G = l(null),
I = l(null),
H = l(!1),
J = l(!1),
Q = l({ id: "", name: "" }),
R = l(!1),
X = l(!1),
Y = l({
id: "",
first_name: "",
last_name: "",
email: "",
temporary_password: "",
}),
ee = [
{ text: "Name", value: "name", sortable: !0 },
{ text: "E-Mail", value: "email", sortable: !0 },
{ text: "Zuletzt eingeladen", value: "last_invited", sortable: !0 },
];
async function ae() {
const e = await t.get("/items/companies", {
params: { fields: ["id", "name"], sort: "name" },
});
q.value = e.data.data;
}
async function te(e) {
((B.value = e), (L.value = !0));
try {
const a = await t.get("/items/client_users", {
params: {
filter: { company: { _eq: e.id } },
fields: ["*"],
sort: "first_name",
},
});
K.value = a.data.data;
} finally {
L.value = !1;
}
}
function le() {
((J.value = !1), (Q.value = { id: "", name: "" }), (H.value = !0));
}
async function ne() {
B.value &&
((Q.value = { id: B.value.id, name: B.value.name }),
(J.value = !0),
await _(),
(H.value = !0));
}
async function ie() {
var e;
if (Q.value.name) {
P.value = !0;
try {
(J.value
? (await t.patch(`/items/companies/${Q.value.id}`, {
name: Q.value.name,
}),
(I.value = { type: "success", message: "Firma aktualisiert!" }))
: (await t.post("/items/companies", { name: Q.value.name }),
(I.value = { type: "success", message: "Firma angelegt!" })),
(H.value = !1),
await ae(),
(null == (e = B.value) ? void 0 : e.id) === Q.value.id &&
(B.value.name = Q.value.name));
} catch (e) {
I.value = { type: "danger", message: e.message };
} finally {
P.value = !1;
}
}
}
function se() {
((X.value = !1),
(Y.value = {
id: "",
first_name: "",
last_name: "",
email: "",
temporary_password: "",
}),
(R.value = !0));
}
async function de() {
if (Y.value.email && B.value) {
P.value = !0;
try {
(X.value
? (await t.patch(`/items/client_users/${Y.value.id}`, {
first_name: Y.value.first_name,
last_name: Y.value.last_name,
email: Y.value.email,
}),
(I.value = {
type: "success",
message: "Mitarbeiter aktualisiert!",
}))
: (await t.post("/items/client_users", {
first_name: Y.value.first_name,
last_name: Y.value.last_name,
email: Y.value.email,
company: B.value.id,
}),
(I.value = {
type: "success",
message: "Mitarbeiter angelegt!",
})),
(R.value = !1),
await te(B.value));
} catch (e) {
I.value = { type: "danger", message: e.message };
} finally {
P.value = !1;
}
}
}
function re(e) {
const a = (null == e ? void 0 : e.item) || e;
a &&
a.id &&
(async function (e) {
((Y.value = {
id: e.id || "",
first_name: e.first_name || "",
last_name: e.last_name || "",
email: e.email || "",
temporary_password: e.temporary_password || "",
}),
(X.value = !0),
await _(),
(R.value = !0));
})(a);
}
return (
n(() => {
ae();
}),
(e, a) => {
const l = i("v-icon"),
n = i("v-list-item-icon"),
_ = i("v-text-overflow"),
ae = i("v-list-item-content"),
ue = i("v-list-item"),
oe = i("v-divider"),
me = i("v-list"),
ce = i("v-notice"),
ve = i("v-button"),
pe = i("v-info"),
fe = i("v-avatar"),
ge = i("v-chip"),
ye = i("v-table"),
be = i("v-input"),
_e = i("v-drawer"),
he = i("private-view"),
xe = s("tooltip");
return (
d(),
r(
he,
{ title: "Customer Manager" },
{
navigation: u(() => [
o(
me,
{ nav: "" },
{
default: u(() => [
o(
ue,
{ onClick: le, clickable: "" },
{
default: u(() => [
o(n, null, {
default: u(() => [
o(l, {
name: "add",
color: "var(--theme--primary)",
}),
]),
_: 1,
}),
o(ae, null, {
default: u(() => [
o(_, { text: "Neue Firma anlegen" }),
]),
_: 1,
}),
]),
_: 1,
},
),
o(oe),
(d(!0),
m(
c,
null,
v(q.value, (e) => {
var a;
return (
d(),
r(
ue,
{
key: e.id,
active:
(null == (a = B.value) ? void 0 : a.id) ===
e.id,
class: "company-item",
clickable: "",
onClick: (a) => te(e),
},
{
default: u(() => [
o(n, null, {
default: u(() => [
o(l, { name: "business" }),
]),
_: 1,
}),
o(
ae,
null,
{
default: u(() => [
o(_, { text: e.name }, null, 8, [
"text",
]),
]),
_: 2,
},
1024,
),
]),
_: 2,
},
1032,
["active", "onClick"],
)
);
}),
128,
)),
]),
_: 1,
},
),
]),
"title-outer:after": u(() => [
I.value
? (d(),
r(
ce,
{
key: 0,
type: I.value.type,
onClose: a[0] || (a[0] = (e) => (I.value = null)),
dismissible: "",
},
{ default: u(() => [p(f(I.value.message), 1)]), _: 1 },
8,
["type"],
))
: g("v-if", !0),
]),
default: u(() => [
y("div", h, [
B.value
? (d(),
m(
c,
{ key: 1 },
[
y("header", w, [
y("div", k, [
y("h1", V, f(B.value.name), 1),
y(
"p",
C,
f(K.value.length) + " Kunden-Mitarbeiter",
1,
),
]),
y("div", M, [
b(
(d(),
r(
ve,
{
secondary: "",
rounded: "",
icon: "",
onClick: ne,
},
{
default: u(() => [
o(l, { name: "edit" }),
]),
_: 1,
},
)),
[
[
xe,
"Firma bearbeiten",
void 0,
{ bottom: !0 },
],
],
),
o(
ve,
{ primary: "", onClick: se },
{
default: u(() => [
...(a[14] ||
(a[14] = [
p(" Mitarbeiter hinzufügen ", -1),
])),
]),
_: 1,
},
),
]),
]),
o(
ye,
{
headers: ee,
items: K.value,
loading: L.value,
class: "clickable-table",
"fixed-header": "",
"onClick:row": re,
},
{
"item.name": u(({ item: e }) => [
y("div", F, [
o(
fe,
{ name: e.first_name, "x-small": "" },
null,
8,
["name"],
),
y(
"span",
N,
f(e.first_name) + " " + f(e.last_name),
1,
),
]),
]),
"item.last_invited": u(({ item: e }) => {
return [
e.last_invited
? (d(),
m(
"span",
z,
f(
((t = e.last_invited),
new Date(t).toLocaleString(
"de-DE",
{
day: "2-digit",
month: "2-digit",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
},
)),
),
1,
))
: (d(),
r(
ge,
{ key: 1, "x-small": "" },
{
default: u(() => [
...(a[15] ||
(a[15] = [p("Noch nie", -1)])),
]),
_: 1,
},
)),
];
var t;
}),
_: 2,
},
1032,
["items", "loading"],
),
],
64,
))
: (d(),
m("div", x, [
o(
pe,
{
title: "Firmen auswählen",
icon: "business",
center: "",
},
{
default: u(() => [
a[12] ||
(a[12] = p(
" Wähle eine Firma in der Navigation aus oder ",
-1,
)),
o(
ve,
{ "x-small": "", onClick: le },
{
default: u(() => [
...(a[11] ||
(a[11] = [
p("erstelle eine neue Firma", -1),
])),
]),
_: 1,
},
),
a[13] || (a[13] = p(". ", -1)),
]),
_: 1,
},
),
])),
]),
o(
_e,
{
modelValue: H.value,
"onUpdate:modelValue":
a[2] || (a[2] = (e) => (H.value = e)),
title: J.value
? "Firma bearbeiten"
: "Neue Firma anlegen",
icon: "business",
onCancel: a[3] || (a[3] = (e) => (H.value = !1)),
},
{
default: u(() => [
H.value
? (d(),
m("div", E, [
y("div", U, [
y("div", S, [
a[16] ||
(a[16] = y(
"span",
{ class: "label" },
"Firmenname",
-1,
)),
o(
be,
{
modelValue: Q.value.name,
"onUpdate:modelValue":
a[1] ||
(a[1] = (e) => (Q.value.name = e)),
placeholder: "z.B. KLZ Cables",
autofocus: "",
},
null,
8,
["modelValue"],
),
]),
]),
y("div", A, [
o(
ve,
{
primary: "",
block: "",
loading: P.value,
onClick: ie,
},
{
default: u(() => [
...(a[17] ||
(a[17] = [p("Speichern", -1)])),
]),
_: 1,
},
8,
["loading"],
),
]),
]))
: g("v-if", !0),
]),
_: 1,
},
8,
["modelValue", "title"],
),
o(
_e,
{
modelValue: R.value,
"onUpdate:modelValue":
a[9] || (a[9] = (e) => (R.value = e)),
title: X.value
? "Mitarbeiter bearbeiten"
: "Neuen Mitarbeiter anlegen",
icon: "person",
onCancel: a[10] || (a[10] = (e) => (R.value = !1)),
},
{
default: u(() => [
R.value
? (d(),
m("div", T, [
y("div", Z, [
y("div", j, [
a[18] ||
(a[18] = y(
"span",
{ class: "label" },
"Vorname",
-1,
)),
o(
be,
{
modelValue: Y.value.first_name,
"onUpdate:modelValue":
a[4] ||
(a[4] = (e) =>
(Y.value.first_name = e)),
placeholder: "Vorname",
autofocus: "",
},
null,
8,
["modelValue"],
),
]),
y("div", $, [
a[19] ||
(a[19] = y(
"span",
{ class: "label" },
"Nachname",
-1,
)),
o(
be,
{
modelValue: Y.value.last_name,
"onUpdate:modelValue":
a[5] ||
(a[5] = (e) => (Y.value.last_name = e)),
placeholder: "Nachname",
},
null,
8,
["modelValue"],
),
]),
y("div", D, [
a[20] ||
(a[20] = y(
"span",
{ class: "label" },
"E-Mail",
-1,
)),
o(
be,
{
modelValue: Y.value.email,
"onUpdate:modelValue":
a[6] ||
(a[6] = (e) => (Y.value.email = e)),
placeholder: "E-Mail Adresse",
type: "email",
},
null,
8,
["modelValue"],
),
]),
X.value
? (d(), r(oe, { key: 0 }))
: g("v-if", !0),
X.value
? (d(),
m("div", O, [
a[21] ||
(a[21] = y(
"span",
{ class: "label" },
"Temporäres Passwort",
-1,
)),
o(
be,
{
modelValue:
Y.value.temporary_password,
"onUpdate:modelValue":
a[7] ||
(a[7] = (e) =>
(Y.value.temporary_password = e)),
readonly: "",
class: "password-input",
},
null,
8,
["modelValue"],
),
a[22] ||
(a[22] = y(
"p",
{ class: "field-note" },
"Wird beim Senden der Zugangsdaten automatisch generiert.",
-1,
)),
]))
: g("v-if", !0),
]),
y("div", W, [
o(
ve,
{
primary: "",
block: "",
loading: P.value,
onClick: de,
},
{
default: u(() => [
...(a[23] ||
(a[23] = [p("Daten speichern", -1)])),
]),
_: 1,
},
8,
["loading"],
),
X.value
? (d(),
m(
c,
{ key: 0 },
[
o(oe),
b(
(d(),
r(
ve,
{
secondary: "",
block: "",
loading: G.value === Y.value.id,
onClick:
a[8] ||
(a[8] = (e) =>
(async function (e) {
G.value = e.id;
try {
if (
(await t.post(
"/flows/trigger/33443f6b-cec7-4668-9607-f33ea674d501",
[e.id],
),
(I.value = {
type: "success",
message: `Zugangsdaten für ${e.first_name} versendet. 📧`,
}),
await te(B.value),
R.value &&
Y.value.id === e.id)
) {
const a = K.value.find(
(a) => a.id === e.id,
);
a &&
(Y.value.temporary_password =
a.temporary_password);
}
} catch (e) {
I.value = {
type: "danger",
message: `Fehler: ${e.message}`,
};
} finally {
G.value = null;
}
})(Y.value)),
},
{
default: u(() => [
o(l, {
name: "send",
left: "",
}),
a[24] ||
(a[24] = p(
" Zugangsdaten senden ",
-1,
)),
]),
_: 1,
},
8,
["loading"],
)),
[
[
xe,
"Generiert PW, speichert es und sendet E-Mail",
void 0,
{ bottom: !0 },
],
],
),
],
64,
))
: g("v-if", !0),
]),
]))
: g("v-if", !0),
]),
_: 1,
},
8,
["modelValue", "title"],
),
]),
_: 1,
},
)
);
}
);
},
}),
B = [],
K = [];
!(function (e, a) {
if (e && "undefined" != typeof document) {
var t,
l = !0 === a.prepend ? "prepend" : "append",
n = !0 === a.singleTag,
i =
"string" == typeof a.container
? document.querySelector(a.container)
: document.getElementsByTagName("head")[0];
if (n) {
var s = B.indexOf(i);
(-1 === s && ((s = B.push(i) - 1), (K[s] = {})),
(t = K[s] && K[s][l] ? K[s][l] : (K[s][l] = d())));
} else t = d();
(65279 === e.charCodeAt(0) && (e = e.substring(1)),
t.styleSheet
? (t.styleSheet.cssText += e)
: t.appendChild(document.createTextNode(e)));
}
function d() {
var e = document.createElement("style");
if ((e.setAttribute("type", "text/css"), a.attributes))
for (var t = Object.keys(a.attributes), n = 0; n < t.length; n++)
e.setAttribute(t[n], a.attributes[t[n]]);
var s = "prepend" === l ? "afterbegin" : "beforeend";
return (i.insertAdjacentElement(s, e), e);
}
})(
"\n.content-wrapper[data-v-3fd11e72] { padding: 32px; height: 100%; display: flex; flex-direction: column;\n}\n.company-item[data-v-3fd11e72] { cursor: pointer;\n}\n.header[data-v-3fd11e72] { margin-bottom: 24px; display: flex; justify-content: space-between; align-items: flex-end;\n}\n.header-right[data-v-3fd11e72] { display: flex; gap: 12px;\n}\n.title[data-v-3fd11e72] { font-size: 24px; font-weight: 800; margin-bottom: 4px;\n}\n.subtitle[data-v-3fd11e72] { color: var(--theme--foreground-subdued); font-size: 14px;\n}\n.empty-state[data-v-3fd11e72] { height: 100%; display: flex; align-items: center; justify-content: center;\n}\n.user-cell[data-v-3fd11e72] { display: flex; align-items: center; gap: 12px;\n}\n.user-name[data-v-3fd11e72] { font-weight: 600;\n}\n.status-date[data-v-3fd11e72] { font-size: 12px; color: var(--theme--foreground-subdued);\n}\n.drawer-content[data-v-3fd11e72] { padding: 24px; display: flex; flex-direction: column; gap: 32px;\n}\n.form-section[data-v-3fd11e72] { display: flex; flex-direction: column; gap: 20px;\n}\n.field[data-v-3fd11e72] { display: flex; flex-direction: column; gap: 8px;\n}\n.label[data-v-3fd11e72] { font-size: 12px; font-weight: 700; text-transform: uppercase; color: var(--theme--foreground-subdued); letter-spacing: 0.5px;\n}\n.field-note[data-v-3fd11e72] { font-size: 11px; color: var(--theme--foreground-subdued); margin-top: 4px;\n}\n.drawer-actions[data-v-3fd11e72] { margin-top: 24px; display: flex; flex-direction: column; gap: 12px;\n}\n.password-input[data-v-3fd11e72] textarea {\n\tfont-family: var(--family-monospace);\n\tfont-weight: 800;\n\tcolor: var(--theme--primary) !important;\n\tbackground: var(--theme--background-subdued) !important;\n}\n.clickable-table[data-v-3fd11e72] tbody tr { cursor: pointer; transition: background-color 0.2s ease;\n}\n.clickable-table[data-v-3fd11e72] tbody tr:hover { background-color: var(--theme--background-subdued) !important;\n}\n[data-v-3fd11e72] .v-list-item { cursor: pointer !important;\n}\n",
{},
);
var L = a({
id: "customer-manager",
name: "Customer Manager",
icon: "supervisor_account",
routes: [
{
path: "",
component: ((e, a) => {
const t = e.__vccOpts || e;
for (const [e, l] of a) t[e] = l;
return t;
})(q, [
["__scopeId", "data-v-3fd11e72"],
["__file", "module.vue"],
]),
},
],
});
export { L as default };

View File

@@ -0,0 +1,29 @@
{
"name": "customer-manager",
"description": "Custom High-Fidelity Customer & Company Management for Directus",
"icon": "supervisor_account",
"version": "1.0.0",
"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",
"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,29 @@
{
"name": "feedback-commander",
"description": "Custom High-Fidelity Feedback Management Extension for Directus",
"icon": "view_kanban",
"version": "1.0.0",
"keywords": [
"directus",
"directus-extension",
"directus-extension-module"
],
"files": [
"index.js"
],
"directus:extension": {
"type": "module",
"path": "index.js",
"source": "src/index.ts",
"host": "*",
"name": "Feedback Commander"
},
"scripts": {
"build": "directus-extension build",
"dev": "directus-extension build -w"
},
"devDependencies": {
"@directus/extensions-sdk": "11.0.2",
"vue": "^3.4.0"
}
}

View File

@@ -0,0 +1,11 @@
{
"name": "@mintel/cms-infra",
"version": "1.6.0",
"private": true,
"type": "module",
"scripts": {
"up": "docker compose up -d",
"down": "docker compose down",
"logs": "docker compose logs -f"
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1 @@
xmKX5

View File

@@ -0,0 +1,851 @@
import { useApi as e, defineModule as a } from "@directus/extensions-sdk";
import {
defineComponent as t,
ref as l,
onMounted as n,
resolveComponent as i,
resolveDirective as s,
openBlock as d,
createBlock as r,
withCtx as u,
createVNode as o,
createElementBlock as m,
Fragment as c,
renderList as v,
createTextVNode as p,
toDisplayString as f,
createCommentVNode as g,
createElementVNode as y,
withDirectives as b,
nextTick as _,
} from "vue";
const h = { class: "content-wrapper" },
x = { key: 0, class: "empty-state" },
w = { class: "header" },
k = { class: "header-left" },
V = { class: "title" },
C = { class: "subtitle" },
M = { class: "header-right" },
F = { class: "user-cell" },
N = { class: "user-name" },
z = { key: 0, class: "status-date" },
E = { key: 0, class: "drawer-content" },
U = { class: "form-section" },
S = { class: "field" },
A = { class: "drawer-actions" },
T = { key: 0, class: "drawer-content" },
Z = { class: "form-section" },
j = { class: "field" },
$ = { class: "field" },
D = { class: "field" },
O = { key: 1, class: "field" },
W = { class: "drawer-actions" };
var q = t({
__name: "module",
setup(a) {
const t = e(),
q = l([]),
B = l(null),
K = l([]),
L = l(!1),
P = l(!1),
G = l(null),
I = l(null),
H = l(!1),
J = l(!1),
Q = l({ id: "", name: "" }),
R = l(!1),
X = l(!1),
Y = l({
id: "",
first_name: "",
last_name: "",
email: "",
temporary_password: "",
}),
ee = [
{ text: "Name", value: "name", sortable: !0 },
{ text: "E-Mail", value: "email", sortable: !0 },
{ text: "Zuletzt eingeladen", value: "last_invited", sortable: !0 },
];
async function ae() {
const e = await t.get("/items/companies", {
params: { fields: ["id", "name"], sort: "name" },
});
q.value = e.data.data;
}
async function te(e) {
((B.value = e), (L.value = !0));
try {
const a = await t.get("/items/client_users", {
params: {
filter: { company: { _eq: e.id } },
fields: ["*"],
sort: "first_name",
},
});
K.value = a.data.data;
} finally {
L.value = !1;
}
}
function le() {
((J.value = !1), (Q.value = { id: "", name: "" }), (H.value = !0));
}
async function ne() {
B.value &&
((Q.value = { id: B.value.id, name: B.value.name }),
(J.value = !0),
await _(),
(H.value = !0));
}
async function ie() {
var e;
if (Q.value.name) {
P.value = !0;
try {
(J.value
? (await t.patch(`/items/companies/${Q.value.id}`, {
name: Q.value.name,
}),
(I.value = { type: "success", message: "Firma aktualisiert!" }))
: (await t.post("/items/companies", { name: Q.value.name }),
(I.value = { type: "success", message: "Firma angelegt!" })),
(H.value = !1),
await ae(),
(null == (e = B.value) ? void 0 : e.id) === Q.value.id &&
(B.value.name = Q.value.name));
} catch (e) {
I.value = { type: "danger", message: e.message };
} finally {
P.value = !1;
}
}
}
function se() {
((X.value = !1),
(Y.value = {
id: "",
first_name: "",
last_name: "",
email: "",
temporary_password: "",
}),
(R.value = !0));
}
async function de() {
if (Y.value.email && B.value) {
P.value = !0;
try {
(X.value
? (await t.patch(`/items/client_users/${Y.value.id}`, {
first_name: Y.value.first_name,
last_name: Y.value.last_name,
email: Y.value.email,
}),
(I.value = {
type: "success",
message: "Mitarbeiter aktualisiert!",
}))
: (await t.post("/items/client_users", {
first_name: Y.value.first_name,
last_name: Y.value.last_name,
email: Y.value.email,
company: B.value.id,
}),
(I.value = {
type: "success",
message: "Mitarbeiter angelegt!",
})),
(R.value = !1),
await te(B.value));
} catch (e) {
I.value = { type: "danger", message: e.message };
} finally {
P.value = !1;
}
}
}
function re(e) {
const a = (null == e ? void 0 : e.item) || e;
a &&
a.id &&
(async function (e) {
((Y.value = {
id: e.id || "",
first_name: e.first_name || "",
last_name: e.last_name || "",
email: e.email || "",
temporary_password: e.temporary_password || "",
}),
(X.value = !0),
await _(),
(R.value = !0));
})(a);
}
return (
n(() => {
ae();
}),
(e, a) => {
const l = i("v-icon"),
n = i("v-list-item-icon"),
_ = i("v-text-overflow"),
ae = i("v-list-item-content"),
ue = i("v-list-item"),
oe = i("v-divider"),
me = i("v-list"),
ce = i("v-notice"),
ve = i("v-button"),
pe = i("v-info"),
fe = i("v-avatar"),
ge = i("v-chip"),
ye = i("v-table"),
be = i("v-input"),
_e = i("v-drawer"),
he = i("private-view"),
xe = s("tooltip");
return (
d(),
r(
he,
{ title: "Customer Manager" },
{
navigation: u(() => [
o(
me,
{ nav: "" },
{
default: u(() => [
o(
ue,
{ onClick: le, clickable: "" },
{
default: u(() => [
o(n, null, {
default: u(() => [
o(l, {
name: "add",
color: "var(--theme--primary)",
}),
]),
_: 1,
}),
o(ae, null, {
default: u(() => [
o(_, { text: "Neue Firma anlegen" }),
]),
_: 1,
}),
]),
_: 1,
},
),
o(oe),
(d(!0),
m(
c,
null,
v(q.value, (e) => {
var a;
return (
d(),
r(
ue,
{
key: e.id,
active:
(null == (a = B.value) ? void 0 : a.id) ===
e.id,
class: "company-item",
clickable: "",
onClick: (a) => te(e),
},
{
default: u(() => [
o(n, null, {
default: u(() => [
o(l, { name: "business" }),
]),
_: 1,
}),
o(
ae,
null,
{
default: u(() => [
o(_, { text: e.name }, null, 8, [
"text",
]),
]),
_: 2,
},
1024,
),
]),
_: 2,
},
1032,
["active", "onClick"],
)
);
}),
128,
)),
]),
_: 1,
},
),
]),
"title-outer:after": u(() => [
I.value
? (d(),
r(
ce,
{
key: 0,
type: I.value.type,
onClose: a[0] || (a[0] = (e) => (I.value = null)),
dismissible: "",
},
{ default: u(() => [p(f(I.value.message), 1)]), _: 1 },
8,
["type"],
))
: g("v-if", !0),
]),
default: u(() => [
y("div", h, [
B.value
? (d(),
m(
c,
{ key: 1 },
[
y("header", w, [
y("div", k, [
y("h1", V, f(B.value.name), 1),
y(
"p",
C,
f(K.value.length) + " Kunden-Mitarbeiter",
1,
),
]),
y("div", M, [
b(
(d(),
r(
ve,
{
secondary: "",
rounded: "",
icon: "",
onClick: ne,
},
{
default: u(() => [
o(l, { name: "edit" }),
]),
_: 1,
},
)),
[
[
xe,
"Firma bearbeiten",
void 0,
{ bottom: !0 },
],
],
),
o(
ve,
{ primary: "", onClick: se },
{
default: u(() => [
...(a[14] ||
(a[14] = [
p(" Mitarbeiter hinzufügen ", -1),
])),
]),
_: 1,
},
),
]),
]),
o(
ye,
{
headers: ee,
items: K.value,
loading: L.value,
class: "clickable-table",
"fixed-header": "",
"onClick:row": re,
},
{
"item.name": u(({ item: e }) => [
y("div", F, [
o(
fe,
{ name: e.first_name, "x-small": "" },
null,
8,
["name"],
),
y(
"span",
N,
f(e.first_name) + " " + f(e.last_name),
1,
),
]),
]),
"item.last_invited": u(({ item: e }) => {
return [
e.last_invited
? (d(),
m(
"span",
z,
f(
((t = e.last_invited),
new Date(t).toLocaleString(
"de-DE",
{
day: "2-digit",
month: "2-digit",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
},
)),
),
1,
))
: (d(),
r(
ge,
{ key: 1, "x-small": "" },
{
default: u(() => [
...(a[15] ||
(a[15] = [p("Noch nie", -1)])),
]),
_: 1,
},
)),
];
var t;
}),
_: 2,
},
1032,
["items", "loading"],
),
],
64,
))
: (d(),
m("div", x, [
o(
pe,
{
title: "Firmen auswählen",
icon: "business",
center: "",
},
{
default: u(() => [
a[12] ||
(a[12] = p(
" Wähle eine Firma in der Navigation aus oder ",
-1,
)),
o(
ve,
{ "x-small": "", onClick: le },
{
default: u(() => [
...(a[11] ||
(a[11] = [
p("erstelle eine neue Firma", -1),
])),
]),
_: 1,
},
),
a[13] || (a[13] = p(". ", -1)),
]),
_: 1,
},
),
])),
]),
o(
_e,
{
modelValue: H.value,
"onUpdate:modelValue":
a[2] || (a[2] = (e) => (H.value = e)),
title: J.value
? "Firma bearbeiten"
: "Neue Firma anlegen",
icon: "business",
onCancel: a[3] || (a[3] = (e) => (H.value = !1)),
},
{
default: u(() => [
H.value
? (d(),
m("div", E, [
y("div", U, [
y("div", S, [
a[16] ||
(a[16] = y(
"span",
{ class: "label" },
"Firmenname",
-1,
)),
o(
be,
{
modelValue: Q.value.name,
"onUpdate:modelValue":
a[1] ||
(a[1] = (e) => (Q.value.name = e)),
placeholder: "z.B. KLZ Cables",
autofocus: "",
},
null,
8,
["modelValue"],
),
]),
]),
y("div", A, [
o(
ve,
{
primary: "",
block: "",
loading: P.value,
onClick: ie,
},
{
default: u(() => [
...(a[17] ||
(a[17] = [p("Speichern", -1)])),
]),
_: 1,
},
8,
["loading"],
),
]),
]))
: g("v-if", !0),
]),
_: 1,
},
8,
["modelValue", "title"],
),
o(
_e,
{
modelValue: R.value,
"onUpdate:modelValue":
a[9] || (a[9] = (e) => (R.value = e)),
title: X.value
? "Mitarbeiter bearbeiten"
: "Neuen Mitarbeiter anlegen",
icon: "person",
onCancel: a[10] || (a[10] = (e) => (R.value = !1)),
},
{
default: u(() => [
R.value
? (d(),
m("div", T, [
y("div", Z, [
y("div", j, [
a[18] ||
(a[18] = y(
"span",
{ class: "label" },
"Vorname",
-1,
)),
o(
be,
{
modelValue: Y.value.first_name,
"onUpdate:modelValue":
a[4] ||
(a[4] = (e) =>
(Y.value.first_name = e)),
placeholder: "Vorname",
autofocus: "",
},
null,
8,
["modelValue"],
),
]),
y("div", $, [
a[19] ||
(a[19] = y(
"span",
{ class: "label" },
"Nachname",
-1,
)),
o(
be,
{
modelValue: Y.value.last_name,
"onUpdate:modelValue":
a[5] ||
(a[5] = (e) => (Y.value.last_name = e)),
placeholder: "Nachname",
},
null,
8,
["modelValue"],
),
]),
y("div", D, [
a[20] ||
(a[20] = y(
"span",
{ class: "label" },
"E-Mail",
-1,
)),
o(
be,
{
modelValue: Y.value.email,
"onUpdate:modelValue":
a[6] ||
(a[6] = (e) => (Y.value.email = e)),
placeholder: "E-Mail Adresse",
type: "email",
},
null,
8,
["modelValue"],
),
]),
X.value
? (d(), r(oe, { key: 0 }))
: g("v-if", !0),
X.value
? (d(),
m("div", O, [
a[21] ||
(a[21] = y(
"span",
{ class: "label" },
"Temporäres Passwort",
-1,
)),
o(
be,
{
modelValue:
Y.value.temporary_password,
"onUpdate:modelValue":
a[7] ||
(a[7] = (e) =>
(Y.value.temporary_password = e)),
readonly: "",
class: "password-input",
},
null,
8,
["modelValue"],
),
a[22] ||
(a[22] = y(
"p",
{ class: "field-note" },
"Wird beim Senden der Zugangsdaten automatisch generiert.",
-1,
)),
]))
: g("v-if", !0),
]),
y("div", W, [
o(
ve,
{
primary: "",
block: "",
loading: P.value,
onClick: de,
},
{
default: u(() => [
...(a[23] ||
(a[23] = [p("Daten speichern", -1)])),
]),
_: 1,
},
8,
["loading"],
),
X.value
? (d(),
m(
c,
{ key: 0 },
[
o(oe),
b(
(d(),
r(
ve,
{
secondary: "",
block: "",
loading: G.value === Y.value.id,
onClick:
a[8] ||
(a[8] = (e) =>
(async function (e) {
G.value = e.id;
try {
if (
(await t.post(
"/flows/trigger/33443f6b-cec7-4668-9607-f33ea674d501",
[e.id],
),
(I.value = {
type: "success",
message: `Zugangsdaten für ${e.first_name} versendet. 📧`,
}),
await te(B.value),
R.value &&
Y.value.id === e.id)
) {
const a = K.value.find(
(a) => a.id === e.id,
);
a &&
(Y.value.temporary_password =
a.temporary_password);
}
} catch (e) {
I.value = {
type: "danger",
message: `Fehler: ${e.message}`,
};
} finally {
G.value = null;
}
})(Y.value)),
},
{
default: u(() => [
o(l, {
name: "send",
left: "",
}),
a[24] ||
(a[24] = p(
" Zugangsdaten senden ",
-1,
)),
]),
_: 1,
},
8,
["loading"],
)),
[
[
xe,
"Generiert PW, speichert es und sendet E-Mail",
void 0,
{ bottom: !0 },
],
],
),
],
64,
))
: g("v-if", !0),
]),
]))
: g("v-if", !0),
]),
_: 1,
},
8,
["modelValue", "title"],
),
]),
_: 1,
},
)
);
}
);
},
}),
B = [],
K = [];
!(function (e, a) {
if (e && "undefined" != typeof document) {
var t,
l = !0 === a.prepend ? "prepend" : "append",
n = !0 === a.singleTag,
i =
"string" == typeof a.container
? document.querySelector(a.container)
: document.getElementsByTagName("head")[0];
if (n) {
var s = B.indexOf(i);
(-1 === s && ((s = B.push(i) - 1), (K[s] = {})),
(t = K[s] && K[s][l] ? K[s][l] : (K[s][l] = d())));
} else t = d();
(65279 === e.charCodeAt(0) && (e = e.substring(1)),
t.styleSheet
? (t.styleSheet.cssText += e)
: t.appendChild(document.createTextNode(e)));
}
function d() {
var e = document.createElement("style");
if ((e.setAttribute("type", "text/css"), a.attributes))
for (var t = Object.keys(a.attributes), n = 0; n < t.length; n++)
e.setAttribute(t[n], a.attributes[t[n]]);
var s = "prepend" === l ? "afterbegin" : "beforeend";
return (i.insertAdjacentElement(s, e), e);
}
})(
"\n.content-wrapper[data-v-3fd11e72] { padding: 32px; height: 100%; display: flex; flex-direction: column;\n}\n.company-item[data-v-3fd11e72] { cursor: pointer;\n}\n.header[data-v-3fd11e72] { margin-bottom: 24px; display: flex; justify-content: space-between; align-items: flex-end;\n}\n.header-right[data-v-3fd11e72] { display: flex; gap: 12px;\n}\n.title[data-v-3fd11e72] { font-size: 24px; font-weight: 800; margin-bottom: 4px;\n}\n.subtitle[data-v-3fd11e72] { color: var(--theme--foreground-subdued); font-size: 14px;\n}\n.empty-state[data-v-3fd11e72] { height: 100%; display: flex; align-items: center; justify-content: center;\n}\n.user-cell[data-v-3fd11e72] { display: flex; align-items: center; gap: 12px;\n}\n.user-name[data-v-3fd11e72] { font-weight: 600;\n}\n.status-date[data-v-3fd11e72] { font-size: 12px; color: var(--theme--foreground-subdued);\n}\n.drawer-content[data-v-3fd11e72] { padding: 24px; display: flex; flex-direction: column; gap: 32px;\n}\n.form-section[data-v-3fd11e72] { display: flex; flex-direction: column; gap: 20px;\n}\n.field[data-v-3fd11e72] { display: flex; flex-direction: column; gap: 8px;\n}\n.label[data-v-3fd11e72] { font-size: 12px; font-weight: 700; text-transform: uppercase; color: var(--theme--foreground-subdued); letter-spacing: 0.5px;\n}\n.field-note[data-v-3fd11e72] { font-size: 11px; color: var(--theme--foreground-subdued); margin-top: 4px;\n}\n.drawer-actions[data-v-3fd11e72] { margin-top: 24px; display: flex; flex-direction: column; gap: 12px;\n}\n.password-input[data-v-3fd11e72] textarea {\n\tfont-family: var(--family-monospace);\n\tfont-weight: 800;\n\tcolor: var(--theme--primary) !important;\n\tbackground: var(--theme--background-subdued) !important;\n}\n.clickable-table[data-v-3fd11e72] tbody tr { cursor: pointer; transition: background-color 0.2s ease;\n}\n.clickable-table[data-v-3fd11e72] tbody tr:hover { background-color: var(--theme--background-subdued) !important;\n}\n[data-v-3fd11e72] .v-list-item { cursor: pointer !important;\n}\n",
{},
);
var L = a({
id: "customer-manager",
name: "Customer Manager",
icon: "supervisor_account",
routes: [
{
path: "",
component: ((e, a) => {
const t = e.__vccOpts || e;
for (const [e, l] of a) t[e] = l;
return t;
})(q, [
["__scopeId", "data-v-3fd11e72"],
["__file", "module.vue"],
]),
},
],
});
export { L as default };

View File

@@ -0,0 +1,29 @@
{
"name": "customer-manager",
"description": "Custom High-Fidelity Customer & Company Management for Directus",
"icon": "supervisor_account",
"version": "1.6.0",
"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",
"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

@@ -2,11 +2,22 @@ import js from "@eslint/js";
import tseslint from "typescript-eslint";
export default tseslint.config(
{
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,24 +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 = [
...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"
}
"@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.6.0",
"publishConfig": {
"access": "public",
"registry": "https://npm.infra.mintel.me"
@@ -20,7 +20,10 @@
"dependencies": {
"@eslint/eslintrc": "^3.0.0",
"@eslint/js": "^9.39.2",
"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"
}
}

View File

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

@@ -0,0 +1,15 @@
> @mintel/gatekeeper@1.0.0 dev
> next dev
⚠ Port 3000 is in use, trying 3001 instead.
▲ Next.js 15.1.6
- Local: http://localhost:3001
- Network: http://192.168.1.126:3001
- Experiments (use with caution):
· clientTraceMetadata
✓ Starting...
warn - It seems like you don't have a global error handler set up. It is recommended that you add a global-error.js file with Sentry instrumentation so that React rendering errors are reported to Sentry. Read more: https://docs.sentry.io/platforms/javascript/guides/nextjs/manual-setup/#react-render-errors-in-app-router (you can suppress this warning by setting SENTRY_SUPPRESS_GLOBAL_ERROR_HANDLER_FILE_WARNING=1 as environment variable)
✓ Ready in 2.7s
[?25h

View File

@@ -0,0 +1,5 @@
{
"LoginPage": {
"title": "Gatekeeper"
}
}

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,19 +1,21 @@
{
"name": "@mintel/gatekeeper",
"version": "1.0.0",
"version": "1.6.0",
"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",
"tailwind-merge": "^2.6.0"
@@ -22,6 +24,7 @@
"@mintel/eslint-config": "workspace:*",
"@mintel/next-config": "workspace:*",
"@mintel/tsconfig": "workspace:*",
"@tailwindcss/typography": "^0.5.19",
"@types/node": "^20.0.0",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",

View File

@@ -0,0 +1,7 @@
/* global module */
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 29 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 17 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 19 KiB

View File

@@ -9,17 +9,79 @@ export async function GET(req: NextRequest) {
const session = cookieStore.get(authCookieName);
if (session?.value === password) {
return new NextResponse("OK", { status: 200 });
}
// Traefik ForwardAuth headers
// 1. URL Parameter Bypass (for automated tests/staging)
const originalUrl = req.headers.get("x-forwarded-uri") || "/";
const host =
req.headers.get("x-forwarded-host") || req.headers.get("host") || "";
const proto = req.headers.get("x-forwarded-proto") || "https";
const loginUrl = `${proto}://${host}/gatekeeper/login?redirect=${encodeURIComponent(originalUrl)}`;
try {
const url = new URL(originalUrl, `${proto}://${host}`);
if (url.searchParams.get("gk_bypass") === password) {
// Remove the bypass parameter from the redirect URL
url.searchParams.delete("gk_bypass");
const cleanUrl = url.pathname + url.search;
const absoluteCleanUrl = `${proto}://${host}${cleanUrl}`;
const response = NextResponse.redirect(absoluteCleanUrl);
// Set the session cookie so the bypass is persistent
const isDev = process.env.NODE_ENV === "development";
const cookieDomain = process.env.COOKIE_DOMAIN;
const sessionValue = JSON.stringify({
identity: "Bypass",
timestamp: Date.now(),
});
response.cookies.set(authCookieName, sessionValue, {
httpOnly: true,
secure: !isDev,
path: "/",
maxAge: 30 * 24 * 60 * 60, // 30 days
sameSite: "lax",
...(cookieDomain ? { domain: cookieDomain } : {}),
});
return response;
}
} catch (_e) {
// URL parsing failed, proceed with normal logic
}
let isAuthenticated = false;
let identity = "Guest";
if (session?.value) {
if (session.value === password) {
isAuthenticated = true;
} else {
try {
const payload = JSON.parse(session.value);
if (payload.identity) {
isAuthenticated = true;
identity = payload.identity;
}
} catch (_e) {
// Fallback or old format
}
}
}
if (isAuthenticated) {
return new NextResponse("OK", {
status: 200,
headers: {
"X-Auth-User": identity,
},
});
}
// Traefik ForwardAuth headers
const gatekeeperUrl =
process.env.NEXT_PUBLIC_BASE_URL || `${proto}://gatekeeper.${host}`;
const absoluteOriginalUrl = `${proto}://${host}${originalUrl}`;
const loginUrl = `${gatekeeperUrl}/login?redirect=${encodeURIComponent(absoluteOriginalUrl)}`;
return NextResponse.redirect(loginUrl);
}

View File

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

View File

@@ -1,131 +0,0 @@
import { cookies } from "next/headers";
import { redirect } from "next/navigation";
import { Lock, ShieldCheck, ArrowRight } from "lucide-react";
interface LoginPageProps {
searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
}
export default async function LoginPage({ searchParams }: LoginPageProps) {
const params = await searchParams;
const redirectUrl = (params.redirect as string) || "/";
const error = params.error === "1";
const projectName = process.env.PROJECT_NAME || "Mintel";
const projectColor = process.env.PROJECT_COLOR || "#82ed20";
async function login(formData: FormData) {
"use server";
const password = formData.get("password");
const expectedPassword = process.env.GATEKEEPER_PASSWORD || "mintel";
const authCookieName =
process.env.AUTH_COOKIE_NAME || "mintel_gatekeeper_session";
const targetRedirect = formData.get("redirect") as string;
if (password === expectedPassword) {
const cookieStore = await cookies();
cookieStore.set(authCookieName, expectedPassword, {
httpOnly: true,
secure: true,
path: "/",
maxAge: 30 * 24 * 60 * 60, // 30 days
sameSite: "lax",
});
redirect(targetRedirect);
} else {
redirect(
`/gatekeeper/login?error=1&redirect=${encodeURIComponent(targetRedirect)}`,
);
}
}
return (
<div className="min-h-screen flex items-center justify-center relative overflow-hidden bg-mintel-dark">
{/* Background Decor */}
<div className="absolute inset-0 bg-grid pointer-events-none opacity-20" />
<div
className="absolute inset-0 pointer-events-none opacity-30"
style={{
background: `radial-gradient(circle at center, ${projectColor}11 0%, transparent 70%)`,
}}
/>
<div className="relative z-10 w-full max-w-md px-6 animate-in fade-in zoom-in duration-700">
{/* Logo / Icon */}
<div className="flex justify-center mb-12">
<div
className="w-20 h-20 rounded-3xl flex items-center justify-center border border-white/10 bg-white/5 backdrop-blur-xl shadow-2xl"
style={{ borderBottom: `2px solid ${projectColor}44` }}
>
<ShieldCheck
className="w-10 h-10"
style={{ color: projectColor }}
/>
</div>
</div>
<div className="bg-white/[0.03] backdrop-blur-3xl border border-white/10 p-10 rounded-[48px] shadow-2xl relative overflow-hidden group">
{/* Subtle accent line */}
<div
className="absolute top-0 left-0 w-full h-1 opacity-50"
style={{
background: `linear-gradient(to right, transparent, ${projectColor}, transparent)`,
}}
/>
<div className="mb-10 text-center">
<h1 className="text-3xl font-black mb-3 tracking-tighter uppercase italic flex items-center justify-center gap-2">
{projectName.split(" ")[0]}
<span style={{ color: projectColor }}>GATEKEEPER</span>
</h1>
<p className="text-white/40 text-sm font-medium">
Restricted Infrastructure Access
</p>
</div>
{error && (
<div className="bg-red-500/10 border border-red-500/20 text-red-200 p-4 rounded-2xl mb-8 text-sm flex items-center gap-3 animate-pulse">
<Lock className="w-5 h-5 flex-shrink-0" />
<span>Invalid access password. Please try again.</span>
</div>
)}
<form action={login} className="space-y-8">
<input type="hidden" name="redirect" value={redirectUrl} />
<div className="space-y-3">
<label className="block text-[10px] font-black uppercase tracking-[0.3em] text-white/20 ml-5">
Authentication Code
</label>
<input
type="password"
name="password"
required
autoFocus
className="w-full bg-white/5 border border-white/10 rounded-3xl px-8 py-6 focus:outline-none focus:ring-2 transition-all text-xl tracking-[0.5em] text-center placeholder:tracking-normal placeholder:text-white/10"
style={{ "--tw-ring-color": `${projectColor}33` } as any}
placeholder="••••••••"
/>
</div>
<button
type="submit"
className="w-full font-black uppercase tracking-[0.2em] py-6 rounded-3xl transition-all active:scale-[0.98] flex items-center justify-center gap-3 shadow-lg hover:shadow-mintel-green/10"
style={{ backgroundColor: projectColor, color: "#000" }}
>
Verify Identity
<ArrowRight className="w-5 h-5" />
</button>
</form>
</div>
<div className="mt-12 text-center">
<p className="text-[10px] font-bold text-white/10 uppercase tracking-[0.5em]">
&copy; 2026 {projectName} Infrastructure
</p>
</div>
</div>
</div>
);
}

View File

@@ -2,20 +2,83 @@
@tailwind components;
@tailwind utilities;
:root {
--background: #000c1f;
--foreground: #ffffff;
@layer base {
html {
scroll-behavior: smooth;
}
body {
@apply bg-white text-slate-800 font-serif antialiased selection:bg-slate-900 selection:text-white;
line-height: 1.6;
}
h1,
h2,
h3,
h4,
h5,
h6 {
@apply font-sans font-bold text-slate-900 tracking-tighter;
}
p {
@apply mb-4 text-base leading-relaxed text-slate-700;
}
a {
@apply text-slate-900 hover:text-slate-700 transition-colors no-underline;
}
}
body {
color: var(--foreground);
background: var(--background);
min-height: 100vh;
@layer components {
.narrow-container {
@apply max-w-4xl mx-auto px-6 py-10;
}
.btn {
@apply inline-flex items-center justify-center px-6 py-3 border border-slate-200 bg-white text-slate-600 font-sans font-bold text-sm uppercase tracking-widest rounded-full transition-all duration-500 ease-industrial hover:border-slate-400 hover:text-slate-900 hover:bg-slate-50 hover:-translate-y-0.5 hover:shadow-xl hover:shadow-slate-100 active:translate-y-0 active:shadow-sm;
}
.btn-primary {
@apply border-slate-900 text-slate-900 hover:bg-slate-900 hover:text-white;
}
}
.bg-grid {
background-image:
linear-gradient(to right, rgba(255, 255, 255, 0.03) 1px, transparent 1px),
linear-gradient(to bottom, rgba(255, 255, 255, 0.03) 1px, transparent 1px);
background-size: 40px 40px;
/* Custom scrollbar */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: #f1f5f9;
}
::-webkit-scrollbar-thumb {
background: #cbd5e1;
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: #94a3b8;
}
/* Animations */
@keyframes shake {
0%,
100% {
transform: translateX(0);
}
25% {
transform: translateX(-4px);
}
75% {
transform: translateX(4px);
}
}
.animate-shake {
animation: shake 0.2s ease-in-out 0s 2;
}

View File

@@ -1,6 +1,15 @@
import type { Metadata } from "next";
import { Inter, Newsreader } from "next/font/google";
import "./globals.css";
const inter = Inter({ subsets: ["latin"], variable: "--font-inter" });
const newsreader = Newsreader({
subsets: ["latin"],
variable: "--font-newsreader",
style: "italic",
display: "swap",
});
export const metadata: Metadata = {
title: "Gatekeeper | Access Control",
description: "Mintel Infrastructure Protection",
@@ -12,7 +21,7 @@ export default function RootLayout({
children: React.ReactNode;
}) {
return (
<html lang="en">
<html lang="en" className={`${inter.variable} ${newsreader.variable}`}>
<body className="antialiased">{children}</body>
</html>
);

View File

@@ -0,0 +1,244 @@
import { cookies } from "next/headers";
import { redirect } from "next/navigation";
import { ArrowRight, ShieldCheck } from "lucide-react";
import Image from "next/image";
interface LoginPageProps {
searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
}
export default async function LoginPage({ searchParams }: LoginPageProps) {
const params = await searchParams;
const redirectUrl = (params.redirect as string) || "/";
const error = params.error === "1";
const projectName = process.env.PROJECT_NAME || "Mintel";
async function login(formData: FormData) {
"use server";
const email = formData.get("email") as string;
const password = formData.get("password") as string;
const expectedCode = process.env.GATEKEEPER_PASSWORD || "mintel";
const adminEmail = process.env.DIRECTUS_ADMIN_EMAIL;
const adminPassword = process.env.DIRECTUS_ADMIN_PASSWORD;
const authCookieName =
process.env.AUTH_COOKIE_NAME || "mintel_gatekeeper_session";
const targetRedirect = formData.get("redirect") as string;
const cookieDomain = process.env.COOKIE_DOMAIN;
let userIdentity = "";
let userCompany: any = null;
// 1. Check Global Admin (from ENV)
if (
adminEmail &&
adminPassword &&
email === adminEmail &&
password === adminPassword
) {
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);
}
}
// 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",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, password }),
});
if (loginRes.ok) {
const { data } = await loginRes.json();
const accessToken = data.access_token;
// 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) {
console.error("Directus Auth Error:", e);
}
}
if (userIdentity) {
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";
cookieStore.set(authCookieName, sessionValue, {
httpOnly: true,
secure: !isDev,
path: "/",
maxAge: 30 * 24 * 60 * 60, // 30 days
sameSite: "lax",
...(cookieDomain ? { domain: cookieDomain } : {}),
});
redirect(targetRedirect);
} else {
redirect(`/login?error=1&redirect=${encodeURIComponent(targetRedirect)}`);
}
}
return (
<div className="min-h-screen flex items-center justify-center relative bg-white font-serif antialiased overflow-hidden">
{/* Background Decor - Signature mintel.me style */}
<div
className="absolute inset-0 pointer-events-none opacity-[0.03] scale-[1.01]"
style={{
backgroundImage: `linear-gradient(to right, #000 1px, transparent 1px), linear-gradient(to bottom, #000 1px, transparent 1px)`,
backgroundSize: "clamp(30px, 8vw, 40px) clamp(30px, 8vw, 40px)",
}}
/>
<main className="relative z-10 w-full max-w-sm px-8 sm:px-6">
<div className="space-y-12 sm:space-y-16 animate-fade-in">
{/* Top Icon Box - Signature mintel.me Black Square */}
<div className="flex justify-center">
<div className="w-16 h-16 bg-black rounded-xl flex items-center justify-center shadow-xl shadow-slate-100 hover:scale-105 transition-all duration-500 ease-[cubic-bezier(0.23,1,0.32,1)] rotate-2 hover:rotate-0">
<Image
src="/icon-white.svg"
alt="Mintel"
width={32}
height={32}
className="w-8 h-8"
/>
</div>
</div>
<div className="space-y-12 animate-slide-up">
<div className="text-center space-y-4">
<h1 className="text-xs font-sans font-bold uppercase tracking-[0.4em] text-slate-900 border-b border-slate-50 pb-4 inline-block mx-auto min-w-[200px]">
{projectName} <span className="text-slate-300">Gatekeeper</span>
</h1>
<p className="text-[10px] text-slate-400 font-sans uppercase tracking-widest italic flex items-center justify-center gap-2">
<span className="w-1 h-1 bg-slate-200 rounded-full" />
Infrastructure Protection
<span className="w-1 h-1 bg-slate-200 rounded-full" />
</p>
</div>
{error && (
<div className="bg-red-50 text-red-600 px-5 py-3 rounded-2xl text-[9px] font-sans font-bold uppercase tracking-widest flex items-center gap-3 border border-red-100 animate-shake">
<ShieldCheck className="w-4 h-4" />
<span>Access Denied. Try Again.</span>
</div>
)}
<form action={login} className="space-y-4">
<input type="hidden" name="redirect" value={redirectUrl} />
<div className="space-y-2">
<div className="relative group">
<input
type="email"
name="email"
placeholder="EMAIL (OPTIONAL)"
className="w-full bg-slate-50/50 border border-slate-200 rounded-2xl px-6 py-4 focus:outline-none focus:border-slate-900 focus:bg-white transition-all text-[10px] font-sans font-bold tracking-[0.2em] uppercase placeholder:text-slate-300 shadow-sm shadow-slate-50"
/>
</div>
<div className="relative group">
<input
type="password"
name="password"
required
autoFocus
autoComplete="current-password"
placeholder="ACCESS CODE"
className="w-full bg-slate-50/50 border border-slate-200 rounded-2xl px-6 py-4 focus:outline-none focus:border-slate-900 focus:bg-white transition-all text-sm font-sans font-bold tracking-[0.3em] uppercase placeholder:text-slate-300 placeholder:tracking-widest shadow-sm shadow-slate-50"
/>
</div>
</div>
<button
type="submit"
className="btn btn-primary w-full py-5 rounded-2xl text-[10px] shadow-lg shadow-slate-100 flex items-center justify-center"
>
Unlock Access
<ArrowRight className="ml-3 w-3 h-3 group-hover:translate-x-1 transition-transform" />
</button>
</form>
{/* Bottom Section - Full Branding Parity */}
<div className="pt-12 sm:pt-20 flex flex-col items-center gap-6 sm:gap-8">
<div className="h-px w-8 bg-slate-100" />
<div className="opacity-80 transition-opacity hover:opacity-100">
<Image
src="/logo-black.svg"
alt={projectName}
width={140}
height={40}
className="h-7 sm:h-auto grayscale contrast-125 w-auto"
/>
</div>
<p className="text-[8px] font-sans font-bold text-slate-300 uppercase tracking-[0.4em] sm:tracking-[0.5em] text-center">
&copy; 2026 MINTEL
</p>
</div>
</div>
</div>
</main>
</div>
);
}

View File

@@ -0,0 +1,5 @@
import { redirect } from "next/navigation";
export default function RootPage() {
redirect("/login");
}

View File

@@ -0,0 +1,7 @@
import { createMintelI18nRequestConfig } from "@mintel/next-utils";
export default createMintelI18nRequestConfig(
["en"],
"en",
(locale) => import(`../../messages/${locale}.json`),
);

View File

@@ -0,0 +1,61 @@
/* global module, require */
/** @type {import('tailwindcss').Config} */
module.exports = {
content: [
"./src/pages/**/*.{js,ts,jsx,tsx,mdx}",
"./src/components/**/*.{js,ts,jsx,tsx,mdx}",
"./src/app/**/*.{js,ts,jsx,tsx,mdx}",
],
theme: {
extend: {
borderRadius: {
xl: "1rem",
"2xl": "1.5rem",
"3xl": "2rem",
full: "9999px",
},
colors: {
slate: {
850: "#1e293b",
900: "#0f172a",
950: "#020617",
},
},
fontFamily: {
sans: ["var(--font-inter)", "Inter", "system-ui", "sans-serif"],
serif: ["var(--font-newsreader)", "Georgia", "serif"],
mono: ["JetBrains Mono", "monospace"],
},
animation: {
"fade-in": "fadeIn 0.5s ease-in-out",
"slide-up": "slideUp 0.6s ease-out",
"slide-down": "slideDown 0.6s ease-out",
shake: "shake 0.2s ease-in-out 0s 2",
},
keyframes: {
fadeIn: {
"0%": { opacity: "0" },
"100%": { opacity: "1" },
},
slideUp: {
"0%": { transform: "translateY(20px)", opacity: "0" },
"100%": { transform: "translateY(0)", opacity: "1" },
},
slideDown: {
"0%": { transform: "translateY(-20px)", opacity: "0" },
"100%": { transform: "translateY(0)", opacity: "1" },
},
shake: {
"0%, 100%": { transform: "translateX(0)" },
"25%": { transform: "translateX(-4px)" },
"75%": { transform: "translateX(4px)" },
},
},
transitionTimingFunction: {
industrial: "cubic-bezier(0.23, 1, 0.32, 1)",
},
},
},
// eslint-disable-next-line @typescript-eslint/no-require-imports
plugins: [require("@tailwindcss/typography")],
};

View File

@@ -1,23 +0,0 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
content: [
"./src/pages/**/*.{js,ts,jsx,tsx,mdx}",
"./src/components/**/*.{js,ts,jsx,tsx,mdx}",
"./src/app/**/*.{js,ts,jsx,tsx,mdx}",
],
theme: {
extend: {
colors: {
mintel: {
green: "#82ed20",
blue: "#001a4d",
dark: "#000c1f",
},
},
backgroundImage: {
"gradient-radial": "radial-gradient(var(--tw-gradient-stops))",
},
},
},
plugins: [],
};

View File

@@ -1,8 +1,10 @@
export default {
const config = {
extends: ["@commitlint/config-conventional"],
rules: {
"header-max-length": [2, "always", 150],
"header-max-length": [2, "always", 250],
"subject-case": [0],
"subject-full-stop": [0],
},
};
export default config;

View File

@@ -1,4 +1,23 @@
export default {
"*.{js,jsx,ts,tsx}": ["eslint --fix", "prettier --write"],
/* global process */
import path from "node:path";
const buildLintCommand = (filenames) => {
const isNext =
process.env.npm_package_devDependencies_next ||
process.env.npm_package_dependencies_next;
if (isNext) {
return `next lint --fix --file ${filenames
.map((f) => path.relative(process.cwd(), f))
.join(" --file ")}`;
}
return "eslint --fix --no-warn-ignored";
};
const config = {
"*.{js,jsx,ts,tsx}": [buildLintCommand, "prettier --write"],
"*.{json,md,css,scss}": ["prettier --write"],
};
export default config;

View File

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

View File

@@ -0,0 +1,33 @@
# Start from the pre-built Nextjs Base image
FROM registry.infra.mintel.me/mintel/nextjs:latest AS builder
WORKDIR /app
# Build-time environment variables for Next.js
ARG NEXT_PUBLIC_BASE_URL
ARG NEXT_PUBLIC_UMAMI_WEBSITE_ID
ARG NEXT_PUBLIC_UMAMI_SCRIPT_URL
ARG NEXT_PUBLIC_TARGET
ARG DIRECTUS_URL
ENV NEXT_PUBLIC_BASE_URL=$NEXT_PUBLIC_BASE_URL
ENV NEXT_PUBLIC_UMAMI_WEBSITE_ID=$NEXT_PUBLIC_UMAMI_WEBSITE_ID
ENV NEXT_PUBLIC_UMAMI_SCRIPT_URL=$NEXT_PUBLIC_UMAMI_SCRIPT_URL
ENV NEXT_PUBLIC_TARGET=$NEXT_PUBLIC_TARGET
ENV DIRECTUS_URL=$DIRECTUS_URL
# Build the specific application
RUN pnpm --filter ${APP_NAME:-app} build
# Production runner image
FROM registry.infra.mintel.me/mintel/runtime:latest AS runner
WORKDIR /app
# Copy standalone output and static files
COPY --from=builder --chown=nextjs:nodejs /app/apps/${APP_NAME:-app}/public ./apps/${APP_NAME:-app}/public
COPY --from=builder --chown=nextjs:nodejs /app/apps/${APP_NAME:-app}/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/apps/${APP_NAME:-app}/.next/static ./apps/${APP_NAME:-app}/.next/static
USER nextjs
CMD ["node", "apps/${APP_NAME:-app}/server.js"]

View File

@@ -0,0 +1,12 @@
FROM directus/directus:11
# Add any custom extensions or configurations here if needed
# COPY ./extensions /directus/extensions
# Default environment for optimized production use
ENV LOGGER_LEVEL="info"
ENV WEBSOCKETS_ENABLED="true"
# Health check
HEALTHCHECK --interval=30s --timeout=30s --start-period=5s --retries=3 \
CMD curl -f http://localhost:8055/health || exit 1

View File

@@ -1,47 +1,58 @@
FROM node:20-alpine AS base
# Install dependencies only when needed
FROM base AS deps
RUN apk add --no-cache libc6-compat
# Step 1: Builder stage
FROM node:20-alpine AS builder
RUN apk add --no-cache libc6-compat curl
WORKDIR /app
RUN corepack enable pnpm
ENV CI=true
# Install dependencies
COPY package.json pnpm-lock.yaml* ./
RUN corepack enable pnpm && pnpm i --frozen-lockfile
# 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
# Rebuild the source code only when needed
FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
# 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 . .
ENV NEXT_TELEMETRY_DISABLED=1
# Build Gatekeeper and its dependencies
RUN --mount=type=cache,target=/app/packages/gatekeeper/.next/cache \
pnpm --filter @mintel/gatekeeper... build
RUN mkdir -p packages/gatekeeper/public
# Build the application
RUN corepack enable pnpm && pnpm run build
# Production image, copy all the files and run next
FROM base AS runner
# Step 2: Runner stage
FROM node:20-alpine AS runner
RUN apk add --no-cache libc6-compat curl
WORKDIR /app
RUN apk add --no-cache curl
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
# Automatically leverage output traces to reduce image size
# https://nextjs.org/docs/advanced-features/output-file-tracing
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
# Set the correct permission for prerender cache
RUN mkdir -p packages/gatekeeper/.next && chown nextjs:nodejs packages/gatekeeper/.next
COPY --from=builder --chown=nextjs:nodejs /app/packages/gatekeeper/public ./packages/gatekeeper/public
COPY --from=builder --chown=nextjs:nodejs /app/packages/gatekeeper/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/packages/gatekeeper/.next/static ./packages/gatekeeper/.next/static
USER nextjs
EXPOSE 3000
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"
CMD ["node", "server.js"]
CMD ["node", "packages/gatekeeper/server.js"]

View File

@@ -1,66 +1,38 @@
FROM node:20-alpine AS base
# Install dependencies only when needed
FROM base AS deps
# Step 1: Builder image
FROM node:20-alpine AS builder
RUN apk add --no-cache libc6-compat curl
WORKDIR /app
RUN corepack enable pnpm
# Install dependencies based on the preferred package manager
COPY package.json package-lock.json* pnpm-lock.yaml* ./
RUN if [ -f pnpm-lock.yaml ]; then corepack enable pnpm && pnpm i --frozen-lockfile; \
elif [ -f package-lock.json ]; then npm ci; \
else npm i; fi
# Step 2: Install dependencies
ENV NPM_TOKEN=placeholder
# Copy manifest files specifically for better layer caching
COPY pnpm-lock.yaml pnpm-workspace.yaml package.json .npmrc ./
# Copy package manifest files individually to preserve directory structure
COPY packages/cli/package.json ./packages/cli/
COPY packages/cms-infra/package.json ./packages/cms-infra/
COPY packages/customer-manager/package.json ./packages/customer-manager/
COPY packages/eslint-config/package.json ./packages/eslint-config/
COPY packages/feedback-commander/package.json ./packages/feedback-commander/
COPY packages/gatekeeper/package.json ./packages/gatekeeper/
COPY packages/husky-config/package.json ./packages/husky-config/
COPY packages/infra/package.json ./packages/infra/
COPY packages/mail/package.json ./packages/mail/
COPY packages/next-config/package.json ./packages/next-config/
COPY packages/next-feedback/package.json ./packages/next-feedback/
COPY packages/next-observability/package.json ./packages/next-observability/
COPY packages/next-utils/package.json ./packages/next-utils/
COPY packages/observability/package.json ./packages/observability/
COPY packages/tsconfig/package.json ./packages/tsconfig/
# packages/ui does not have a package.json
# Rebuild the source code only when needed
FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
# Use a secret for NPM_TOKEN and a standardized cache mount
RUN --mount=type=cache,id=pnpm,target=/pnpm/store \
--mount=type=secret,id=NPM_TOKEN \
export NPM_TOKEN=$(cat /run/secrets/NPM_TOKEN) && \
pnpm config set store-dir /pnpm/store && \
pnpm i --frozen-lockfile
# Step 3: Build shared packages
COPY . .
# Next.js collects completely anonymous telemetry data about general usage.
ENV NEXT_TELEMETRY_DISABLED=1
# Build-time environment variables for Next.js
ARG NEXT_PUBLIC_BASE_URL
ARG NEXT_PUBLIC_UMAMI_WEBSITE_ID
ARG NEXT_PUBLIC_UMAMI_SCRIPT_URL
ENV NEXT_PUBLIC_BASE_URL=$NEXT_PUBLIC_BASE_URL
ENV NEXT_PUBLIC_UMAMI_WEBSITE_ID=$NEXT_PUBLIC_UMAMI_WEBSITE_ID
ENV NEXT_PUBLIC_UMAMI_SCRIPT_URL=$NEXT_PUBLIC_UMAMI_SCRIPT_URL
# Build the application
RUN if [ -f pnpm-lock.yaml ]; then corepack enable pnpm && pnpm run build; \
else npm run build; fi
# Production image, copy all the files and run next
FROM base AS runner
WORKDIR /app
# Install curl for health checks
RUN apk add --no-cache curl
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
COPY --from=builder /app/public ./public
# Set the correct permission for prerender cache
RUN mkdir .next
RUN chown nextjs:nodejs .next
# Automatically leverage output traces to reduce image size
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
USER nextjs
EXPOSE 3000
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"
CMD ["node", "server.js"]
RUN pnpm --filter "./packages/*" -r build

View File

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

View File

@@ -1,6 +1,5 @@
services:
app:
image: registry.infra.mintel.me/mintel/${APP_NAME:-app}:${IMAGE_TAG:-latest}
restart: always
networks:
- infra
@@ -40,7 +39,6 @@ services:
gatekeeper:
image: registry.infra.mintel.me/mintel/gatekeeper:${IMAGE_TAG:-latest}
container_name: ${PROJECT_NAME}-gatekeeper
restart: always
networks:
- infra
@@ -55,7 +53,7 @@ services:
- "traefik.http.services.${PROJECT_NAME}-gatekeeper.loadbalancer.server.port=3000"
directus:
image: directus/directus:11
image: registry.infra.mintel.me/mintel/directus:latest
restart: always
networks:
- infra

View File

@@ -24,6 +24,8 @@ jobs:
prepare:
name: 🔍 Prepare Environment
runs-on: docker
container:
image: catthehacker/ubuntu:act-latest
outputs:
target: ${{ steps.determine.outputs.target }}
image_tag: ${{ steps.determine.outputs.image_tag }}
@@ -39,17 +41,23 @@ jobs:
short_sha: ${{ steps.determine.outputs.short_sha }}
commit_msg: ${{ steps.determine.outputs.commit_msg }}
steps:
- name: 🧹 Maintenance (High Density Cleanup)
shell: bash
run: |
echo "Purging old build layers and dangling images..."
docker image prune -f
docker builder prune -f --filter "until=6h"
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0
fetch-depth: 2
- name: 🔍 Environment & Version ermitteln
id: determine
run: |
TAG="${{ github.ref_name }}"
SHORT_SHA="${{ github.sha }}"
SHORT_SHA="${SHORT_SHA:0:9}"
SHORT_SHA=$(echo "${{ github.sha }}" | cut -c1-9)
COMMIT_MSG=$(git log -1 --pretty=%s || echo "No commit message available")
# Base Domain (e.g. example.com)
@@ -79,7 +87,6 @@ jobs:
TARGET="production"
IMAGE_TAG="$TAG"
ENV_FILE=".env.prod"
TRAEFIK_HOST="\${DOMAIN_BASE}, www.\${DOMAIN_BASE}" # Note: Host() backticks usually needed in compose
TRAEFIK_HOST="\`\${DOMAIN_BASE}\`, \`www.\${DOMAIN_BASE}\`"
NEXT_PUBLIC_BASE_URL="https://\${DOMAIN_BASE}"
DIRECTUS_URL="https://cms.\${DOMAIN_BASE}"
@@ -88,7 +95,7 @@ jobs:
IS_PROD="true"
GOTIFY_TITLE="🚀 Production-Release"
GOTIFY_PRIORITY=6
elif [[ "$TAG" =~ -rc\. || "$TAG" =~ -beta\. || "$TAG" =~ -alpha\. ]]; then
elif [[ "$TAG" =~ -rc || "$TAG" =~ -beta || "$TAG" =~ -alpha ]]; then
TARGET="staging"
IMAGE_TAG="$TAG"
ENV_FILE=".env.staging"
@@ -107,19 +114,21 @@ jobs:
TARGET="skip"
fi
echo "target=$TARGET" >> $GITHUB_OUTPUT
echo "image_tag=$IMAGE_TAG" >> $GITHUB_OUTPUT
echo "env_file=$ENV_FILE" >> $GITHUB_OUTPUT
echo "traefik_host=$TRAEFIK_HOST" >> $GITHUB_OUTPUT
echo "next_public_base_url=$NEXT_PUBLIC_BASE_URL" >> $GITHUB_OUTPUT
echo "directus_url=$DIRECTUS_URL" >> $GITHUB_OUTPUT
echo "directus_host=$DIRECTUS_HOST" >> $GITHUB_OUTPUT
echo "project_name=$PROJECT_NAME" >> $GITHUB_OUTPUT
echo "is_prod=$IS_PROD" >> $GITHUB_OUTPUT
echo "gotify_title=$GOTIFY_TITLE" >> $GITHUB_OUTPUT
echo "gotify_priority=$GOTIFY_PRIORITY" >> $GITHUB_OUTPUT
echo "short_sha=$SHORT_SHA" >> $GITHUB_OUTPUT
echo "commit_msg=$COMMIT_MSG" >> $GITHUB_OUTPUT
{
echo "target=$TARGET"
echo "image_tag=$IMAGE_TAG"
echo "env_file=$ENV_FILE"
echo "traefik_host=$TRAEFIK_HOST"
echo "next_public_base_url=$NEXT_PUBLIC_BASE_URL"
echo "directus_url=$DIRECTUS_URL"
echo "directus_host=$DIRECTUS_HOST"
echo "project_name=$PROJECT_NAME"
echo "is_prod=$IS_PROD"
echo "gotify_title=$GOTIFY_TITLE"
echo "gotify_priority=$GOTIFY_PRIORITY"
echo "short_sha=$SHORT_SHA"
echo "commit_msg=$COMMIT_MSG"
} >> "$GITHUB_OUTPUT"
# ──────────────────────────────────────────────────────────────────────────────
# JOB 2: Quality Assurance (Lint & Test)
@@ -129,6 +138,8 @@ jobs:
needs: prepare
if: needs.prepare.outputs.target != 'skip'
runs-on: docker
container:
image: catthehacker/ubuntu:act-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
@@ -137,17 +148,23 @@ jobs:
uses: actions/setup-node@v4
with:
node-version: 20
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: 🧪 Run Checks
- name: 🧪 Run Checks in Parallel
if: github.event.inputs.skip_long_checks != 'true'
run: |
npm run lint
npm run typecheck
npm run test
npm run lint &
LINT_PID=$!
npm run typecheck &
TYPE_PID=$!
npm run test &
TEST_PID=$!
wait $LINT_PID || exit 1
wait $TYPE_PID || exit 1
wait $TEST_PID || exit 1
# ──────────────────────────────────────────────────────────────────────────────
# JOB 3: Build & Push
@@ -157,25 +174,38 @@ jobs:
needs: prepare
if: needs.prepare.outputs.target != 'skip'
runs-on: docker
container:
image: catthehacker/ubuntu:act-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: 🐳 Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: 🔐 Registry Login
run: |
echo "${{ secrets.REGISTRY_PASS }}" | docker login registry.infra.mintel.me -u "${{ secrets.REGISTRY_USER }}" --password-stdin
uses: docker/login-action@v3
with:
registry: registry.infra.mintel.me
username: ${{ secrets.REGISTRY_USER }}
password: ${{ secrets.REGISTRY_PASS }}
- name: 🏗️ Docker Build & Push
env:
IMAGE_TAG: ${{ needs.prepare.outputs.image_tag }}
NEXT_PUBLIC_BASE_URL: ${{ needs.prepare.outputs.next_public_base_url }}
run: |
docker buildx build \
--pull \
--platform linux/arm64 \
--build-arg NEXT_PUBLIC_BASE_URL="$NEXT_PUBLIC_BASE_URL" \
-t registry.infra.mintel.me/mintel/${{ github.event.repository.name }}:$IMAGE_TAG \
--push .
uses: docker/build-push-action@v5
with:
context: .
file: packages/infra/docker/Dockerfile.nextjs
platforms: linux/arm64
pull: true
build-args: |
NEXT_PUBLIC_BASE_URL=${{ needs.prepare.outputs.next_public_base_url }}
NEXT_PUBLIC_TARGET=${{ needs.prepare.outputs.target }}
push: true
secrets: |
NPM_TOKEN=${{ secrets.NPM_TOKEN }}
tags: registry.infra.mintel.me/mintel/${{ github.event.repository.name }}:${{ needs.prepare.outputs.image_tag }}
cache-from: type=registry,ref=registry.infra.mintel.me/mintel/${{ github.event.repository.name }}:buildcache
cache-to: type=registry,ref=registry.infra.mintel.me/mintel/${{ github.event.repository.name }}:buildcache,mode=max
# ──────────────────────────────────────────────────────────────────────────────
# JOB 4: Deploy
@@ -185,18 +215,18 @@ jobs:
needs: [prepare, build, qa]
if: needs.prepare.outputs.target != 'skip'
runs-on: docker
container:
image: catthehacker/ubuntu:act-latest
env:
TARGET: ${{ needs.prepare.outputs.target }}
IMAGE_TAG: ${{ needs.prepare.outputs.image_tag }}
PROJECT_NAME: ${{ needs.prepare.outputs.project_name }}
ENV_FILE: ${{ needs.prepare.outputs.env_file }}
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: 🚀 Deploy via SSH
env:
IMAGE_TAG: ${{ needs.prepare.outputs.image_tag }}
ENV_FILE: ${{ needs.prepare.outputs.env_file }}
TRAEFIK_HOST: ${{ needs.prepare.outputs.traefik_host }}
PROJECT_NAME: ${{ needs.prepare.outputs.project_name }}
run: |
mkdir -p ~/.ssh
echo "${{ secrets.SSH_KEY }}" > ~/.ssh/id_ed25519
@@ -208,12 +238,13 @@ jobs:
# Generated by CI - $TARGET - $(date -u)
NODE_ENV=production
IMAGE_TAG=$IMAGE_TAG
TRAEFIK_HOST=$TRAEFIK_HOST
TRAEFIK_HOST=${{ needs.prepare.outputs.traefik_host }}
PROJECT_NAME=$PROJECT_NAME
ENV_FILE=$ENV_FILE
# App Config
NEXT_PUBLIC_BASE_URL=${{ needs.prepare.outputs.next_public_base_url }}
NEXT_PUBLIC_TARGET=$TARGET
# Directus Config
DIRECTUS_URL=${{ needs.prepare.outputs.directus_url }}
@@ -237,10 +268,11 @@ jobs:
ssh root@${{ secrets.SSH_HOST }} IMAGE_TAG="$IMAGE_TAG" ENV_FILE="$ENV_FILE" PROJECT_NAME="$PROJECT_NAME" bash << 'EOF'
set -e
cd "/home/deploy/sites/${{ github.event.repository.name }}"
chmod 600 "$ENV_FILE"
echo "${{ secrets.REGISTRY_PASS }}" | docker login registry.infra.mintel.me -u "${{ secrets.REGISTRY_USER }}" --password-stdin
docker compose -p "$PROJECT_NAME" --env-file "$ENV_FILE" pull
docker compose -p "$PROJECT_NAME" --env-file "$ENV_FILE" up -d --remove-orphans
docker system prune -f --filter "until=168h"
docker system prune -f --filter "until=24h"
EOF
# ──────────────────────────────────────────────────────────────────────────────
@@ -251,11 +283,25 @@ jobs:
needs: [prepare, deploy]
if: always()
runs-on: docker
container:
image: catthehacker/ubuntu:act-latest
steps:
- name: 🔔 Gotify
- name: 🔔 Gotify - Success
if: needs.deploy.result == 'success'
run: |
curl -s -k -X POST "${{ secrets.GOTIFY_URL }}/message?token=${{ secrets.GOTIFY_TOKEN }}" \
-F "title=${{ needs.prepare.outputs.gotify_title }}" \
-F "message=Erfolgreich deployt auf **${{ needs.prepare.outputs.target }}**" \
-F "message=Erfolgreich deployt auf **${{ needs.prepare.outputs.target }}**\n\nVersion: **${{ needs.prepare.outputs.image_tag }}**\nCommit: ${{ needs.prepare.outputs.short_sha }}\nRun: ${{ github.run_id }}" \
-F "priority=4" || true
- name: 🔔 Gotify - Failure
if: |
needs.prepare.result == 'failure' ||
needs.qa.result == 'failure' ||
needs.build.result == 'failure' ||
needs.deploy.result == 'failure'
run: |
curl -s -k -X POST "${{ secrets.GOTIFY_URL }}/message?token=${{ secrets.GOTIFY_TOKEN }}" \
-F "title=❌ Deployment FEHLGESCHLAGEN ${{ github.event.repository.name }}" \
-F "message=**Fehler beim Deploy auf ${{ needs.prepare.outputs.target || 'unknown' }}**\n\nRun: ${{ github.run_id }}\nBitte Logs prüfen!" \
-F "priority=8" || true

View File

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

View File

@@ -0,0 +1,60 @@
#!/bin/bash
set -e
# Configuration
REGISTRY_DATA="/opt/infra/registry/data/docker/registry/v2"
KEEP_TAGS=3
echo "🏥 Starting Aggressive Registry & Docker Maintenance..."
# 1. Prune Registry Tags (Filesystem level)
for repo_dir in "$REGISTRY_DATA/repositories/mintel/"*; do
repo_name=$(basename "$repo_dir")
tags_dir="$repo_dir/_manifests/tags"
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
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)
if [ -d "$tags_dir/buildcache" ]; then
echo " 🧹 Deleting buildcache tag"
rm -rf "$tags_dir/buildcache"
fi
fi
done
# 2. Run Garbage Collection
echo "♻️ Running Registry Garbage Collection..."
docker exec registry-registry-1 bin/registry garbage-collect /etc/docker/registry/config.yml
# 3. Prune Host Docker resources (Shorter window: 24h)
echo "🧹 Pruning Host Docker resources..."
docker system prune -af --filter "until=24h"
docker volume prune -f
echo "✅ Maintenance complete!"
df -h /

View File

@@ -0,0 +1,68 @@
#!/bin/bash
# Mintel Directus Sync Engine
# Synchronizes Directus Data (Postgres + Uploads) between Local and Remote
REMOTE_HOST="${SSH_HOST:-root@alpha.mintel.me}"
ACTION=$1
ENV=$2
# Help
if [ -z "$ACTION" ] || [ -z "$ENV" ]; then
echo "Usage: mintel-sync [push|pull] [testing|staging|production]"
echo ""
echo "Commands:"
echo " push Sync LOCAL data -> REMOTE"
echo " pull Sync REMOTE data -> LOCAL"
echo ""
echo "Environments:"
echo " testing, staging, production"
exit 1
fi
PRJ_ID=$(jq -r .name package.json | sed 's/@mintel\///')
case $ENV in
testing) PROJECT_NAME="${PRJ_ID}-testing"; ENV_FILE=".env.testing" ;;
staging) PROJECT_NAME="${PRJ_ID}-staging"; ENV_FILE=".env.staging" ;;
production) PROJECT_NAME="${PRJ_ID}-prod"; ENV_FILE=".env.prod" ;;
*) echo "❌ Invalid environment: $ENV"; exit 1 ;;
esac
REMOTE_DIR="/home/deploy/sites/${PRJ_ID}.com"
# DB Details
DB_USER="directus"
DB_NAME="directus"
echo "🔍 Detecting local database..."
LOCAL_DB_CONTAINER=$(docker compose ps -q directus-db)
if [ -z "$LOCAL_DB_CONTAINER" ]; then
echo "❌ Local directus-db container not found. Running?"
exit 1
fi
if [ "$ACTION" == "push" ]; then
echo "🚀 Pushing LOCAL -> $ENV ($PROJECT_NAME)..."
docker exec "$LOCAL_DB_CONTAINER" pg_dump -U "$DB_USER" --clean --if-exists --no-owner --no-privileges "$DB_NAME" > dump.sql
scp dump.sql "$REMOTE_HOST:$REMOTE_DIR/dump.sql"
REMOTE_DB_CONTAINER=$(ssh "$REMOTE_HOST" "cd $REMOTE_DIR && docker compose -p $PROJECT_NAME ps -q directus-db")
ssh "$REMOTE_HOST" "docker exec -i $REMOTE_DB_CONTAINER psql -U $DB_USER $DB_NAME < $REMOTE_DIR/dump.sql"
rsync -avz --progress ./directus/uploads/ "$REMOTE_HOST:$REMOTE_DIR/directus/uploads/"
rm dump.sql
ssh "$REMOTE_HOST" "rm $REMOTE_DIR/dump.sql"
echo "✨ Push complete!"
elif [ "$ACTION" == "pull" ]; then
echo "📥 Pulling $ENV -> LOCAL..."
REMOTE_DB_CONTAINER=$(ssh "$REMOTE_HOST" "cd $REMOTE_DIR && docker compose -p $PROJECT_NAME ps -q directus-db")
ssh "$REMOTE_HOST" "docker exec $REMOTE_DB_CONTAINER pg_dump -U $DB_USER --clean --if-exists --no-owner --no-privileges $DB_NAME > $REMOTE_DIR/dump.sql"
scp "$REMOTE_HOST:$REMOTE_DIR/dump.sql" dump.sql
docker exec -i "$LOCAL_DB_CONTAINER" psql -U "$DB_USER" "$DB_NAME" < dump.sql
rsync -avz --progress "$REMOTE_HOST:$REMOTE_DIR/directus/uploads/" ./directus/uploads/
rm dump.sql
ssh "$REMOTE_HOST" "rm $REMOTE_DIR/dump.sql"
echo "✨ Pull complete!"
fi

View File

@@ -27,3 +27,8 @@ yarn-error.log*
# typescript
*.tsbuildinfo
next-env.d.ts
# directus
/directus/uploads
/directus/extensions
/.env

View File

@@ -1,18 +1,46 @@
import client, { ensureAuthenticated } from "../src/lib/directus";
import {
createMintelDirectusClient,
ensureDirectusAuthenticated,
} from "@mintel/next-utils";
import { updateSettings } from "@directus/sdk";
const client = createMintelDirectusClient();
async function setupBranding() {
console.log("🎨 Setup Directus Branding...");
await ensureAuthenticated();
const prjName = process.env.PROJECT_NAME || "Mintel Project";
const prjColor = process.env.PROJECT_COLOR || "#82ed20";
console.log(`🎨 Setup Directus Branding for ${prjName}...`);
await ensureDirectusAuthenticated(client);
const cssInjection = `
<style>
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap');
body, .v-app { font-family: 'Inter', sans-serif !important; }
.public-view .v-card {
backdrop-filter: blur(20px);
border-radius: 32px !important;
box-shadow: 0 50px 100px -20px rgba(0, 0, 0, 0.4) !important;
}
.v-navigation-drawer { background: #000c24 !important; }
</style>
<div style="font-family: 'Inter', sans-serif; text-align: center; margin-top: 24px;">
<p style="color: rgba(255,255,255,0.7); font-size: 14px; margin-bottom: 4px; font-weight: 500;">MINTEL INFRASTRUCTURE ENGINE</p>
<h1 style="color: #ffffff; font-size: 18px; font-weight: 700; margin: 0;">${prjName.toUpperCase()} <span style="color: ${prjColor};">RELIABILITY.</span></h1>
</div>
`;
try {
await client.request(
updateSettings({
project_name: process.env.PROJECT_NAME || "Mintel Project",
project_color: process.env.PROJECT_COLOR || "#82ed20",
project_name: prjName,
project_color: prjColor,
public_note: cssInjection,
theme_light_overrides: {
primary: process.env.PROJECT_COLOR || "#82ed20",
primary: prjColor,
borderRadius: "16px",
navigationBackground: "#000c24",
navigationForeground: "#ffffff",
},
} as any),
);

View File

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

View File

@@ -0,0 +1,46 @@
{
"name": "@mintel/mail",
"version": "1.7.0",
"private": false,
"publishConfig": {
"access": "public",
"registry": "https://npm.infra.mintel.me"
},
"type": "module",
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
},
"./templates/*": {
"types": "./dist/templates/*.d.ts",
"import": "./dist/templates/*.js"
}
},
"scripts": {
"build": "tsup src/index.ts src/templates/*.tsx --format esm --dts --clean",
"dev": "tsup src/index.ts src/templates/*.tsx --format esm --watch --dts",
"lint": "eslint src",
"test": "vitest run"
},
"dependencies": {
"@react-email/components": "^0.0.33"
},
"peerDependencies": {
"react": "^19.0.0",
"react-dom": "^19.0.0"
},
"devDependencies": {
"@mintel/eslint-config": "workspace:*",
"@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"
}
}

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 17 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 19 KiB

View File

@@ -0,0 +1,29 @@
import * as React from "react";
import { Link, Img } from "@react-email/components";
export interface MintelLogoProps {
size?: number;
}
export const MintelLogo = ({ size = 200 }: MintelLogoProps) => {
// Original Logo is 545x260, we scale it
const width = size;
const height = (size * 260) / 545;
return (
<Link
href="https://mintel.me"
style={{
textDecoration: "none",
display: "inline-block",
}}
>
<Img
src="data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiIHN0YW5kYWxvbmU9Im5vIj8+CjwhRE9DVFlQRSBzdmcgUFVCTElDICItLy9XM0MvL0RURCBTVkcgMS4xLy9FTiIgImh0dHA6Ly93d3cudzMub3JnL0dyYXBoaWNzL1NWRy8xLjEvRFREL3N2ZzExLmR0ZCI+Cjxzdmcgd2lkdGg9IjEwMCUiIGhlaWdodD0iMTAwJSIgdmlld0JveD0iMCAwIDU0NSAyNjAiIHZlcnNpb249IjEuMSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIiB4bWxuczp4bGluaz0iaHR0cDovL3d3dy53My5vcmcvMTk5OS94bGluayIgeG1sOnNwYWNlPSJwcmVzZXJ2ZSIgeG1sbnM6c2VyaWY9Imh0dHA6Ly93d3cuc2VyaWYuY29tLyIgc3R5bGU9ImZpbGwtcnVsZTpldmVub2RkO2NsaXAtcnVsZTpldmVub2RkO3N0cm9rZS1saW5lam9pbjpyb3VuZDtzdHJva2UtbWl0ZXJsaW1pdDoyOyI+CiAgICA8ZyB0cmFuc2Zvcm09Im1hdHJpeCgxLDAsMCwxLC0xMjg2LC0xMTUwKSI+CiAgICAgICAgPGcgdHJhbnNmb3JtPSJtYXRyaXgoMSwtMCwtMCwxLDEyODYsMTE1MCkiPgogICAgICAgICAgICA8dXNlIHhsaW5rOmhyZWY9IiNfSW1hZ2UxIiB4PSI0MS41NjkiIHk9IjMxLjM4NSIgd2lkdGg9IjQ2MnB4IiBoZWlnaHQ9IjE5N3B4Ii8+CiAgICAgICAgPC9nPgogICAgPC9nPgogICAgPGRlZnM+CiAgICAgICAgPGltYWdlIGlkPSJfSW1hZ2UxIiB3aWR0aD0iNDYycHgiIGhlaWdodD0iMTk3cHgiIHhsaW5rOmhyZWY9ImRhdGE6aW1hZ2UvcG5nO2Jhc2U2NCxpVkJPUncwS0dnb0FBQU5TVWhFVWdBQUFjNFNBQUFERkNBWUFBQUNYQlJXMEFBQWdBRWxFUVZSNFhMU0JTQlNScGRmZWU0eE9lTXVlemV6NnoxY3VaeXpNeXpNeDU3cnI5ZXY5ZTFlNTVxNU56WXpNeFpPYmRkNWRNdG93MHcwMHcwMHcwMHcwMHV0eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4cDVxNHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4O0VYSVRDT0RFOiAwIgogICAgPC9kZWZzPgo8L3N2Zz4K"
alt="Mintel Logo"
width={width}
height={height}
/>
</Link>
);
};

View File

@@ -0,0 +1,25 @@
import { describe, it, expect } from "vitest";
import * as React from "react";
import { render } from "./index";
import { MintelLogo } from "./components/MintelLogo";
import { ContactFormNotification } from "./templates/ContactFormNotification";
describe("@mintel/mail rendering", () => {
it("should render the MintelLogo to HTML", async () => {
const html = await render(React.createElement(MintelLogo));
expect(html).toContain("Mintel Logo");
});
it("should render a ContactFormNotification to HTML", async () => {
const html = await render(
React.createElement(ContactFormNotification, {
name: "Test User",
email: "test@example.com",
message: "Hello World",
}),
);
expect(html).toContain("New Submission");
expect(html).toContain("Test User");
expect(html).toContain("test@example.com");
});
});

View File

@@ -0,0 +1,24 @@
import { render as reactEmailRender } from "@react-email/components";
import { ReactElement } from "react";
/**
* Renders a React email template to HTML.
*/
export async function render(
template: ReactElement,
options?: any,
): Promise<string> {
return reactEmailRender(template, options);
}
// Export Components
export * from "./components/MintelLogo";
// Export Layouts
export * from "./layouts/BaseLayout";
export * from "./layouts/MintelLayout";
export * from "./layouts/ClientLayout";
// Export Templates
export * from "./templates/ContactFormNotification";
export * from "./templates/ConfirmationMessage";

View File

@@ -0,0 +1,49 @@
import {
Body,
Container,
Head,
Html,
Preview,
Section,
} from "@react-email/components";
import * as React from "react";
export interface BaseLayoutProps {
preview: string;
children: React.ReactNode;
brandColor?: string;
}
export const BaseLayout = ({ preview, children }: BaseLayoutProps) => {
return (
<Html>
<Head />
<Preview>{preview}</Preview>
<Body style={main}>
<Container style={container}>
<Section style={content}>{children}</Section>
</Container>
</Body>
</Html>
);
};
const main = {
backgroundColor: "#0a0a0a",
color: "#ffffff",
fontFamily:
'-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Ubuntu,sans-serif',
};
const container = {
backgroundColor: "#0f0f0f",
margin: "0 auto",
padding: "40px 0",
maxWidth: "600px",
border: "1px solid #1a1a1a",
borderRadius: "12px",
};
const content = {
padding: "0 40px",
};

View File

@@ -0,0 +1,80 @@
import * as React from "react";
import { Hr, Section, Text, Img } from "@react-email/components";
import { BaseLayout } from "./BaseLayout";
export interface ClientLayoutProps {
preview: string;
children: React.ReactNode;
clientLogo?: string;
clientName: string;
brandColor?: string;
}
export const ClientLayout = ({
preview,
children,
clientLogo,
clientName,
brandColor = "#82ed20",
}: ClientLayoutProps) => {
return (
<BaseLayout preview={preview} brandColor={brandColor}>
<Section style={header}>
{clientLogo ? (
<Img src={clientLogo} alt={clientName} height="40" style={logo} />
) : (
<Text style={logoText(brandColor)}>{clientName}</Text>
)}
</Section>
<Hr style={hr} />
<Section style={mainContent}>{children}</Section>
<Hr style={hr} />
<Section style={footer}>
<Text style={footerText}>
&copy; 2026 {clientName}. All rights reserved.
</Text>
</Section>
</BaseLayout>
);
};
const header = {
marginBottom: "32px",
};
const logo = {
margin: "0 auto",
display: "block",
};
const logoText = (color: string) => ({
margin: "0 auto",
textAlign: "center" as const,
fontSize: "24px",
fontWeight: 900,
color: "#ffffff",
letterSpacing: "-0.02em",
borderLeft: `4px solid ${color}`,
paddingLeft: "12px",
});
const mainContent = {
marginBottom: "32px",
};
const hr = {
borderColor: "#222222",
margin: "20px 0",
};
const footer = {
marginTop: "32px",
textAlign: "center" as const,
};
const footerText = {
fontSize: "10px",
color: "#333333",
textTransform: "uppercase" as const,
letterSpacing: "0.1em",
};

View File

@@ -0,0 +1,53 @@
import * as React from "react";
import { Hr, Section, Text } from "@react-email/components";
import { BaseLayout } from "./BaseLayout";
import { MintelLogo } from "../components/MintelLogo";
export interface MintelLayoutProps {
preview: string;
children: React.ReactNode;
}
export const MintelLayout = ({ preview, children }: MintelLayoutProps) => {
return (
<BaseLayout preview={preview}>
<Section style={header}>
<MintelLogo />
</Section>
<Hr style={hr} />
<Section style={mainContent}>{children}</Section>
<Hr style={hr} />
<Section style={footer}>
<Text style={footerText}>
&copy; 2026 Mintel Infrastructure. Secure Communication Channel.
</Text>
</Section>
</BaseLayout>
);
};
const header = {
marginBottom: "32px",
};
const mainContent = {
marginBottom: "32px",
};
const hr = {
borderColor: "#222222",
margin: "20px 0",
};
const footer = {
marginTop: "32px",
textAlign: "center" as const,
};
const footerText = {
fontSize: "12px",
color: "#444444",
fontWeight: 700,
textTransform: "uppercase" as const,
letterSpacing: "0.1em",
};

View File

@@ -0,0 +1,57 @@
import * as React from "react";
import { Heading, Text } from "@react-email/components";
import { ClientLayout } from "../layouts/ClientLayout";
export interface ConfirmationMessageProps {
name: string;
clientName: string;
clientLogo?: string;
brandColor?: string;
}
export const ConfirmationMessage = ({
name,
clientName,
clientLogo,
brandColor,
}: ConfirmationMessageProps) => {
const preview = `Thank you for your message, ${name}`;
return (
<ClientLayout
preview={preview}
clientName={clientName}
clientLogo={clientLogo}
brandColor={brandColor}
>
<Heading style={h1}>Thank You</Heading>
<Text style={text}>Hello {name},</Text>
<Text style={text}>
Thank you for contacting us. We have received your message and will get
back to you as soon as possible.
</Text>
<Text style={text}>
Best regards,
<br />
The {clientName} Team
</Text>
</ClientLayout>
);
};
export default ConfirmationMessage;
const h1 = {
fontSize: "28px",
fontWeight: "900",
margin: "0 0 16px",
color: "#ffffff",
letterSpacing: "-0.04em",
};
const text = {
fontSize: "16px",
lineHeight: "24px",
color: "#cccccc",
margin: "16px 0",
};

View File

@@ -0,0 +1,118 @@
import * as React from "react";
import { Heading, Section, Text, Row, Column } from "@react-email/components";
import { MintelLayout } from "../layouts/MintelLayout";
export interface ContactFormNotificationProps {
name: string;
email: string;
message: string;
productName?: string;
}
export const ContactFormNotification = ({
name,
email,
message,
productName,
}: ContactFormNotificationProps) => {
const preview = `New message from ${name}`;
return (
<MintelLayout preview={preview}>
<Heading style={h1}>New Submission</Heading>
<Text style={intro}>
A new message has been received via the contact form.
</Text>
<Section style={detailsContainer}>
<Row>
<Column style={labelCol}>
<Text style={label}>Name</Text>
</Column>
<Column>
<Text style={value}>{name}</Text>
</Column>
</Row>
<Row>
<Column style={labelCol}>
<Text style={label}>Email</Text>
</Column>
<Column>
<Text style={value}>{email}</Text>
</Column>
</Row>
{productName && (
<Row>
<Column style={labelCol}>
<Text style={label}>Product</Text>
</Column>
<Column>
<Text style={value}>{productName}</Text>
</Column>
</Row>
)}
</Section>
<Section style={messageSection}>
<Text style={label}>Message</Text>
<Text style={messageText}>{message}</Text>
</Section>
</MintelLayout>
);
};
export default ContactFormNotification;
const h1 = {
fontSize: "28px",
fontWeight: "900",
margin: "0 0 16px",
color: "#ffffff",
letterSpacing: "-0.04em",
};
const intro = {
fontSize: "16px",
color: "#888888",
margin: "0 0 32px",
};
const detailsContainer = {
backgroundColor: "#151515",
padding: "24px",
borderRadius: "8px",
marginBottom: "24px",
};
const labelCol = {
width: "100px",
};
const label = {
fontSize: "10px",
fontWeight: "900",
textTransform: "uppercase" as const,
color: "#444444",
margin: "0 0 4px",
letterSpacing: "0.1em",
};
const value = {
fontSize: "16px",
color: "#ffffff",
margin: "0 0 12px",
};
const messageSection = {
padding: "0 24px",
};
const messageText = {
fontSize: "16px",
lineHeight: "24px",
color: "#cccccc",
fontStyle: "italic",
borderLeft: "2px solid #222222",
paddingLeft: "16px",
margin: "12px 0 0",
};

View File

@@ -0,0 +1,14 @@
{
"extends": "@mintel/tsconfig/base.json",
"compilerOptions": {
"jsx": "react-jsx",
"jsxImportSource": "react",
"lib": ["ESNext", "DOM", "DOM.Iterable"],
"module": "ESNext",
"target": "ESNext",
"moduleResolution": "Bundler",
"allowImportingTsExtensions": true,
"noEmit": true
},
"include": ["src"]
}

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

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