Compare commits

...

20 Commits

Author SHA1 Message Date
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
55 changed files with 14865 additions and 248 deletions

View File

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

View File

@@ -2,6 +2,8 @@ name: Monorepo Pipeline
on:
push:
branches:
- '**'
tags:
- 'v*'
@@ -15,6 +17,8 @@ jobs:
runs-on: docker
container:
image: catthehacker/ubuntu:act-latest
env:
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
steps:
- name: Checkout
uses: actions/checkout@v4
@@ -31,8 +35,6 @@ jobs:
- name: Install dependencies
run: pnpm install --frozen-lockfile
env:
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
- name: Lint
run: pnpm lint
@@ -79,12 +81,28 @@ jobs:
pnpm release:tag
build-images:
name: 🐳 Build & Push 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
@@ -99,58 +117,19 @@ jobs:
username: ${{ secrets.REGISTRY_USER }}
password: ${{ secrets.REGISTRY_PASS }}
- name: 🏗️ Build & Push Nextjs Build-Base
- name: 🏗️ Build & Push ${{ matrix.name }}
uses: docker/build-push-action@v5
with:
context: .
file: packages/infra/docker/Dockerfile.nextjs
file: ${{ matrix.file }}
platforms: linux/arm64
pull: true
push: true
secrets: |
NPM_TOKEN=${{ secrets.NPM_TOKEN }}
tags: |
registry.infra.mintel.me/mintel/nextjs:${{ github.ref_name }}
registry.infra.mintel.me/mintel/nextjs:latest
registry.infra.mintel.me/mintel/${{ matrix.image }}:${{ github.ref_name }}
registry.infra.mintel.me/mintel/${{ matrix.image }}:latest
cache-from: type=registry,ref=registry.infra.mintel.me/mintel/${{ matrix.image }}:buildcache
cache-to: type=registry,ref=registry.infra.mintel.me/mintel/${{ matrix.image }}:buildcache,mode=max
- name: 🏗️ Build & Push Production Runtime
uses: docker/build-push-action@v5
with:
context: .
file: packages/infra/docker/Dockerfile.runtime
platforms: linux/arm64
pull: true
push: true
secrets: |
NPM_TOKEN=${{ secrets.NPM_TOKEN }}
tags: |
registry.infra.mintel.me/mintel/runtime:${{ github.ref_name }}
registry.infra.mintel.me/mintel/runtime:latest
- name: 🏗️ Build & Push Gatekeeper (Product)
uses: docker/build-push-action@v5
with:
context: .
file: packages/infra/docker/Dockerfile.gatekeeper
platforms: linux/arm64
pull: true
push: true
secrets: |
NPM_TOKEN=${{ secrets.NPM_TOKEN }}
tags: |
registry.infra.mintel.me/mintel/gatekeeper:${{ github.ref_name }}
registry.infra.mintel.me/mintel/gatekeeper:latest
- name: 🏗️ Build & Push Directus (Base)
uses: docker/build-push-action@v5
with:
context: .
file: packages/infra/docker/Dockerfile.directus
platforms: linux/arm64
pull: true
push: true
secrets: |
NPM_TOKEN=${{ secrets.NPM_TOKEN }}
tags: |
registry.infra.mintel.me/mintel/directus:${{ github.ref_name }}
registry.infra.mintel.me/mintel/directus:latest

1
.gitignore vendored
View File

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

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

View File

@@ -1,3 +1,22 @@
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/**",
],
},
...baseConfig,
...nextConfig.map((config) => ({
...config,
files: [
"apps/sample-website/**/*.{ts,tsx}",
"packages/gatekeeper/**/*.{ts,tsx}",
"../klz-2026/**/*.{ts,tsx}",
],
})),
];

View File

@@ -22,6 +22,7 @@
"@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",
@@ -38,5 +39,11 @@
"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"
}
}

View File

@@ -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"
@@ -28,4 +28,4 @@
"@types/prompts": "^2.4.4",
"@mintel/tsconfig": "workspace:*"
}
}
}

View File

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

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

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.0.0",
"private": true,
"type": "module",
"scripts": {
"up": "docker compose up -d",
"down": "docker compose down",
"logs": "docker compose logs -f"
}
}

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -20,7 +20,10 @@
"dependencies": {
"@eslint/eslintrc": "^3.0.0",
"@eslint/js": "^9.39.2",
"@next/eslint-plugin-next": "15.1.6",
"eslint-config-next": "15.1.6",
"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.0.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

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

@@ -14,7 +14,7 @@
"@mintel/next-utils": "workspace:*",
"clsx": "^2.1.1",
"lucide-react": "^0.474.0",
"next": "15.1.6",
"next": "15.1.7",
"next-intl": "^4.8.2",
"react": "^19.0.0",
"react-dom": "^19.0.0",

View File

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

View File

@@ -9,16 +9,74 @@ 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";
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}`;

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

@@ -17,18 +17,118 @@ export default async function LoginPage({ searchParams }: LoginPageProps) {
async function login(formData: FormData) {
"use server";
const password = formData.get("password");
const expectedPassword = process.env.GATEKEEPER_PASSWORD || "mintel";
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;
if (password === expectedPassword) {
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();
cookieStore.set(authCookieName, expectedPassword, {
// 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: true,
secure: !isDev,
path: "/",
maxAge: 30 * 24 * 60 * 60, // 30 days
sameSite: "lax",
@@ -85,24 +185,35 @@ export default async function LoginPage({ searchParams }: LoginPageProps) {
</div>
)}
<form action={login} className="space-y-6">
<form action={login} className="space-y-4">
<input type="hidden" name="redirect" value={redirectUrl} />
<div className="relative group">
<input
type="password"
name="password"
required
autoFocus
autoComplete="current-password"
placeholder="GATEKEEPER 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 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"
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" />

View File

@@ -1,3 +1,4 @@
/* global module, require */
/** @type {import('tailwindcss').Config} */
module.exports = {
content: [
@@ -55,5 +56,6 @@ module.exports = {
},
},
},
// eslint-disable-next-line @typescript-eslint/no-require-imports
plugins: [require("@tailwindcss/typography")],
};

View File

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

View File

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

View File

@@ -5,15 +5,34 @@ WORKDIR /app
RUN corepack enable pnpm
# Step 2: Install dependencies
# We copy everything first because we have a .dockerignore
# and we need the workspace structure for pnpm to work correctly
COPY . .
ENV NPM_TOKEN=placeholder
# Copy manifest files specifically for better layer caching
COPY pnpm-lock.yaml pnpm-workspace.yaml package.json .npmrc ./
# Copy package manifest files individually to preserve directory structure
COPY packages/cli/package.json ./packages/cli/
COPY packages/cms-infra/package.json ./packages/cms-infra/
COPY packages/customer-manager/package.json ./packages/customer-manager/
COPY packages/eslint-config/package.json ./packages/eslint-config/
COPY packages/feedback-commander/package.json ./packages/feedback-commander/
COPY packages/gatekeeper/package.json ./packages/gatekeeper/
COPY packages/husky-config/package.json ./packages/husky-config/
COPY packages/infra/package.json ./packages/infra/
COPY packages/mail/package.json ./packages/mail/
COPY packages/next-config/package.json ./packages/next-config/
COPY packages/next-feedback/package.json ./packages/next-feedback/
COPY packages/next-observability/package.json ./packages/next-observability/
COPY packages/next-utils/package.json ./packages/next-utils/
COPY packages/observability/package.json ./packages/observability/
COPY packages/tsconfig/package.json ./packages/tsconfig/
# packages/ui does not have a package.json
# Use a secret for NPM_TOKEN to authenticate with private registry
RUN --mount=type=cache,target=/root/.local/share/pnpm/store/v3 \
# Use a secret for NPM_TOKEN and a standardized cache mount
RUN --mount=type=cache,id=pnpm,target=/pnpm/store \
--mount=type=secret,id=NPM_TOKEN \
export NPM_TOKEN=$(cat /run/secrets/NPM_TOKEN) && \
pnpm config set store-dir /pnpm/store && \
pnpm i --frozen-lockfile
# Step 3: Build shared packages
COPY . .
RUN pnpm --filter "./packages/*" -r build

View File

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

View File

@@ -38,6 +38,7 @@
"@mintel/tsconfig": "workspace:*",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"prettier": "^3.8.1",
"tsup": "^8.3.5",
"typescript": "^5.0.0",
"vitest": "^3.0.4"

View File

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

View File

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

View File

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

View File

@@ -1,3 +1,4 @@
/* global process, URL */
import createNextIntlPlugin from "next-intl/plugin";
import { withSentryConfig } from "@sentry/nextjs";
import fs from "node:fs";

View File

@@ -0,0 +1,51 @@
{
"name": "@mintel/next-feedback",
"version": "1.0.0",
"publishConfig": {
"access": "public",
"registry": "https://npm.infra.mintel.me"
},
"main": "./dist/index.js",
"module": "./dist/index.mjs",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.mjs",
"require": "./dist/index.js"
},
"./FeedbackOverlay": {
"types": "./dist/components/FeedbackOverlay.d.ts",
"import": "./dist/components/FeedbackOverlay.mjs",
"require": "./dist/components/FeedbackOverlay.js"
}
},
"scripts": {
"build": "tsup",
"dev": "tsup --watch",
"lint": "eslint src/"
},
"dependencies": {
"@directus/sdk": "^21.0.0",
"clsx": "^2.1.1",
"framer-motion": "^11.5.4",
"html2canvas": "^1.4.1",
"lucide-react": "^0.441.0",
"next": "15.1.7",
"tailwind-merge": "^2.5.2"
},
"devDependencies": {
"@mintel/eslint-config": "workspace:*",
"@mintel/tsconfig": "workspace:*",
"@types/node": "^20.17.16",
"@types/react": "^19.2.10",
"@types/react-dom": "^19.2.3",
"eslint": "^9.39.2",
"tsup": "^8.0.0",
"typescript": "^5.0.0"
},
"peerDependencies": {
"react": "^19.0.0",
"react-dom": "^19.0.0"
}
}

View File

@@ -0,0 +1,621 @@
"use client";
import React, { useState, useEffect, useMemo } from "react";
import { motion, AnimatePresence } from "framer-motion";
import { MessageSquare, X, Check, Plus, List, Send, User } from "lucide-react";
import { clsx } from "clsx";
import { twMerge } from "tailwind-merge";
import html2canvas from "html2canvas";
function cn(...inputs: any[]) {
return twMerge(clsx(inputs));
}
interface FeedbackComment {
id: string;
userName: string;
text: string;
createdAt: string;
}
interface Feedback {
id: string;
x: number;
y: number;
selector: string;
text: string;
type: "design" | "content";
elementRect: DOMRect | null;
userName: string;
comments: FeedbackComment[];
}
export function FeedbackOverlay() {
const [isActive, setIsActive] = useState(false);
const [hoveredElement, setHoveredElement] = useState<HTMLElement | null>(
null,
);
const [selectedElement, setSelectedElement] = useState<HTMLElement | null>(
null,
);
const [feedbacks, setFeedbacks] = useState<Feedback[]>([]);
const [currentComment, setCurrentComment] = useState("");
const [currentType, setCurrentType] = useState<"design" | "content">(
"design",
);
const [showList, setShowList] = useState(false);
const [currentUser, setCurrentUser] = useState<{
identity: string;
isDevFallback?: boolean;
} | null>(null);
const [newCommentTexts, setNewCommentTexts] = useState<{
[feedbackId: string]: string;
}>({});
const [isCapturing, setIsCapturing] = useState(false);
// 1. Fetch Identity and Existing Feedback
useEffect(() => {
const checkAuth = async () => {
try {
const urlParams = new URLSearchParams(window.location.search);
const bypass = urlParams.get("gatekeeper_bypass");
const apiUrl = bypass
? `/api/whoami?gatekeeper_bypass=${bypass}`
: "/api/whoami";
const res = await fetch(apiUrl);
if (res.ok) {
const data = await res.json();
setCurrentUser(data);
} else {
setCurrentUser({ identity: "Guest" });
}
} catch (_e) {
setCurrentUser({ identity: "Guest" });
}
};
const fetchFeedback = async () => {
try {
const res = await fetch("/api/feedback");
if (res.ok) {
const data = await res.json();
const mapped = data.map((fb: any) => ({
id: fb.id,
x: fb.x,
y: fb.y,
selector: fb.selector,
text: fb.text,
type: fb.type,
userName: fb.user_name,
comments: (fb.comments || []).map((c: any) => ({
id: c.id,
userName: c.user_name,
text: c.text,
createdAt: c.date_created,
})),
}));
setFeedbacks(mapped);
}
} catch (e) {
console.error("Failed to fetch feedbacks", e);
}
};
checkAuth();
fetchFeedback();
}, []);
const getSelector = (el: HTMLElement): string => {
if (el.id) return `#${el.id}`;
const path = [];
let curr: HTMLElement | null = el;
while (curr && curr.parentElement) {
const index = Array.from(curr.parentElement.children).indexOf(curr) + 1;
path.unshift(`${curr.tagName.toLowerCase()}:nth-child(${index})`);
curr = curr.parentElement;
}
return path.join(" > ");
};
useEffect(() => {
if (!isActive) {
setHoveredElement(null);
return;
}
const handleMouseMove = (e: MouseEvent) => {
if (selectedElement) return;
const target = e.target as HTMLElement;
if (target.closest(".feedback-ui-ignore")) {
setHoveredElement(null);
return;
}
setHoveredElement(target);
};
const handleClick = (e: MouseEvent) => {
if (selectedElement) return;
const target = e.target as HTMLElement;
if (target.closest(".feedback-ui-ignore")) return;
e.preventDefault();
e.stopPropagation();
setSelectedElement(target);
setHoveredElement(null);
};
window.addEventListener("mousemove", handleMouseMove);
window.addEventListener("click", handleClick, true);
return () => {
window.removeEventListener("mousemove", handleMouseMove);
window.removeEventListener("click", handleClick, true);
};
}, [isActive, selectedElement]);
const captureScreenshot = async (): Promise<string | null> => {
try {
setIsCapturing(true);
const canvas = await html2canvas(document.body, {
useCORS: true,
scale: 1,
ignoreElements: (el) => el.classList.contains("feedback-ui-ignore"),
});
return canvas.toDataURL("image/png");
} catch (e) {
console.error("Screenshot failed", e);
return null;
} finally {
setIsCapturing(false);
}
};
const saveFeedback = async () => {
if (!selectedElement || !currentComment) return;
const rect = selectedElement.getBoundingClientRect();
const screenshot = await captureScreenshot();
const feedbackData = {
url: window.location.href,
x: rect.left + rect.width / 2 + window.scrollX,
y: rect.top + rect.height / 2 + window.scrollY,
selector: getSelector(selectedElement),
text: currentComment,
type: currentType,
userName: currentUser?.identity || "Unknown",
userIdentity: currentUser?.identity === "Admin" ? "admin" : "user",
screenshot_base64: screenshot,
};
try {
const res = await fetch("/api/feedback", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(feedbackData),
});
if (res.ok) {
const savedFb = await res.json();
const newFeedback: Feedback = {
id: savedFb.id,
x: savedFb.x,
y: savedFb.y,
selector: savedFb.selector,
text: savedFb.text,
type: savedFb.type,
elementRect: rect,
userName: savedFb.user_name,
comments: [],
};
setFeedbacks([...feedbacks, newFeedback]);
setSelectedElement(null);
setCurrentComment("");
}
} catch (e) {
console.error("Failed to save feedback", e);
}
};
const addReply = async (feedbackId: string) => {
const text = newCommentTexts[feedbackId];
if (!text) return;
if (!currentUser?.identity || currentUser.identity === "Guest") {
alert("Nur angemeldete Benutzer können antworten.");
return;
}
try {
const res = await fetch("/api/feedback", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
action: "reply",
feedbackId,
userName: currentUser?.identity || "Unknown",
text,
}),
});
if (res.ok) {
const savedReply = await res.json();
setFeedbacks(
feedbacks.map((f) => {
if (f.id === feedbackId) {
return {
...f,
comments: [
...f.comments,
{
id: savedReply.id,
userName: savedReply.user_name,
text: savedReply.text,
createdAt: savedReply.date_created,
},
],
};
}
return f;
}),
);
setNewCommentTexts({ ...newCommentTexts, [feedbackId]: "" });
}
} catch (e) {
console.error("Failed to save reply", e);
}
};
const hoveredRect = useMemo(
() => hoveredElement?.getBoundingClientRect(),
[hoveredElement],
);
const selectedRect = useMemo(
() => selectedElement?.getBoundingClientRect(),
[selectedElement],
);
return (
<div className="feedback-ui-ignore">
{/* 1. Global Toolbar */}
<div className="fixed bottom-6 left-1/2 -translate-x-1/2 z-[9999]">
<div className="bg-black/80 backdrop-blur-xl border border-white/10 p-2 rounded-2xl shadow-2xl flex items-center gap-2">
<div
className={cn(
"flex items-center gap-2 px-3 py-2 rounded-xl transition-all",
currentUser?.isDevFallback
? "bg-orange-500/20 text-orange-400"
: "bg-white/5 text-white/40",
)}
>
<User size={14} />
<span className="text-[10px] font-bold uppercase tracking-wider">
{currentUser?.identity || "Loading..."}
{currentUser?.isDevFallback && " (Local Dev Bypass)"}
</span>
</div>
<div className="w-px h-6 bg-white/10 mx-1" />
<button
onClick={() => {
if (!currentUser?.identity || currentUser.identity === "Guest") {
alert("Bitte logge dich ein, um Feedback zu geben.");
return;
}
setIsActive(!isActive);
}}
disabled={
!currentUser?.identity || currentUser.identity === "Guest"
}
className={cn(
"flex items-center gap-2 px-4 py-2 rounded-xl transition-all font-medium disabled:opacity-30 disabled:cursor-not-allowed",
isActive
? "bg-blue-500 text-white shadow-lg shadow-blue-500/20"
: "text-white/70 hover:text-white hover:bg-white/10",
)}
>
{isActive ? <X size={18} /> : <MessageSquare size={18} />}
{isActive ? "Modus beenden" : "Feedback geben"}
</button>
<div className="w-px h-6 bg-white/10 mx-1" />
<button
onClick={() => setShowList(!showList)}
className="p-2 text-white/70 hover:text-white hover:bg-white/10 rounded-xl relative"
>
<List size={20} />
{feedbacks.length > 0 && (
<span className="absolute -top-1 -right-1 w-5 h-5 bg-blue-500 text-[10px] flex items-center justify-center rounded-full text-white font-bold border-2 border-[#1a1a1a]">
{feedbacks.length}
</span>
)}
</button>
</div>
</div>
{/* 2. Feedback Markers & Highlights */}
<AnimatePresence>
{isActive && (
<>
{/* Fixed Overlay for real-time highlights */}
<div className="fixed inset-0 pointer-events-none z-[9998]">
{hoveredRect && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="absolute border-2 border-blue-400 bg-blue-400/10 rounded-sm transition-all duration-200"
style={{
top: hoveredRect.top,
left: hoveredRect.left,
width: hoveredRect.width,
height: hoveredRect.height,
}}
/>
)}
{selectedRect && (
<motion.div
initial={{ scale: 0.9, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
className="absolute border-2 border-yellow-400 bg-yellow-400/20 rounded-sm"
style={{
top: selectedRect.top,
left: selectedRect.left,
width: selectedRect.width,
height: selectedRect.height,
}}
/>
)}
</div>
{/* Absolute Overlay for persistent pins */}
<div className="absolute inset-0 pointer-events-none z-[9997]">
{feedbacks.map((fb) => (
<div
key={fb.id}
className="absolute"
style={{ top: fb.y, left: fb.x }}
>
<button
onClick={() => {
setShowList(true);
}}
className={cn(
"w-6 h-6 -translate-x-1/2 -translate-y-1/2 rounded-full border-2 border-white shadow-lg flex items-center justify-center text-white cursor-pointer pointer-events-auto transition-transform hover:scale-110",
fb.type === "design" ? "bg-purple-500" : "bg-orange-500",
)}
>
<Plus size={14} className="rotate-45" />
</button>
</div>
))}
</div>
</>
)}
</AnimatePresence>
{/* 3. Feedback Modal */}
<AnimatePresence>
{selectedElement && (
<div className="fixed inset-0 flex items-center justify-center z-[10000] bg-black/40 backdrop-blur-sm">
<motion.div
initial={{ opacity: 0, y: 20, scale: 0.95 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 20, scale: 0.95 }}
className="bg-[#1c1c1e] border border-white/10 rounded-3xl p-6 w-[400px] shadow-2xl"
>
<div className="flex items-center justify-between mb-6">
<h3 className="text-white font-bold text-lg">Feedback geben</h3>
<button
onClick={() => setSelectedElement(null)}
className="text-white/40 hover:text-white"
>
<X size={20} />
</button>
</div>
<div className="flex gap-2 mb-6">
{(["design", "content"] as const).map((type) => (
<button
key={type}
onClick={() => setCurrentType(type)}
className={cn(
"flex-1 py-3 px-4 rounded-xl text-sm font-medium transition-all capitalize",
currentType === type
? "bg-white text-black shadow-lg"
: "bg-white/5 text-white/40 hover:bg-white/10",
)}
>
{type === "design" ? "🎨 Design" : "✍️ Content"}
</button>
))}
</div>
<textarea
autoFocus
value={currentComment}
onChange={(e) => setCurrentComment(e.target.value)}
placeholder="Was möchtest du anmerken?"
className="w-full h-32 bg-white/5 border border-white/5 rounded-2xl p-4 text-white placeholder:text-white/20 focus:outline-none focus:border-blue-500/50 transition-colors resize-none mb-6"
/>
<button
disabled={!currentComment || isCapturing}
onClick={saveFeedback}
className="w-full bg-blue-500 hover:bg-blue-400 disabled:opacity-50 disabled:cursor-not-allowed text-white font-bold py-4 rounded-2xl flex items-center justify-center gap-2 transition-all shadow-lg shadow-blue-500/20"
>
{isCapturing ? (
"Erfasse Screenshot..."
) : (
<>
<Check size={20} />
Feedback speichern
</>
)}
</button>
</motion.div>
</div>
)}
</AnimatePresence>
{/* 4. Feedback List Sidebar */}
<AnimatePresence>
{showList && (
<>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={() => setShowList(false)}
className="fixed inset-0 bg-black/60 backdrop-blur-sm z-[10001]"
/>
<motion.div
initial={{ x: "100%" }}
animate={{ x: 0 }}
exit={{ x: "100%" }}
transition={{ type: "spring", damping: 25, stiffness: 200 }}
className="fixed top-0 right-0 h-full w-[400px] bg-[#1c1c1e] border-l border-white/10 z-[10002] shadow-2xl flex flex-col"
>
<div className="p-8 border-b border-white/10 flex items-center justify-between">
<div>
<h2 className="text-2xl font-bold text-white mb-1">
Feedback
</h2>
<p className="text-white/40 text-sm">
{feedbacks.length} Anmerkungen live
</p>
</div>
<button
onClick={() => setShowList(false)}
className="p-2 text-white/40 hover:text-white bg-white/5 rounded-xl transition-colors"
>
<X size={20} />
</button>
</div>
<div className="flex-1 overflow-y-auto p-6 space-y-6">
{feedbacks.length === 0 ? (
<div className="h-full flex flex-col items-center justify-center text-center px-8 opacity-40">
<MessageSquare size={48} className="mb-4" />
<p>
Noch kein Feedback vorhanden. Aktiviere den Modus um
Stellen auf der Seite zu markieren.
</p>
</div>
) : (
feedbacks.map((fb) => (
<div
key={fb.id}
className="bg-white/5 border border-white/5 rounded-3xl overflow-hidden hover:border-white/20 transition-all flex flex-col"
>
<div className="p-5 border-b border-white/5 bg-white/[0.02]">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<div className="w-8 h-8 rounded-full bg-blue-500/20 flex items-center justify-center text-blue-400">
<User size={14} />
</div>
<div>
<p className="text-white text-[11px] font-bold uppercase tracking-wider">
{fb.userName}
</p>
<p className="text-white/20 text-[9px] uppercase tracking-widest">
Original Poster
</p>
</div>
</div>
<span
className={cn(
"px-3 py-1 rounded-full text-[9px] font-bold uppercase tracking-wider",
fb.type === "design"
? "bg-purple-500/20 text-purple-400"
: "bg-orange-500/20 text-orange-400",
)}
>
{fb.type}
</span>
</div>
<p className="text-white/80 whitespace-pre-wrap text-sm leading-relaxed">
{fb.text}
</p>
<div className="mt-3 flex items-center gap-2">
<div className="w-1 h-1 bg-white/10 rounded-full" />
<span className="text-white/20 text-[9px] truncate tracking-wider italic">
{fb.selector}
</span>
</div>
</div>
{fb.comments.length > 0 && (
<div className="bg-black/20 p-5 space-y-4">
{fb.comments.map((comment) => (
<div key={comment.id} className="flex gap-3">
<div className="w-6 h-6 rounded-full bg-white/10 flex items-center justify-center text-white/40 shrink-0">
<User size={10} />
</div>
<div className="space-y-1">
<div className="flex items-center gap-2">
<p className="text-[10px] font-bold text-white/60 uppercase">
{comment.userName}
</p>
<p className="text-[10px] text-white/20">
{new Date(
comment.createdAt,
).toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
})}
</p>
</div>
<p className="text-white/80 text-xs leading-snug">
{comment.text}
</p>
</div>
</div>
))}
</div>
)}
<div className="p-4 bg-white/[0.01] mt-auto border-t border-white/5">
<div className="relative">
<input
type="text"
value={newCommentTexts[fb.id] || ""}
onChange={(e) =>
setNewCommentTexts({
...newCommentTexts,
[fb.id]: e.target.value,
})
}
placeholder="Antworten..."
className="w-full bg-black/40 border border-white/5 rounded-2xl py-3 pl-4 pr-12 text-xs text-white placeholder:text-white/20 focus:outline-none focus:border-blue-500/50 transition-colors"
onKeyDown={(e) => {
if (e.key === "Enter") addReply(fb.id);
}}
/>
<button
onClick={() => addReply(fb.id)}
className="absolute right-2 top-1/2 -translate-y-1/2 p-2 text-blue-500 hover:text-blue-400 transition-colors disabled:opacity-30"
disabled={!newCommentTexts[fb.id]}
>
<Send size={14} />
</button>
</div>
</div>
</div>
))
)}
</div>
</motion.div>
</>
)}
</AnimatePresence>
</div>
);
}

View File

@@ -0,0 +1,131 @@
import { NextRequest, NextResponse } from "next/server";
import {
createDirectus,
rest,
staticToken,
createItem,
readItems,
} from "@directus/sdk";
export interface CMSConfig {
url: string;
token: string;
}
export function createCMSClient(config: CMSConfig) {
return createDirectus(config.url)
.with(staticToken(config.token))
.with(rest());
}
export async function handleFeedbackRequest(
req: NextRequest,
config: CMSConfig,
) {
const client = createCMSClient(config);
if (req.method === "GET") {
try {
const items = await client.request(
readItems("visual_feedback", {
fields: ["*", { comments: ["*"] }],
sort: ["-date_created"],
}),
);
return NextResponse.json(items);
} catch (error: any) {
return NextResponse.json({ error: error.message }, { status: 500 });
}
}
if (req.method === "POST") {
try {
const body = await req.json();
const { action, screenshot_base64, ...data } = body;
if (action === "reply") {
const reply = await client.request(
createItem("visual_feedback_comments", {
feedback_id: data.feedbackId,
user_name: data.userName,
text: data.text,
}),
);
return NextResponse.json(reply);
}
let screenshotId = null;
if (screenshot_base64) {
try {
const base64Data = screenshot_base64.split(";base64,").pop();
const buffer = Buffer.from(base64Data, "base64");
const formData = new FormData();
const blob = new Blob([buffer], { type: "image/png" });
formData.append("file", blob, `feedback-${Date.now()}.png`);
const fileRes = await fetch(`${config.url}/files`, {
method: "POST",
headers: { Authorization: `Bearer ${config.token}` },
body: formData,
});
if (fileRes.ok) {
const fileData = await fileRes.json();
screenshotId = fileData.data.id;
}
} catch (e) {
console.error("Failed to upload screenshot:", e);
}
}
const feedback = await client.request(
createItem("visual_feedback", {
project: data.project || req.headers.get("host") || "unknown",
url: data.url,
selector: data.selector,
x: data.x,
y: data.y,
type: data.type,
text: data.text,
user_name: data.userName,
user_identity: data.userIdentity,
status: "open",
screenshot: screenshotId,
company: data.companyId,
}),
);
return NextResponse.json(feedback);
} catch (error: any) {
return NextResponse.json({ error: error.message }, { status: 500 });
}
}
return NextResponse.json({ error: "Method not allowed" }, { status: 405 });
}
export async function handleWhoAmIRequest(
req: NextRequest,
gatekeeperUrl: string,
) {
try {
const bypass = req.nextUrl.searchParams.get("gatekeeper_bypass");
const targetUrl = bypass
? `${gatekeeperUrl}/api/whoami?gatekeeper_bypass=${bypass}`
: `${gatekeeperUrl}/api/whoami`;
// Forward cookies
const cookieHeader = req.headers.get("cookie") || "";
const res = await fetch(targetUrl, {
headers: { Cookie: cookieHeader },
});
if (res.ok) {
return NextResponse.json(await res.json());
}
return NextResponse.json({ identity: "Guest" });
} catch (_e) {
return NextResponse.json({ identity: "Guest" });
}
}

View File

@@ -0,0 +1,2 @@
export * from "./handlers";
export * from "./components/FeedbackOverlay";

View File

@@ -0,0 +1,10 @@
{
"extends": "@mintel/tsconfig/nextjs.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src",
"baseUrl": "."
},
"include": ["src"],
"exclude": ["node_modules", "dist"]
}

View File

@@ -0,0 +1,12 @@
import { defineConfig } from "tsup";
export default defineConfig({
entry: ["src/index.ts", "src/components/FeedbackOverlay.tsx"],
format: ["cjs", "esm"],
dts: true,
clean: true,
sourcemap: true,
banner: {
js: "'use client';",
},
});

View File

@@ -29,7 +29,7 @@
"dependencies": {
"@mintel/observability": "workspace:*",
"@sentry/nextjs": "^8.55.0",
"next": "15.1.6"
"next": "15.1.7"
},
"peerDependencies": {
"react": "^18.0.0 || ^19.0.0",

View File

@@ -37,7 +37,7 @@ export function createUmamiProxyHandler(config: {
}
return NextResponse.json({ status: "ok" });
} catch (error) {
} catch (_error) {
return NextResponse.json(
{ error: "Internal Server Error" },
{ status: 500 },
@@ -80,7 +80,7 @@ export function createSentryRelayHandler(config: { dsn?: string }) {
}
return NextResponse.json({ status: "ok" });
} catch (error) {
} catch (_error) {
return NextResponse.json(
{ error: "Internal Server Error" },
{ status: 500 },

View File

@@ -16,7 +16,7 @@
},
"dependencies": {
"@directus/sdk": "^21.0.0",
"next": "15.1.6",
"next": "15.1.7",
"next-intl": "^4.8.2",
"zod": "^3.0.0"
},

View File

@@ -5,11 +5,11 @@ import type { AnalyticsService, AnalyticsEventProperties } from "./service";
* Used when analytics are disabled or for local development.
*/
export class NoopAnalyticsService implements AnalyticsService {
track(eventName: string, props?: AnalyticsEventProperties): void {
track(_eventName: string, _props?: AnalyticsEventProperties): void {
// Do nothing
}
trackPageview(url?: string): void {
trackPageview(_url?: string): void {
// Do nothing
}
}

10572
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,3 +1,4 @@
packages:
- 'packages/*'
- 'apps/*'
- '../klz-2026'