Compare commits
61 Commits
v1.1.4
...
feature/aq
| Author | SHA1 | Date | |
|---|---|---|---|
| 9e4e296e3b | |||
| f2c0a4581c | |||
| 367c4d8404 | |||
| 587c88980f | |||
| fcdfdb4588 | |||
| 6bbaa8d105 | |||
| eccc084441 | |||
| da6b8aba64 | |||
| 290097b4e6 | |||
| 45894cce34 | |||
| 7195906da0 | |||
| dcb466f53b | |||
| 14089766ea | |||
| 6ecabe4a04 | |||
| b205220bde | |||
| 3d5a802c6e | |||
| b5d1272f85 | |||
| e152fb8171 | |||
| d7cec1fa0e | |||
| 67c2af958a | |||
| 015e295370 | |||
| c9952bfd1d | |||
| f9aaf3712e | |||
| d2bbfe3b40 | |||
| f3fafa8ea0 | |||
| 625c58398c | |||
| a306d24f51 | |||
| 59d3e97ef0 | |||
| 0c0d0caae6 | |||
| 2c9f12623e | |||
| a55649c5f2 | |||
| 0d7c588536 | |||
| b6debcbb59 | |||
| 5847bc5795 | |||
| e662415137 | |||
| 580b087e8a | |||
| ac3c405cb2 | |||
| a594affdfa | |||
| 61e78ea672 | |||
| 6501eac38a | |||
| 7f9206ae77 | |||
| 6229f8e886 | |||
| 8ac090aff3 | |||
| 696f9d361d | |||
| 31840da9e7 | |||
| 96ec2c7d8d | |||
| 9029375247 | |||
| 95d0a1622f | |||
| 646d615e76 | |||
| 51409099fc | |||
| 22cd20e639 | |||
| e7cc1c8ca5 | |||
| 0ccb15a929 | |||
| a94ddcfbb2 | |||
| d3a9af140c | |||
| 0dc3ba0da4 | |||
| 1a94465dba | |||
| 7e256025ea | |||
| e843de42da | |||
| 4d1b2231e3 | |||
| 71f47f9037 |
12
.dockerignore
Normal file
12
.dockerignore
Normal file
@@ -0,0 +1,12 @@
|
||||
node_modules
|
||||
.next
|
||||
.git
|
||||
# .npmrc is allowed as it contains the registry template
|
||||
dist
|
||||
build
|
||||
out
|
||||
coverage
|
||||
.vercel
|
||||
.turbo
|
||||
*.log
|
||||
.DS_Store
|
||||
@@ -1,4 +1,5 @@
|
||||
# Project
|
||||
IMAGE_TAG=v1.7.0
|
||||
PROJECT_NAME=sample-website
|
||||
PROJECT_COLOR=#82ed20
|
||||
|
||||
@@ -3,12 +3,9 @@ name: Monorepo Pipeline
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- '**'
|
||||
tags:
|
||||
- 'v*'
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
@@ -18,6 +15,10 @@ jobs:
|
||||
qa:
|
||||
name: 🧪 Quality Assurance
|
||||
runs-on: docker
|
||||
container:
|
||||
image: catthehacker/ubuntu:act-latest
|
||||
env:
|
||||
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
@@ -31,12 +32,13 @@ jobs:
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node_version: 20
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
env:
|
||||
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
|
||||
- name: 🏷️ Sync Versions (if Tagged)
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
run: pnpm sync-versions
|
||||
|
||||
- name: Lint
|
||||
run: pnpm lint
|
||||
@@ -52,6 +54,8 @@ jobs:
|
||||
needs: qa
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
runs-on: docker
|
||||
container:
|
||||
image: catthehacker/ubuntu:act-latest
|
||||
env:
|
||||
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -70,7 +74,6 @@ jobs:
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node_version: 20
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
@@ -78,14 +81,31 @@ jobs:
|
||||
- name: 🏷️ Release Packages (Tag-Driven)
|
||||
run: |
|
||||
echo "🏷️ Tag detected [${{ github.ref_name }}], performing sync release..."
|
||||
pnpm sync-versions
|
||||
pnpm release:tag
|
||||
|
||||
build-images:
|
||||
name: 🐳 Build & 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
|
||||
@@ -94,49 +114,25 @@ jobs:
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: 🔐 Registry Login
|
||||
run: |
|
||||
echo "${{ secrets.REGISTRY_PASS }}" | docker login registry.infra.mintel.me -u "${{ secrets.REGISTRY_USER }}" --password-stdin
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: registry.infra.mintel.me
|
||||
username: ${{ secrets.REGISTRY_USER }}
|
||||
password: ${{ secrets.REGISTRY_PASS }}
|
||||
|
||||
- name: 🏗️ Build & Push Nextjs Build-Base
|
||||
env:
|
||||
TAG: ${{ github.ref_name }}
|
||||
run: |
|
||||
docker buildx build \
|
||||
--platform linux/amd64,linux/arm64 \
|
||||
-t registry.infra.mintel.me/mintel/nextjs:$TAG \
|
||||
-t registry.infra.mintel.me/mintel/nextjs:latest \
|
||||
-f packages/infra/docker/Dockerfile.nextjs \
|
||||
--push .
|
||||
- name: 🏗️ Build & Push ${{ matrix.name }}
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
file: ${{ matrix.file }}
|
||||
platforms: linux/arm64
|
||||
pull: true
|
||||
push: true
|
||||
secrets: |
|
||||
NPM_TOKEN=${{ secrets.NPM_TOKEN }}
|
||||
tags: |
|
||||
registry.infra.mintel.me/mintel/${{ matrix.image }}:${{ github.ref_name }}
|
||||
registry.infra.mintel.me/mintel/${{ matrix.image }}:latest
|
||||
cache-from: type=registry,ref=registry.infra.mintel.me/mintel/${{ matrix.image }}:buildcache
|
||||
cache-to: type=registry,ref=registry.infra.mintel.me/mintel/${{ matrix.image }}:buildcache,mode=max
|
||||
|
||||
- name: 🏗️ Build & Push Production Runtime
|
||||
env:
|
||||
TAG: ${{ github.ref_name }}
|
||||
run: |
|
||||
docker buildx build \
|
||||
--platform linux/amd64,linux/arm64 \
|
||||
-t registry.infra.mintel.me/mintel/runtime:$TAG \
|
||||
-t registry.infra.mintel.me/mintel/runtime:latest \
|
||||
-f packages/infra/docker/Dockerfile.runtime \
|
||||
--push .
|
||||
|
||||
- name: 🏗️ Build & Push Gatekeeper (Product)
|
||||
env:
|
||||
TAG: ${{ github.ref_name }}
|
||||
run: |
|
||||
docker buildx build \
|
||||
--platform linux/amd64,linux/arm64 \
|
||||
-t registry.infra.mintel.me/mintel/gatekeeper:$TAG \
|
||||
-t registry.infra.mintel.me/mintel/gatekeeper:latest \
|
||||
-f packages/infra/docker/Dockerfile.gatekeeper \
|
||||
--push .
|
||||
|
||||
- name: 🏗️ Build & Push Directus (Base)
|
||||
env:
|
||||
TAG: ${{ github.ref_name }}
|
||||
run: |
|
||||
docker buildx build \
|
||||
--platform linux/amd64,linux/arm64 \
|
||||
-t registry.infra.mintel.me/mintel/directus:$TAG \
|
||||
-t registry.infra.mintel.me/mintel/directus:latest \
|
||||
-f packages/infra/docker/Dockerfile.directus \
|
||||
--push .
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,6 +1,7 @@
|
||||
# dependencies
|
||||
node_modules
|
||||
.pnpm-debug.log*
|
||||
.pnpm-store/
|
||||
|
||||
# next.js
|
||||
.next/
|
||||
|
||||
16
.husky/pre-push
Executable file
16
.husky/pre-push
Executable file
@@ -0,0 +1,16 @@
|
||||
|
||||
# Check if we are pushing a tag
|
||||
while read local_ref local_sha remote_ref remote_sha
|
||||
do
|
||||
if [[ "$remote_ref" == refs/tags/v* ]]; then
|
||||
TAG=${remote_ref#refs/tags/}
|
||||
echo "🏷️ Tag detected: $TAG, syncing versions..."
|
||||
pnpm sync-versions "$TAG"
|
||||
|
||||
# Stage the changed files
|
||||
git add package.json packages/*/package.json apps/*/package.json .env .env.example
|
||||
|
||||
echo "⚠️ package.json and .env files updated to match tag $TAG."
|
||||
echo "⚠️ Note: You might need to push again if these changes were not already in your commit/tag."
|
||||
fi
|
||||
done
|
||||
3
.npmrc
3
.npmrc
@@ -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
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
# Start from the pre-built Nextjs Base image
|
||||
FROM registry.infra.mintel.me/mintel/nextjs:latest AS builder
|
||||
ARG IMAGE_TAG=latest
|
||||
FROM registry.infra.mintel.me/mintel/nextjs:${IMAGE_TAG} AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
@@ -20,7 +21,7 @@ ENV DIRECTUS_URL=$DIRECTUS_URL
|
||||
RUN pnpm --filter sample-website build
|
||||
|
||||
# Production runner image
|
||||
FROM registry.infra.mintel.me/mintel/runtime:latest AS runner
|
||||
FROM registry.infra.mintel.me/mintel/runtime:${IMAGE_TAG} AS runner
|
||||
WORKDIR /app
|
||||
|
||||
COPY --from=builder /app/apps/sample-website/public ./apps/sample-website/public
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
import { nextConfig } from "@mintel/eslint-config/next";
|
||||
|
||||
export default nextConfig;
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "sample-website",
|
||||
"version": "0.1.1",
|
||||
"version": "1.7.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
@@ -8,21 +8,18 @@
|
||||
"dev:local": "mintel dev --local",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint",
|
||||
"lint": "eslint src/",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run --passWithNoTests",
|
||||
"cms:bootstrap": "mintel directus bootstrap",
|
||||
"cms:push:testing": "mintel directus sync push testing",
|
||||
"cms:pull:testing": "mintel directus sync pull testing",
|
||||
"cms:push:staging": "mintel directus sync push staging",
|
||||
"cms:pull:staging": "mintel directus sync pull staging",
|
||||
"cms:push:prod": "mintel directus sync push production",
|
||||
"cms:pull:prod": "mintel directus sync pull production",
|
||||
"pagespeed:test": "mintel pagespeed"
|
||||
},
|
||||
"dependencies": {
|
||||
"@mintel/next-utils": "workspace:*",
|
||||
"next": "15.1.6",
|
||||
"@mintel/observability": "workspace:*",
|
||||
"@mintel/next-observability": "workspace:*",
|
||||
"@sentry/nextjs": "10.38.0",
|
||||
"next": "16.1.6",
|
||||
"next-intl": "^4.8.2",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0"
|
||||
|
||||
9
apps/sample-website/sentry.client.config.ts
Normal file
9
apps/sample-website/sentry.client.config.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { initSentry } from "@mintel/next-observability";
|
||||
|
||||
initSentry({
|
||||
// Use a placeholder DSN on the client if you want to bypass ad-blockers via tunnel
|
||||
// Or just use the real DSN if you don't care about ad-blockers for errors.
|
||||
// The Mintel standard is to use the relay.
|
||||
dsn: "https://public@errors.infra.mintel.me/1", // Placeholder for relay
|
||||
tunnel: "/errors/api/relay",
|
||||
});
|
||||
8
apps/sample-website/sentry.edge.config.ts
Normal file
8
apps/sample-website/sentry.edge.config.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { initSentry } from "@mintel/next-observability";
|
||||
import { validateMintelEnv } from "@mintel/next-utils";
|
||||
|
||||
const env = validateMintelEnv();
|
||||
|
||||
initSentry({
|
||||
dsn: env.SENTRY_DSN,
|
||||
});
|
||||
8
apps/sample-website/sentry.server.config.ts
Normal file
8
apps/sample-website/sentry.server.config.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { initSentry } from "@mintel/next-observability";
|
||||
import { validateMintelEnv } from "@mintel/next-utils";
|
||||
|
||||
const env = validateMintelEnv();
|
||||
|
||||
initSentry({
|
||||
dsn: env.SENTRY_DSN,
|
||||
});
|
||||
6
apps/sample-website/src/app/errors/api/relay/route.ts
Normal file
6
apps/sample-website/src/app/errors/api/relay/route.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { createSentryRelayHandler } from "@mintel/next-observability";
|
||||
import { validateMintelEnv } from "@mintel/next-utils";
|
||||
|
||||
export const POST = createSentryRelayHandler({
|
||||
dsn: validateMintelEnv().SENTRY_DSN,
|
||||
});
|
||||
@@ -1,5 +1,11 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Suspense } from "react";
|
||||
import "./globals.css";
|
||||
import {
|
||||
AnalyticsContextProvider,
|
||||
AnalyticsAutoTracker,
|
||||
} from "@mintel/next-observability/client";
|
||||
import { getAnalyticsConfig } from "@/lib/observability";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Sample Website",
|
||||
@@ -11,9 +17,18 @@ export default function RootLayout({
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
const analyticsConfig = getAnalyticsConfig();
|
||||
|
||||
return (
|
||||
<html lang="en">
|
||||
<body>{children}</body>
|
||||
<body>
|
||||
<AnalyticsContextProvider config={analyticsConfig}>
|
||||
<Suspense fallback={null}>
|
||||
<AnalyticsAutoTracker />
|
||||
</Suspense>
|
||||
{children}
|
||||
</AnalyticsContextProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
|
||||
7
apps/sample-website/src/app/stats/api/send/route.ts
Normal file
7
apps/sample-website/src/app/stats/api/send/route.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { createUmamiProxyHandler } from "@mintel/next-observability";
|
||||
import { validateMintelEnv } from "@mintel/next-utils";
|
||||
|
||||
export const POST = createUmamiProxyHandler({
|
||||
websiteId: validateMintelEnv().UMAMI_WEBSITE_ID,
|
||||
apiEndpoint: validateMintelEnv().UMAMI_API_ENDPOINT,
|
||||
});
|
||||
13
apps/sample-website/src/instrumentation.ts
Normal file
13
apps/sample-website/src/instrumentation.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import * as Sentry from "@sentry/nextjs";
|
||||
|
||||
export async function register() {
|
||||
if (process.env.NEXT_RUNTIME === "nodejs") {
|
||||
await import("../sentry.server.config");
|
||||
}
|
||||
|
||||
if (process.env.NEXT_RUNTIME === "edge") {
|
||||
await import("../sentry.edge.config");
|
||||
}
|
||||
}
|
||||
|
||||
export const onRequestError = Sentry.captureRequestError;
|
||||
54
apps/sample-website/src/lib/observability.ts
Normal file
54
apps/sample-website/src/lib/observability.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import {
|
||||
UmamiAnalyticsService,
|
||||
GotifyNotificationService,
|
||||
NoopNotificationService,
|
||||
} from "@mintel/observability";
|
||||
import { validateMintelEnv } from "@mintel/next-utils";
|
||||
|
||||
let analyticsService: any = null;
|
||||
let notificationService: any = null;
|
||||
|
||||
export function getAnalyticsConfig() {
|
||||
const isClient = typeof window !== "undefined";
|
||||
|
||||
if (isClient) {
|
||||
return {
|
||||
enabled: true,
|
||||
apiEndpoint: "/stats",
|
||||
};
|
||||
}
|
||||
|
||||
const env = validateMintelEnv();
|
||||
return {
|
||||
enabled: Boolean(env.UMAMI_WEBSITE_ID),
|
||||
websiteId: env.UMAMI_WEBSITE_ID,
|
||||
apiEndpoint: env.UMAMI_API_ENDPOINT,
|
||||
};
|
||||
}
|
||||
|
||||
export function getAnalyticsService() {
|
||||
if (analyticsService) return analyticsService;
|
||||
|
||||
const config = getAnalyticsConfig();
|
||||
analyticsService = new UmamiAnalyticsService(config);
|
||||
|
||||
return analyticsService;
|
||||
}
|
||||
|
||||
export function getNotificationService() {
|
||||
if (notificationService) return notificationService;
|
||||
|
||||
if (typeof window === "undefined") {
|
||||
const env = validateMintelEnv();
|
||||
notificationService = new GotifyNotificationService({
|
||||
enabled: Boolean(env.GOTIFY_URL && env.GOTIFY_TOKEN),
|
||||
url: env.GOTIFY_URL || "",
|
||||
token: env.GOTIFY_TOKEN || "",
|
||||
});
|
||||
} else {
|
||||
// Notifications are typically server-side only to protect tokens
|
||||
notificationService = new NoopNotificationService();
|
||||
}
|
||||
|
||||
return notificationService;
|
||||
}
|
||||
19
directus/schema/snapshot.yaml
Normal file
19
directus/schema/snapshot.yaml
Normal file
@@ -0,0 +1,19 @@
|
||||
version: 1
|
||||
directus: 11.15.1
|
||||
vendor: postgres
|
||||
collections: []
|
||||
fields: []
|
||||
systemFields:
|
||||
- collection: directus_activity
|
||||
field: timestamp
|
||||
schema:
|
||||
is_indexed: true
|
||||
- collection: directus_revisions
|
||||
field: activity
|
||||
schema:
|
||||
is_indexed: true
|
||||
- collection: directus_revisions
|
||||
field: parent
|
||||
schema:
|
||||
is_indexed: true
|
||||
relations: []
|
||||
0
directus/uploads/.gitkeep
Normal file
0
directus/uploads/.gitkeep
Normal file
@@ -1,17 +1,18 @@
|
||||
services:
|
||||
app:
|
||||
build:
|
||||
context: .
|
||||
context: ./apps/sample-website
|
||||
dockerfile: Dockerfile
|
||||
args:
|
||||
NEXT_PUBLIC_BASE_URL: ${NEXT_PUBLIC_BASE_URL:-http://localhost:3000}
|
||||
NEXT_PUBLIC_UMAMI_WEBSITE_ID: ${NEXT_PUBLIC_UMAMI_WEBSITE_ID}
|
||||
NEXT_PUBLIC_UMAMI_SCRIPT_URL: ${NEXT_PUBLIC_UMAMI_SCRIPT_URL}
|
||||
NEXT_PUBLIC_TARGET: ${TARGET:-development}
|
||||
DIRECTUS_URL: ${DIRECTUS_URL:-http://directus:8055}
|
||||
restart: always
|
||||
networks:
|
||||
- infra
|
||||
environment:
|
||||
- DIRECTUS_URL=${DIRECTUS_URL:-http://directus:8055}
|
||||
env_file:
|
||||
- .env
|
||||
ports:
|
||||
@@ -22,7 +23,7 @@ services:
|
||||
- "traefik.http.services.sample-website.loadbalancer.server.port=3000"
|
||||
|
||||
directus:
|
||||
image: registry.infra.mintel.me/mintel/directus:latest
|
||||
image: registry.infra.mintel.me/mintel/directus:${IMAGE_TAG:-latest}
|
||||
restart: always
|
||||
networks:
|
||||
- infra
|
||||
@@ -46,6 +47,7 @@ services:
|
||||
volumes:
|
||||
- ./directus/uploads:/directus/uploads
|
||||
- ./directus/extensions:/directus/extensions
|
||||
- ./directus/schema:/directus/schema
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.http.routers.sample-website-directus.rule=Host(`${DIRECTUS_HOST:-cms.sample-website.localhost}`)"
|
||||
@@ -1,3 +1,26 @@
|
||||
import baseConfig from "@mintel/eslint-config";
|
||||
import { nextConfig } from "@mintel/eslint-config/next";
|
||||
|
||||
export default nextConfig;
|
||||
export default [
|
||||
{
|
||||
ignores: [
|
||||
"packages/cms-infra/extensions/**",
|
||||
"packages/customer-manager/index.js",
|
||||
"**/*.db",
|
||||
"**/build/**",
|
||||
"**/data/**",
|
||||
"**/reference/**",
|
||||
"**/dist/**",
|
||||
"**/.next/**",
|
||||
],
|
||||
},
|
||||
...baseConfig,
|
||||
...nextConfig.map((config) => ({
|
||||
...config,
|
||||
files: [
|
||||
"apps/sample-website/**/*.{ts,tsx}",
|
||||
"packages/gatekeeper/**/*.{ts,tsx}",
|
||||
"../klz-2026/**/*.{ts,tsx}",
|
||||
],
|
||||
})),
|
||||
];
|
||||
|
||||
26
package.json
26
package.json
@@ -5,11 +5,17 @@
|
||||
"scripts": {
|
||||
"build": "pnpm -r build",
|
||||
"dev": "pnpm -r dev",
|
||||
"lint": "pnpm -r lint",
|
||||
"lint": "pnpm -r --filter='./packages/**' --filter='./apps/**' lint",
|
||||
"test": "pnpm -r test",
|
||||
"changeset": "changeset",
|
||||
"version-packages": "changeset version",
|
||||
"sync-versions": "tsx scripts/sync-versions.ts",
|
||||
"sync-versions": "tsx scripts/sync-versions.ts --",
|
||||
"cms:push:infra": "./scripts/sync-directus.sh push infra",
|
||||
"cms:pull:infra": "./scripts/sync-directus.sh pull infra",
|
||||
"cms:schema:snapshot": "./scripts/cms-snapshot.sh",
|
||||
"cms:schema:apply": "./scripts/cms-apply.sh local",
|
||||
"cms:schema:apply:infra": "./scripts/cms-apply.sh infra",
|
||||
"dev:infra": "docker-compose up -d directus directus-db",
|
||||
"release": "pnpm build && changeset publish",
|
||||
"release:tag": "pnpm build && pnpm -r publish --no-git-checks --access public",
|
||||
"prepare": "husky"
|
||||
@@ -22,11 +28,12 @@
|
||||
"@mintel/husky-config": "workspace:*",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@types/node": "^20.17.16",
|
||||
"@types/react": "^19.2.10",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^5.1.2",
|
||||
"eslint": "^9.39.2",
|
||||
"eslint-plugin-next": "^0.0.0",
|
||||
"@next/eslint-plugin-next": "16.1.6",
|
||||
"eslint-plugin-react": "^7.37.5",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"happy-dom": "^20.4.0",
|
||||
@@ -38,5 +45,18 @@
|
||||
"typescript": "^5.0.0",
|
||||
"typescript-eslint": "^8.54.0",
|
||||
"vitest": "^4.0.18"
|
||||
},
|
||||
"dependencies": {
|
||||
"import-in-the-middle": "^3.0.0",
|
||||
"pino": "^10.3.1",
|
||||
"pino-pretty": "^13.1.3",
|
||||
"require-in-the-middle": "^8.0.1"
|
||||
},
|
||||
"version": "1.7.0",
|
||||
"pnpm": {
|
||||
"overrides": {
|
||||
"next": "16.1.6",
|
||||
"@sentry/nextjs": "10.38.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
32
packages/acquisition/package.json
Normal file
32
packages/acquisition/package.json
Normal file
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"name": "@mintel/acquisition",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"scripts": {
|
||||
"build": "tsup src/index.ts --format esm --dts --clean",
|
||||
"dev": "tsup src/index.ts --format esm --watch --dts",
|
||||
"lint": "eslint src",
|
||||
"test": "vitest run",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "^1.7.9",
|
||||
"crawlee": "^3.12.2",
|
||||
"cheerio": "^1.0.0",
|
||||
"react": "^19.0.0",
|
||||
"@react-pdf/renderer": "^4.3.0",
|
||||
"framer-motion": "^12.4.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@mintel/tsconfig": "workspace:*",
|
||||
"@mintel/eslint-config": "workspace:*",
|
||||
"tsup": "^8.3.5",
|
||||
"typescript": "^5.0.0",
|
||||
"vitest": "^3.0.4",
|
||||
"@types/node": "^20.17.16"
|
||||
}
|
||||
}
|
||||
93
packages/acquisition/src/components/EstimationPDF.tsx
Normal file
93
packages/acquisition/src/components/EstimationPDF.tsx
Normal file
@@ -0,0 +1,93 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { Page as PDFPage, Document as PDFDocument } from "@react-pdf/renderer";
|
||||
import { pdfStyles } from "./pdf/SharedUI.js";
|
||||
import { SimpleLayout } from "./pdf/SimpleLayout.js";
|
||||
|
||||
// Modules
|
||||
import { FrontPageModule } from "./pdf/modules/FrontPageModule.js";
|
||||
import { BriefingModule } from "./pdf/modules/BriefingModule.js";
|
||||
import { SitemapModule } from "./pdf/modules/SitemapModule.js";
|
||||
import { EstimationModule } from "./pdf/modules/EstimationModule.js";
|
||||
import { TransparenzModule } from "./pdf/modules/TransparenzModule.js";
|
||||
import { ClosingModule } from "./pdf/modules/CommonModules.js";
|
||||
|
||||
import { calculatePositions } from "../logic/pricing/calculator.js";
|
||||
|
||||
interface PDFProps {
|
||||
state: any;
|
||||
totalPrice: number;
|
||||
pricing: any;
|
||||
headerIcon?: string;
|
||||
footerLogo?: string;
|
||||
}
|
||||
|
||||
export const EstimationPDF = ({
|
||||
state,
|
||||
totalPrice,
|
||||
pricing,
|
||||
headerIcon,
|
||||
footerLogo,
|
||||
}: PDFProps) => {
|
||||
const date = new Date().toLocaleDateString("de-DE", {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
});
|
||||
|
||||
const positions = calculatePositions(state, pricing);
|
||||
|
||||
const companyData = {
|
||||
name: "Marc Mintel",
|
||||
address1: "Georg-Meistermann-Straße 7",
|
||||
address2: "54586 Schüller",
|
||||
ustId: "DE367588065",
|
||||
};
|
||||
|
||||
const commonProps = {
|
||||
state,
|
||||
date,
|
||||
icon: headerIcon,
|
||||
footerLogo,
|
||||
companyData,
|
||||
};
|
||||
|
||||
let pageCounter = 1;
|
||||
const getPageNum = () => (pageCounter++).toString().padStart(2, "0");
|
||||
|
||||
return (
|
||||
<PDFDocument title={`Angebot - ${state.companyName || "Projekt"}`}>
|
||||
<PDFPage size="A4" style={pdfStyles.titlePage}>
|
||||
<FrontPageModule state={state} headerIcon={headerIcon} date={date} />
|
||||
</PDFPage>
|
||||
|
||||
<SimpleLayout {...commonProps} pageNumber={getPageNum()}>
|
||||
<BriefingModule state={state} />
|
||||
</SimpleLayout>
|
||||
|
||||
{state.sitemap && state.sitemap.length > 0 && (
|
||||
<SimpleLayout {...commonProps} pageNumber={getPageNum()}>
|
||||
<SitemapModule state={state} />
|
||||
</SimpleLayout>
|
||||
)}
|
||||
|
||||
<SimpleLayout {...commonProps} pageNumber={getPageNum()}>
|
||||
<EstimationModule
|
||||
state={state}
|
||||
positions={positions}
|
||||
totalPrice={totalPrice}
|
||||
date={date}
|
||||
/>
|
||||
</SimpleLayout>
|
||||
|
||||
<SimpleLayout {...commonProps} pageNumber={getPageNum()}>
|
||||
<TransparenzModule pricing={pricing} />
|
||||
</SimpleLayout>
|
||||
|
||||
<SimpleLayout {...commonProps} pageNumber={getPageNum()}>
|
||||
<ClosingModule />
|
||||
</SimpleLayout>
|
||||
</PDFDocument>
|
||||
);
|
||||
};
|
||||
401
packages/acquisition/src/components/pdf/SharedUI.tsx
Normal file
401
packages/acquisition/src/components/pdf/SharedUI.tsx
Normal file
@@ -0,0 +1,401 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import {
|
||||
View as PDFView,
|
||||
Text as PDFText,
|
||||
StyleSheet,
|
||||
Image as PDFImage,
|
||||
} from "@react-pdf/renderer";
|
||||
|
||||
// INDUSTRIAL DESIGN SYSTEM TOKENS
|
||||
export const COLORS = {
|
||||
CHARCOAL: "#0f172a", // Slate 900
|
||||
TEXT_MAIN: "#334155", // Slate 700
|
||||
TEXT_DIM: "#64748b", // Slate 500
|
||||
TEXT_LIGHT: "#94a3b8", // Slate 400
|
||||
DIVIDER: "#cbd5e1", // Slate 300
|
||||
GRID: "#f1f5f9", // Slate 100
|
||||
BLUEPRINT: "#e2e8f0", // Slate 200
|
||||
WHITE: "#ffffff",
|
||||
};
|
||||
|
||||
export const FONT_SIZES = {
|
||||
HERO: 24, // Main Page Titles
|
||||
HEADING: 14, // Section Headers
|
||||
BODY: 11, // Standard Content
|
||||
LABEL: 10, // Bold Labels / Keys
|
||||
SMALL: 9, // Descriptions / Footnotes
|
||||
TINY: 8, // Metadata / Unit prices
|
||||
};
|
||||
|
||||
export const pdfStyles = StyleSheet.create({
|
||||
page: {
|
||||
paddingTop: 45, // DIN 5008
|
||||
paddingLeft: 70, // ~25mm
|
||||
paddingRight: 57, // ~20mm
|
||||
paddingBottom: 80, // Safe buffer for absolute footer
|
||||
backgroundColor: COLORS.WHITE,
|
||||
fontFamily: "Helvetica",
|
||||
fontSize: FONT_SIZES.BODY,
|
||||
color: COLORS.CHARCOAL,
|
||||
},
|
||||
titlePage: {
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
backgroundColor: COLORS.WHITE,
|
||||
fontFamily: "Helvetica",
|
||||
color: COLORS.CHARCOAL,
|
||||
padding: 0,
|
||||
},
|
||||
header: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "flex-start",
|
||||
marginBottom: 20,
|
||||
minHeight: 120,
|
||||
},
|
||||
addressBlock: {
|
||||
width: "55%",
|
||||
marginTop: 45,
|
||||
},
|
||||
senderLine: {
|
||||
fontSize: FONT_SIZES.TINY,
|
||||
textDecoration: "underline",
|
||||
color: COLORS.TEXT_DIM,
|
||||
marginBottom: 8,
|
||||
},
|
||||
recipientAddress: {
|
||||
fontSize: FONT_SIZES.BODY,
|
||||
lineHeight: 1.4,
|
||||
},
|
||||
brandLogoContainer: {
|
||||
width: "40%",
|
||||
alignItems: "flex-end",
|
||||
},
|
||||
brandIconContainer: {
|
||||
width: 40,
|
||||
height: 40,
|
||||
backgroundColor: "#0f172a",
|
||||
borderRadius: 8,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
marginBottom: 12,
|
||||
},
|
||||
brandIconText: {
|
||||
color: COLORS.WHITE,
|
||||
fontSize: 20,
|
||||
fontWeight: "bold",
|
||||
},
|
||||
titleInfo: {
|
||||
marginBottom: 24,
|
||||
},
|
||||
mainTitle: {
|
||||
fontSize: FONT_SIZES.HEADING,
|
||||
fontWeight: "bold",
|
||||
marginBottom: 4,
|
||||
color: COLORS.CHARCOAL,
|
||||
letterSpacing: 0.5,
|
||||
},
|
||||
subTitle: {
|
||||
fontSize: FONT_SIZES.BODY,
|
||||
color: COLORS.TEXT_DIM,
|
||||
marginTop: 2,
|
||||
lineHeight: 1.4,
|
||||
},
|
||||
section: {
|
||||
marginBottom: 32,
|
||||
},
|
||||
sectionTitle: {
|
||||
fontSize: FONT_SIZES.LABEL,
|
||||
fontWeight: "bold",
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: 1,
|
||||
color: COLORS.TEXT_LIGHT,
|
||||
marginBottom: 8,
|
||||
},
|
||||
footer: {
|
||||
position: "absolute",
|
||||
bottom: 32,
|
||||
left: 70,
|
||||
right: 57,
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: COLORS.GRID,
|
||||
paddingTop: 16,
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "flex-start",
|
||||
},
|
||||
footerColumn: {
|
||||
flex: 1,
|
||||
alignItems: "flex-start",
|
||||
},
|
||||
footerLogo: {
|
||||
height: 20,
|
||||
width: "auto",
|
||||
objectFit: "contain",
|
||||
marginBottom: 8,
|
||||
},
|
||||
footerText: {
|
||||
fontSize: FONT_SIZES.TINY,
|
||||
color: COLORS.TEXT_LIGHT,
|
||||
lineHeight: 1.4,
|
||||
},
|
||||
asymmetryContainer: {
|
||||
flexDirection: "row",
|
||||
gap: 32,
|
||||
},
|
||||
asymmetryLeft: {
|
||||
width: "32%",
|
||||
},
|
||||
asymmetryRight: {
|
||||
width: "63%",
|
||||
},
|
||||
specRow: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
paddingVertical: 6,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: COLORS.GRID,
|
||||
},
|
||||
specLabel: {
|
||||
fontSize: FONT_SIZES.TINY,
|
||||
fontWeight: "bold",
|
||||
color: COLORS.TEXT_LIGHT,
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: 0.5,
|
||||
},
|
||||
specValue: {
|
||||
fontSize: FONT_SIZES.SMALL,
|
||||
color: COLORS.CHARCOAL,
|
||||
fontWeight: "bold",
|
||||
},
|
||||
blueprintBox: {
|
||||
borderWidth: 1,
|
||||
borderColor: COLORS.GRID,
|
||||
padding: 16,
|
||||
backgroundColor: "#fafafa",
|
||||
},
|
||||
footerLabel: {
|
||||
fontWeight: "bold",
|
||||
color: COLORS.TEXT_DIM,
|
||||
},
|
||||
pageNumber: {
|
||||
fontSize: FONT_SIZES.TINY,
|
||||
color: COLORS.DIVIDER,
|
||||
fontWeight: "bold",
|
||||
marginTop: 8,
|
||||
textAlign: "right",
|
||||
},
|
||||
foldingMark: {
|
||||
position: "absolute",
|
||||
left: 20,
|
||||
width: 10,
|
||||
borderTopWidth: 0.5,
|
||||
borderTopColor: COLORS.DIVIDER,
|
||||
},
|
||||
divider: {
|
||||
width: "100%",
|
||||
height: 1,
|
||||
backgroundColor: COLORS.DIVIDER,
|
||||
marginVertical: 12,
|
||||
},
|
||||
industrialListItem: {
|
||||
flexDirection: "row",
|
||||
alignItems: "flex-start",
|
||||
marginBottom: 6,
|
||||
},
|
||||
industrialBulletBox: {
|
||||
width: 6,
|
||||
height: 6,
|
||||
backgroundColor: COLORS.DIVIDER,
|
||||
marginRight: 8,
|
||||
marginTop: 5,
|
||||
},
|
||||
industrialTitle: {
|
||||
fontSize: FONT_SIZES.HERO,
|
||||
fontWeight: "bold",
|
||||
color: COLORS.CHARCOAL,
|
||||
marginBottom: 6,
|
||||
letterSpacing: 0,
|
||||
},
|
||||
});
|
||||
|
||||
export const IndustrialListItem = ({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) => (
|
||||
<PDFView style={pdfStyles.industrialListItem}>
|
||||
<PDFView style={pdfStyles.industrialBulletBox} />
|
||||
{children}
|
||||
</PDFView>
|
||||
);
|
||||
|
||||
export const Divider = ({ style = {} }: { style?: any }) => (
|
||||
<PDFView style={[pdfStyles.divider, style]} />
|
||||
);
|
||||
|
||||
export const Footer = ({
|
||||
logo,
|
||||
companyData,
|
||||
showDetails = true,
|
||||
showPageNumber = true,
|
||||
}: {
|
||||
logo?: string;
|
||||
companyData: any;
|
||||
showDetails?: boolean;
|
||||
showPageNumber?: boolean;
|
||||
}) => (
|
||||
<PDFView style={pdfStyles.footer}>
|
||||
<PDFView style={pdfStyles.footerColumn}>
|
||||
{logo ? (
|
||||
<PDFImage src={logo} style={pdfStyles.footerLogo} />
|
||||
) : (
|
||||
<PDFText style={{ fontSize: 12, fontWeight: "bold", marginBottom: 8 }}>
|
||||
marc mintel
|
||||
</PDFText>
|
||||
)}
|
||||
</PDFView>
|
||||
{showDetails && (
|
||||
<>
|
||||
<PDFView style={pdfStyles.footerColumn}>
|
||||
<PDFText style={pdfStyles.footerText}>
|
||||
<PDFText style={pdfStyles.footerLabel}>{companyData.name}</PDFText>
|
||||
{"\n"}
|
||||
{companyData.address1}
|
||||
{"\n"}
|
||||
{companyData.address2}
|
||||
{"\n"}UST: {companyData.ustId}
|
||||
</PDFText>
|
||||
</PDFView>
|
||||
<PDFView style={[pdfStyles.footerColumn, { alignItems: "flex-end" }]}>
|
||||
{showPageNumber && (
|
||||
<PDFText
|
||||
style={pdfStyles.pageNumber}
|
||||
render={({ pageNumber, totalPages }) =>
|
||||
`${pageNumber} / ${totalPages}`
|
||||
}
|
||||
fixed
|
||||
/>
|
||||
)}
|
||||
</PDFView>
|
||||
</>
|
||||
)}
|
||||
</PDFView>
|
||||
);
|
||||
|
||||
export const Header = ({
|
||||
sender,
|
||||
recipient,
|
||||
icon,
|
||||
showAddress = true,
|
||||
}: {
|
||||
sender?: string;
|
||||
recipient?: {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
email?: string;
|
||||
address?: string;
|
||||
phone?: string;
|
||||
taxId?: string;
|
||||
};
|
||||
icon?: string;
|
||||
showAddress?: boolean;
|
||||
}) => (
|
||||
<PDFView
|
||||
style={[
|
||||
pdfStyles.header,
|
||||
showAddress ? {} : { minHeight: 40, marginBottom: 0 },
|
||||
]}
|
||||
>
|
||||
<PDFView style={pdfStyles.addressBlock}>
|
||||
{showAddress && sender && (
|
||||
<>
|
||||
<PDFText style={pdfStyles.senderLine}>{sender}</PDFText>
|
||||
{recipient && (
|
||||
<PDFView style={pdfStyles.recipientAddress}>
|
||||
<PDFText style={{ fontWeight: "bold" }}>
|
||||
{recipient.title}
|
||||
</PDFText>
|
||||
{recipient.subtitle && <PDFText>{recipient.subtitle}</PDFText>}
|
||||
{recipient.address && <PDFText>{recipient.address}</PDFText>}
|
||||
{recipient.phone && <PDFText>{recipient.phone}</PDFText>}
|
||||
{recipient.email && <PDFText>{recipient.email}</PDFText>}
|
||||
{recipient.taxId && <PDFText>USt-ID: {recipient.taxId}</PDFText>}
|
||||
</PDFView>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</PDFView>
|
||||
<PDFView style={pdfStyles.brandLogoContainer}>
|
||||
<PDFView style={pdfStyles.brandIconContainer}>
|
||||
{icon ? (
|
||||
<PDFImage src={icon} style={{ width: 24, height: 24 }} />
|
||||
) : (
|
||||
<PDFText style={pdfStyles.brandIconText}>M</PDFText>
|
||||
)}
|
||||
</PDFView>
|
||||
</PDFView>
|
||||
</PDFView>
|
||||
);
|
||||
|
||||
export const DocumentTitle = ({
|
||||
title,
|
||||
subLines,
|
||||
isHero = false,
|
||||
}: {
|
||||
title: string;
|
||||
subLines?: string[];
|
||||
isHero?: boolean;
|
||||
}) => (
|
||||
<PDFView style={pdfStyles.titleInfo}>
|
||||
<PDFText
|
||||
style={[
|
||||
pdfStyles.mainTitle,
|
||||
{ fontSize: isHero ? FONT_SIZES.HERO : FONT_SIZES.HEADING },
|
||||
]}
|
||||
>
|
||||
{title}
|
||||
</PDFText>
|
||||
{subLines?.map((line, i) => (
|
||||
<PDFText
|
||||
key={i}
|
||||
style={[
|
||||
pdfStyles.subTitle,
|
||||
i === 1 ? { fontWeight: "bold", color: COLORS.CHARCOAL } : {},
|
||||
]}
|
||||
>
|
||||
{line}
|
||||
</PDFText>
|
||||
))}
|
||||
</PDFView>
|
||||
);
|
||||
|
||||
export const TechnicalSpec = ({
|
||||
label,
|
||||
value,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
}) => (
|
||||
<PDFView style={pdfStyles.specRow}>
|
||||
<PDFText style={pdfStyles.specLabel}>{label}</PDFText>
|
||||
<PDFText style={pdfStyles.specValue}>{value}</PDFText>
|
||||
</PDFView>
|
||||
);
|
||||
|
||||
export const AsymmetryView = ({
|
||||
left,
|
||||
right,
|
||||
style = {},
|
||||
}: {
|
||||
left: React.ReactNode;
|
||||
right: React.ReactNode;
|
||||
style?: any;
|
||||
}) => (
|
||||
<PDFView style={[pdfStyles.asymmetryContainer, style]}>
|
||||
<PDFView style={pdfStyles.asymmetryLeft}>{left}</PDFView>
|
||||
<PDFView style={pdfStyles.asymmetryRight}>{right}</PDFView>
|
||||
</PDFView>
|
||||
);
|
||||
64
packages/acquisition/src/components/pdf/SimpleLayout.tsx
Normal file
64
packages/acquisition/src/components/pdf/SimpleLayout.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { Page as PDFPage, View as PDFView, Text as PDFText, StyleSheet } from '@react-pdf/renderer';
|
||||
import { Header, Footer, pdfStyles } from './SharedUI.js';
|
||||
|
||||
const simpleStyles = StyleSheet.create({
|
||||
industrialPage: {
|
||||
padding: 30,
|
||||
paddingTop: 20,
|
||||
backgroundColor: '#ffffff',
|
||||
},
|
||||
industrialNumber: {
|
||||
fontSize: 60,
|
||||
fontWeight: 'bold',
|
||||
color: '#f1f5f9',
|
||||
position: 'absolute',
|
||||
top: -10,
|
||||
right: 0,
|
||||
zIndex: -1,
|
||||
},
|
||||
industrialSection: {
|
||||
marginTop: 16,
|
||||
paddingTop: 12,
|
||||
flexDirection: 'row',
|
||||
position: 'relative',
|
||||
},
|
||||
});
|
||||
|
||||
interface SimpleLayoutProps {
|
||||
children: React.ReactNode;
|
||||
pageNumber?: string;
|
||||
icon?: string;
|
||||
footerLogo?: string;
|
||||
companyData: any;
|
||||
showPageNumber?: boolean;
|
||||
}
|
||||
|
||||
export const SimpleLayout = ({
|
||||
children,
|
||||
pageNumber,
|
||||
icon,
|
||||
footerLogo,
|
||||
companyData,
|
||||
showPageNumber = true
|
||||
}: SimpleLayoutProps) => {
|
||||
return (
|
||||
<PDFPage size="A4" style={[pdfStyles.page, simpleStyles.industrialPage]}>
|
||||
<Header icon={icon} showAddress={false} />
|
||||
{pageNumber && <PDFText style={simpleStyles.industrialNumber}>{pageNumber}</PDFText>}
|
||||
<PDFView style={simpleStyles.industrialSection}>
|
||||
<PDFView style={{ width: '100%' }}>
|
||||
{children}
|
||||
</PDFView>
|
||||
</PDFView>
|
||||
<Footer
|
||||
logo={footerLogo}
|
||||
companyData={companyData}
|
||||
showDetails={false}
|
||||
showPageNumber={showPageNumber}
|
||||
/>
|
||||
</PDFPage>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,69 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import {
|
||||
View as PDFView,
|
||||
Text as PDFText,
|
||||
StyleSheet,
|
||||
} from "@react-pdf/renderer";
|
||||
import { DocumentTitle, COLORS, FONT_SIZES } from "../SharedUI.js";
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
section: { marginBottom: 24 },
|
||||
sectionTitle: {
|
||||
fontSize: FONT_SIZES.LABEL,
|
||||
fontWeight: "bold",
|
||||
marginBottom: 8,
|
||||
color: COLORS.CHARCOAL,
|
||||
},
|
||||
visionText: {
|
||||
fontSize: FONT_SIZES.BODY,
|
||||
color: COLORS.TEXT_MAIN,
|
||||
lineHeight: 1.4,
|
||||
textAlign: "justify",
|
||||
},
|
||||
});
|
||||
|
||||
export const BriefingModule = ({ state }: any) => (
|
||||
<>
|
||||
<DocumentTitle title="Projektdetails" isHero={true} />
|
||||
{state.briefingSummary && (
|
||||
<PDFView style={styles.section}>
|
||||
<PDFText style={styles.sectionTitle}>Briefing Analyse</PDFText>
|
||||
<PDFText
|
||||
style={{
|
||||
fontSize: FONT_SIZES.BODY,
|
||||
color: COLORS.TEXT_MAIN,
|
||||
lineHeight: 1.6,
|
||||
textAlign: "justify",
|
||||
}}
|
||||
>
|
||||
{state.briefingSummary}
|
||||
</PDFText>
|
||||
</PDFView>
|
||||
)}
|
||||
{state.designVision && (
|
||||
<PDFView
|
||||
style={[
|
||||
styles.section,
|
||||
{
|
||||
padding: 12,
|
||||
borderLeftWidth: 2,
|
||||
borderLeftColor: COLORS.DIVIDER,
|
||||
backgroundColor: COLORS.GRID,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<PDFText
|
||||
style={[
|
||||
styles.sectionTitle,
|
||||
{ color: COLORS.CHARCOAL, marginBottom: 4 },
|
||||
]}
|
||||
>
|
||||
Strategische Vision
|
||||
</PDFText>
|
||||
<PDFText style={styles.visionText}>{state.designVision}</PDFText>
|
||||
</PDFView>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
@@ -0,0 +1,97 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import {
|
||||
View as PDFView,
|
||||
Text as PDFText,
|
||||
StyleSheet,
|
||||
} from "@react-pdf/renderer";
|
||||
import {
|
||||
DocumentTitle,
|
||||
COLORS,
|
||||
FONT_SIZES,
|
||||
} from "../SharedUI.js";
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
section: { marginBottom: 24 },
|
||||
moduleLabel: {
|
||||
fontSize: FONT_SIZES.LABEL,
|
||||
fontWeight: "bold",
|
||||
color: COLORS.CHARCOAL,
|
||||
letterSpacing: 0.5,
|
||||
marginBottom: 4,
|
||||
},
|
||||
moduleDesc: {
|
||||
fontSize: FONT_SIZES.SMALL,
|
||||
color: COLORS.TEXT_DIM,
|
||||
lineHeight: 1.5,
|
||||
},
|
||||
ledgerRow: {
|
||||
paddingVertical: 14,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: COLORS.GRID,
|
||||
flexDirection: "row",
|
||||
alignItems: "flex-start",
|
||||
},
|
||||
ledgerPrice: {
|
||||
fontSize: FONT_SIZES.BODY,
|
||||
fontWeight: "bold",
|
||||
color: COLORS.CHARCOAL,
|
||||
},
|
||||
ledgerUnit: {
|
||||
fontSize: FONT_SIZES.TINY,
|
||||
color: COLORS.TEXT_LIGHT,
|
||||
marginLeft: 2,
|
||||
},
|
||||
});
|
||||
|
||||
export const ClosingModule = () => (
|
||||
<>
|
||||
<DocumentTitle title="Abschluss & Kontakt" isHero={true} />
|
||||
<PDFView style={styles.section}>
|
||||
<PDFText
|
||||
style={[
|
||||
styles.moduleLabel,
|
||||
{ fontSize: FONT_SIZES.HEADING, marginBottom: 12 },
|
||||
]}
|
||||
>
|
||||
Vielen Dank für Ihr Interesse!
|
||||
</PDFText>
|
||||
<PDFText style={styles.moduleDesc}>
|
||||
Die aufgeführten Positionen stellen eine detaillierte Schätzung auf
|
||||
Basis unseres aktuellen Stands dar. Sollten sich Anforderungen ändern
|
||||
oder Sie Fragen zu einzelnen Details haben, lassen Sie uns die
|
||||
Positionen gerne gemeinsam besprechen.
|
||||
</PDFText>
|
||||
<PDFView
|
||||
style={{
|
||||
marginTop: 24,
|
||||
padding: 16,
|
||||
backgroundColor: COLORS.GRID,
|
||||
borderLeftWidth: 2,
|
||||
borderLeftColor: COLORS.DIVIDER,
|
||||
}}
|
||||
>
|
||||
<PDFText style={[styles.moduleLabel, { marginBottom: 8 }]}>
|
||||
Haben Sie Fragen?
|
||||
</PDFText>
|
||||
<PDFText style={styles.moduleDesc}>
|
||||
Ich erkläre Ihnen gerne noch einmal persönlich, was die technische
|
||||
Umsetzung für Ihr Projekt bedeutet und wie wir die nächsten Schritte
|
||||
gemeinsam gehen können.
|
||||
</PDFText>
|
||||
<PDFView style={{ marginTop: 16 }}>
|
||||
<PDFText style={styles.moduleLabel}>Kontakt:</PDFText>
|
||||
<PDFText
|
||||
style={[
|
||||
styles.moduleDesc,
|
||||
{ color: COLORS.CHARCOAL, fontWeight: "bold" },
|
||||
]}
|
||||
>
|
||||
Marc Mintel – marc@mintel.me
|
||||
</PDFText>
|
||||
</PDFView>
|
||||
</PDFView>
|
||||
</PDFView>
|
||||
</>
|
||||
);
|
||||
@@ -0,0 +1,159 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import {
|
||||
View as PDFView,
|
||||
Text as PDFText,
|
||||
StyleSheet,
|
||||
} from "@react-pdf/renderer";
|
||||
import { DocumentTitle, COLORS, FONT_SIZES } from "../SharedUI.js";
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
table: { marginTop: 12 },
|
||||
tableHeader: {
|
||||
flexDirection: "row",
|
||||
paddingBottom: 8,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: COLORS.CHARCOAL,
|
||||
marginBottom: 12,
|
||||
},
|
||||
tableRow: {
|
||||
flexDirection: "row",
|
||||
paddingVertical: 10,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: COLORS.GRID,
|
||||
alignItems: "flex-start",
|
||||
},
|
||||
colPos: { width: "8%" },
|
||||
colDesc: { width: "62%" },
|
||||
colQty: { width: "10%", textAlign: "center" },
|
||||
colPrice: { width: "20%", textAlign: "right" },
|
||||
headerText: {
|
||||
fontSize: FONT_SIZES.TINY,
|
||||
fontWeight: "bold",
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: 1,
|
||||
color: COLORS.TEXT_DIM,
|
||||
},
|
||||
posText: { fontSize: FONT_SIZES.TINY, color: COLORS.TEXT_LIGHT },
|
||||
itemTitle: {
|
||||
fontSize: FONT_SIZES.LABEL,
|
||||
fontWeight: "bold",
|
||||
color: COLORS.CHARCOAL,
|
||||
marginBottom: 4,
|
||||
},
|
||||
itemDesc: {
|
||||
fontSize: FONT_SIZES.SMALL,
|
||||
color: COLORS.TEXT_DIM,
|
||||
lineHeight: 1.4,
|
||||
},
|
||||
priceText: {
|
||||
fontSize: FONT_SIZES.BODY,
|
||||
fontWeight: "bold",
|
||||
color: COLORS.CHARCOAL,
|
||||
},
|
||||
summaryContainer: {
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: COLORS.CHARCOAL,
|
||||
paddingTop: 8,
|
||||
},
|
||||
summaryRow: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "flex-end",
|
||||
paddingVertical: 4,
|
||||
alignItems: "baseline",
|
||||
},
|
||||
summaryLabel: {
|
||||
fontSize: FONT_SIZES.TINY,
|
||||
color: COLORS.TEXT_DIM,
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: 1,
|
||||
fontWeight: "bold",
|
||||
marginRight: 12,
|
||||
},
|
||||
summaryValue: {
|
||||
fontSize: FONT_SIZES.BODY,
|
||||
fontWeight: "bold",
|
||||
width: 100,
|
||||
textAlign: "right",
|
||||
color: COLORS.CHARCOAL,
|
||||
},
|
||||
totalRow: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "flex-end",
|
||||
paddingTop: 12,
|
||||
marginTop: 8,
|
||||
borderTopWidth: 2,
|
||||
borderTopColor: COLORS.CHARCOAL,
|
||||
alignItems: "baseline",
|
||||
},
|
||||
});
|
||||
|
||||
export const EstimationModule = ({
|
||||
state,
|
||||
positions,
|
||||
totalPrice,
|
||||
date,
|
||||
}: any) => (
|
||||
<>
|
||||
<DocumentTitle
|
||||
title="Kostenschätzung"
|
||||
subLines={[
|
||||
`Datum: ${date}`,
|
||||
`Projekt: ${state.projectType === "website" ? "Website" : "Web App"}`,
|
||||
]}
|
||||
isHero={true}
|
||||
/>
|
||||
<PDFView style={styles.table}>
|
||||
<PDFView style={styles.tableHeader}>
|
||||
<PDFText style={[styles.headerText, styles.colPos]}>Pos</PDFText>
|
||||
<PDFText style={[styles.headerText, styles.colDesc]}>
|
||||
Beschreibung
|
||||
</PDFText>
|
||||
<PDFText style={[styles.headerText, styles.colQty]}>Menge</PDFText>
|
||||
<PDFText style={[styles.headerText, styles.colPrice]}>Betrag</PDFText>
|
||||
</PDFView>
|
||||
{positions.map((item: any, i: number) => (
|
||||
<PDFView key={i} style={styles.tableRow} wrap={false}>
|
||||
<PDFText style={[styles.posText, styles.colPos]}>
|
||||
{item.pos.toString().padStart(2, "0")}
|
||||
</PDFText>
|
||||
<PDFView style={styles.colDesc}>
|
||||
<PDFText style={styles.itemTitle}>{item.title}</PDFText>
|
||||
<PDFText style={styles.itemDesc}>
|
||||
{state.positionDescriptions?.[item.title] || item.desc}
|
||||
</PDFText>
|
||||
</PDFView>
|
||||
<PDFText style={[styles.posText, styles.colQty]}>{item.qty}</PDFText>
|
||||
<PDFText style={[styles.priceText, styles.colPrice]}>
|
||||
{item.price > 0
|
||||
? `${item.price.toLocaleString("de-DE")} €`
|
||||
: "n. A."}
|
||||
</PDFText>
|
||||
</PDFView>
|
||||
))}
|
||||
</PDFView>
|
||||
<PDFView style={styles.summaryContainer} wrap={false}>
|
||||
<PDFView style={styles.summaryRow}>
|
||||
<PDFText style={styles.summaryLabel}>Nettobetrag</PDFText>
|
||||
<PDFText style={styles.summaryValue}>
|
||||
{totalPrice.toLocaleString("de-DE")} €
|
||||
</PDFText>
|
||||
</PDFView>
|
||||
<PDFView style={styles.summaryRow}>
|
||||
<PDFText style={styles.summaryLabel}>Umsatzsteuer (19%)</PDFText>
|
||||
<PDFText style={styles.summaryValue}>
|
||||
{(totalPrice * 0.19).toLocaleString("de-DE")} €
|
||||
</PDFText>
|
||||
</PDFView>
|
||||
<PDFView style={styles.totalRow}>
|
||||
<PDFText style={styles.summaryLabel}>Gesamtbetrag (Brutto)</PDFText>
|
||||
<PDFText
|
||||
style={[styles.summaryValue, { fontSize: FONT_SIZES.HEADING }]}
|
||||
>
|
||||
{(totalPrice * 1.19).toLocaleString("de-DE")} €
|
||||
</PDFText>
|
||||
</PDFView>
|
||||
</PDFView>
|
||||
</>
|
||||
);
|
||||
@@ -0,0 +1,70 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import {
|
||||
View as PDFView,
|
||||
Text as PDFText,
|
||||
Image as PDFImage,
|
||||
StyleSheet,
|
||||
} from "@react-pdf/renderer";
|
||||
import { COLORS, FONT_SIZES } from "../SharedUI.js";
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
titlePage: {
|
||||
flex: 1,
|
||||
padding: 60,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
backgroundColor: COLORS.WHITE,
|
||||
},
|
||||
titleBrandIcon: {
|
||||
width: 80,
|
||||
height: 80,
|
||||
backgroundColor: COLORS.CHARCOAL,
|
||||
borderRadius: 16,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
marginBottom: 40,
|
||||
},
|
||||
brandIconText: {
|
||||
fontSize: 40,
|
||||
color: COLORS.WHITE,
|
||||
fontWeight: "bold",
|
||||
},
|
||||
titleProjectName: {
|
||||
fontSize: FONT_SIZES.HERO,
|
||||
fontWeight: "bold",
|
||||
color: COLORS.CHARCOAL,
|
||||
marginBottom: 16,
|
||||
textAlign: "center",
|
||||
maxWidth: "85%",
|
||||
lineHeight: 1.2,
|
||||
},
|
||||
titleDate: {
|
||||
fontSize: FONT_SIZES.BODY,
|
||||
color: COLORS.TEXT_LIGHT,
|
||||
marginTop: 40,
|
||||
},
|
||||
});
|
||||
|
||||
export const FrontPageModule = ({ state, headerIcon, date }: any) => {
|
||||
const fullTitle = `Digitale Webpräsenz für\n${state.companyName || "Ihr Projekt"}`;
|
||||
const fontSize = fullTitle.length > 60 ? 14 : fullTitle.length > 40 ? 18 : 22;
|
||||
|
||||
return (
|
||||
<PDFView style={styles.titlePage}>
|
||||
<PDFView style={styles.titleBrandIcon}>
|
||||
{headerIcon ? (
|
||||
<PDFImage src={headerIcon} style={{ width: 40, height: 40 }} />
|
||||
) : (
|
||||
<PDFText style={styles.brandIconText}>M</PDFText>
|
||||
)}
|
||||
</PDFView>
|
||||
<PDFText style={[styles.titleProjectName, { fontSize }]}>
|
||||
{fullTitle}
|
||||
</PDFText>
|
||||
<PDFView style={{ marginBottom: 40 }} />
|
||||
<PDFText style={styles.titleDate}>{date} | Marc Mintel</PDFText>
|
||||
</PDFView>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,56 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { View as PDFView, Text as PDFText, StyleSheet } from "@react-pdf/renderer";
|
||||
import { DocumentTitle, COLORS, FONT_SIZES, IndustrialListItem } from "../SharedUI.js";
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
section: { marginBottom: 24 },
|
||||
categoryBox: {
|
||||
marginBottom: 20,
|
||||
padding: 12,
|
||||
backgroundColor: COLORS.GRID,
|
||||
borderLeftWidth: 2,
|
||||
borderLeftColor: COLORS.DIVIDER,
|
||||
},
|
||||
categoryTitle: {
|
||||
fontSize: FONT_SIZES.TINY,
|
||||
fontWeight: "bold",
|
||||
color: COLORS.TEXT_LIGHT,
|
||||
textTransform: "uppercase",
|
||||
marginBottom: 10,
|
||||
letterSpacing: 1,
|
||||
},
|
||||
pageTitle: {
|
||||
fontSize: FONT_SIZES.LABEL,
|
||||
fontWeight: "bold",
|
||||
color: COLORS.CHARCOAL,
|
||||
marginBottom: 2,
|
||||
},
|
||||
pageDesc: {
|
||||
fontSize: FONT_SIZES.TINY,
|
||||
color: COLORS.TEXT_DIM,
|
||||
lineHeight: 1.4,
|
||||
},
|
||||
});
|
||||
|
||||
export const SitemapModule = ({ state }: any) => (
|
||||
<>
|
||||
<DocumentTitle title="Informations-Architektur" isHero={true} />
|
||||
<PDFView style={styles.section}>
|
||||
{state.sitemap?.map((cat: any, i: number) => (
|
||||
<PDFView key={i} style={styles.categoryBox}>
|
||||
<PDFText style={styles.categoryTitle}>{cat.category}</PDFText>
|
||||
{cat.pages?.map((p: any, j: number) => (
|
||||
<IndustrialListItem key={j}>
|
||||
<PDFView style={{ marginBottom: 8 }}>
|
||||
<PDFText style={styles.pageTitle}>{p.title}</PDFText>
|
||||
<PDFText style={styles.pageDesc}>{p.desc}</PDFText>
|
||||
</PDFView>
|
||||
</IndustrialListItem>
|
||||
))}
|
||||
</PDFView>
|
||||
))}
|
||||
</PDFView>
|
||||
</>
|
||||
);
|
||||
@@ -0,0 +1,125 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import {
|
||||
View as PDFView,
|
||||
Text as PDFText,
|
||||
StyleSheet,
|
||||
} from "@react-pdf/renderer";
|
||||
import { DocumentTitle, COLORS, FONT_SIZES } from "../SharedUI.js";
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
section: { marginBottom: 24 },
|
||||
ledgerRow: {
|
||||
paddingVertical: 14,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: COLORS.GRID,
|
||||
flexDirection: "row",
|
||||
alignItems: "flex-start",
|
||||
},
|
||||
moduleLabel: {
|
||||
fontSize: FONT_SIZES.LABEL,
|
||||
fontWeight: "bold",
|
||||
color: COLORS.CHARCOAL,
|
||||
letterSpacing: 0.5,
|
||||
marginBottom: 4,
|
||||
},
|
||||
moduleDesc: {
|
||||
fontSize: FONT_SIZES.SMALL,
|
||||
color: COLORS.TEXT_DIM,
|
||||
lineHeight: 1.5,
|
||||
},
|
||||
ledgerPrice: {
|
||||
fontSize: FONT_SIZES.BODY,
|
||||
fontWeight: "bold",
|
||||
color: COLORS.CHARCOAL,
|
||||
},
|
||||
ledgerUnit: {
|
||||
fontSize: FONT_SIZES.TINY,
|
||||
color: COLORS.TEXT_LIGHT,
|
||||
marginLeft: 2,
|
||||
},
|
||||
});
|
||||
|
||||
export const TransparenzModule = ({ pricing }: any) => {
|
||||
const sorglosPrice = (pricing.HOSTING_MONTHLY || 250) * 12;
|
||||
|
||||
return (
|
||||
<>
|
||||
<DocumentTitle title="Preis-Transparenz & Modell" isHero={true} />
|
||||
<PDFView style={styles.section}>
|
||||
<PDFView style={{ borderTopWidth: 1, borderTopColor: COLORS.CHARCOAL }}>
|
||||
{[
|
||||
{
|
||||
l: "Fundament",
|
||||
d: "Bereitstellung der techn. Infrastruktur & System-Umgebung.",
|
||||
p: pricing.BASE_WEBSITE,
|
||||
},
|
||||
{
|
||||
l: "Einzelseiten",
|
||||
d: "Individuelle Gestaltung, Layout & responsive Struktur.",
|
||||
p: pricing.PAGE,
|
||||
unit: "/ Stk",
|
||||
},
|
||||
{
|
||||
l: "Core Features",
|
||||
d: "Geschlossene Datensysteme mit eigener Datenstruktur.",
|
||||
p: pricing.FEATURE,
|
||||
unit: "/ Stk",
|
||||
},
|
||||
{
|
||||
l: "Logik & Funktionen",
|
||||
d: "Interaktive Funktions-Bausteine & Prozess-Logik.",
|
||||
p: pricing.FUNCTION,
|
||||
unit: "/ Stk",
|
||||
},
|
||||
{
|
||||
l: "Schnittstellen",
|
||||
d: "Synchronisation mit externen Zielsystemen.",
|
||||
p: pricing.API_INTEGRATION,
|
||||
unit: "/ Stk",
|
||||
},
|
||||
{
|
||||
l: "Sprachversionen",
|
||||
d: "Skalierung der System-Architektur auf Zweit-Sprachen.",
|
||||
p: "+20%",
|
||||
},
|
||||
{
|
||||
l: "Initial-Pflege",
|
||||
d: "Konvertierung & Aufbereitung von Bestandsdaten.",
|
||||
p: pricing.NEW_DATASET,
|
||||
unit: "/ Stk",
|
||||
},
|
||||
{
|
||||
l: "Sorglos Betrieb",
|
||||
d: "Hosting, Instandhaltung, Security & techn. Support.",
|
||||
p: sorglosPrice,
|
||||
unit: "/ Jahr",
|
||||
},
|
||||
].map((item: any, i: number) => (
|
||||
<PDFView key={i} style={styles.ledgerRow}>
|
||||
<PDFView style={{ width: "25%" }}>
|
||||
<PDFText style={styles.moduleLabel}>
|
||||
{item.l.toUpperCase()}
|
||||
</PDFText>
|
||||
</PDFView>
|
||||
<PDFView style={{ width: "50%" }}>
|
||||
<PDFText style={styles.moduleDesc}>{item.d}</PDFText>
|
||||
</PDFView>
|
||||
<PDFView style={{ width: "25%", alignItems: "flex-end" }}>
|
||||
<PDFText style={styles.ledgerPrice}>
|
||||
{typeof item.p === "number"
|
||||
? `${item.p.toLocaleString("de-DE")} €`
|
||||
: item.p}
|
||||
{item.unit && (
|
||||
<PDFText style={styles.ledgerUnit}> {item.unit}</PDFText>
|
||||
)}
|
||||
</PDFText>
|
||||
</PDFView>
|
||||
</PDFView>
|
||||
))}
|
||||
</PDFView>
|
||||
</PDFView>
|
||||
</>
|
||||
);
|
||||
};
|
||||
6
packages/acquisition/src/index.ts
Normal file
6
packages/acquisition/src/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export * from "./logic/pricing/types.js";
|
||||
export * from "./logic/pricing/constants.js";
|
||||
export * from "./logic/pricing/calculator.js";
|
||||
export * from "./services/AcquisitionService.js";
|
||||
export * from "./services/PdfEngine.js";
|
||||
export * from "./components/EstimationPDF.js";
|
||||
224
packages/acquisition/src/logic/pricing/calculator.ts
Normal file
224
packages/acquisition/src/logic/pricing/calculator.ts
Normal file
@@ -0,0 +1,224 @@
|
||||
import { FormState, Position, Totals } from "./types.js";
|
||||
import {
|
||||
FEATURE_LABELS,
|
||||
FUNCTION_LABELS,
|
||||
API_LABELS,
|
||||
PAGE_LABELS,
|
||||
} from "./constants.js";
|
||||
|
||||
export function calculateTotals(state: FormState, pricing: any): Totals {
|
||||
if (state.projectType !== "website") {
|
||||
return {
|
||||
totalPrice: 0,
|
||||
monthlyPrice: 0,
|
||||
totalPagesCount: 0,
|
||||
totalFeatures: 0,
|
||||
totalFunctions: 0,
|
||||
totalApis: 0,
|
||||
languagesCount: 0,
|
||||
};
|
||||
}
|
||||
|
||||
const sitemapPagesCount =
|
||||
state.sitemap?.reduce(
|
||||
(acc: number, cat: any) => acc + (cat.pages?.length || 0),
|
||||
0,
|
||||
) || 0;
|
||||
const totalPagesCount = Math.max(
|
||||
(state.selectedPages?.length || 0) +
|
||||
(state.otherPages?.length || 0) +
|
||||
(state.otherPagesCount || 0),
|
||||
sitemapPagesCount,
|
||||
);
|
||||
|
||||
const totalFeatures =
|
||||
(state.features?.length || 0) +
|
||||
(state.otherFeatures?.length || 0) +
|
||||
(state.otherFeaturesCount || 0);
|
||||
const totalFunctions =
|
||||
(state.functions?.length || 0) +
|
||||
(state.otherFunctions?.length || 0) +
|
||||
(state.otherFunctionsCount || 0);
|
||||
const totalApis =
|
||||
(state.apiSystems?.length || 0) +
|
||||
(state.otherTech?.length || 0) +
|
||||
(state.otherTechCount || 0);
|
||||
|
||||
let total = pricing.BASE_WEBSITE;
|
||||
total += totalPagesCount * pricing.PAGE;
|
||||
total += totalFeatures * pricing.FEATURE;
|
||||
total += totalFunctions * pricing.FUNCTION;
|
||||
total += totalApis * pricing.API_INTEGRATION;
|
||||
total += (state.newDatasets || 0) * pricing.NEW_DATASET;
|
||||
|
||||
if (state.cmsSetup) {
|
||||
total += Math.max(1, totalFeatures) * pricing.CMS_CONNECTION_PER_FEATURE;
|
||||
}
|
||||
|
||||
const languagesCount = state.languagesList?.length || 1;
|
||||
if (languagesCount > 1) {
|
||||
total *= 1 + (languagesCount - 1) * 0.2;
|
||||
}
|
||||
|
||||
const monthlyPrice =
|
||||
pricing.HOSTING_MONTHLY +
|
||||
(state.storageExpansion || 0) * pricing.STORAGE_EXPANSION_MONTHLY;
|
||||
|
||||
return {
|
||||
totalPrice: Math.round(total),
|
||||
monthlyPrice: Math.round(monthlyPrice),
|
||||
totalPagesCount,
|
||||
totalFeatures,
|
||||
totalFunctions,
|
||||
totalApis,
|
||||
languagesCount,
|
||||
};
|
||||
}
|
||||
|
||||
export function calculatePositions(state: FormState, pricing: any): Position[] {
|
||||
const positions: Position[] = [];
|
||||
let pos = 1;
|
||||
|
||||
if (state.projectType === "website") {
|
||||
positions.push({
|
||||
pos: pos++,
|
||||
title: "Das technische Fundament",
|
||||
desc: "Projekt-Setup, Infrastruktur, Hosting-Bereitstellung, Grundstruktur & Design-Vorlage, technisches SEO-Basics, Analytics.",
|
||||
qty: 1,
|
||||
price: pricing.BASE_WEBSITE,
|
||||
});
|
||||
|
||||
const sitemapPagesCount =
|
||||
state.sitemap?.reduce(
|
||||
(acc: number, cat: any) => acc + (cat.pages?.length || 0),
|
||||
0,
|
||||
) || 0;
|
||||
const totalPagesCount = Math.max(
|
||||
(state.selectedPages?.length || 0) +
|
||||
(state.otherPages?.length || 0) +
|
||||
(state.otherPagesCount || 0),
|
||||
sitemapPagesCount,
|
||||
);
|
||||
|
||||
const allPages = [
|
||||
...(state.selectedPages || []).map((p: string) => PAGE_LABELS[p] || p),
|
||||
...(state.otherPages || []),
|
||||
...(state.sitemap?.flatMap((cat: any) =>
|
||||
cat.pages?.map((p: any) => p.title),
|
||||
) || []),
|
||||
];
|
||||
|
||||
// Deduplicate labels
|
||||
const uniquePages = Array.from(new Set(allPages));
|
||||
|
||||
positions.push({
|
||||
pos: pos++,
|
||||
title: "Individuelle Seiten",
|
||||
desc: `Gestaltung und Umsetzung von ${totalPagesCount} individuellen Seiten-Layouts (${uniquePages.join(", ")}).`,
|
||||
qty: totalPagesCount,
|
||||
price: totalPagesCount * pricing.PAGE,
|
||||
});
|
||||
|
||||
if ((state.features?.length || 0) > 0 || (state.otherFeatures?.length || 0) > 0) {
|
||||
const allFeatures = [
|
||||
...(state.features || []).map((f: string) => FEATURE_LABELS[f] || f),
|
||||
...(state.otherFeatures || []),
|
||||
];
|
||||
positions.push({
|
||||
pos: pos++,
|
||||
title: "System-Module (Features)",
|
||||
desc: `Implementierung funktionaler Bereiche: ${allFeatures.join(", ")}. Inklusive Datenstruktur und Darstellung.`,
|
||||
qty: allFeatures.length,
|
||||
price: allFeatures.length * pricing.FEATURE,
|
||||
});
|
||||
}
|
||||
|
||||
if ((state.functions?.length || 0) > 0 || (state.otherFunctions?.length || 0) > 0) {
|
||||
const allFunctions = [
|
||||
...(state.functions || []).map((f: string) => FUNCTION_LABELS[f] || f),
|
||||
...(state.otherFunctions || []),
|
||||
];
|
||||
positions.push({
|
||||
pos: pos++,
|
||||
title: "Logik-Funktionen",
|
||||
desc: `Implementierung technischer Logik: ${allFunctions.join(", ")}.`,
|
||||
qty: allFunctions.length,
|
||||
price: allFunctions.length * pricing.FUNCTION,
|
||||
});
|
||||
}
|
||||
|
||||
if ((state.apiSystems?.length || 0) > 0 || (state.otherTech?.length || 0) > 0) {
|
||||
const allApis = [
|
||||
...(state.apiSystems || []).map((a: string) => API_LABELS[a] || a),
|
||||
...(state.otherTech || []),
|
||||
];
|
||||
positions.push({
|
||||
pos: pos++,
|
||||
title: "Schnittstellen (API)",
|
||||
desc: `Anbindung externer Systeme zur Datensynchronisation: ${allApis.join(", ")}.`,
|
||||
qty: allApis.length,
|
||||
price: allApis.length * pricing.API_INTEGRATION,
|
||||
});
|
||||
}
|
||||
|
||||
if (state.cmsSetup) {
|
||||
const totalFeatures =
|
||||
(state.features?.length || 0) +
|
||||
(state.otherFeatures?.length || 0) +
|
||||
(state.otherFeaturesCount || 0);
|
||||
const qty = Math.max(1, totalFeatures);
|
||||
positions.push({
|
||||
pos: pos++,
|
||||
title: "Inhalts-Verwaltung",
|
||||
desc: "Anbindung der System-Module an das Redaktions-System zur eigenständigen Pflege von Inhalten.",
|
||||
qty: qty,
|
||||
price: qty * pricing.CMS_CONNECTION_PER_FEATURE,
|
||||
});
|
||||
}
|
||||
|
||||
if (state.newDatasets > 0) {
|
||||
positions.push({
|
||||
pos: pos++,
|
||||
title: "Inhaltliche Initial-Pflege",
|
||||
desc: `Manuelle Übernahme und Aufbereitung von ${state.newDatasets} Datensätzen (Produkte, Artikel) in das Zielsystem.`,
|
||||
qty: state.newDatasets,
|
||||
price: state.newDatasets * pricing.NEW_DATASET,
|
||||
});
|
||||
}
|
||||
|
||||
const languagesCount = state.languagesList?.length || 1;
|
||||
if (languagesCount > 1) {
|
||||
const subtotal = positions.reduce((sum, p) => sum + p.price, 0);
|
||||
const factorPrice = subtotal * ((languagesCount - 1) * 0.2);
|
||||
|
||||
positions.push({
|
||||
pos: pos++,
|
||||
title: "Mehrsprachigkeit",
|
||||
desc: `Erweiterung des Systems auf ${languagesCount} Sprachen (Struktur & Logik).`,
|
||||
qty: languagesCount,
|
||||
price: Math.round(factorPrice),
|
||||
});
|
||||
}
|
||||
|
||||
const monthlyRate =
|
||||
pricing.HOSTING_MONTHLY +
|
||||
state.storageExpansion * pricing.STORAGE_EXPANSION_MONTHLY;
|
||||
positions.push({
|
||||
pos: pos++,
|
||||
title: "Sorglos Betrieb (1 Jahr)",
|
||||
desc: `Inklusive 1 Jahr Sicherung des technischen Betriebs, Hosting, Instandhaltung, Sicherheits-Updates und techn. Support gemäß AGB Punkt 7a.`,
|
||||
qty: 1,
|
||||
price: monthlyRate * 12,
|
||||
});
|
||||
} else {
|
||||
positions.push({
|
||||
pos: pos++,
|
||||
title: "Web App / Software Entwicklung",
|
||||
desc: "Individuelle Software-Entwicklung nach Aufwand. Abrechnung erfolgt auf Stundenbasis.",
|
||||
qty: 1,
|
||||
price: 0,
|
||||
});
|
||||
}
|
||||
|
||||
return positions;
|
||||
}
|
||||
332
packages/acquisition/src/logic/pricing/constants.ts
Normal file
332
packages/acquisition/src/logic/pricing/constants.ts
Normal file
@@ -0,0 +1,332 @@
|
||||
import { FormState } from "./types.js";
|
||||
|
||||
export const PRICING = {
|
||||
BASE_WEBSITE: 5440, // Updated to match AI prompt requirement in Pass 1
|
||||
PAGE: 600,
|
||||
FEATURE: 1500,
|
||||
FUNCTION: 800,
|
||||
NEW_DATASET: 450,
|
||||
HOSTING_MONTHLY: 250,
|
||||
STORAGE_EXPANSION_MONTHLY: 10,
|
||||
CMS_SETUP: 1500,
|
||||
CMS_CONNECTION_PER_FEATURE: 1500,
|
||||
API_INTEGRATION: 800,
|
||||
APP_HOURLY: 120,
|
||||
};
|
||||
|
||||
export const initialState: FormState = {
|
||||
projectType: "website",
|
||||
// Company
|
||||
companyName: "",
|
||||
employeeCount: "",
|
||||
// Existing Presence
|
||||
existingWebsite: "",
|
||||
socialMedia: [],
|
||||
socialMediaUrls: {},
|
||||
existingDomain: "",
|
||||
wishedDomain: "",
|
||||
// Project
|
||||
websiteTopic: "",
|
||||
selectedPages: ["Home"],
|
||||
otherPages: [],
|
||||
otherPagesCount: 0,
|
||||
features: [],
|
||||
otherFeatures: [],
|
||||
otherFeaturesCount: 0,
|
||||
functions: [],
|
||||
otherFunctions: [],
|
||||
otherFunctionsCount: 0,
|
||||
apiSystems: [],
|
||||
otherTech: [],
|
||||
otherTechCount: 0,
|
||||
assets: [],
|
||||
otherAssets: [],
|
||||
otherAssetsCount: 0,
|
||||
newDatasets: 0,
|
||||
cmsSetup: false,
|
||||
storageExpansion: 0,
|
||||
name: "",
|
||||
email: "",
|
||||
role: "",
|
||||
message: "",
|
||||
sitemapFile: null,
|
||||
contactFiles: [],
|
||||
// Design
|
||||
designVibe: "minimal",
|
||||
colorScheme: ["#ffffff", "#f8fafc", "#0f172a"],
|
||||
references: [],
|
||||
designWishes: "",
|
||||
// Maintenance
|
||||
expectedAdjustments: "low",
|
||||
languagesList: ["Deutsch"],
|
||||
personName: "",
|
||||
// Timeline
|
||||
deadline: "flexible",
|
||||
// Web App specific
|
||||
targetAudience: "internal",
|
||||
userRoles: [],
|
||||
dataSensitivity: "standard",
|
||||
platformType: "web-only",
|
||||
// Meta
|
||||
dontKnows: [],
|
||||
visualStaging: "standard",
|
||||
complexInteractions: "standard",
|
||||
// AI generated / Post-processed
|
||||
briefingSummary: "",
|
||||
designVision: "",
|
||||
positionDescriptions: {},
|
||||
taxId: "",
|
||||
sitemap: [],
|
||||
};
|
||||
|
||||
export const PAGE_SAMPLES = [
|
||||
{ id: "Home", label: "Startseite", desc: "Der erste Eindruck Ihrer Marke." },
|
||||
{ id: "About", label: "Über uns", desc: "Ihre Geschichte und Ihr Team." },
|
||||
{ id: "Services", label: "Leistungen", desc: "Übersicht Ihres Angebots." },
|
||||
{ id: "Contact", label: "Kontakt", desc: "Anlaufstelle für Ihre Kunden." },
|
||||
{
|
||||
id: "Landing",
|
||||
label: "Landingpage",
|
||||
desc: "Optimiert für Marketing-Kampagnen.",
|
||||
},
|
||||
{ id: "Legal", label: "Rechtliches", desc: "Impressum & Datenschutz." },
|
||||
];
|
||||
|
||||
export const FEATURE_OPTIONS = [
|
||||
{
|
||||
id: "blog_news",
|
||||
label: "Blog / News",
|
||||
desc: "Ein Bereich für aktuelle Beiträge und Neuigkeiten.",
|
||||
},
|
||||
{
|
||||
id: "products",
|
||||
label: "Produktbereich",
|
||||
desc: "Katalog Ihrer Leistungen oder Produkte.",
|
||||
},
|
||||
{
|
||||
id: "jobs",
|
||||
label: "Karriere / Jobs",
|
||||
desc: "Stellenanzeigen und Bewerbungsoptionen.",
|
||||
},
|
||||
{
|
||||
id: "refs",
|
||||
label: "Referenzen / Cases",
|
||||
desc: "Präsentation Ihrer Projekte.",
|
||||
},
|
||||
{ id: "events", label: "Events / Termine", desc: "Veranstaltungskalender." },
|
||||
];
|
||||
|
||||
export const FUNCTION_OPTIONS = [
|
||||
{ id: "search", label: "Suche", desc: "Volltextsuche über alle Inhalte." },
|
||||
{
|
||||
id: "filter",
|
||||
label: "Filter-Systeme",
|
||||
desc: "Kategorisierung und Sortierung.",
|
||||
},
|
||||
{ id: "pdf", label: "PDF-Export", desc: "Automatisierte PDF-Erstellung." },
|
||||
{
|
||||
id: "forms",
|
||||
label: "Individuelle Formular-Logik",
|
||||
desc: "Smarte Validierung & mehrstufige Prozesse.",
|
||||
},
|
||||
];
|
||||
|
||||
export const API_OPTIONS = [
|
||||
{
|
||||
id: "crm",
|
||||
label: "CRM System",
|
||||
desc: "HubSpot, Salesforce, Pipedrive etc.",
|
||||
},
|
||||
{
|
||||
id: "erp",
|
||||
label: "ERP / Warenwirtschaft",
|
||||
desc: "SAP, Microsoft Dynamics, Xentral etc.",
|
||||
},
|
||||
{
|
||||
id: "stripe",
|
||||
label: "Stripe / Payment",
|
||||
desc: "Zahlungsabwicklung und Abonnements.",
|
||||
},
|
||||
{
|
||||
id: "newsletter",
|
||||
label: "Newsletter / Marketing",
|
||||
desc: "Mailchimp, Brevo, ActiveCampaign etc.",
|
||||
},
|
||||
{
|
||||
id: "ecommerce",
|
||||
label: "E-Commerce / Shop",
|
||||
desc: "Shopify, WooCommerce, Shopware Sync.",
|
||||
},
|
||||
{
|
||||
id: "hr",
|
||||
label: "HR / Recruiting",
|
||||
desc: "Personio, Workday, Recruitee etc.",
|
||||
},
|
||||
{
|
||||
id: "realestate",
|
||||
label: "Immobilien",
|
||||
desc: "OpenImmo, FlowFact, Immowelt Sync.",
|
||||
},
|
||||
{
|
||||
id: "calendar",
|
||||
label: "Termine / Booking",
|
||||
desc: "Calendly, Shore, Doctolib etc.",
|
||||
},
|
||||
{
|
||||
id: "social",
|
||||
label: "Social Media Sync",
|
||||
desc: "Automatisierte Posts oder Feeds.",
|
||||
},
|
||||
{
|
||||
id: "maps",
|
||||
label: "Google Maps / Places",
|
||||
desc: "Standortsuche und Kartenintegration.",
|
||||
},
|
||||
{
|
||||
id: "analytics",
|
||||
label: "Custom Analytics",
|
||||
desc: "Anbindung an spezialisierte Tracking-Tools.",
|
||||
},
|
||||
];
|
||||
|
||||
export const ASSET_OPTIONS = [
|
||||
{
|
||||
id: "existing_website",
|
||||
label: "Bestehende Website",
|
||||
desc: "Inhalte oder Struktur können übernommen werden.",
|
||||
},
|
||||
{ id: "logo", label: "Logo", desc: "Vektordatei Ihres Logos." },
|
||||
{
|
||||
id: "styleguide",
|
||||
label: "Styleguide",
|
||||
desc: "Farben, Schriften, Design-Vorgaben.",
|
||||
},
|
||||
{
|
||||
id: "content_concept",
|
||||
label: "Inhalts-Konzept",
|
||||
desc: "Struktur und Texte sind bereits geplant.",
|
||||
},
|
||||
{
|
||||
id: "media",
|
||||
label: "Bild/Video-Material",
|
||||
desc: "Professionelles Bildmaterial vorhanden.",
|
||||
},
|
||||
{ id: "icons", label: "Icons", desc: "Eigene Icon-Sets vorhanden." },
|
||||
{
|
||||
id: "illustrations",
|
||||
label: "Illustrationen",
|
||||
desc: "Eigene Illustrationen vorhanden.",
|
||||
},
|
||||
{
|
||||
id: "fonts",
|
||||
label: "Fonts",
|
||||
desc: "Lizenzen für Hausschriften vorhanden.",
|
||||
},
|
||||
];
|
||||
|
||||
export const DESIGN_OPTIONS = [
|
||||
{
|
||||
id: "minimal",
|
||||
label: "Minimalistisch",
|
||||
desc: "Viel Weißraum, klare Typografie.",
|
||||
},
|
||||
{
|
||||
id: "bold",
|
||||
label: "Mutig & Laut",
|
||||
desc: "Starke Kontraste, große Schriften.",
|
||||
},
|
||||
{
|
||||
id: "nature",
|
||||
label: "Natürlich",
|
||||
desc: "Sanfte Erdtöne, organische Formen.",
|
||||
},
|
||||
{ id: "tech", label: "Technisch", desc: "Präzise Linien, dunkle Akzente." },
|
||||
];
|
||||
|
||||
export const EMPLOYEE_OPTIONS = [
|
||||
{ id: "1-5", label: "1-5 Mitarbeiter" },
|
||||
{ id: "6-20", label: "6-20 Mitarbeiter" },
|
||||
{ id: "21-100", label: "21-100 Mitarbeiter" },
|
||||
{ id: "100+", label: "100+ Mitarbeiter" },
|
||||
];
|
||||
|
||||
export const SOCIAL_MEDIA_OPTIONS = [
|
||||
{ id: "instagram", label: "Instagram" },
|
||||
{ id: "linkedin", label: "LinkedIn" },
|
||||
{ id: "facebook", label: "Facebook" },
|
||||
{ id: "twitter", label: "Twitter / X" },
|
||||
{ id: "tiktok", label: "TikTok" },
|
||||
{ id: "youtube", label: "YouTube" },
|
||||
];
|
||||
|
||||
export const VIBE_LABELS: Record<string, string> = {
|
||||
minimal: "Minimalistisch",
|
||||
bold: "Mutig & Laut",
|
||||
nature: "Natürlich",
|
||||
tech: "Technisch",
|
||||
};
|
||||
|
||||
export const DEADLINE_LABELS: Record<string, string> = {
|
||||
asap: "So schnell wie möglich",
|
||||
"2-3-months": "In 2-3 Monaten",
|
||||
"3-6-months": "In 3-6 Monaten",
|
||||
flexible: "Flexibel",
|
||||
};
|
||||
|
||||
export const ASSET_LABELS: Record<string, string> = {
|
||||
existing_website: "Bestehende Website",
|
||||
logo: "Logo",
|
||||
styleguide: "Styleguide",
|
||||
content_concept: "Inhalts-Konzept",
|
||||
media: "Bild/Video-Material",
|
||||
icons: "Icons",
|
||||
illustrations: "Illustrationen",
|
||||
fonts: "Fonts",
|
||||
};
|
||||
|
||||
export const FEATURE_LABELS: Record<string, string> = {
|
||||
blog_news: "Blog / News",
|
||||
products: "Produktbereich",
|
||||
jobs: "Karriere / Jobs",
|
||||
refs: "Referenzen / Cases",
|
||||
events: "Events / Termine",
|
||||
};
|
||||
|
||||
export const FUNCTION_LABELS: Record<string, string> = {
|
||||
search: "Suche",
|
||||
filter: "Filter-Systeme",
|
||||
pdf: "PDF-Export",
|
||||
forms: "Individuelle Formular-Logik",
|
||||
members: "Mitgliederbereich",
|
||||
calendar: "Event-Kalender",
|
||||
multilang: "Mehrsprachigkeit",
|
||||
chat: "Echtzeit-Chat",
|
||||
};
|
||||
|
||||
export const API_LABELS: Record<string, string> = {
|
||||
crm_erp: "CRM / ERP",
|
||||
payment: "Payment",
|
||||
marketing: "Marketing",
|
||||
ecommerce: "E-Commerce",
|
||||
maps: "Google Maps / Places",
|
||||
social: "Social Media Sync",
|
||||
analytics: "Custom Analytics",
|
||||
};
|
||||
|
||||
export const SOCIAL_LABELS: Record<string, string> = {
|
||||
instagram: "Instagram",
|
||||
linkedin: "LinkedIn",
|
||||
facebook: "Facebook",
|
||||
twitter: "Twitter / X",
|
||||
tiktok: "TikTok",
|
||||
youtube: "YouTube",
|
||||
};
|
||||
|
||||
export const PAGE_LABELS: Record<string, string> = {
|
||||
Home: "Startseite",
|
||||
About: "Über uns",
|
||||
Services: "Leistungen",
|
||||
Contact: "Kontakt",
|
||||
Landing: "Landingpage",
|
||||
Legal: "Impressum & Datenschutz",
|
||||
};
|
||||
89
packages/acquisition/src/logic/pricing/types.ts
Normal file
89
packages/acquisition/src/logic/pricing/types.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
export type ProjectType = 'website' | 'web-app';
|
||||
|
||||
export interface FormState {
|
||||
projectType: ProjectType;
|
||||
// Company
|
||||
companyName: string;
|
||||
employeeCount: string;
|
||||
// Existing Presence
|
||||
existingWebsite: string;
|
||||
socialMedia: string[];
|
||||
socialMediaUrls: Record<string, string>;
|
||||
existingDomain: string;
|
||||
wishedDomain: string;
|
||||
// Project
|
||||
websiteTopic: string;
|
||||
selectedPages: string[];
|
||||
otherPages: string[];
|
||||
otherPagesCount: number;
|
||||
features: string[];
|
||||
otherFeatures: string[];
|
||||
otherFeaturesCount: number;
|
||||
functions: string[];
|
||||
otherFunctions: string[];
|
||||
otherFunctionsCount: number;
|
||||
apiSystems: string[];
|
||||
otherTech: string[];
|
||||
otherTechCount: number;
|
||||
assets: string[];
|
||||
otherAssets: string[];
|
||||
otherAssetsCount: number;
|
||||
newDatasets: number;
|
||||
cmsSetup: boolean;
|
||||
storageExpansion: number;
|
||||
name: string;
|
||||
email: string;
|
||||
role: string;
|
||||
message: string;
|
||||
sitemapFile: any;
|
||||
contactFiles: any[];
|
||||
// Design
|
||||
designVibe: string;
|
||||
colorScheme: string[];
|
||||
references: string[];
|
||||
designWishes: string;
|
||||
// Maintenance
|
||||
expectedAdjustments: string;
|
||||
languagesList: string[];
|
||||
// Timeline
|
||||
deadline: string;
|
||||
// Web App specific
|
||||
targetAudience: string;
|
||||
userRoles: string[];
|
||||
dataSensitivity: string;
|
||||
platformType: string;
|
||||
// Meta
|
||||
dontKnows: string[];
|
||||
visualStaging: string;
|
||||
complexInteractions: string;
|
||||
gridDontKnows?: Record<string, string>;
|
||||
briefingSummary?: string;
|
||||
companyAddress?: string;
|
||||
companyPhone?: string;
|
||||
personName?: string;
|
||||
taxId?: string;
|
||||
designVision?: string;
|
||||
positionDescriptions?: Record<string, string>;
|
||||
sitemap?: {
|
||||
category: string;
|
||||
pages: { title: string; desc: string }[];
|
||||
}[];
|
||||
}
|
||||
|
||||
export interface Position {
|
||||
pos: number;
|
||||
title: string;
|
||||
desc: string;
|
||||
qty: number;
|
||||
price: number;
|
||||
isRecurring?: boolean;
|
||||
}
|
||||
export interface Totals {
|
||||
totalPrice: number;
|
||||
monthlyPrice: number;
|
||||
totalPagesCount: number;
|
||||
totalFeatures: number;
|
||||
totalFunctions: number;
|
||||
totalApis: number;
|
||||
languagesCount: number;
|
||||
}
|
||||
153
packages/acquisition/src/services/AcquisitionService.ts
Normal file
153
packages/acquisition/src/services/AcquisitionService.ts
Normal file
@@ -0,0 +1,153 @@
|
||||
import { CheerioCrawler } from "crawlee";
|
||||
import axios from "axios";
|
||||
import { FileCacheAdapter } from "../utils/cache/FileCacheAdapter.js";
|
||||
import { initialState } from "../logic/pricing/constants.js";
|
||||
import { FormState } from "../logic/pricing/types.js";
|
||||
|
||||
export interface AcquisitionResult {
|
||||
state: FormState;
|
||||
usage: {
|
||||
prompt: number;
|
||||
completion: number;
|
||||
cost: number;
|
||||
};
|
||||
}
|
||||
|
||||
export class AcquisitionService {
|
||||
private cache: FileCacheAdapter;
|
||||
private openRouterKey: string;
|
||||
|
||||
constructor(openRouterKey: string) {
|
||||
this.openRouterKey = openRouterKey;
|
||||
this.cache = new FileCacheAdapter({ prefix: "acq_" });
|
||||
}
|
||||
|
||||
async runFullSequence(url: string, briefing: string, comments?: string): Promise<AcquisitionResult> {
|
||||
console.log(`🚀 Starting Acquisition Sequence for: ${url}`);
|
||||
|
||||
// 1. Crawl
|
||||
const crawlData = await this.performCrawl(url);
|
||||
|
||||
// 2. Distill
|
||||
const distilledContext = await this.distillCrawlContext(crawlData);
|
||||
|
||||
// 3. AI Estimation (using parts of the original ai-estimate logic)
|
||||
// For brevity in this initial port, I'll implement a combined prompt strategy
|
||||
// or keep the multi-pass if needed.
|
||||
|
||||
const result = await this.getAiEstimation(briefing, distilledContext, comments || null);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private async performCrawl(url: string): Promise<string> {
|
||||
const pages: any[] = [];
|
||||
const origin = new URL(url).origin;
|
||||
|
||||
const crawler = new CheerioCrawler({
|
||||
maxRequestsPerCrawl: 15,
|
||||
async requestHandler({ $, request, enqueueLinks }) {
|
||||
const title = $("title").text();
|
||||
const bodyText = $("body").text().replace(/\s+/g, " ").substring(0, 10000);
|
||||
|
||||
pages.push({
|
||||
url: request.url,
|
||||
content: `Title: ${title}\nText: ${bodyText}`,
|
||||
});
|
||||
|
||||
await enqueueLinks({
|
||||
limit: 10,
|
||||
transformRequestFunction: (req) => {
|
||||
try {
|
||||
const reqUrl = new URL(req.url);
|
||||
if (reqUrl.origin !== origin) return false;
|
||||
if (reqUrl.pathname.match(/\.(pdf|zip|jpg|png|svg|webp)$/i)) return false;
|
||||
return req;
|
||||
} catch (_error) {
|
||||
// Ignored - malformed URL in enqueueLinks
|
||||
return false;
|
||||
}
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
await crawler.run([url]);
|
||||
return pages.map((p) => `--- PAGE: ${p.url} ---\n${p.content}`).join("\n\n");
|
||||
}
|
||||
|
||||
private async distillCrawlContext(rawCrawl: string): Promise<string> {
|
||||
const systemPrompt = `
|
||||
You are a context distiller. Extract the "Company DNA" in 5-8 bullet points (GERMAN).
|
||||
Focus on: Services, USPs, Target Audience, Tone.
|
||||
`;
|
||||
const resp = await axios.post(
|
||||
"https://openrouter.ai/api/v1/chat/completions",
|
||||
{
|
||||
model: "google/gemini-3-flash-preview",
|
||||
messages: [
|
||||
{ role: "system", content: systemPrompt },
|
||||
{ role: "user", content: `RAW_CRAWL:\n${rawCrawl.substring(0, 20000)}` },
|
||||
],
|
||||
},
|
||||
{
|
||||
headers: { Authorization: `Bearer ${this.openRouterKey}` },
|
||||
}
|
||||
);
|
||||
|
||||
return resp.data.choices[0].message.content;
|
||||
}
|
||||
|
||||
private async getAiEstimation(briefing: string, context: string, comments: string | null): Promise<AcquisitionResult> {
|
||||
// Porting a simplified version of Pass 1 and Pass 3 together for the "Audit"
|
||||
const systemPrompt = `
|
||||
You are a Digital Architect. Analyze the briefing and crawl context.
|
||||
Generate a JSON state for a project estimation.
|
||||
Language: GERMAN.
|
||||
Format: ROOT LEVEL JSON matching FormState interface.
|
||||
|
||||
### PRICING RULES:
|
||||
- Base: 5440 €
|
||||
- Page: 600 €
|
||||
- Feature: 1500 €
|
||||
- Function/API: 800 €
|
||||
|
||||
Return ONLY the JSON.
|
||||
`;
|
||||
const userPrompt = `BRIEFING: ${briefing}\n\nCONTEXT: ${context}\n\nCOMMENTS: ${comments}`;
|
||||
|
||||
const resp = await axios.post(
|
||||
"https://openrouter.ai/api/v1/chat/completions",
|
||||
{
|
||||
model: "google/gemini-3-flash-preview",
|
||||
messages: [
|
||||
{ role: "system", content: systemPrompt },
|
||||
{ role: "user", content: userPrompt },
|
||||
],
|
||||
response_format: { type: "json_object" },
|
||||
},
|
||||
{
|
||||
headers: { Authorization: `Bearer ${this.openRouterKey}` },
|
||||
}
|
||||
);
|
||||
|
||||
let state: FormState;
|
||||
try {
|
||||
state = JSON.parse(resp.data.choices[0].message.content);
|
||||
} catch (_error) {
|
||||
console.error("Failed to parse AI estimation JSON, returning initial state.");
|
||||
state = initialState;
|
||||
}
|
||||
// Ensure it matches FormState defaults
|
||||
const finalState = { ...initialState, ...state };
|
||||
|
||||
return {
|
||||
state: finalState,
|
||||
usage: {
|
||||
prompt: resp.data.usage?.prompt_tokens || 0,
|
||||
completion: resp.data.usage?.completion_tokens || 0,
|
||||
cost: resp.data.usage?.cost || 0,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
24
packages/acquisition/src/services/PdfEngine.ts
Normal file
24
packages/acquisition/src/services/PdfEngine.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { renderToFile } from "@react-pdf/renderer";
|
||||
import { createElement } from "react";
|
||||
import { EstimationPDF } from "../components/EstimationPDF.js";
|
||||
import { PRICING } from "../logic/pricing/constants.js";
|
||||
import { calculateTotals } from "../logic/pricing/calculator.js";
|
||||
|
||||
export class PdfEngine {
|
||||
constructor() { }
|
||||
|
||||
async generateEstimatePdf(state: any, outputPath: string): Promise<string> {
|
||||
const totals = calculateTotals(state, PRICING);
|
||||
|
||||
await renderToFile(
|
||||
createElement(EstimationPDF as any, {
|
||||
state,
|
||||
totalPrice: totals.totalPrice,
|
||||
pricing: PRICING,
|
||||
} as any) as any,
|
||||
outputPath
|
||||
);
|
||||
|
||||
return outputPath;
|
||||
}
|
||||
}
|
||||
78
packages/acquisition/src/utils/cache/FileCacheAdapter.ts
vendored
Normal file
78
packages/acquisition/src/utils/cache/FileCacheAdapter.ts
vendored
Normal file
@@ -0,0 +1,78 @@
|
||||
import * as fs from 'node:fs/promises';
|
||||
import * as path from 'node:path';
|
||||
import { existsSync } from 'node:fs';
|
||||
import * as crypto from 'node:crypto';
|
||||
|
||||
export class FileCacheAdapter {
|
||||
private cacheDir: string;
|
||||
private prefix: string;
|
||||
private defaultTTL: number;
|
||||
|
||||
constructor(config?: { cacheDir?: string; prefix?: string; defaultTTL?: number }) {
|
||||
this.cacheDir = config?.cacheDir || path.resolve(process.cwd(), '.cache');
|
||||
this.prefix = config?.prefix || '';
|
||||
this.defaultTTL = config?.defaultTTL || 3600;
|
||||
|
||||
if (!existsSync(this.cacheDir)) {
|
||||
fs.mkdir(this.cacheDir, { recursive: true }).catch(() => { });
|
||||
}
|
||||
}
|
||||
|
||||
private sanitize(key: string): string {
|
||||
const clean = key.replace(/[^a-z0-9]/gi, '_');
|
||||
if (clean.length > 64) {
|
||||
return crypto.createHash('md5').update(key).digest('hex');
|
||||
}
|
||||
return clean;
|
||||
}
|
||||
|
||||
private getFilePath(key: string): string {
|
||||
const safeKey = this.sanitize(`${this.prefix}${key}`).toLowerCase();
|
||||
return path.join(this.cacheDir, `${safeKey}.json`);
|
||||
}
|
||||
|
||||
async get<T>(key: string): Promise<T | null> {
|
||||
const filePath = this.getFilePath(key);
|
||||
try {
|
||||
if (!existsSync(filePath)) return null;
|
||||
const content = await fs.readFile(filePath, 'utf8');
|
||||
const data = JSON.parse(content);
|
||||
|
||||
if (data.expiry && Date.now() > data.expiry) {
|
||||
await this.del(key);
|
||||
return null;
|
||||
}
|
||||
|
||||
return data.value;
|
||||
} catch (_error) {
|
||||
return null; // Keeping original return type Promise<T | null>
|
||||
}
|
||||
}
|
||||
|
||||
async set<T>(key: string, value: T, ttl?: number): Promise<void> {
|
||||
const filePath = this.getFilePath(key);
|
||||
const effectiveTTL = ttl !== undefined ? ttl : this.defaultTTL;
|
||||
const data = {
|
||||
value,
|
||||
expiry: effectiveTTL > 0 ? Date.now() + effectiveTTL * 1000 : null,
|
||||
updatedAt: new Date().toISOString()
|
||||
};
|
||||
|
||||
try {
|
||||
await fs.writeFile(filePath, JSON.stringify(data, null, 2), 'utf8');
|
||||
} catch (_error) {
|
||||
// Ignore
|
||||
}
|
||||
}
|
||||
|
||||
async del(key: string): Promise<void> {
|
||||
const filePath = this.getFilePath(key);
|
||||
try {
|
||||
if (existsSync(filePath)) {
|
||||
await fs.unlink(filePath);
|
||||
}
|
||||
} catch (_error) {
|
||||
// Ignored - best effort cleanup
|
||||
}
|
||||
}
|
||||
}
|
||||
59
packages/acquisition/test/pricing.test.ts
Normal file
59
packages/acquisition/test/pricing.test.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { calculateTotals, calculatePositions } from "../src/logic/pricing/calculator.js";
|
||||
import { PRICING, initialState } from "../src/logic/pricing/constants.js";
|
||||
import { FormState } from "../src/logic/pricing/types.js";
|
||||
|
||||
describe("Pricing Logic", () => {
|
||||
it("should calculate base website price correctly", () => {
|
||||
const state: FormState = {
|
||||
...initialState,
|
||||
projectType: "website",
|
||||
selectedPages: [] // Clear for base test
|
||||
};
|
||||
const totals = calculateTotals(state, PRICING);
|
||||
expect(totals.totalPrice).toBe(PRICING.BASE_WEBSITE);
|
||||
});
|
||||
|
||||
it("should add page costs correctly", () => {
|
||||
const state: FormState = {
|
||||
...initialState,
|
||||
projectType: "website",
|
||||
selectedPages: [], // Clear for clean test
|
||||
otherPagesCount: 5
|
||||
};
|
||||
const totals = calculateTotals(state, PRICING);
|
||||
expect(totals.totalPrice).toBe(PRICING.BASE_WEBSITE + (5 * PRICING.PAGE));
|
||||
});
|
||||
|
||||
it("should apply multi-language multiplier", () => {
|
||||
const state: FormState = {
|
||||
...initialState,
|
||||
projectType: "website",
|
||||
selectedPages: [], // Clear for clean test
|
||||
languagesList: ["Deutsch", "Englisch"]
|
||||
};
|
||||
const totals = calculateTotals(state, PRICING);
|
||||
expect(totals.totalPrice).toBe(Math.round(PRICING.BASE_WEBSITE * 1.2));
|
||||
});
|
||||
|
||||
it("should generate correct positions for a website", () => {
|
||||
const state: FormState = {
|
||||
...initialState,
|
||||
projectType: "website",
|
||||
selectedPages: ["Home"],
|
||||
otherPagesCount: 2
|
||||
};
|
||||
const positions = calculatePositions(state, PRICING);
|
||||
|
||||
// Find "Fundament" position (Das technische Fundament)
|
||||
const basePos = positions.find(p => p.title.includes("Fundament"));
|
||||
expect(basePos).toBeDefined();
|
||||
expect(basePos?.price).toBe(PRICING.BASE_WEBSITE);
|
||||
|
||||
// Find "Individuelle Seiten" position
|
||||
const pagesPos = positions.find(p => p.title.includes("Seiten"));
|
||||
expect(pagesPos).toBeDefined();
|
||||
expect(pagesPos?.qty).toBe(3); // 1 selected + 2 other
|
||||
expect(pagesPos?.price).toBe(3 * PRICING.PAGE);
|
||||
});
|
||||
});
|
||||
15
packages/acquisition/tsconfig.json
Normal file
15
packages/acquisition/tsconfig.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"extends": "@mintel/tsconfig/base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"jsx": "react-jsx"
|
||||
},
|
||||
"include": [
|
||||
"src/**/*"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules",
|
||||
"dist"
|
||||
]
|
||||
}
|
||||
@@ -4,10 +4,30 @@ The Mintel CLI is the primary automation tool for managing the monorepo and ensu
|
||||
|
||||
## 🚀 Installation
|
||||
|
||||
The CLI is intended to be used within the monorepo:
|
||||
### Using npx (Recommended)
|
||||
|
||||
Run the CLI without installing it globally. This always uses the latest version from the registry:
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
npx @mintel/cli init apps/my-new-website.com
|
||||
```
|
||||
|
||||
### Global Installation
|
||||
|
||||
Install the CLI globally from the Mintel registry:
|
||||
|
||||
```bash
|
||||
npm install -g @mintel/cli
|
||||
```
|
||||
|
||||
### Development (Local Link)
|
||||
|
||||
If you are contributing to the CLI, you can link it locally:
|
||||
|
||||
```bash
|
||||
cd packages/cli
|
||||
pnpm build
|
||||
npm link
|
||||
```
|
||||
|
||||
## 🛠 Commands
|
||||
@@ -17,10 +37,11 @@ pnpm install
|
||||
Scaffolds a new, production-ready client website in the specified path.
|
||||
|
||||
```bash
|
||||
pnpm --filter @mintel/cli start init apps/my-new-website.com
|
||||
mintel init apps/my-new-website.com
|
||||
```
|
||||
|
||||
#### What it does:
|
||||
|
||||
1. **Project Structure**: Creates a modern Next.js directory layout.
|
||||
2. **Shared Configs**: Generates `package.json`, `tsconfig.json`, and `eslint.config.mjs` that extend the `@mintel` shared packages.
|
||||
3. **Localization**: Sets up a localized routing structure (`src/app/[locale]`) with `next-intl` pre-configured.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@mintel/cli",
|
||||
"version": "1.0.1",
|
||||
"version": "1.7.0",
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
"registry": "https://npm.infra.mintel.me"
|
||||
@@ -10,7 +10,7 @@
|
||||
"mintel": "./dist/index.js"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsup src/index.ts --format esm --target es2020",
|
||||
"build": "tsup",
|
||||
"start": "node dist/index.js",
|
||||
"dev": "tsup src/index.ts --format esm --watch --target es2020",
|
||||
"test": "vitest run"
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
#!/usr/bin/env node
|
||||
import { Command } from "commander";
|
||||
import fs from "fs-extra";
|
||||
import path from "path";
|
||||
@@ -24,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
|
||||
@@ -59,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")
|
||||
@@ -87,7 +198,7 @@ program
|
||||
.action(async (projectPath) => {
|
||||
const fullPath = path.isAbsolute(projectPath)
|
||||
? projectPath
|
||||
: path.resolve(process.cwd(), "../../", projectPath);
|
||||
: path.resolve(process.cwd(), projectPath);
|
||||
const projectName = path.basename(fullPath);
|
||||
|
||||
console.log(chalk.blue(`Initializing new project: ${projectName}...`));
|
||||
@@ -120,10 +231,11 @@ program
|
||||
"pagespeed:test": "mintel pagespeed",
|
||||
},
|
||||
dependencies: {
|
||||
next: "15.1.6",
|
||||
next: "16.1.6",
|
||||
react: "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"@mintel/next-utils": "workspace:*",
|
||||
"@mintel/next-observability": "workspace:*",
|
||||
"@directus/sdk": "^21.0.0",
|
||||
},
|
||||
devDependencies: {
|
||||
@@ -262,11 +374,15 @@ export default createMintelI18nRequestConfig(
|
||||
// Create instrumentation.ts
|
||||
await fs.writeFile(
|
||||
path.join(fullPath, "src/instrumentation.ts"),
|
||||
`import * as Sentry from '@sentry/nextjs';
|
||||
`import { Sentry } from '@mintel/next-observability';
|
||||
|
||||
export async function register() {
|
||||
if (process.env.NEXT_RUNTIME === 'nodejs') {
|
||||
// Server-side initialization
|
||||
await import('./sentry.server.config');
|
||||
}
|
||||
|
||||
if (process.env.NEXT_RUNTIME === 'edge') {
|
||||
await import('./sentry.edge.config');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -396,8 +512,12 @@ DIRECTUS_DB_PASSWORD=mintel-db-pass
|
||||
SENTRY_DSN=
|
||||
|
||||
# Analytics (Umami)
|
||||
NEXT_PUBLIC_UMAMI_WEBSITE_ID=
|
||||
NEXT_PUBLIC_UMAMI_SCRIPT_URL=https://analytics.infra.mintel.me/script.js
|
||||
UMAMI_WEBSITE_ID=
|
||||
UMAMI_API_ENDPOINT=https://analytics.infra.mintel.me
|
||||
|
||||
# Notifications (Gotify)
|
||||
GOTIFY_URL=
|
||||
GOTIFY_TOKEN=
|
||||
`;
|
||||
await fs.writeFile(path.join(fullPath, ".env.example"), envExample);
|
||||
|
||||
|
||||
11
packages/cli/tsup.config.ts
Normal file
11
packages/cli/tsup.config.ts
Normal 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',
|
||||
},
|
||||
});
|
||||
42
packages/cms-infra/Dockerfile
Normal file
42
packages/cms-infra/Dockerfile
Normal file
@@ -0,0 +1,42 @@
|
||||
# Build Stage
|
||||
FROM node:20-slim AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Core environment for pnpm
|
||||
RUN corepack enable && corepack prepare pnpm@latest --activate
|
||||
|
||||
# Copy root configurations
|
||||
COPY pnpm-lock.yaml pnpm-workspace.yaml package.json ./
|
||||
|
||||
# Copy all packages for extensions build
|
||||
COPY packages ./packages
|
||||
|
||||
# Install dependencies (only what's needed for extensions)
|
||||
RUN pnpm install --no-frozen-lockfile \
|
||||
--filter "@mintel/directus-extension-*" \
|
||||
--filter "acquisition" \
|
||||
--filter "acquisition-manager" \
|
||||
--filter "customer-manager" \
|
||||
--filter "feedback-commander" \
|
||||
--filter "people-manager" \
|
||||
--filter "./packages/acquisition" \
|
||||
--filter "./packages/mail"
|
||||
|
||||
|
||||
# Runtime Stage
|
||||
FROM directus/directus:11
|
||||
|
||||
WORKDIR /directus
|
||||
|
||||
# Copy built extensions
|
||||
COPY --from=builder /app/packages/cms-infra/extensions ./extensions
|
||||
|
||||
# Environment defaults (can be overridden)
|
||||
ENV KEY="infra-cms-key"
|
||||
ENV SECRET="infra-cms-secret"
|
||||
ENV DB_CLIENT="sqlite3"
|
||||
ENV DB_FILENAME="/directus/database/data.db"
|
||||
|
||||
# Expose port
|
||||
EXPOSE 8055
|
||||
0
packages/cms-infra/database/RELOAD_TEST
Normal file
0
packages/cms-infra/database/RELOAD_TEST
Normal file
BIN
packages/cms-infra/database/data.db
Normal file
BIN
packages/cms-infra/database/data.db
Normal file
Binary file not shown.
43
packages/cms-infra/docker-compose.yml
Normal file
43
packages/cms-infra/docker-compose.yml
Normal file
@@ -0,0 +1,43 @@
|
||||
services:
|
||||
infra-cms:
|
||||
build:
|
||||
context: ../../
|
||||
dockerfile: packages/cms-infra/Dockerfile
|
||||
image: mintel/cms-infra:latest
|
||||
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"
|
||||
LOG_LEVEL: "trace"
|
||||
volumes:
|
||||
- ./database:/directus/database
|
||||
- ./uploads:/directus/uploads
|
||||
- ./schema:/directus/schema
|
||||
- ./extensions:/directus/extensions
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.http.routers.infra-cms.rule=Host(`cms.localhost`)"
|
||||
- "traefik.http.services.infra-cms.loadbalancer.server.port=8055"
|
||||
- "traefik.docker.network=infra"
|
||||
|
||||
networks:
|
||||
default:
|
||||
name: mintel-infra-cms-internal
|
||||
infra:
|
||||
external: true
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"name": "acquisition-manager",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"directus:extension": {
|
||||
"type": "module",
|
||||
"path": "dist/index.js",
|
||||
"source": "src/index.ts",
|
||||
"host": "^11.0.0"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "directus-extension build",
|
||||
"dev": "directus-extension build -w"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@directus/extensions-sdk": "11.0.2",
|
||||
"vue": "^3.4.34",
|
||||
"typescript": "^5.6.3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { defineModule } from "@directus/extensions-sdk";
|
||||
import ModuleComponent from "./module.vue";
|
||||
|
||||
export default defineModule({
|
||||
id: "acquisition-manager",
|
||||
name: "Acquisition",
|
||||
icon: "auto_awesome",
|
||||
routes: [
|
||||
{
|
||||
path: "",
|
||||
component: ModuleComponent,
|
||||
},
|
||||
{
|
||||
path: ":id",
|
||||
component: ModuleComponent,
|
||||
props: true
|
||||
}
|
||||
],
|
||||
});
|
||||
403
packages/cms-infra/extensions/acquisition-manager/src/module.vue
Normal file
403
packages/cms-infra/extensions/acquisition-manager/src/module.vue
Normal file
@@ -0,0 +1,403 @@
|
||||
<template>
|
||||
<private-view title="Acquisition Manager">
|
||||
<template #navigation>
|
||||
<v-list nav>
|
||||
<v-list-item @click="showAddLead = true" clickable>
|
||||
<v-list-item-icon><v-icon name="add" color="var(--theme--primary)" /></v-list-item-icon>
|
||||
<v-list-item-content>
|
||||
<v-text-overflow text="Neuen Lead anlegen" />
|
||||
</v-list-item-content>
|
||||
</v-list-item>
|
||||
|
||||
<v-divider />
|
||||
|
||||
<v-list-item
|
||||
v-for="lead in leads"
|
||||
:key="lead.id"
|
||||
:active="selectedLeadId === lead.id"
|
||||
class="lead-item"
|
||||
clickable
|
||||
@click="selectLead(lead.id)"
|
||||
>
|
||||
<v-list-item-icon>
|
||||
<v-icon :name="getStatusIcon(lead.status)" :color="getStatusColor(lead.status)" />
|
||||
</v-list-item-icon>
|
||||
<v-list-item-content>
|
||||
<v-text-overflow :text="lead.company_name" />
|
||||
</v-list-item-content>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</template>
|
||||
|
||||
<template #title-outer:after>
|
||||
<v-notice v-if="notice" :type="notice.type" @close="notice = null" dismissible>
|
||||
{{ notice.message }}
|
||||
</v-notice>
|
||||
</template>
|
||||
|
||||
<div class="content-wrapper">
|
||||
<v-notice type="success" style="margin-bottom: 16px;">
|
||||
DEBUG: Module Version 1.1.0 - Native Build - {{ new Date().toISOString() }}
|
||||
</v-notice>
|
||||
|
||||
<div v-if="!selectedLead" class="empty-state">
|
||||
<v-info title="Lead auswählen" icon="auto_awesome" center>
|
||||
Wähle einen Lead in der Navigation aus oder
|
||||
<v-button x-small @click="showAddLead = true">registriere einen neuen Lead</v-button>.
|
||||
</v-info>
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<header class="header">
|
||||
<div class="header-left">
|
||||
<h1 class="title">{{ selectedLead.company_name }}</h1>
|
||||
<p class="subtitle">
|
||||
<v-icon name="language" x-small />
|
||||
<a :href="selectedLead.website_url" target="_blank" class="url-link">
|
||||
{{ selectedLead.website_url.replace(/^https?:\/\//, '') }}
|
||||
</a>
|
||||
· Status: {{ selectedLead.status.toUpperCase() }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<v-button
|
||||
v-if="selectedLead.status === 'new'"
|
||||
secondary
|
||||
:loading="loadingAudit"
|
||||
@click="runAudit"
|
||||
>
|
||||
<v-icon name="settings_suggest" left />
|
||||
Audit starten
|
||||
</v-button>
|
||||
|
||||
<template v-if="selectedLead.status === 'audit_ready'">
|
||||
<v-button secondary :loading="loadingEmail" @click="sendAuditEmail">
|
||||
<v-icon name="mail" left />
|
||||
Audit E-Mail
|
||||
</v-button>
|
||||
<v-button :loading="loadingPdf" @click="generatePdf">
|
||||
<v-icon name="picture_as_pdf" left />
|
||||
PDF Erstellen
|
||||
</v-button>
|
||||
</template>
|
||||
|
||||
<v-button v-if="selectedLead.audit_pdf_path" secondary icon v-tooltip.bottom="'PDF öffnen'" @click="openPdf">
|
||||
<v-icon name="open_in_new" />
|
||||
</v-button>
|
||||
|
||||
<v-button
|
||||
v-if="selectedLead.audit_pdf_path"
|
||||
primary
|
||||
:loading="loadingEmail"
|
||||
@click="sendEstimateEmail"
|
||||
>
|
||||
<v-icon name="send" left />
|
||||
Angebot senden
|
||||
</v-button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="sections">
|
||||
<div class="main-info">
|
||||
<div class="form-grid">
|
||||
<div class="field">
|
||||
<span class="label">Kontaktperson</span>
|
||||
<div v-if="selectedLead.contact_person" class="value person-link" @click="goToPerson(selectedLead.contact_person)">
|
||||
{{ getPersonName(selectedLead.contact_person) }}
|
||||
</div>
|
||||
<div v-else class="value text-subdued">Keine Person verknüpft</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<span class="label">E-Mail (Legacy)</span>
|
||||
<div class="value">{{ selectedLead.contact_email || '—' }}</div>
|
||||
</div>
|
||||
<div class="field full">
|
||||
<span class="label">Briefing / Fokus</span>
|
||||
<div class="value text-block">{{ selectedLead.briefing || 'Kein Briefing hinterlegt.' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<v-divider />
|
||||
|
||||
<div v-if="selectedLead.ai_state" class="ai-observations">
|
||||
<h3 class="section-title">AI Observations & Estimation</h3>
|
||||
|
||||
<div class="metrics">
|
||||
<v-info label="Projekt-Modus" :value="selectedLead.ai_state.projectType || 'Unbekannt'" />
|
||||
<v-info label="Seitenanzahl" :value="selectedLead.ai_state.sitemap?.length || '0'" />
|
||||
</div>
|
||||
|
||||
<v-table
|
||||
v-if="selectedLead.ai_state.sitemap"
|
||||
:headers="[ { text: 'Seite', value: 'title' }, { text: 'URL', value: 'url' } ]"
|
||||
:items="selectedLead.ai_state.sitemap"
|
||||
class="observation-table"
|
||||
>
|
||||
<template #[`item.title`]="{ item }">
|
||||
<span class="page-title">{{ item.title }}</span>
|
||||
</template>
|
||||
<template #[`item.url`]="{ item }">
|
||||
<span class="page-url">{{ item.url }}</span>
|
||||
</template>
|
||||
</v-table>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Drawer: New Lead -->
|
||||
<v-drawer
|
||||
v-model="showAddLead"
|
||||
title="Neuen Lead registrieren"
|
||||
icon="person_add"
|
||||
@cancel="showAddLead = false"
|
||||
>
|
||||
<div class="drawer-content">
|
||||
<div class="form-section">
|
||||
<div class="field">
|
||||
<span class="label">Firma</span>
|
||||
<v-input v-model="newLead.company_name" placeholder="z.B. Schmidt GmbH" autofocus />
|
||||
</div>
|
||||
<div class="field">
|
||||
<span class="label">Website URL</span>
|
||||
<v-input v-model="newLead.website_url" placeholder="https://..." />
|
||||
</div>
|
||||
<div class="field">
|
||||
<span class="label">Ansprechpartner</span>
|
||||
<v-input v-model="newLead.contact_name" placeholder="Vorname Nachname" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<span class="label">E-Mail Adresse</span>
|
||||
<v-input v-model="newLead.contact_email" placeholder="email@beispiel.de" type="email" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<span class="label">Briefing / Fokus</span>
|
||||
<v-textarea v-model="newLead.briefing" placeholder="Besonderheiten für das Audit..." />
|
||||
</div>
|
||||
<div class="field">
|
||||
<span class="label">Kontaktperson (Optional)</span>
|
||||
<v-select
|
||||
v-model="newLead.contact_person"
|
||||
:items="peopleOptions"
|
||||
placeholder="Person auswählen..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="drawer-actions">
|
||||
<v-button primary block :loading="savingLead" @click="saveLead">Lead speichern</v-button>
|
||||
</div>
|
||||
</div>
|
||||
</v-drawer>
|
||||
</private-view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, computed } from 'vue';
|
||||
import { useApi } from '@directus/extensions-sdk';
|
||||
|
||||
const api = useApi();
|
||||
const leads = ref<any[]>([]);
|
||||
const selectedLeadId = ref<string | null>(null);
|
||||
const loadingAudit = ref(false);
|
||||
const loadingPdf = ref(false);
|
||||
const loadingEmail = ref(false);
|
||||
const showAddLead = ref(false);
|
||||
const savingLead = ref(false);
|
||||
const notice = ref<{ type: string; message: string } | null>(null);
|
||||
|
||||
const newLead = ref({
|
||||
company_name: '',
|
||||
website_url: '',
|
||||
contact_name: '',
|
||||
contact_email: '',
|
||||
contact_person: null,
|
||||
briefing: '',
|
||||
status: 'new'
|
||||
});
|
||||
|
||||
const people = ref<any[]>([]);
|
||||
|
||||
const peopleOptions = computed(() =>
|
||||
people.value.map(p => ({
|
||||
text: `${p.first_name} ${p.last_name}`,
|
||||
value: p.id
|
||||
}))
|
||||
);
|
||||
|
||||
function getPersonName(id: string) {
|
||||
const person = people.value.find(p => p.id === id);
|
||||
return person ? `${person.first_name} ${person.last_name}` : id;
|
||||
}
|
||||
|
||||
function goToPerson(id: string) {
|
||||
// Logic to navigate to people manager or open details
|
||||
notice.value = { type: 'info', message: `Navigiere zu Person: ${id}` };
|
||||
}
|
||||
|
||||
const selectedLead = computed(() => leads.value.find(l => l.id === selectedLeadId.value));
|
||||
|
||||
onMounted(fetchLeads);
|
||||
|
||||
async function fetchLeads() {
|
||||
const [leadsResp, peopleResp] = await Promise.all([
|
||||
api.get('/items/leads', { params: { sort: '-date_created' } }),
|
||||
api.get('/items/people', { params: { sort: 'last_name' } })
|
||||
]);
|
||||
leads.value = leadsResp.data.data;
|
||||
people.value = peopleResp.data.data;
|
||||
if (!selectedLeadId.value && leads.value.length > 0) {
|
||||
selectedLeadId.value = leads.value[0].id;
|
||||
}
|
||||
}
|
||||
|
||||
function selectLead(id: string) {
|
||||
selectedLeadId.value = id;
|
||||
}
|
||||
|
||||
async function runAudit() {
|
||||
if (!selectedLeadId.value) return;
|
||||
loadingAudit.value = true;
|
||||
try {
|
||||
await api.post(`/acquisition/audit/${selectedLeadId.value}`);
|
||||
notice.value = { type: 'success', message: 'Audit erfolgreich gestartet!' };
|
||||
await fetchLeads();
|
||||
} catch (e: any) {
|
||||
notice.value = { type: 'danger', message: `Fehler beim Audit: ${e.message}` };
|
||||
} finally {
|
||||
loadingAudit.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function sendAuditEmail() {
|
||||
if (!selectedLeadId.value) return;
|
||||
loadingEmail.value = true;
|
||||
try {
|
||||
await api.post(`/acquisition/audit-email/${selectedLeadId.value}`);
|
||||
notice.value = { type: 'success', message: 'Audit E-Mail versendet!' };
|
||||
await fetchLeads();
|
||||
} catch (e: any) {
|
||||
notice.value = { type: 'danger', message: `Fehler beim Versenden: ${e.message}` };
|
||||
} finally {
|
||||
loadingEmail.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function generatePdf() {
|
||||
if (!selectedLeadId.value) return;
|
||||
loadingPdf.value = true;
|
||||
try {
|
||||
await api.post(`/acquisition/estimate/${selectedLeadId.value}`);
|
||||
notice.value = { type: 'success', message: 'Angebot (PDF) wurde generiert!' };
|
||||
await fetchLeads();
|
||||
} catch (e: any) {
|
||||
notice.value = { type: 'danger', message: `Fehler bei PDF Generierung: ${e.message}` };
|
||||
} finally {
|
||||
loadingPdf.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function sendEstimateEmail() {
|
||||
if (!selectedLeadId.value) return;
|
||||
loadingEmail.value = true;
|
||||
try {
|
||||
await api.post(`/acquisition/estimate-email/${selectedLeadId.value}`);
|
||||
notice.value = { type: 'success', message: 'Angebot erfolgreich versendet!' };
|
||||
await fetchLeads();
|
||||
} catch (e: any) {
|
||||
notice.value = { type: 'danger', message: `Fehler beim Versenden: ${e.message}` };
|
||||
} finally {
|
||||
loadingEmail.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function openPdf() {
|
||||
if (!selectedLead.value?.audit_pdf_path) return;
|
||||
window.open(`${window.location.origin}/assets/${selectedLead.value.audit_pdf_path}`, '_blank');
|
||||
}
|
||||
|
||||
async function saveLead() {
|
||||
if (!newLead.value.company_name) return;
|
||||
savingLead.value = true;
|
||||
try {
|
||||
const payload = {
|
||||
id: crypto.randomUUID(),
|
||||
...newLead.value
|
||||
};
|
||||
await api.post('/items/leads', payload);
|
||||
notice.value = { type: 'success', message: 'Lead erfolgreich registriert!' };
|
||||
showAddLead.value = false;
|
||||
await fetchLeads();
|
||||
selectedLeadId.value = payload.id;
|
||||
newLead.value = {
|
||||
company_name: '',
|
||||
website_url: '',
|
||||
contact_name: '',
|
||||
contact_email: '',
|
||||
contact_person: null,
|
||||
briefing: '',
|
||||
status: 'new'
|
||||
};
|
||||
} catch (e: any) {
|
||||
notice.value = { type: 'danger', message: `Fehler beim Speichern: ${e.message}` };
|
||||
} finally {
|
||||
savingLead.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function getStatusIcon(status: string) {
|
||||
switch(status) {
|
||||
case 'new': return 'fiber_new';
|
||||
case 'auditing': return 'hourglass_empty';
|
||||
case 'audit_ready': return 'check_circle';
|
||||
case 'contacted': return 'mail_outline';
|
||||
default: return 'help_outline';
|
||||
}
|
||||
}
|
||||
|
||||
function getStatusColor(status: string) {
|
||||
switch(status) {
|
||||
case 'new': return 'var(--theme--primary)';
|
||||
case 'auditing': return 'var(--theme--warning)';
|
||||
case 'audit_ready': return 'var(--theme--success)';
|
||||
case 'contacted': return 'var(--theme--secondary)';
|
||||
default: return 'var(--theme--foreground-subdued)';
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.content-wrapper { padding: 32px; height: 100%; display: flex; flex-direction: column; overflow-y: auto; }
|
||||
.lead-item { cursor: pointer; }
|
||||
.header { margin-bottom: 24px; display: flex; justify-content: space-between; align-items: flex-end; }
|
||||
.header-right { display: flex; gap: 12px; }
|
||||
.title { font-size: 24px; font-weight: 800; margin-bottom: 4px; color: var(--theme--foreground); }
|
||||
.subtitle { color: var(--theme--foreground-subdued); font-size: 14px; display: flex; align-items: center; gap: 8px; }
|
||||
.url-link { color: inherit; text-decoration: none; border-bottom: 1px solid transparent; }
|
||||
.url-link:hover { border-bottom-color: currentColor; }
|
||||
.empty-state { height: 100%; display: flex; align-items: center; justify-content: center; }
|
||||
|
||||
.sections { display: flex; flex-direction: column; gap: 32px; }
|
||||
|
||||
.form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 24px; }
|
||||
.field { display: flex; flex-direction: column; gap: 8px; }
|
||||
.field.full { grid-column: span 2; }
|
||||
.label { font-size: 11px; font-weight: 700; text-transform: uppercase; color: var(--theme--foreground-subdued); letter-spacing: 0.5px; }
|
||||
.value { font-size: 15px; color: var(--theme--foreground); }
|
||||
.text-block { line-height: 1.6; white-space: pre-wrap; background: var(--theme--background-subdued); padding: 16px; border-radius: 8px; }
|
||||
|
||||
.ai-observations { display: flex; flex-direction: column; gap: 16px; }
|
||||
.section-title { font-size: 16px; font-weight: 700; color: var(--theme--foreground); margin-bottom: 8px; }
|
||||
.metrics { display: flex; gap: 32px; margin-bottom: 16px; }
|
||||
|
||||
.observation-table { border: 1px solid var(--theme--border); border-radius: 8px; overflow: hidden; }
|
||||
.page-title { font-weight: 600; }
|
||||
.page-url { font-family: var(--family-monospace); font-size: 12px; color: var(--theme--foreground-subdued); }
|
||||
|
||||
.drawer-content { padding: 24px; display: flex; flex-direction: column; gap: 32px; }
|
||||
.form-section { display: flex; flex-direction: column; gap: 20px; }
|
||||
.drawer-actions { margin-top: 24px; display: flex; flex-direction: column; gap: 12px; }
|
||||
|
||||
:deep(.v-list-item) { cursor: pointer !important; }
|
||||
</style>
|
||||
50
packages/cms-infra/extensions/acquisition/build.js
Normal file
50
packages/cms-infra/extensions/acquisition/build.js
Normal file
@@ -0,0 +1,50 @@
|
||||
import { build } from 'esbuild';
|
||||
import { resolve, dirname } from 'path';
|
||||
import { mkdirSync } from 'fs';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
||||
const entryPoint = resolve(__dirname, 'src/index.ts');
|
||||
const outfile = resolve(__dirname, 'dist/index.js');
|
||||
|
||||
try {
|
||||
mkdirSync(dirname(outfile), { recursive: true });
|
||||
} catch (e) { }
|
||||
|
||||
console.log(`Building from ${entryPoint} to ${outfile}...`);
|
||||
|
||||
build({
|
||||
entryPoints: [entryPoint],
|
||||
bundle: true,
|
||||
platform: 'node',
|
||||
target: 'node18',
|
||||
outfile: outfile,
|
||||
format: 'esm',
|
||||
// Bundle everything, including Directus SDK, to avoid resolution issues in Docker
|
||||
external: [],
|
||||
plugins: [{
|
||||
name: 'mock-jquery',
|
||||
setup(build) {
|
||||
build.onResolve({ filter: /^jquery$/ }, args => ({ path: args.path, namespace: 'mock-jquery' }));
|
||||
build.onLoad({ filter: /.*/, namespace: 'mock-jquery' }, () => ({ contents: 'export default {};', loader: 'js' }));
|
||||
}
|
||||
}, {
|
||||
name: 'mock-canvas',
|
||||
setup(build) {
|
||||
build.onResolve({ filter: /^canvas$/ }, args => ({ path: args.path, namespace: 'mock-canvas' }));
|
||||
build.onLoad({ filter: /.*/, namespace: 'mock-canvas' }, () => ({ contents: 'export default {};', loader: 'js' }));
|
||||
}
|
||||
}, {
|
||||
name: 'mock-jsdom',
|
||||
setup(build) {
|
||||
return;
|
||||
}
|
||||
}]
|
||||
}).then(() => {
|
||||
console.log("Build succeeded!");
|
||||
}).catch((e) => {
|
||||
console.error("Build failed:", e);
|
||||
process.exit(1);
|
||||
});
|
||||
513
packages/cms-infra/extensions/acquisition/index.js
Normal file
513
packages/cms-infra/extensions/acquisition/index.js
Normal file
File diff suppressed because one or more lines are too long
27
packages/cms-infra/extensions/acquisition/package.json
Normal file
27
packages/cms-infra/extensions/acquisition/package.json
Normal file
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"name": "acquisition",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"directus:extension": {
|
||||
"type": "endpoint",
|
||||
"path": "dist/index.js",
|
||||
"source": "src/index.ts",
|
||||
"host": "^11.0.0"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "node build.js",
|
||||
"dev": "node build.js --watch"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@directus/extensions-sdk": "11.0.2",
|
||||
"@mintel/acquisition": "workspace:*",
|
||||
"@mintel/mail": "workspace:*",
|
||||
"esbuild": "^0.25.0",
|
||||
"typescript": "^5.6.3"
|
||||
},
|
||||
"dependencies": {
|
||||
"jquery": "^3.7.1",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4"
|
||||
}
|
||||
}
|
||||
172
packages/cms-infra/extensions/acquisition/src/index.ts
Normal file
172
packages/cms-infra/extensions/acquisition/src/index.ts
Normal file
@@ -0,0 +1,172 @@
|
||||
import "./shim";
|
||||
import { defineEndpoint } from "@directus/extensions-sdk";
|
||||
import { AcquisitionService, PdfEngine } from "@mintel/acquisition";
|
||||
import { render, SiteAuditTemplate, ProjectEstimateTemplate } from "@mintel/mail";
|
||||
import { createElement } from "react";
|
||||
import * as path from "path";
|
||||
import * as fs from "fs";
|
||||
|
||||
export default defineEndpoint((router, { services, env }) => {
|
||||
const { ItemsService, MailService } = services;
|
||||
|
||||
router.get("/ping", (req, res) => res.send("pong"));
|
||||
|
||||
router.post("/audit/:id", async (req: any, res: any) => {
|
||||
const { id } = req.params;
|
||||
const leadsService = new ItemsService("leads", { schema: req.schema, accountability: req.accountability });
|
||||
|
||||
try {
|
||||
const lead = await leadsService.readOne(id);
|
||||
if (!lead) return res.status(404).send({ error: "Lead not found" });
|
||||
|
||||
await leadsService.updateOne(id, { status: "auditing" });
|
||||
|
||||
const acqService = new AcquisitionService(env.OPENROUTER_API_KEY);
|
||||
const result = await acqService.runFullSequence(lead.website_url, lead.briefing, lead.comments);
|
||||
|
||||
await leadsService.updateOne(id, {
|
||||
status: "audit_ready",
|
||||
ai_state: result.state,
|
||||
audit_context: JSON.stringify(result.usage),
|
||||
});
|
||||
|
||||
res.send({ success: true, result });
|
||||
} catch (error: any) {
|
||||
console.error("Audit failed:", error);
|
||||
await leadsService.updateOne(id, { status: "new", comments: `Audit failed: ${error.message}` });
|
||||
res.status(500).send({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.post("/audit-email/:id", async (req: any, res: any) => {
|
||||
const { id } = req.params;
|
||||
const leadsService = new ItemsService("leads", { schema: req.schema, accountability: req.accountability });
|
||||
const peopleService = new ItemsService("people", { schema: req.schema, accountability: req.accountability });
|
||||
const mailService = new MailService({ schema: req.schema, accountability: req.accountability });
|
||||
|
||||
try {
|
||||
const lead = await leadsService.readOne(id);
|
||||
if (!lead || !lead.ai_state) return res.status(400).send({ error: "Lead or Audit not ready" });
|
||||
|
||||
let recipientEmail = lead.contact_email;
|
||||
let companyName = lead.company_name;
|
||||
|
||||
if (lead.contact_person) {
|
||||
const person = await peopleService.readOne(lead.contact_person);
|
||||
if (person && person.email) {
|
||||
recipientEmail = person.email;
|
||||
companyName = person.company || lead.company_name;
|
||||
}
|
||||
}
|
||||
|
||||
if (!recipientEmail) return res.status(400).send({ error: "No recipient email found" });
|
||||
|
||||
const auditHighlights = [
|
||||
`Projekt-Typ: ${lead.ai_state.projectType === "website" ? "Website" : "Web App"}`,
|
||||
...(lead.ai_state.sitemap || []).slice(0, 3).map((item: any) => `Potenzial in: ${item.category}`),
|
||||
];
|
||||
|
||||
const html = await render(createElement(SiteAuditTemplate, {
|
||||
companyName: companyName,
|
||||
websiteUrl: lead.website_url,
|
||||
auditHighlights
|
||||
}));
|
||||
|
||||
await mailService.send({
|
||||
to: recipientEmail,
|
||||
subject: `Analyse Ihrer Webpräsenz: ${companyName}`,
|
||||
html
|
||||
});
|
||||
|
||||
await leadsService.updateOne(id, {
|
||||
status: "contacted",
|
||||
last_contacted_at: new Date().toISOString(),
|
||||
});
|
||||
|
||||
res.send({ success: true });
|
||||
} catch (error: any) {
|
||||
console.error("Audit Email failed:", error);
|
||||
res.status(500).send({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.post("/estimate/:id", async (req: any, res: any) => {
|
||||
const { id } = req.params;
|
||||
const leadsService = new ItemsService("leads", { schema: req.schema, accountability: req.accountability });
|
||||
|
||||
try {
|
||||
const lead = await leadsService.readOne(id);
|
||||
if (!lead || !lead.ai_state) return res.status(400).send({ error: "Lead or AI state not found" });
|
||||
|
||||
const pdfEngine = new PdfEngine();
|
||||
const filename = `estimate_${id}_${Date.now()}.pdf`;
|
||||
const storageRoot = env.STORAGE_LOCAL_ROOT || "./storage";
|
||||
const outputPath = path.join(storageRoot, filename);
|
||||
|
||||
await pdfEngine.generateEstimatePdf(lead.ai_state, outputPath);
|
||||
|
||||
await leadsService.updateOne(id, {
|
||||
audit_pdf_path: filename,
|
||||
});
|
||||
|
||||
res.send({ success: true, filename });
|
||||
} catch (error: any) {
|
||||
console.error("PDF Generation failed:", error);
|
||||
res.status(500).send({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.post("/estimate-email/:id", async (req: any, res: any) => {
|
||||
const { id } = req.params;
|
||||
const leadsService = new ItemsService("leads", { schema: req.schema, accountability: req.accountability });
|
||||
const peopleService = new ItemsService("people", { schema: req.schema, accountability: req.accountability });
|
||||
const mailService = new MailService({ schema: req.schema, accountability: req.accountability });
|
||||
|
||||
try {
|
||||
const lead = await leadsService.readOne(id);
|
||||
if (!lead || !lead.audit_pdf_path) return res.status(400).send({ error: "PDF not generated" });
|
||||
|
||||
let recipientEmail = lead.contact_email;
|
||||
let companyName = lead.company_name;
|
||||
|
||||
if (lead.contact_person) {
|
||||
const person = await peopleService.readOne(lead.contact_person);
|
||||
if (person && person.email) {
|
||||
recipientEmail = person.email;
|
||||
companyName = person.company || lead.company_name;
|
||||
}
|
||||
}
|
||||
|
||||
if (!recipientEmail) return res.status(400).send({ error: "No recipient email found" });
|
||||
|
||||
const html = await render(createElement(ProjectEstimateTemplate, {
|
||||
companyName: companyName,
|
||||
}));
|
||||
|
||||
const storageRoot = env.STORAGE_LOCAL_ROOT || "./storage";
|
||||
const attachmentPath = path.join(storageRoot, lead.audit_pdf_path);
|
||||
|
||||
await mailService.send({
|
||||
to: recipientEmail,
|
||||
subject: `Ihre Projekt-Schätzung: ${companyName}`,
|
||||
html,
|
||||
attachments: [
|
||||
{
|
||||
filename: `Angebot_${companyName}.pdf`,
|
||||
content: fs.readFileSync(attachmentPath)
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
await leadsService.updateOne(id, {
|
||||
status: "contacted",
|
||||
last_contacted_at: new Date().toISOString(),
|
||||
});
|
||||
|
||||
res.send({ success: true });
|
||||
} catch (error: any) {
|
||||
console.error("Estimate Email failed:", error);
|
||||
res.status(500).send({ error: error.message });
|
||||
}
|
||||
});
|
||||
});
|
||||
22
packages/cms-infra/extensions/acquisition/src/shim.ts
Normal file
22
packages/cms-infra/extensions/acquisition/src/shim.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { fileURLToPath } from 'url';
|
||||
import { dirname } from 'path';
|
||||
import { createRequire } from 'module';
|
||||
|
||||
try {
|
||||
const url = import.meta?.url;
|
||||
// Hardcode fallback path for Directus Docker environment
|
||||
const fallbackPath = '/directus/extensions/acquisition/dist/index.js';
|
||||
const filename = url ? fileURLToPath(url) : fallbackPath;
|
||||
const dir = dirname(filename);
|
||||
|
||||
// @ts-ignore
|
||||
globalThis.__filename = filename;
|
||||
// @ts-ignore
|
||||
globalThis.__dirname = dir;
|
||||
// @ts-ignore
|
||||
globalThis.require = createRequire(url || `file://${fallbackPath}`);
|
||||
|
||||
console.log(`[Shim] Loaded. __dirname: ${dir}`);
|
||||
} catch (e) {
|
||||
console.warn("[Shim] Failed to shim __dirname/require", e);
|
||||
}
|
||||
1
packages/cms-infra/extensions/customer-manager/index.js
Normal file
1
packages/cms-infra/extensions/customer-manager/index.js
Normal file
File diff suppressed because one or more lines are too long
20
packages/cms-infra/extensions/customer-manager/package.json
Normal file
20
packages/cms-infra/extensions/customer-manager/package.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"name": "customer-manager",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"directus:extension": {
|
||||
"type": "module",
|
||||
"path": "dist/index.js",
|
||||
"source": "src/index.ts",
|
||||
"host": "^11.0.0"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "directus-extension build",
|
||||
"dev": "directus-extension build -w"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@directus/extensions-sdk": "11.0.2",
|
||||
"vue": "^3.4.34",
|
||||
"typescript": "^5.6.3"
|
||||
}
|
||||
}
|
||||
14
packages/cms-infra/extensions/customer-manager/src/index.ts
Normal file
14
packages/cms-infra/extensions/customer-manager/src/index.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { defineModule } from '@directus/extensions-sdk';
|
||||
import ModuleComponent from './module.vue';
|
||||
|
||||
export default defineModule({
|
||||
id: 'customer-manager',
|
||||
name: 'Customer Manager',
|
||||
icon: 'supervisor_account',
|
||||
routes: [
|
||||
{
|
||||
path: '',
|
||||
component: ModuleComponent,
|
||||
},
|
||||
],
|
||||
});
|
||||
399
packages/cms-infra/extensions/customer-manager/src/module.vue
Normal file
399
packages/cms-infra/extensions/customer-manager/src/module.vue
Normal file
@@ -0,0 +1,399 @@
|
||||
<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>
|
||||
|
||||
<div class="field">
|
||||
<span class="label">Zugehörige Person (Zentral)</span>
|
||||
<v-select
|
||||
v-model="employeeForm.contact_person"
|
||||
:items="peopleOptions"
|
||||
placeholder="Zentrale Person auswählen..."
|
||||
show-deselect
|
||||
/>
|
||||
<p class="field-note">Verknüpft diesen Mitarbeiter mit dem globalen Personen-Verzeichnis.</p>
|
||||
</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, computed } 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 people = 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);
|
||||
|
||||
const peopleOptions = computed(() =>
|
||||
people.value.map(p => ({
|
||||
text: `${p.first_name} ${p.last_name} (${p.company || 'Keine Firma'})`,
|
||||
value: p.id
|
||||
}))
|
||||
);
|
||||
|
||||
// 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: '',
|
||||
contact_person: null as string | null,
|
||||
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 [companyRes, peopleRes] = await Promise.all([
|
||||
api.get('/items/companies', { params: { fields: ['id', 'name'], sort: 'name' } }),
|
||||
api.get('/items/people', { params: { sort: 'last_name' } })
|
||||
]);
|
||||
companies.value = companyRes.data.data;
|
||||
people.value = peopleRes.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: '', contact_person: null, 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 || '',
|
||||
contact_person: item.contact_person || null,
|
||||
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,
|
||||
contact_person: employeeForm.value.contact_person
|
||||
});
|
||||
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,
|
||||
contact_person: employeeForm.value.contact_person
|
||||
});
|
||||
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>
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"name": "feedback-commander",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"directus:extension": {
|
||||
"type": "module",
|
||||
"path": "dist/index.js",
|
||||
"source": "src/index.ts",
|
||||
"host": "^11.0.0"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "directus-extension build",
|
||||
"dev": "directus-extension build -w"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@directus/extensions-sdk": "11.0.2",
|
||||
"vue": "^3.4.34",
|
||||
"typescript": "^5.6.3"
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
],
|
||||
});
|
||||
746
packages/cms-infra/extensions/feedback-commander/src/module.vue
Normal file
746
packages/cms-infra/extensions/feedback-commander/src/module.vue
Normal file
@@ -0,0 +1,746 @@
|
||||
<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-select
|
||||
v-model="selectedItem.contact_person"
|
||||
:items="peopleOptions"
|
||||
inline
|
||||
placeholder="Bezugsperson..."
|
||||
show-deselect
|
||||
@update:model-value="updatePerson"
|
||||
/>
|
||||
<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 people = 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 peopleOptions = computed(() =>
|
||||
people.value.map(p => ({
|
||||
text: `${p.first_name} ${p.last_name}`,
|
||||
value: p.id
|
||||
}))
|
||||
);
|
||||
|
||||
async function fetchData() {
|
||||
loading.value = true;
|
||||
fetchError.value = null;
|
||||
try {
|
||||
const [feedbackRes, peopleRes] = await Promise.all([
|
||||
api.get('/items/visual_feedback', {
|
||||
params: {
|
||||
sort: '-date_created,-id',
|
||||
limit: 300
|
||||
}
|
||||
}),
|
||||
api.get('/items/people', { params: { sort: 'last_name' } })
|
||||
]);
|
||||
items.value = feedbackRes.data.data;
|
||||
people.value = peopleRes.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 updatePerson(val) {
|
||||
if (!selectedItem.value) return;
|
||||
try {
|
||||
await api.patch(`/items/visual_feedback/${selectedItem.value.id}`, {
|
||||
contact_person: 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>
|
||||
1
packages/cms-infra/extensions/people-manager/index.js
Normal file
1
packages/cms-infra/extensions/people-manager/index.js
Normal file
File diff suppressed because one or more lines are too long
20
packages/cms-infra/extensions/people-manager/package.json
Normal file
20
packages/cms-infra/extensions/people-manager/package.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"name": "people-manager",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"directus:extension": {
|
||||
"type": "module",
|
||||
"path": "dist/index.js",
|
||||
"source": "src/index.ts",
|
||||
"host": "^11.0.0"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "directus-extension build",
|
||||
"dev": "directus-extension build -w"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@directus/extensions-sdk": "11.0.2",
|
||||
"vue": "^3.4.34",
|
||||
"typescript": "^5.6.3"
|
||||
}
|
||||
}
|
||||
14
packages/cms-infra/extensions/people-manager/src/index.ts
Normal file
14
packages/cms-infra/extensions/people-manager/src/index.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { defineModule } from '@directus/extensions-sdk';
|
||||
import ModuleComponent from './module.vue';
|
||||
|
||||
export default defineModule({
|
||||
id: 'people-manager',
|
||||
name: 'People Manager',
|
||||
icon: 'person',
|
||||
routes: [
|
||||
{
|
||||
path: '',
|
||||
component: ModuleComponent,
|
||||
},
|
||||
],
|
||||
});
|
||||
296
packages/cms-infra/extensions/people-manager/src/module.vue
Normal file
296
packages/cms-infra/extensions/people-manager/src/module.vue
Normal file
@@ -0,0 +1,296 @@
|
||||
<template>
|
||||
<private-view title="People Manager">
|
||||
<template #navigation>
|
||||
<v-list nav>
|
||||
<v-list-item @click="openCreateDrawer" clickable>
|
||||
<v-list-item-icon>
|
||||
<v-icon name="add" color="var(--theme--primary)" />
|
||||
</v-list-item-icon>
|
||||
<v-list-item-content>
|
||||
<v-text-overflow text="Neue Person anlegen" />
|
||||
</v-list-item-content>
|
||||
</v-list-item>
|
||||
|
||||
<v-divider />
|
||||
|
||||
<v-list-item
|
||||
v-for="person in people"
|
||||
:key="person.id"
|
||||
:active="selectedPerson?.id === person.id"
|
||||
class="person-item"
|
||||
clickable
|
||||
@click="selectPerson(person)"
|
||||
>
|
||||
<v-list-item-icon>
|
||||
<v-icon name="person" />
|
||||
</v-list-item-icon>
|
||||
<v-list-item-content>
|
||||
<v-text-overflow :text="`${person.first_name} ${person.last_name}`" />
|
||||
</v-list-item-content>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</template>
|
||||
|
||||
<div class="content-wrapper">
|
||||
<v-notice v-if="feedback" :type="feedback.type" @close="feedback = null" dismissible>
|
||||
{{ feedback.message }}
|
||||
</v-notice>
|
||||
|
||||
<div v-if="!selectedPerson" class="empty-state">
|
||||
<v-info title="Person auswählen" icon="person" center>
|
||||
Wähle eine Person in der Navigation aus oder
|
||||
<v-button x-small @click="openCreateDrawer">erstelle eine neue Person</v-button>.
|
||||
</v-info>
|
||||
</div>
|
||||
|
||||
<div v-else>
|
||||
<header class="header">
|
||||
<div class="header-left">
|
||||
<h1 class="title">{{ selectedPerson.first_name }} {{ selectedPerson.last_name }}</h1>
|
||||
<p class="subtitle">{{ selectedPerson.email || 'Keine E-Mail angegeben' }}</p>
|
||||
</div>
|
||||
|
||||
<div class="header-right">
|
||||
<v-button secondary rounded icon v-tooltip="'Person bearbeiten'" @click="openEditDrawer">
|
||||
<v-icon name="edit" />
|
||||
</v-button>
|
||||
<v-button danger rounded icon v-tooltip="'Person löschen'" @click="deletePerson">
|
||||
<v-icon name="delete" />
|
||||
</v-button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<v-divider />
|
||||
|
||||
<div class="details-grid">
|
||||
<div class="detail-item">
|
||||
<span class="label">Organisation</span>
|
||||
<p class="value">{{ selectedPerson.company || '---' }}</p>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<span class="label">Telefon</span>
|
||||
<p class="value">{{ selectedPerson.phone || '---' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Create/Edit Drawer -->
|
||||
<v-drawer
|
||||
v-model="drawerActive"
|
||||
:title="isEditing ? 'Person bearbeiten' : 'Neue Person anlegen'"
|
||||
icon="person"
|
||||
@cancel="drawerActive = false"
|
||||
>
|
||||
<template #default>
|
||||
<div class="drawer-content">
|
||||
<div class="form-section">
|
||||
<div class="field">
|
||||
<span class="label">Vorname</span>
|
||||
<v-input v-model="form.first_name" placeholder="Vorname" autofocus />
|
||||
</div>
|
||||
<div class="field">
|
||||
<span class="label">Nachname</span>
|
||||
<v-input v-model="form.last_name" placeholder="Nachname" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<span class="label">E-Mail</span>
|
||||
<v-input v-model="form.email" placeholder="email@beispiel.de" type="email" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<span class="label">Organisation / Firma</span>
|
||||
<v-input v-model="form.company" placeholder="z.B. Mintel" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<span class="label">Telefon</span>
|
||||
<v-input v-model="form.phone" placeholder="+49 ..." />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="drawer-actions">
|
||||
<v-button primary block :loading="saving" @click="savePerson">
|
||||
Person speichern
|
||||
</v-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</v-drawer>
|
||||
</private-view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { useApi } from '@directus/extensions-sdk';
|
||||
|
||||
const api = useApi();
|
||||
const people = ref([]);
|
||||
const selectedPerson = ref(null);
|
||||
const feedback = ref(null);
|
||||
const saving = ref(false);
|
||||
const drawerActive = ref(false);
|
||||
const isEditing = ref(false);
|
||||
|
||||
const form = ref({
|
||||
id: null,
|
||||
first_name: '',
|
||||
last_name: '',
|
||||
email: '',
|
||||
company: '',
|
||||
phone: ''
|
||||
});
|
||||
|
||||
async function fetchPeople() {
|
||||
try {
|
||||
const response = await api.get('/items/people', {
|
||||
params: { sort: 'last_name' }
|
||||
});
|
||||
people.ref = response.data.data;
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch people:', error);
|
||||
}
|
||||
}
|
||||
|
||||
function selectPerson(person) {
|
||||
selectedPerson.value = person;
|
||||
}
|
||||
|
||||
function openCreateDrawer() {
|
||||
isEditing.value = false;
|
||||
form.value = { id: null, first_name: '', last_name: '', email: '', company: '', phone: '' };
|
||||
drawerActive.value = true;
|
||||
}
|
||||
|
||||
function openEditDrawer() {
|
||||
isEditing.value = true;
|
||||
form.value = { ...selectedPerson.value };
|
||||
drawerActive.value = true;
|
||||
}
|
||||
|
||||
async function savePerson() {
|
||||
if (!form.value.first_name || !form.value.last_name) {
|
||||
feedback.value = { type: 'danger', message: 'Vor- und Nachname sind erforderlich.' };
|
||||
return;
|
||||
}
|
||||
|
||||
saving.value = true;
|
||||
try {
|
||||
if (isEditing.value) {
|
||||
await api.patch(`/items/people/${form.value.id}`, form.value);
|
||||
feedback.value = { type: 'success', message: 'Person aktualisiert!' };
|
||||
} else {
|
||||
await api.post('/items/people', form.value);
|
||||
feedback.value = { type: 'success', message: 'Person angelegt!' };
|
||||
}
|
||||
drawerActive.value = false;
|
||||
await fetchPeople();
|
||||
if (isEditing.value) {
|
||||
selectedPerson.value = form.value;
|
||||
}
|
||||
} catch (error) {
|
||||
feedback.value = { type: 'danger', message: error.message };
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function deletePerson() {
|
||||
if (!confirm('Soll diese Person wirklich gelöscht werden?')) return;
|
||||
|
||||
try {
|
||||
await api.delete(`/items/people/${selectedPerson.value.id}`);
|
||||
feedback.value = { type: 'success', message: 'Person gelöscht.' };
|
||||
selectedPerson.value = null;
|
||||
await fetchPeople();
|
||||
} catch (error) {
|
||||
feedback.value = { type: 'danger', message: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(fetchPeople);
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.content-wrapper {
|
||||
padding: 32px;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.header {
|
||||
margin-bottom: 24px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 24px;
|
||||
font-weight: 800;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
color: var(--theme--foreground-subdued);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.header-right {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.details-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 32px;
|
||||
margin-top: 32px;
|
||||
}
|
||||
|
||||
.detail-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.label {
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
color: var(--theme--foreground-subdued);
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.value {
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.drawer-content {
|
||||
padding: 24px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 32px;
|
||||
}
|
||||
|
||||
.form-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.drawer-actions {
|
||||
margin-top: 24px;
|
||||
}
|
||||
</style>
|
||||
14
packages/cms-infra/package.json
Normal file
14
packages/cms-infra/package.json
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"name": "@mintel/cms-infra",
|
||||
"version": "1.7.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "pnpm --filter \"./extensions/**\" build",
|
||||
"dev": "pnpm --filter \"./extensions/**\" dev",
|
||||
"up": "docker compose up -d",
|
||||
"up:build": "docker compose up -d --build",
|
||||
"down": "docker compose down",
|
||||
"logs": "docker compose logs -f"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export default (router) => {
|
||||
router.get('/ping', (req, res) => res.send('pong'));
|
||||
};
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"name": "test-extension",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"directus:extension": {
|
||||
"type": "endpoint",
|
||||
"path": "index.js",
|
||||
"host": "^11.0.0"
|
||||
}
|
||||
}
|
||||
1416
packages/cms-infra/schema/snapshot.yaml
Normal file
1416
packages/cms-infra/schema/snapshot.yaml
Normal file
File diff suppressed because it is too large
Load Diff
75
packages/cms-infra/scripts/bootstrap-schema.sh
Normal file
75
packages/cms-infra/scripts/bootstrap-schema.sh
Normal file
@@ -0,0 +1,75 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Configuration
|
||||
API_URL="http://localhost:8059"
|
||||
EMAIL="marc@mintel.me"
|
||||
PASSWORD="Tim300493."
|
||||
|
||||
echo "Logging in to Directus..."
|
||||
TOKEN=$(curl -s -X POST "${API_URL}/auth/login" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"email\":\"${EMAIL}\", \"password\":\"${PASSWORD}\"}" | grep -o '"access_token":"[^"]*' | cut -d'"' -f4)
|
||||
|
||||
if [ -z "$TOKEN" ]; then
|
||||
echo "Login failed!"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Hiding 'leads' collection..."
|
||||
curl -s -X PATCH "${API_URL}/collections/leads" \
|
||||
-H "Authorization: Bearer ${TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"meta": {"hidden": true}}'
|
||||
|
||||
echo "Creating 'people' collection..."
|
||||
curl -s -X POST "${API_URL}/collections" \
|
||||
-H "Authorization: Bearer ${TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"collection": "people",
|
||||
"schema": {},
|
||||
"meta": {
|
||||
"icon": "person",
|
||||
"display_template": "{{first_name}} {{last_name}}",
|
||||
"show_status_indicator": true
|
||||
}
|
||||
}'
|
||||
|
||||
echo "Adding fields to 'people'..."
|
||||
FIELDS='[
|
||||
{"field": "first_name", "type": "string", "meta": {"interface": "input", "width": "half"}},
|
||||
{"field": "last_name", "type": "string", "meta": {"interface": "input", "width": "half"}},
|
||||
{"field": "email", "type": "string", "meta": {"interface": "input", "width": "half"}},
|
||||
{"field": "phone", "type": "string", "meta": {"interface": "input", "width": "half"}},
|
||||
{"field": "company", "type": "string", "meta": {"interface": "input", "width": "full"}}
|
||||
]'
|
||||
|
||||
for field in $(echo "${FIELDS}" | jq -c '.[]'); do
|
||||
curl -s -X POST "${API_URL}/fields/people" \
|
||||
-H "Authorization: Bearer ${TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "${field}"
|
||||
done
|
||||
|
||||
echo "Adding 'contact_person' to 'leads', 'client_users', and 'visual_feedback'..."
|
||||
for collection in leads client_users visual_feedback; do
|
||||
curl -s -X POST "${API_URL}/fields/${collection}" \
|
||||
-H "Authorization: Bearer ${TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"field": "contact_person",
|
||||
"type": "uuid",
|
||||
"meta": {
|
||||
"interface": "select-dropdown-m2o",
|
||||
"options": {
|
||||
"template": "{{first_name}} {{last_name}}"
|
||||
}
|
||||
},
|
||||
"schema": {
|
||||
"foreign_key_column": "id",
|
||||
"foreign_key_table": "people"
|
||||
}
|
||||
}'
|
||||
done
|
||||
|
||||
echo "Done!"
|
||||
1
packages/cms-infra/uploads/directus-health-file
Normal file
1
packages/cms-infra/uploads/directus-health-file
Normal file
@@ -0,0 +1 @@
|
||||
xmKX5
|
||||
1
packages/customer-manager/index.js
Normal file
1
packages/customer-manager/index.js
Normal file
File diff suppressed because one or more lines are too long
29
packages/customer-manager/package.json
Normal file
29
packages/customer-manager/package.json
Normal file
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"name": "customer-manager",
|
||||
"description": "Custom High-Fidelity Customer & Company Management for Directus",
|
||||
"icon": "supervisor_account",
|
||||
"version": "1.7.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"
|
||||
}
|
||||
}
|
||||
14
packages/customer-manager/src/index.ts
Normal file
14
packages/customer-manager/src/index.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { defineModule } from '@directus/extensions-sdk';
|
||||
import ModuleComponent from './module.vue';
|
||||
|
||||
export default defineModule({
|
||||
id: 'customer-manager',
|
||||
name: 'Customer Manager',
|
||||
icon: 'supervisor_account',
|
||||
routes: [
|
||||
{
|
||||
path: '',
|
||||
component: ModuleComponent,
|
||||
},
|
||||
],
|
||||
});
|
||||
377
packages/customer-manager/src/module.vue
Normal file
377
packages/customer-manager/src/module.vue
Normal 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>
|
||||
@@ -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",
|
||||
},
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@mintel/eslint-config",
|
||||
"version": "1.0.1",
|
||||
"version": "1.7.0",
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
"registry": "https://npm.infra.mintel.me"
|
||||
@@ -20,7 +20,10 @@
|
||||
"dependencies": {
|
||||
"@eslint/eslintrc": "^3.0.0",
|
||||
"@eslint/js": "^9.39.2",
|
||||
"eslint-config-next": "15.1.6",
|
||||
"@next/eslint-plugin-next": "16.1.6",
|
||||
"eslint-config-next": "16.1.6",
|
||||
"eslint-plugin-react": "^7.37.5",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"typescript-eslint": "^8.54.0"
|
||||
}
|
||||
}
|
||||
|
||||
29
packages/feedback-commander/package.json
Normal file
29
packages/feedback-commander/package.json
Normal file
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"name": "@mintel/extension-feedback-commander",
|
||||
"description": "Custom High-Fidelity Feedback Management Extension for Directus",
|
||||
"icon": "view_kanban",
|
||||
"version": "1.7.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"
|
||||
}
|
||||
}
|
||||
14
packages/feedback-commander/src/index.ts
Normal file
14
packages/feedback-commander/src/index.ts
Normal 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,
|
||||
},
|
||||
],
|
||||
});
|
||||
723
packages/feedback-commander/src/module.vue
Normal file
723
packages/feedback-commander/src/module.vue
Normal 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>
|
||||
15
packages/gatekeeper/dev-output.log
Normal file
15
packages/gatekeeper/dev-output.log
Normal file
@@ -0,0 +1,15 @@
|
||||
|
||||
> @mintel/gatekeeper@1.0.0 dev
|
||||
> next dev
|
||||
|
||||
⚠ Port 3000 is in use, trying 3001 instead.
|
||||
▲ Next.js 15.1.6
|
||||
- Local: http://localhost:3001
|
||||
- Network: http://192.168.1.126:3001
|
||||
- Experiments (use with caution):
|
||||
· clientTraceMetadata
|
||||
|
||||
✓ Starting...
|
||||
warn - It seems like you don't have a global error handler set up. It is recommended that you add a global-error.js file with Sentry instrumentation so that React rendering errors are reported to Sentry. Read more: https://docs.sentry.io/platforms/javascript/guides/nextjs/manual-setup/#react-render-errors-in-app-router (you can suppress this warning by setting SENTRY_SUPPRESS_GLOBAL_ERROR_HANDLER_FILE_WARNING=1 as environment variable)
|
||||
✓ Ready in 2.7s
|
||||
[?25h
|
||||
@@ -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);
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
{
|
||||
"name": "@mintel/gatekeeper",
|
||||
"version": "1.0.0",
|
||||
"version": "1.7.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint",
|
||||
"lint": "eslint src/",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@mintel/next-utils": "workspace:*",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^0.474.0",
|
||||
"next": "15.1.6",
|
||||
"next": "16.1.6",
|
||||
"next-intl": "^4.8.2",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
@@ -24,6 +24,7 @@
|
||||
"@mintel/eslint-config": "workspace:*",
|
||||
"@mintel/next-config": "workspace:*",
|
||||
"@mintel/tsconfig": "workspace:*",
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"@types/node": "^20.0.0",
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
|
||||
7
packages/gatekeeper/postcss.config.cjs
Normal file
7
packages/gatekeeper/postcss.config.cjs
Normal file
@@ -0,0 +1,7 @@
|
||||
/* global module */
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
12
packages/gatekeeper/public/icon-white.svg
Normal file
12
packages/gatekeeper/public/icon-white.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 29 KiB |
12
packages/gatekeeper/public/logo-black.svg
Normal file
12
packages/gatekeeper/public/logo-black.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 17 KiB |
12
packages/gatekeeper/public/logo-white.svg
Normal file
12
packages/gatekeeper/public/logo-white.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 19 KiB |
@@ -9,17 +9,79 @@ export async function GET(req: NextRequest) {
|
||||
|
||||
const session = cookieStore.get(authCookieName);
|
||||
|
||||
if (session?.value === password) {
|
||||
return new NextResponse("OK", { status: 200 });
|
||||
}
|
||||
|
||||
// Traefik ForwardAuth headers
|
||||
// 1. URL Parameter Bypass (for automated tests/staging)
|
||||
const originalUrl = req.headers.get("x-forwarded-uri") || "/";
|
||||
const host =
|
||||
req.headers.get("x-forwarded-host") || req.headers.get("host") || "";
|
||||
const proto = req.headers.get("x-forwarded-proto") || "https";
|
||||
|
||||
const loginUrl = `${proto}://${host}/gatekeeper/login?redirect=${encodeURIComponent(originalUrl)}`;
|
||||
try {
|
||||
const url = new URL(originalUrl, `${proto}://${host}`);
|
||||
if (url.searchParams.get("gk_bypass") === password) {
|
||||
// Remove the bypass parameter from the redirect URL
|
||||
url.searchParams.delete("gk_bypass");
|
||||
const cleanUrl = url.pathname + url.search;
|
||||
const absoluteCleanUrl = `${proto}://${host}${cleanUrl}`;
|
||||
|
||||
const response = NextResponse.redirect(absoluteCleanUrl);
|
||||
|
||||
// Set the session cookie so the bypass is persistent
|
||||
const isDev = process.env.NODE_ENV === "development";
|
||||
const cookieDomain = process.env.COOKIE_DOMAIN;
|
||||
const sessionValue = JSON.stringify({
|
||||
identity: "Bypass",
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
|
||||
response.cookies.set(authCookieName, sessionValue, {
|
||||
httpOnly: true,
|
||||
secure: !isDev,
|
||||
path: "/",
|
||||
maxAge: 30 * 24 * 60 * 60, // 30 days
|
||||
sameSite: "lax",
|
||||
...(cookieDomain ? { domain: cookieDomain } : {}),
|
||||
});
|
||||
|
||||
return response;
|
||||
}
|
||||
} catch (_e) {
|
||||
// URL parsing failed, proceed with normal logic
|
||||
}
|
||||
|
||||
let isAuthenticated = false;
|
||||
let identity = "Guest";
|
||||
|
||||
if (session?.value) {
|
||||
if (session.value === password) {
|
||||
isAuthenticated = true;
|
||||
} else {
|
||||
try {
|
||||
const payload = JSON.parse(session.value);
|
||||
if (payload.identity) {
|
||||
isAuthenticated = true;
|
||||
identity = payload.identity;
|
||||
}
|
||||
} catch (_e) {
|
||||
// Fallback or old format
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (isAuthenticated) {
|
||||
return new NextResponse("OK", {
|
||||
status: 200,
|
||||
headers: {
|
||||
"X-Auth-User": identity,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Traefik ForwardAuth headers
|
||||
const gatekeeperUrl =
|
||||
process.env.NEXT_PUBLIC_BASE_URL || `${proto}://gatekeeper.${host}`;
|
||||
const absoluteOriginalUrl = `${proto}://${host}${originalUrl}`;
|
||||
|
||||
const loginUrl = `${gatekeeperUrl}/login?redirect=${encodeURIComponent(absoluteOriginalUrl)}`;
|
||||
|
||||
return NextResponse.redirect(loginUrl);
|
||||
}
|
||||
|
||||
29
packages/gatekeeper/src/app/api/whoami/route.ts
Normal file
29
packages/gatekeeper/src/app/api/whoami/route.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
@@ -1,131 +0,0 @@
|
||||
import { cookies } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
import { Lock, ShieldCheck, ArrowRight } from "lucide-react";
|
||||
|
||||
interface LoginPageProps {
|
||||
searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
|
||||
}
|
||||
|
||||
export default async function LoginPage({ searchParams }: LoginPageProps) {
|
||||
const params = await searchParams;
|
||||
const redirectUrl = (params.redirect as string) || "/";
|
||||
const error = params.error === "1";
|
||||
|
||||
const projectName = process.env.PROJECT_NAME || "Mintel";
|
||||
const projectColor = process.env.PROJECT_COLOR || "#82ed20";
|
||||
|
||||
async function login(formData: FormData) {
|
||||
"use server";
|
||||
|
||||
const password = formData.get("password");
|
||||
const expectedPassword = process.env.GATEKEEPER_PASSWORD || "mintel";
|
||||
const authCookieName =
|
||||
process.env.AUTH_COOKIE_NAME || "mintel_gatekeeper_session";
|
||||
const targetRedirect = formData.get("redirect") as string;
|
||||
|
||||
if (password === expectedPassword) {
|
||||
const cookieStore = await cookies();
|
||||
cookieStore.set(authCookieName, expectedPassword, {
|
||||
httpOnly: true,
|
||||
secure: true,
|
||||
path: "/",
|
||||
maxAge: 30 * 24 * 60 * 60, // 30 days
|
||||
sameSite: "lax",
|
||||
});
|
||||
redirect(targetRedirect);
|
||||
} else {
|
||||
redirect(
|
||||
`/gatekeeper/login?error=1&redirect=${encodeURIComponent(targetRedirect)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center relative overflow-hidden bg-mintel-dark">
|
||||
{/* Background Decor */}
|
||||
<div className="absolute inset-0 bg-grid pointer-events-none opacity-20" />
|
||||
<div
|
||||
className="absolute inset-0 pointer-events-none opacity-30"
|
||||
style={{
|
||||
background: `radial-gradient(circle at center, ${projectColor}11 0%, transparent 70%)`,
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="relative z-10 w-full max-w-md px-6 animate-in fade-in zoom-in duration-700">
|
||||
{/* Logo / Icon */}
|
||||
<div className="flex justify-center mb-12">
|
||||
<div
|
||||
className="w-20 h-20 rounded-3xl flex items-center justify-center border border-white/10 bg-white/5 backdrop-blur-xl shadow-2xl"
|
||||
style={{ borderBottom: `2px solid ${projectColor}44` }}
|
||||
>
|
||||
<ShieldCheck
|
||||
className="w-10 h-10"
|
||||
style={{ color: projectColor }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white/[0.03] backdrop-blur-3xl border border-white/10 p-10 rounded-[48px] shadow-2xl relative overflow-hidden group">
|
||||
{/* Subtle accent line */}
|
||||
<div
|
||||
className="absolute top-0 left-0 w-full h-1 opacity-50"
|
||||
style={{
|
||||
background: `linear-gradient(to right, transparent, ${projectColor}, transparent)`,
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="mb-10 text-center">
|
||||
<h1 className="text-3xl font-black mb-3 tracking-tighter uppercase italic flex items-center justify-center gap-2">
|
||||
{projectName.split(" ")[0]}
|
||||
<span style={{ color: projectColor }}>GATEKEEPER</span>
|
||||
</h1>
|
||||
<p className="text-white/40 text-sm font-medium">
|
||||
Restricted Infrastructure Access
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="bg-red-500/10 border border-red-500/20 text-red-200 p-4 rounded-2xl mb-8 text-sm flex items-center gap-3 animate-pulse">
|
||||
<Lock className="w-5 h-5 flex-shrink-0" />
|
||||
<span>Invalid access password. Please try again.</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form action={login} className="space-y-8">
|
||||
<input type="hidden" name="redirect" value={redirectUrl} />
|
||||
|
||||
<div className="space-y-3">
|
||||
<label className="block text-[10px] font-black uppercase tracking-[0.3em] text-white/20 ml-5">
|
||||
Authentication Code
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
name="password"
|
||||
required
|
||||
autoFocus
|
||||
className="w-full bg-white/5 border border-white/10 rounded-3xl px-8 py-6 focus:outline-none focus:ring-2 transition-all text-xl tracking-[0.5em] text-center placeholder:tracking-normal placeholder:text-white/10"
|
||||
style={{ "--tw-ring-color": `${projectColor}33` } as any}
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
className="w-full font-black uppercase tracking-[0.2em] py-6 rounded-3xl transition-all active:scale-[0.98] flex items-center justify-center gap-3 shadow-lg hover:shadow-mintel-green/10"
|
||||
style={{ backgroundColor: projectColor, color: "#000" }}
|
||||
>
|
||||
Verify Identity
|
||||
<ArrowRight className="w-5 h-5" />
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div className="mt-12 text-center">
|
||||
<p className="text-[10px] font-bold text-white/10 uppercase tracking-[0.5em]">
|
||||
© 2026 {projectName} Infrastructure
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,20 +2,83 @@
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
:root {
|
||||
--background: #000c1f;
|
||||
--foreground: #ffffff;
|
||||
@layer base {
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-white text-slate-800 font-serif antialiased selection:bg-slate-900 selection:text-white;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6 {
|
||||
@apply font-sans font-bold text-slate-900 tracking-tighter;
|
||||
}
|
||||
|
||||
p {
|
||||
@apply mb-4 text-base leading-relaxed text-slate-700;
|
||||
}
|
||||
|
||||
a {
|
||||
@apply text-slate-900 hover:text-slate-700 transition-colors no-underline;
|
||||
}
|
||||
}
|
||||
|
||||
body {
|
||||
color: var(--foreground);
|
||||
background: var(--background);
|
||||
min-height: 100vh;
|
||||
@layer components {
|
||||
.narrow-container {
|
||||
@apply max-w-4xl mx-auto px-6 py-10;
|
||||
}
|
||||
|
||||
.btn {
|
||||
@apply inline-flex items-center justify-center px-6 py-3 border border-slate-200 bg-white text-slate-600 font-sans font-bold text-sm uppercase tracking-widest rounded-full transition-all duration-500 ease-industrial hover:border-slate-400 hover:text-slate-900 hover:bg-slate-50 hover:-translate-y-0.5 hover:shadow-xl hover:shadow-slate-100 active:translate-y-0 active:shadow-sm;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
@apply border-slate-900 text-slate-900 hover:bg-slate-900 hover:text-white;
|
||||
}
|
||||
}
|
||||
|
||||
.bg-grid {
|
||||
background-image:
|
||||
linear-gradient(to right, rgba(255, 255, 255, 0.03) 1px, transparent 1px),
|
||||
linear-gradient(to bottom, rgba(255, 255, 255, 0.03) 1px, transparent 1px);
|
||||
background-size: 40px 40px;
|
||||
/* Custom scrollbar */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: #f1f5f9;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #cbd5e1;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: #94a3b8;
|
||||
}
|
||||
|
||||
/* Animations */
|
||||
@keyframes shake {
|
||||
0%,
|
||||
100% {
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
25% {
|
||||
transform: translateX(-4px);
|
||||
}
|
||||
|
||||
75% {
|
||||
transform: translateX(4px);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-shake {
|
||||
animation: shake 0.2s ease-in-out 0s 2;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,15 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Inter, Newsreader } from "next/font/google";
|
||||
import "./globals.css";
|
||||
|
||||
const inter = Inter({ subsets: ["latin"], variable: "--font-inter" });
|
||||
const newsreader = Newsreader({
|
||||
subsets: ["latin"],
|
||||
variable: "--font-newsreader",
|
||||
style: "italic",
|
||||
display: "swap",
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Gatekeeper | Access Control",
|
||||
description: "Mintel Infrastructure Protection",
|
||||
@@ -12,7 +21,7 @@ export default function RootLayout({
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<html lang="en" className={`${inter.variable} ${newsreader.variable}`}>
|
||||
<body className="antialiased">{children}</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
244
packages/gatekeeper/src/app/login/page.tsx
Normal file
244
packages/gatekeeper/src/app/login/page.tsx
Normal file
@@ -0,0 +1,244 @@
|
||||
import { cookies } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
import { ArrowRight, ShieldCheck } from "lucide-react";
|
||||
import Image from "next/image";
|
||||
|
||||
interface LoginPageProps {
|
||||
searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
|
||||
}
|
||||
|
||||
export default async function LoginPage({ searchParams }: LoginPageProps) {
|
||||
const params = await searchParams;
|
||||
const redirectUrl = (params.redirect as string) || "/";
|
||||
const error = params.error === "1";
|
||||
|
||||
const projectName = process.env.PROJECT_NAME || "Mintel";
|
||||
|
||||
async function login(formData: FormData) {
|
||||
"use server";
|
||||
|
||||
const email = formData.get("email") as string;
|
||||
const password = formData.get("password") as string;
|
||||
|
||||
const expectedCode = process.env.GATEKEEPER_PASSWORD || "mintel";
|
||||
const adminEmail = process.env.DIRECTUS_ADMIN_EMAIL;
|
||||
const adminPassword = process.env.DIRECTUS_ADMIN_PASSWORD;
|
||||
const authCookieName =
|
||||
process.env.AUTH_COOKIE_NAME || "mintel_gatekeeper_session";
|
||||
const targetRedirect = formData.get("redirect") as string;
|
||||
const cookieDomain = process.env.COOKIE_DOMAIN;
|
||||
|
||||
let userIdentity = "";
|
||||
let userCompany: any = null;
|
||||
|
||||
// 1. Check Global Admin (from ENV)
|
||||
if (
|
||||
adminEmail &&
|
||||
adminPassword &&
|
||||
email === adminEmail &&
|
||||
password === adminPassword
|
||||
) {
|
||||
userIdentity = "Admin";
|
||||
}
|
||||
// 2. Check Generic Code (Guest)
|
||||
else if (!email && password === expectedCode) {
|
||||
userIdentity = "Guest";
|
||||
}
|
||||
// 3. Check Lightweight Client Users (dedicated collection)
|
||||
if (email && password && process.env.INFRA_DIRECTUS_URL) {
|
||||
try {
|
||||
const clientUsersRes = await fetch(
|
||||
`${process.env.INFRA_DIRECTUS_URL}/items/client_users?filter[email][_eq]=${encodeURIComponent(
|
||||
email,
|
||||
)}&fields=*,company.*`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${process.env.INFRA_DIRECTUS_TOKEN}`,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
if (clientUsersRes.ok) {
|
||||
const { data: users } = await clientUsersRes.json();
|
||||
const clientUser = users[0];
|
||||
|
||||
// ⚠️ NOTE: Plain text check for demo/dev, should use argon2 in production
|
||||
if (
|
||||
clientUser &&
|
||||
(clientUser.password === password ||
|
||||
clientUser.temporary_password === password)
|
||||
) {
|
||||
userIdentity = clientUser.first_name || clientUser.email;
|
||||
userCompany = {
|
||||
id: clientUser.company?.id,
|
||||
name: clientUser.company?.name,
|
||||
};
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Client User Auth Error:", e);
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Fallback to Directus Staff Auth if still not identified
|
||||
if (!userIdentity && email && password && process.env.DIRECTUS_URL) {
|
||||
try {
|
||||
const loginRes = await fetch(`${process.env.DIRECTUS_URL}/auth/login`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ email, password }),
|
||||
});
|
||||
|
||||
if (loginRes.ok) {
|
||||
const { data } = await loginRes.json();
|
||||
const accessToken = data.access_token;
|
||||
|
||||
// Fetch user info with company depth
|
||||
const userRes = await fetch(
|
||||
`${process.env.DIRECTUS_URL}/users/me?fields=*,company.*`,
|
||||
{
|
||||
headers: { Authorization: `Bearer ${accessToken}` },
|
||||
},
|
||||
);
|
||||
|
||||
if (userRes.ok) {
|
||||
const { data: user } = await userRes.json();
|
||||
userIdentity = user.first_name || user.email;
|
||||
userCompany = {
|
||||
id: user.company?.id,
|
||||
name: user.company?.name,
|
||||
};
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Directus Auth Error:", e);
|
||||
}
|
||||
}
|
||||
|
||||
if (userIdentity) {
|
||||
const cookieStore = await cookies();
|
||||
// Store identity in the cookie (simplified for now, ideally signed)
|
||||
const sessionValue = JSON.stringify({
|
||||
identity: userIdentity,
|
||||
company: userCompany,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
|
||||
const isDev = process.env.NODE_ENV === "development";
|
||||
|
||||
cookieStore.set(authCookieName, sessionValue, {
|
||||
httpOnly: true,
|
||||
secure: !isDev,
|
||||
path: "/",
|
||||
maxAge: 30 * 24 * 60 * 60, // 30 days
|
||||
sameSite: "lax",
|
||||
...(cookieDomain ? { domain: cookieDomain } : {}),
|
||||
});
|
||||
redirect(targetRedirect);
|
||||
} else {
|
||||
redirect(`/login?error=1&redirect=${encodeURIComponent(targetRedirect)}`);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center relative bg-white font-serif antialiased overflow-hidden">
|
||||
{/* Background Decor - Signature mintel.me style */}
|
||||
<div
|
||||
className="absolute inset-0 pointer-events-none opacity-[0.03] scale-[1.01]"
|
||||
style={{
|
||||
backgroundImage: `linear-gradient(to right, #000 1px, transparent 1px), linear-gradient(to bottom, #000 1px, transparent 1px)`,
|
||||
backgroundSize: "clamp(30px, 8vw, 40px) clamp(30px, 8vw, 40px)",
|
||||
}}
|
||||
/>
|
||||
|
||||
<main className="relative z-10 w-full max-w-sm px-8 sm:px-6">
|
||||
<div className="space-y-12 sm:space-y-16 animate-fade-in">
|
||||
{/* Top Icon Box - Signature mintel.me Black Square */}
|
||||
<div className="flex justify-center">
|
||||
<div className="w-16 h-16 bg-black rounded-xl flex items-center justify-center shadow-xl shadow-slate-100 hover:scale-105 transition-all duration-500 ease-[cubic-bezier(0.23,1,0.32,1)] rotate-2 hover:rotate-0">
|
||||
<Image
|
||||
src="/icon-white.svg"
|
||||
alt="Mintel"
|
||||
width={32}
|
||||
height={32}
|
||||
className="w-8 h-8"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-12 animate-slide-up">
|
||||
<div className="text-center space-y-4">
|
||||
<h1 className="text-xs font-sans font-bold uppercase tracking-[0.4em] text-slate-900 border-b border-slate-50 pb-4 inline-block mx-auto min-w-[200px]">
|
||||
{projectName} <span className="text-slate-300">Gatekeeper</span>
|
||||
</h1>
|
||||
<p className="text-[10px] text-slate-400 font-sans uppercase tracking-widest italic flex items-center justify-center gap-2">
|
||||
<span className="w-1 h-1 bg-slate-200 rounded-full" />
|
||||
Infrastructure Protection
|
||||
<span className="w-1 h-1 bg-slate-200 rounded-full" />
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="bg-red-50 text-red-600 px-5 py-3 rounded-2xl text-[9px] font-sans font-bold uppercase tracking-widest flex items-center gap-3 border border-red-100 animate-shake">
|
||||
<ShieldCheck className="w-4 h-4" />
|
||||
<span>Access Denied. Try Again.</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form action={login} className="space-y-4">
|
||||
<input type="hidden" name="redirect" value={redirectUrl} />
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="relative group">
|
||||
<input
|
||||
type="email"
|
||||
name="email"
|
||||
placeholder="EMAIL (OPTIONAL)"
|
||||
className="w-full bg-slate-50/50 border border-slate-200 rounded-2xl px-6 py-4 focus:outline-none focus:border-slate-900 focus:bg-white transition-all text-[10px] font-sans font-bold tracking-[0.2em] uppercase placeholder:text-slate-300 shadow-sm shadow-slate-50"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="relative group">
|
||||
<input
|
||||
type="password"
|
||||
name="password"
|
||||
required
|
||||
autoFocus
|
||||
autoComplete="current-password"
|
||||
placeholder="ACCESS CODE"
|
||||
className="w-full bg-slate-50/50 border border-slate-200 rounded-2xl px-6 py-4 focus:outline-none focus:border-slate-900 focus:bg-white transition-all text-sm font-sans font-bold tracking-[0.3em] uppercase placeholder:text-slate-300 placeholder:tracking-widest shadow-sm shadow-slate-50"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
className="btn btn-primary w-full py-5 rounded-2xl text-[10px] shadow-lg shadow-slate-100 flex items-center justify-center"
|
||||
>
|
||||
Unlock Access
|
||||
<ArrowRight className="ml-3 w-3 h-3 group-hover:translate-x-1 transition-transform" />
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{/* Bottom Section - Full Branding Parity */}
|
||||
<div className="pt-12 sm:pt-20 flex flex-col items-center gap-6 sm:gap-8">
|
||||
<div className="h-px w-8 bg-slate-100" />
|
||||
<div className="opacity-80 transition-opacity hover:opacity-100">
|
||||
<Image
|
||||
src="/logo-black.svg"
|
||||
alt={projectName}
|
||||
width={140}
|
||||
height={40}
|
||||
className="h-7 sm:h-auto grayscale contrast-125 w-auto"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-[8px] font-sans font-bold text-slate-300 uppercase tracking-[0.4em] sm:tracking-[0.5em] text-center">
|
||||
© 2026 MINTEL
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user