Compare commits
39 Commits
v1.2.5
...
f2c0a4581c
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 |
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"@mintel/mail": minor
|
||||
---
|
||||
|
||||
Initial release of the branded email system package.
|
||||
@@ -1,7 +1,7 @@
|
||||
node_modules
|
||||
.next
|
||||
.git
|
||||
.npmrc
|
||||
# .npmrc is allowed as it contains the registry template
|
||||
dist
|
||||
build
|
||||
out
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
# Project
|
||||
IMAGE_TAG=v1.7.0
|
||||
PROJECT_NAME=sample-website
|
||||
PROJECT_COLOR=#82ed20
|
||||
|
||||
@@ -2,6 +2,8 @@ name: Monorepo Pipeline
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- '**'
|
||||
tags:
|
||||
- 'v*'
|
||||
|
||||
@@ -15,6 +17,8 @@ jobs:
|
||||
runs-on: docker
|
||||
container:
|
||||
image: catthehacker/ubuntu:act-latest
|
||||
env:
|
||||
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
@@ -31,8 +35,10 @@ jobs:
|
||||
|
||||
- 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
|
||||
@@ -75,16 +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
|
||||
@@ -99,58 +120,19 @@ jobs:
|
||||
username: ${{ secrets.REGISTRY_USER }}
|
||||
password: ${{ secrets.REGISTRY_PASS }}
|
||||
|
||||
- name: 🏗️ Build & Push Nextjs Build-Base
|
||||
- name: 🏗️ Build & Push ${{ matrix.name }}
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
file: packages/infra/docker/Dockerfile.nextjs
|
||||
file: ${{ matrix.file }}
|
||||
platforms: linux/arm64
|
||||
pull: true
|
||||
push: true
|
||||
secrets: |
|
||||
NPM_TOKEN=${{ secrets.NPM_TOKEN }}
|
||||
tags: |
|
||||
registry.infra.mintel.me/mintel/nextjs:${{ github.ref_name }}
|
||||
registry.infra.mintel.me/mintel/nextjs:latest
|
||||
registry.infra.mintel.me/mintel/${{ matrix.image }}:${{ github.ref_name }}
|
||||
registry.infra.mintel.me/mintel/${{ matrix.image }}:latest
|
||||
cache-from: type=registry,ref=registry.infra.mintel.me/mintel/${{ matrix.image }}:buildcache
|
||||
cache-to: type=registry,ref=registry.infra.mintel.me/mintel/${{ matrix.image }}:buildcache,mode=max
|
||||
|
||||
- name: 🏗️ Build & Push Production Runtime
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
file: packages/infra/docker/Dockerfile.runtime
|
||||
platforms: linux/arm64
|
||||
pull: true
|
||||
push: true
|
||||
secrets: |
|
||||
NPM_TOKEN=${{ secrets.NPM_TOKEN }}
|
||||
tags: |
|
||||
registry.infra.mintel.me/mintel/runtime:${{ github.ref_name }}
|
||||
registry.infra.mintel.me/mintel/runtime:latest
|
||||
|
||||
- name: 🏗️ Build & Push Gatekeeper (Product)
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
file: packages/infra/docker/Dockerfile.gatekeeper
|
||||
platforms: linux/arm64
|
||||
pull: true
|
||||
push: true
|
||||
secrets: |
|
||||
NPM_TOKEN=${{ secrets.NPM_TOKEN }}
|
||||
tags: |
|
||||
registry.infra.mintel.me/mintel/gatekeeper:${{ github.ref_name }}
|
||||
registry.infra.mintel.me/mintel/gatekeeper:latest
|
||||
|
||||
- name: 🏗️ Build & Push Directus (Base)
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
file: packages/infra/docker/Dockerfile.directus
|
||||
platforms: linux/arm64
|
||||
pull: true
|
||||
push: true
|
||||
secrets: |
|
||||
NPM_TOKEN=${{ secrets.NPM_TOKEN }}
|
||||
tags: |
|
||||
registry.infra.mintel.me/mintel/directus:${{ github.ref_name }}
|
||||
registry.infra.mintel.me/mintel/directus:latest
|
||||
|
||||
1
.gitignore
vendored
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,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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -25,24 +25,25 @@ program
|
||||
console.log(chalk.cyan("Running Next.js locally..."));
|
||||
execSync("next dev", { stdio: "inherit" });
|
||||
} else {
|
||||
console.log(chalk.cyan("Starting Docker stack (App, Directus, DB)..."));
|
||||
// Ensure network exists
|
||||
try {
|
||||
execSync("docker network create infra", { stdio: "ignore" });
|
||||
} catch (e) {}
|
||||
console.log(chalk.cyan("Starting Docker stack (App, Directus, DB)..."));
|
||||
// Ensure network exists
|
||||
} catch (_e) {
|
||||
// Network already exists or docker is not running
|
||||
}
|
||||
}
|
||||
|
||||
console.log(
|
||||
chalk.yellow(`
|
||||
console.log(
|
||||
chalk.yellow(`
|
||||
📱 App: http://localhost:3000
|
||||
🗄️ CMS: http://localhost:8055/admin
|
||||
🚦 Traefik: http://localhost:8080
|
||||
`),
|
||||
);
|
||||
execSync(
|
||||
"docker-compose down --remove-orphans && docker-compose up app directus directus-db",
|
||||
{ stdio: "inherit" },
|
||||
);
|
||||
}
|
||||
);
|
||||
execSync(
|
||||
"docker-compose down --remove-orphans && docker-compose up app directus directus-db",
|
||||
{ stdio: "inherit" },
|
||||
);
|
||||
});
|
||||
|
||||
const directus = program
|
||||
@@ -60,6 +61,115 @@ directus
|
||||
});
|
||||
});
|
||||
|
||||
directus
|
||||
.command("bootstrap-feedback")
|
||||
.description("Setup Directus collections and flows for Feedback")
|
||||
.action(async () => {
|
||||
const { execSync } = await import("child_process");
|
||||
console.log(chalk.blue("📧 Bootstrapping Visual Feedback System..."));
|
||||
// Use the logic from setup-feedback-hardened.ts
|
||||
const bootstrapScript = `
|
||||
import { createDirectus, rest, authentication, createCollection, createDashboard, createPanel, createItems, createPermission, readPolicies, readRoles, readUsers } from '@directus/sdk';
|
||||
|
||||
async function setup() {
|
||||
const url = process.env.DIRECTUS_URL || 'http://localhost:8055';
|
||||
const email = process.env.DIRECTUS_ADMIN_EMAIL;
|
||||
const password = process.env.DIRECTUS_ADMIN_PASSWORD;
|
||||
|
||||
if (!email || !password) {
|
||||
console.error('❌ DIRECTUS_ADMIN_EMAIL or DIRECTUS_ADMIN_PASSWORD not set');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const client = createDirectus(url).with(authentication('json')).with(rest());
|
||||
|
||||
try {
|
||||
console.log('🔑 Authenticating...');
|
||||
await client.login(email, password);
|
||||
|
||||
const roles = await client.request(readRoles());
|
||||
const adminRole = roles.find(r => r.name === 'Administrator');
|
||||
const policies = await client.request(readPolicies());
|
||||
const adminPolicy = policies.find(p => p.name === 'Administrator');
|
||||
|
||||
console.log('🏗️ Creating Collection "visual_feedback"...');
|
||||
try {
|
||||
await client.request(createCollection({
|
||||
collection: 'visual_feedback',
|
||||
meta: { icon: 'feedback', display_template: '{{user_name}}: {{text}}' },
|
||||
fields: [
|
||||
{ field: 'id', type: 'uuid', schema: { is_primary_key: true } },
|
||||
{ field: 'status', type: 'string', schema: { default_value: 'open' }, meta: { interface: 'select-dropdown' } },
|
||||
{ field: 'url', type: 'string' },
|
||||
{ field: 'selector', type: 'string' },
|
||||
{ field: 'x', type: 'float' },
|
||||
{ field: 'y', type: 'float' },
|
||||
{ field: 'type', type: 'string' },
|
||||
{ field: 'text', type: 'text' },
|
||||
{ field: 'user_name', type: 'string' },
|
||||
{ field: 'user_identity', type: 'string' },
|
||||
{ field: 'screenshot', type: 'uuid', meta: { interface: 'file' } },
|
||||
{ field: 'date_created', type: 'timestamp', schema: { default_value: 'NOW()' } }
|
||||
]
|
||||
} as any));
|
||||
} catch (_e) { console.log(' (Collection might already exist)'); }
|
||||
|
||||
try {
|
||||
await client.request(createCollection({
|
||||
collection: 'visual_feedback_comments',
|
||||
meta: { icon: 'comment' },
|
||||
fields: [
|
||||
{ field: 'id', type: 'integer', schema: { is_primary_key: true, has_auto_increment: true } },
|
||||
{ field: 'feedback_id', type: 'uuid', meta: { interface: 'select-dropdown' } },
|
||||
{ field: 'user_name', type: 'string' },
|
||||
{ field: 'text', type: 'text' },
|
||||
{ field: 'date_created', type: 'timestamp', schema: { default_value: 'NOW()' } }
|
||||
]
|
||||
} as any));
|
||||
} catch (e) { }
|
||||
|
||||
if (adminPolicy) {
|
||||
console.log('🔐 Granting ALL permissions to Administrator Policy...');
|
||||
for (const coll of ['visual_feedback', 'visual_feedback_comments']) {
|
||||
for (const action of ['create', 'read', 'update', 'delete']) {
|
||||
try {
|
||||
await client.request(createPermission({
|
||||
collection: coll,
|
||||
action,
|
||||
fields: ['*'],
|
||||
policy: adminPolicy.id
|
||||
} as any));
|
||||
} catch (_e) { }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log('📊 Creating Dashboard...');
|
||||
try {
|
||||
const dash = await client.request(createDashboard({ name: 'Visual Feedback', icon: 'feedback', color: '#6366f1' }));
|
||||
await client.request(createPanel({
|
||||
dashboard: dash.id,
|
||||
name: 'Total Feedbacks',
|
||||
type: 'metric',
|
||||
width: 12, height: 6, position_x: 1, position_y: 1,
|
||||
options: { collection: 'visual_feedback', function: 'count', field: 'id' }
|
||||
} as any));
|
||||
} catch (e) { }
|
||||
|
||||
console.log('✨ FEEDBACK BOOTSTRAP DONE.');
|
||||
} catch (e) { console.error('❌ FAILURE:', e); }
|
||||
}
|
||||
setup();
|
||||
`;
|
||||
const tempFile = path.join(process.cwd(), "temp-bootstrap-feedback.ts");
|
||||
await fs.writeFile(tempFile, bootstrapScript);
|
||||
try {
|
||||
execSync("npx tsx --env-file=.env " + tempFile, { stdio: "inherit" });
|
||||
} finally {
|
||||
await fs.remove(tempFile);
|
||||
}
|
||||
});
|
||||
|
||||
directus
|
||||
.command("sync <action> <env>")
|
||||
.description("Sync Directus data (push/pull) for a specific environment")
|
||||
@@ -121,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: {
|
||||
@@ -263,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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -397,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',
|
||||
},
|
||||
});
|
||||
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.
39
packages/cms-infra/docker-compose.yml
Normal file
39
packages/cms-infra/docker-compose.yml
Normal file
@@ -0,0 +1,39 @@
|
||||
services:
|
||||
infra-cms:
|
||||
image: directus/directus:11
|
||||
ports:
|
||||
- "8059:8055"
|
||||
networks:
|
||||
- default
|
||||
- infra
|
||||
environment:
|
||||
KEY: "infra-cms-key"
|
||||
SECRET: "infra-cms-secret"
|
||||
ADMIN_EMAIL: "marc@mintel.me"
|
||||
ADMIN_PASSWORD: "Tim300493."
|
||||
DB_CLIENT: "sqlite3"
|
||||
DB_FILENAME: "/directus/database/data.db"
|
||||
WEBSOCKETS_ENABLED: "true"
|
||||
EMAIL_TRANSPORT: "smtp"
|
||||
EMAIL_SMTP_HOST: "smtp.eu.mailgun.org"
|
||||
EMAIL_SMTP_PORT: "587"
|
||||
EMAIL_SMTP_USER: "postmaster@mg.mintel.me"
|
||||
EMAIL_SMTP_PASSWORD: "4592fcb94599ee1a45b4ac2386fd0a64-102c75d8-ca2870e6"
|
||||
EMAIL_SMTP_SECURE: "false"
|
||||
EMAIL_FROM: "postmaster@mg.mintel.me"
|
||||
volumes:
|
||||
- ./database:/directus/database
|
||||
- ./uploads:/directus/uploads
|
||||
- ./schema:/directus/schema
|
||||
- ./extensions:/directus/extensions
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.http.routers.infra-cms.rule=Host(`cms.localhost`)"
|
||||
- "traefik.http.services.infra-cms.loadbalancer.server.port=8055"
|
||||
- "traefik.docker.network=infra"
|
||||
|
||||
networks:
|
||||
default:
|
||||
name: mintel-infra-cms-internal
|
||||
infra:
|
||||
external: true
|
||||
851
packages/cms-infra/extensions/customer-manager/index.js
Normal file
851
packages/cms-infra/extensions/customer-manager/index.js
Normal file
@@ -0,0 +1,851 @@
|
||||
import { useApi as e, defineModule as a } from "@directus/extensions-sdk";
|
||||
import {
|
||||
defineComponent as t,
|
||||
ref as l,
|
||||
onMounted as n,
|
||||
resolveComponent as i,
|
||||
resolveDirective as s,
|
||||
openBlock as d,
|
||||
createBlock as r,
|
||||
withCtx as u,
|
||||
createVNode as o,
|
||||
createElementBlock as m,
|
||||
Fragment as c,
|
||||
renderList as v,
|
||||
createTextVNode as p,
|
||||
toDisplayString as f,
|
||||
createCommentVNode as g,
|
||||
createElementVNode as y,
|
||||
withDirectives as b,
|
||||
nextTick as _,
|
||||
} from "vue";
|
||||
const h = { class: "content-wrapper" },
|
||||
x = { key: 0, class: "empty-state" },
|
||||
w = { class: "header" },
|
||||
k = { class: "header-left" },
|
||||
V = { class: "title" },
|
||||
C = { class: "subtitle" },
|
||||
M = { class: "header-right" },
|
||||
F = { class: "user-cell" },
|
||||
N = { class: "user-name" },
|
||||
z = { key: 0, class: "status-date" },
|
||||
E = { key: 0, class: "drawer-content" },
|
||||
U = { class: "form-section" },
|
||||
S = { class: "field" },
|
||||
A = { class: "drawer-actions" },
|
||||
T = { key: 0, class: "drawer-content" },
|
||||
Z = { class: "form-section" },
|
||||
j = { class: "field" },
|
||||
$ = { class: "field" },
|
||||
D = { class: "field" },
|
||||
O = { key: 1, class: "field" },
|
||||
W = { class: "drawer-actions" };
|
||||
var q = t({
|
||||
__name: "module",
|
||||
setup(a) {
|
||||
const t = e(),
|
||||
q = l([]),
|
||||
B = l(null),
|
||||
K = l([]),
|
||||
L = l(!1),
|
||||
P = l(!1),
|
||||
G = l(null),
|
||||
I = l(null),
|
||||
H = l(!1),
|
||||
J = l(!1),
|
||||
Q = l({ id: "", name: "" }),
|
||||
R = l(!1),
|
||||
X = l(!1),
|
||||
Y = l({
|
||||
id: "",
|
||||
first_name: "",
|
||||
last_name: "",
|
||||
email: "",
|
||||
temporary_password: "",
|
||||
}),
|
||||
ee = [
|
||||
{ text: "Name", value: "name", sortable: !0 },
|
||||
{ text: "E-Mail", value: "email", sortable: !0 },
|
||||
{ text: "Zuletzt eingeladen", value: "last_invited", sortable: !0 },
|
||||
];
|
||||
async function ae() {
|
||||
const e = await t.get("/items/companies", {
|
||||
params: { fields: ["id", "name"], sort: "name" },
|
||||
});
|
||||
q.value = e.data.data;
|
||||
}
|
||||
async function te(e) {
|
||||
((B.value = e), (L.value = !0));
|
||||
try {
|
||||
const a = await t.get("/items/client_users", {
|
||||
params: {
|
||||
filter: { company: { _eq: e.id } },
|
||||
fields: ["*"],
|
||||
sort: "first_name",
|
||||
},
|
||||
});
|
||||
K.value = a.data.data;
|
||||
} finally {
|
||||
L.value = !1;
|
||||
}
|
||||
}
|
||||
function le() {
|
||||
((J.value = !1), (Q.value = { id: "", name: "" }), (H.value = !0));
|
||||
}
|
||||
async function ne() {
|
||||
B.value &&
|
||||
((Q.value = { id: B.value.id, name: B.value.name }),
|
||||
(J.value = !0),
|
||||
await _(),
|
||||
(H.value = !0));
|
||||
}
|
||||
async function ie() {
|
||||
var e;
|
||||
if (Q.value.name) {
|
||||
P.value = !0;
|
||||
try {
|
||||
(J.value
|
||||
? (await t.patch(`/items/companies/${Q.value.id}`, {
|
||||
name: Q.value.name,
|
||||
}),
|
||||
(I.value = { type: "success", message: "Firma aktualisiert!" }))
|
||||
: (await t.post("/items/companies", { name: Q.value.name }),
|
||||
(I.value = { type: "success", message: "Firma angelegt!" })),
|
||||
(H.value = !1),
|
||||
await ae(),
|
||||
(null == (e = B.value) ? void 0 : e.id) === Q.value.id &&
|
||||
(B.value.name = Q.value.name));
|
||||
} catch (e) {
|
||||
I.value = { type: "danger", message: e.message };
|
||||
} finally {
|
||||
P.value = !1;
|
||||
}
|
||||
}
|
||||
}
|
||||
function se() {
|
||||
((X.value = !1),
|
||||
(Y.value = {
|
||||
id: "",
|
||||
first_name: "",
|
||||
last_name: "",
|
||||
email: "",
|
||||
temporary_password: "",
|
||||
}),
|
||||
(R.value = !0));
|
||||
}
|
||||
async function de() {
|
||||
if (Y.value.email && B.value) {
|
||||
P.value = !0;
|
||||
try {
|
||||
(X.value
|
||||
? (await t.patch(`/items/client_users/${Y.value.id}`, {
|
||||
first_name: Y.value.first_name,
|
||||
last_name: Y.value.last_name,
|
||||
email: Y.value.email,
|
||||
}),
|
||||
(I.value = {
|
||||
type: "success",
|
||||
message: "Mitarbeiter aktualisiert!",
|
||||
}))
|
||||
: (await t.post("/items/client_users", {
|
||||
first_name: Y.value.first_name,
|
||||
last_name: Y.value.last_name,
|
||||
email: Y.value.email,
|
||||
company: B.value.id,
|
||||
}),
|
||||
(I.value = {
|
||||
type: "success",
|
||||
message: "Mitarbeiter angelegt!",
|
||||
})),
|
||||
(R.value = !1),
|
||||
await te(B.value));
|
||||
} catch (e) {
|
||||
I.value = { type: "danger", message: e.message };
|
||||
} finally {
|
||||
P.value = !1;
|
||||
}
|
||||
}
|
||||
}
|
||||
function re(e) {
|
||||
const a = (null == e ? void 0 : e.item) || e;
|
||||
a &&
|
||||
a.id &&
|
||||
(async function (e) {
|
||||
((Y.value = {
|
||||
id: e.id || "",
|
||||
first_name: e.first_name || "",
|
||||
last_name: e.last_name || "",
|
||||
email: e.email || "",
|
||||
temporary_password: e.temporary_password || "",
|
||||
}),
|
||||
(X.value = !0),
|
||||
await _(),
|
||||
(R.value = !0));
|
||||
})(a);
|
||||
}
|
||||
return (
|
||||
n(() => {
|
||||
ae();
|
||||
}),
|
||||
(e, a) => {
|
||||
const l = i("v-icon"),
|
||||
n = i("v-list-item-icon"),
|
||||
_ = i("v-text-overflow"),
|
||||
ae = i("v-list-item-content"),
|
||||
ue = i("v-list-item"),
|
||||
oe = i("v-divider"),
|
||||
me = i("v-list"),
|
||||
ce = i("v-notice"),
|
||||
ve = i("v-button"),
|
||||
pe = i("v-info"),
|
||||
fe = i("v-avatar"),
|
||||
ge = i("v-chip"),
|
||||
ye = i("v-table"),
|
||||
be = i("v-input"),
|
||||
_e = i("v-drawer"),
|
||||
he = i("private-view"),
|
||||
xe = s("tooltip");
|
||||
return (
|
||||
d(),
|
||||
r(
|
||||
he,
|
||||
{ title: "Customer Manager" },
|
||||
{
|
||||
navigation: u(() => [
|
||||
o(
|
||||
me,
|
||||
{ nav: "" },
|
||||
{
|
||||
default: u(() => [
|
||||
o(
|
||||
ue,
|
||||
{ onClick: le, clickable: "" },
|
||||
{
|
||||
default: u(() => [
|
||||
o(n, null, {
|
||||
default: u(() => [
|
||||
o(l, {
|
||||
name: "add",
|
||||
color: "var(--theme--primary)",
|
||||
}),
|
||||
]),
|
||||
_: 1,
|
||||
}),
|
||||
o(ae, null, {
|
||||
default: u(() => [
|
||||
o(_, { text: "Neue Firma anlegen" }),
|
||||
]),
|
||||
_: 1,
|
||||
}),
|
||||
]),
|
||||
_: 1,
|
||||
},
|
||||
),
|
||||
o(oe),
|
||||
(d(!0),
|
||||
m(
|
||||
c,
|
||||
null,
|
||||
v(q.value, (e) => {
|
||||
var a;
|
||||
return (
|
||||
d(),
|
||||
r(
|
||||
ue,
|
||||
{
|
||||
key: e.id,
|
||||
active:
|
||||
(null == (a = B.value) ? void 0 : a.id) ===
|
||||
e.id,
|
||||
class: "company-item",
|
||||
clickable: "",
|
||||
onClick: (a) => te(e),
|
||||
},
|
||||
{
|
||||
default: u(() => [
|
||||
o(n, null, {
|
||||
default: u(() => [
|
||||
o(l, { name: "business" }),
|
||||
]),
|
||||
_: 1,
|
||||
}),
|
||||
o(
|
||||
ae,
|
||||
null,
|
||||
{
|
||||
default: u(() => [
|
||||
o(_, { text: e.name }, null, 8, [
|
||||
"text",
|
||||
]),
|
||||
]),
|
||||
_: 2,
|
||||
},
|
||||
1024,
|
||||
),
|
||||
]),
|
||||
_: 2,
|
||||
},
|
||||
1032,
|
||||
["active", "onClick"],
|
||||
)
|
||||
);
|
||||
}),
|
||||
128,
|
||||
)),
|
||||
]),
|
||||
_: 1,
|
||||
},
|
||||
),
|
||||
]),
|
||||
"title-outer:after": u(() => [
|
||||
I.value
|
||||
? (d(),
|
||||
r(
|
||||
ce,
|
||||
{
|
||||
key: 0,
|
||||
type: I.value.type,
|
||||
onClose: a[0] || (a[0] = (e) => (I.value = null)),
|
||||
dismissible: "",
|
||||
},
|
||||
{ default: u(() => [p(f(I.value.message), 1)]), _: 1 },
|
||||
8,
|
||||
["type"],
|
||||
))
|
||||
: g("v-if", !0),
|
||||
]),
|
||||
default: u(() => [
|
||||
y("div", h, [
|
||||
B.value
|
||||
? (d(),
|
||||
m(
|
||||
c,
|
||||
{ key: 1 },
|
||||
[
|
||||
y("header", w, [
|
||||
y("div", k, [
|
||||
y("h1", V, f(B.value.name), 1),
|
||||
y(
|
||||
"p",
|
||||
C,
|
||||
f(K.value.length) + " Kunden-Mitarbeiter",
|
||||
1,
|
||||
),
|
||||
]),
|
||||
y("div", M, [
|
||||
b(
|
||||
(d(),
|
||||
r(
|
||||
ve,
|
||||
{
|
||||
secondary: "",
|
||||
rounded: "",
|
||||
icon: "",
|
||||
onClick: ne,
|
||||
},
|
||||
{
|
||||
default: u(() => [
|
||||
o(l, { name: "edit" }),
|
||||
]),
|
||||
_: 1,
|
||||
},
|
||||
)),
|
||||
[
|
||||
[
|
||||
xe,
|
||||
"Firma bearbeiten",
|
||||
void 0,
|
||||
{ bottom: !0 },
|
||||
],
|
||||
],
|
||||
),
|
||||
o(
|
||||
ve,
|
||||
{ primary: "", onClick: se },
|
||||
{
|
||||
default: u(() => [
|
||||
...(a[14] ||
|
||||
(a[14] = [
|
||||
p(" Mitarbeiter hinzufügen ", -1),
|
||||
])),
|
||||
]),
|
||||
_: 1,
|
||||
},
|
||||
),
|
||||
]),
|
||||
]),
|
||||
o(
|
||||
ye,
|
||||
{
|
||||
headers: ee,
|
||||
items: K.value,
|
||||
loading: L.value,
|
||||
class: "clickable-table",
|
||||
"fixed-header": "",
|
||||
"onClick:row": re,
|
||||
},
|
||||
{
|
||||
"item.name": u(({ item: e }) => [
|
||||
y("div", F, [
|
||||
o(
|
||||
fe,
|
||||
{ name: e.first_name, "x-small": "" },
|
||||
null,
|
||||
8,
|
||||
["name"],
|
||||
),
|
||||
y(
|
||||
"span",
|
||||
N,
|
||||
f(e.first_name) + " " + f(e.last_name),
|
||||
1,
|
||||
),
|
||||
]),
|
||||
]),
|
||||
"item.last_invited": u(({ item: e }) => {
|
||||
return [
|
||||
e.last_invited
|
||||
? (d(),
|
||||
m(
|
||||
"span",
|
||||
z,
|
||||
f(
|
||||
((t = e.last_invited),
|
||||
new Date(t).toLocaleString(
|
||||
"de-DE",
|
||||
{
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
year: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
},
|
||||
)),
|
||||
),
|
||||
1,
|
||||
))
|
||||
: (d(),
|
||||
r(
|
||||
ge,
|
||||
{ key: 1, "x-small": "" },
|
||||
{
|
||||
default: u(() => [
|
||||
...(a[15] ||
|
||||
(a[15] = [p("Noch nie", -1)])),
|
||||
]),
|
||||
_: 1,
|
||||
},
|
||||
)),
|
||||
];
|
||||
var t;
|
||||
}),
|
||||
_: 2,
|
||||
},
|
||||
1032,
|
||||
["items", "loading"],
|
||||
),
|
||||
],
|
||||
64,
|
||||
))
|
||||
: (d(),
|
||||
m("div", x, [
|
||||
o(
|
||||
pe,
|
||||
{
|
||||
title: "Firmen auswählen",
|
||||
icon: "business",
|
||||
center: "",
|
||||
},
|
||||
{
|
||||
default: u(() => [
|
||||
a[12] ||
|
||||
(a[12] = p(
|
||||
" Wähle eine Firma in der Navigation aus oder ",
|
||||
-1,
|
||||
)),
|
||||
o(
|
||||
ve,
|
||||
{ "x-small": "", onClick: le },
|
||||
{
|
||||
default: u(() => [
|
||||
...(a[11] ||
|
||||
(a[11] = [
|
||||
p("erstelle eine neue Firma", -1),
|
||||
])),
|
||||
]),
|
||||
_: 1,
|
||||
},
|
||||
),
|
||||
a[13] || (a[13] = p(". ", -1)),
|
||||
]),
|
||||
_: 1,
|
||||
},
|
||||
),
|
||||
])),
|
||||
]),
|
||||
o(
|
||||
_e,
|
||||
{
|
||||
modelValue: H.value,
|
||||
"onUpdate:modelValue":
|
||||
a[2] || (a[2] = (e) => (H.value = e)),
|
||||
title: J.value
|
||||
? "Firma bearbeiten"
|
||||
: "Neue Firma anlegen",
|
||||
icon: "business",
|
||||
onCancel: a[3] || (a[3] = (e) => (H.value = !1)),
|
||||
},
|
||||
{
|
||||
default: u(() => [
|
||||
H.value
|
||||
? (d(),
|
||||
m("div", E, [
|
||||
y("div", U, [
|
||||
y("div", S, [
|
||||
a[16] ||
|
||||
(a[16] = y(
|
||||
"span",
|
||||
{ class: "label" },
|
||||
"Firmenname",
|
||||
-1,
|
||||
)),
|
||||
o(
|
||||
be,
|
||||
{
|
||||
modelValue: Q.value.name,
|
||||
"onUpdate:modelValue":
|
||||
a[1] ||
|
||||
(a[1] = (e) => (Q.value.name = e)),
|
||||
placeholder: "z.B. KLZ Cables",
|
||||
autofocus: "",
|
||||
},
|
||||
null,
|
||||
8,
|
||||
["modelValue"],
|
||||
),
|
||||
]),
|
||||
]),
|
||||
y("div", A, [
|
||||
o(
|
||||
ve,
|
||||
{
|
||||
primary: "",
|
||||
block: "",
|
||||
loading: P.value,
|
||||
onClick: ie,
|
||||
},
|
||||
{
|
||||
default: u(() => [
|
||||
...(a[17] ||
|
||||
(a[17] = [p("Speichern", -1)])),
|
||||
]),
|
||||
_: 1,
|
||||
},
|
||||
8,
|
||||
["loading"],
|
||||
),
|
||||
]),
|
||||
]))
|
||||
: g("v-if", !0),
|
||||
]),
|
||||
_: 1,
|
||||
},
|
||||
8,
|
||||
["modelValue", "title"],
|
||||
),
|
||||
o(
|
||||
_e,
|
||||
{
|
||||
modelValue: R.value,
|
||||
"onUpdate:modelValue":
|
||||
a[9] || (a[9] = (e) => (R.value = e)),
|
||||
title: X.value
|
||||
? "Mitarbeiter bearbeiten"
|
||||
: "Neuen Mitarbeiter anlegen",
|
||||
icon: "person",
|
||||
onCancel: a[10] || (a[10] = (e) => (R.value = !1)),
|
||||
},
|
||||
{
|
||||
default: u(() => [
|
||||
R.value
|
||||
? (d(),
|
||||
m("div", T, [
|
||||
y("div", Z, [
|
||||
y("div", j, [
|
||||
a[18] ||
|
||||
(a[18] = y(
|
||||
"span",
|
||||
{ class: "label" },
|
||||
"Vorname",
|
||||
-1,
|
||||
)),
|
||||
o(
|
||||
be,
|
||||
{
|
||||
modelValue: Y.value.first_name,
|
||||
"onUpdate:modelValue":
|
||||
a[4] ||
|
||||
(a[4] = (e) =>
|
||||
(Y.value.first_name = e)),
|
||||
placeholder: "Vorname",
|
||||
autofocus: "",
|
||||
},
|
||||
null,
|
||||
8,
|
||||
["modelValue"],
|
||||
),
|
||||
]),
|
||||
y("div", $, [
|
||||
a[19] ||
|
||||
(a[19] = y(
|
||||
"span",
|
||||
{ class: "label" },
|
||||
"Nachname",
|
||||
-1,
|
||||
)),
|
||||
o(
|
||||
be,
|
||||
{
|
||||
modelValue: Y.value.last_name,
|
||||
"onUpdate:modelValue":
|
||||
a[5] ||
|
||||
(a[5] = (e) => (Y.value.last_name = e)),
|
||||
placeholder: "Nachname",
|
||||
},
|
||||
null,
|
||||
8,
|
||||
["modelValue"],
|
||||
),
|
||||
]),
|
||||
y("div", D, [
|
||||
a[20] ||
|
||||
(a[20] = y(
|
||||
"span",
|
||||
{ class: "label" },
|
||||
"E-Mail",
|
||||
-1,
|
||||
)),
|
||||
o(
|
||||
be,
|
||||
{
|
||||
modelValue: Y.value.email,
|
||||
"onUpdate:modelValue":
|
||||
a[6] ||
|
||||
(a[6] = (e) => (Y.value.email = e)),
|
||||
placeholder: "E-Mail Adresse",
|
||||
type: "email",
|
||||
},
|
||||
null,
|
||||
8,
|
||||
["modelValue"],
|
||||
),
|
||||
]),
|
||||
X.value
|
||||
? (d(), r(oe, { key: 0 }))
|
||||
: g("v-if", !0),
|
||||
X.value
|
||||
? (d(),
|
||||
m("div", O, [
|
||||
a[21] ||
|
||||
(a[21] = y(
|
||||
"span",
|
||||
{ class: "label" },
|
||||
"Temporäres Passwort",
|
||||
-1,
|
||||
)),
|
||||
o(
|
||||
be,
|
||||
{
|
||||
modelValue:
|
||||
Y.value.temporary_password,
|
||||
"onUpdate:modelValue":
|
||||
a[7] ||
|
||||
(a[7] = (e) =>
|
||||
(Y.value.temporary_password = e)),
|
||||
readonly: "",
|
||||
class: "password-input",
|
||||
},
|
||||
null,
|
||||
8,
|
||||
["modelValue"],
|
||||
),
|
||||
a[22] ||
|
||||
(a[22] = y(
|
||||
"p",
|
||||
{ class: "field-note" },
|
||||
"Wird beim Senden der Zugangsdaten automatisch generiert.",
|
||||
-1,
|
||||
)),
|
||||
]))
|
||||
: g("v-if", !0),
|
||||
]),
|
||||
y("div", W, [
|
||||
o(
|
||||
ve,
|
||||
{
|
||||
primary: "",
|
||||
block: "",
|
||||
loading: P.value,
|
||||
onClick: de,
|
||||
},
|
||||
{
|
||||
default: u(() => [
|
||||
...(a[23] ||
|
||||
(a[23] = [p("Daten speichern", -1)])),
|
||||
]),
|
||||
_: 1,
|
||||
},
|
||||
8,
|
||||
["loading"],
|
||||
),
|
||||
X.value
|
||||
? (d(),
|
||||
m(
|
||||
c,
|
||||
{ key: 0 },
|
||||
[
|
||||
o(oe),
|
||||
b(
|
||||
(d(),
|
||||
r(
|
||||
ve,
|
||||
{
|
||||
secondary: "",
|
||||
block: "",
|
||||
loading: G.value === Y.value.id,
|
||||
onClick:
|
||||
a[8] ||
|
||||
(a[8] = (e) =>
|
||||
(async function (e) {
|
||||
G.value = e.id;
|
||||
try {
|
||||
if (
|
||||
(await t.post(
|
||||
"/flows/trigger/33443f6b-cec7-4668-9607-f33ea674d501",
|
||||
[e.id],
|
||||
),
|
||||
(I.value = {
|
||||
type: "success",
|
||||
message: `Zugangsdaten für ${e.first_name} versendet. 📧`,
|
||||
}),
|
||||
await te(B.value),
|
||||
R.value &&
|
||||
Y.value.id === e.id)
|
||||
) {
|
||||
const a = K.value.find(
|
||||
(a) => a.id === e.id,
|
||||
);
|
||||
a &&
|
||||
(Y.value.temporary_password =
|
||||
a.temporary_password);
|
||||
}
|
||||
} catch (e) {
|
||||
I.value = {
|
||||
type: "danger",
|
||||
message: `Fehler: ${e.message}`,
|
||||
};
|
||||
} finally {
|
||||
G.value = null;
|
||||
}
|
||||
})(Y.value)),
|
||||
},
|
||||
{
|
||||
default: u(() => [
|
||||
o(l, {
|
||||
name: "send",
|
||||
left: "",
|
||||
}),
|
||||
a[24] ||
|
||||
(a[24] = p(
|
||||
" Zugangsdaten senden ",
|
||||
-1,
|
||||
)),
|
||||
]),
|
||||
_: 1,
|
||||
},
|
||||
8,
|
||||
["loading"],
|
||||
)),
|
||||
[
|
||||
[
|
||||
xe,
|
||||
"Generiert PW, speichert es und sendet E-Mail",
|
||||
void 0,
|
||||
{ bottom: !0 },
|
||||
],
|
||||
],
|
||||
),
|
||||
],
|
||||
64,
|
||||
))
|
||||
: g("v-if", !0),
|
||||
]),
|
||||
]))
|
||||
: g("v-if", !0),
|
||||
]),
|
||||
_: 1,
|
||||
},
|
||||
8,
|
||||
["modelValue", "title"],
|
||||
),
|
||||
]),
|
||||
_: 1,
|
||||
},
|
||||
)
|
||||
);
|
||||
}
|
||||
);
|
||||
},
|
||||
}),
|
||||
B = [],
|
||||
K = [];
|
||||
!(function (e, a) {
|
||||
if (e && "undefined" != typeof document) {
|
||||
var t,
|
||||
l = !0 === a.prepend ? "prepend" : "append",
|
||||
n = !0 === a.singleTag,
|
||||
i =
|
||||
"string" == typeof a.container
|
||||
? document.querySelector(a.container)
|
||||
: document.getElementsByTagName("head")[0];
|
||||
if (n) {
|
||||
var s = B.indexOf(i);
|
||||
(-1 === s && ((s = B.push(i) - 1), (K[s] = {})),
|
||||
(t = K[s] && K[s][l] ? K[s][l] : (K[s][l] = d())));
|
||||
} else t = d();
|
||||
(65279 === e.charCodeAt(0) && (e = e.substring(1)),
|
||||
t.styleSheet
|
||||
? (t.styleSheet.cssText += e)
|
||||
: t.appendChild(document.createTextNode(e)));
|
||||
}
|
||||
function d() {
|
||||
var e = document.createElement("style");
|
||||
if ((e.setAttribute("type", "text/css"), a.attributes))
|
||||
for (var t = Object.keys(a.attributes), n = 0; n < t.length; n++)
|
||||
e.setAttribute(t[n], a.attributes[t[n]]);
|
||||
var s = "prepend" === l ? "afterbegin" : "beforeend";
|
||||
return (i.insertAdjacentElement(s, e), e);
|
||||
}
|
||||
})(
|
||||
"\n.content-wrapper[data-v-3fd11e72] { padding: 32px; height: 100%; display: flex; flex-direction: column;\n}\n.company-item[data-v-3fd11e72] { cursor: pointer;\n}\n.header[data-v-3fd11e72] { margin-bottom: 24px; display: flex; justify-content: space-between; align-items: flex-end;\n}\n.header-right[data-v-3fd11e72] { display: flex; gap: 12px;\n}\n.title[data-v-3fd11e72] { font-size: 24px; font-weight: 800; margin-bottom: 4px;\n}\n.subtitle[data-v-3fd11e72] { color: var(--theme--foreground-subdued); font-size: 14px;\n}\n.empty-state[data-v-3fd11e72] { height: 100%; display: flex; align-items: center; justify-content: center;\n}\n.user-cell[data-v-3fd11e72] { display: flex; align-items: center; gap: 12px;\n}\n.user-name[data-v-3fd11e72] { font-weight: 600;\n}\n.status-date[data-v-3fd11e72] { font-size: 12px; color: var(--theme--foreground-subdued);\n}\n.drawer-content[data-v-3fd11e72] { padding: 24px; display: flex; flex-direction: column; gap: 32px;\n}\n.form-section[data-v-3fd11e72] { display: flex; flex-direction: column; gap: 20px;\n}\n.field[data-v-3fd11e72] { display: flex; flex-direction: column; gap: 8px;\n}\n.label[data-v-3fd11e72] { font-size: 12px; font-weight: 700; text-transform: uppercase; color: var(--theme--foreground-subdued); letter-spacing: 0.5px;\n}\n.field-note[data-v-3fd11e72] { font-size: 11px; color: var(--theme--foreground-subdued); margin-top: 4px;\n}\n.drawer-actions[data-v-3fd11e72] { margin-top: 24px; display: flex; flex-direction: column; gap: 12px;\n}\n.password-input[data-v-3fd11e72] textarea {\n\tfont-family: var(--family-monospace);\n\tfont-weight: 800;\n\tcolor: var(--theme--primary) !important;\n\tbackground: var(--theme--background-subdued) !important;\n}\n.clickable-table[data-v-3fd11e72] tbody tr { cursor: pointer; transition: background-color 0.2s ease;\n}\n.clickable-table[data-v-3fd11e72] tbody tr:hover { background-color: var(--theme--background-subdued) !important;\n}\n[data-v-3fd11e72] .v-list-item { cursor: pointer !important;\n}\n",
|
||||
{},
|
||||
);
|
||||
var L = a({
|
||||
id: "customer-manager",
|
||||
name: "Customer Manager",
|
||||
icon: "supervisor_account",
|
||||
routes: [
|
||||
{
|
||||
path: "",
|
||||
component: ((e, a) => {
|
||||
const t = e.__vccOpts || e;
|
||||
for (const [e, l] of a) t[e] = l;
|
||||
return t;
|
||||
})(q, [
|
||||
["__scopeId", "data-v-3fd11e72"],
|
||||
["__file", "module.vue"],
|
||||
]),
|
||||
},
|
||||
],
|
||||
});
|
||||
export { L as default };
|
||||
29
packages/cms-infra/extensions/customer-manager/package.json
Normal file
29
packages/cms-infra/extensions/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.0.0",
|
||||
"keywords": [
|
||||
"directus",
|
||||
"directus-extension",
|
||||
"directus-extension-module"
|
||||
],
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"directus:extension": {
|
||||
"type": "module",
|
||||
"path": "index.js",
|
||||
"source": "src/index.ts",
|
||||
"host": "*",
|
||||
"name": "Customer Manager"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "directus-extension build",
|
||||
"dev": "directus-extension build -w"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@directus/extensions-sdk": "11.0.2",
|
||||
"vue": "^3.4.0"
|
||||
}
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"name": "feedback-commander",
|
||||
"description": "Custom High-Fidelity Feedback Management Extension for Directus",
|
||||
"icon": "view_kanban",
|
||||
"version": "1.0.0",
|
||||
"keywords": [
|
||||
"directus",
|
||||
"directus-extension",
|
||||
"directus-extension-module"
|
||||
],
|
||||
"files": [
|
||||
"index.js"
|
||||
],
|
||||
"directus:extension": {
|
||||
"type": "module",
|
||||
"path": "index.js",
|
||||
"source": "src/index.ts",
|
||||
"host": "*",
|
||||
"name": "Feedback Commander"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "directus-extension build",
|
||||
"dev": "directus-extension build -w"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@directus/extensions-sdk": "11.0.2",
|
||||
"vue": "^3.4.0"
|
||||
}
|
||||
}
|
||||
11
packages/cms-infra/package.json
Normal file
11
packages/cms-infra/package.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"name": "@mintel/cms-infra",
|
||||
"version": "1.7.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"up": "docker compose up -d",
|
||||
"down": "docker compose down",
|
||||
"logs": "docker compose logs -f"
|
||||
}
|
||||
}
|
||||
1221
packages/cms-infra/schema/snapshot.yaml
Normal file
1221
packages/cms-infra/schema/snapshot.yaml
Normal file
File diff suppressed because it is too large
Load Diff
1
packages/cms-infra/uploads/directus-health-file
Normal file
1
packages/cms-infra/uploads/directus-health-file
Normal file
@@ -0,0 +1 @@
|
||||
xmKX5
|
||||
851
packages/customer-manager/index.js
Normal file
851
packages/customer-manager/index.js
Normal file
@@ -0,0 +1,851 @@
|
||||
import { useApi as e, defineModule as a } from "@directus/extensions-sdk";
|
||||
import {
|
||||
defineComponent as t,
|
||||
ref as l,
|
||||
onMounted as n,
|
||||
resolveComponent as i,
|
||||
resolveDirective as s,
|
||||
openBlock as d,
|
||||
createBlock as r,
|
||||
withCtx as u,
|
||||
createVNode as o,
|
||||
createElementBlock as m,
|
||||
Fragment as c,
|
||||
renderList as v,
|
||||
createTextVNode as p,
|
||||
toDisplayString as f,
|
||||
createCommentVNode as g,
|
||||
createElementVNode as y,
|
||||
withDirectives as b,
|
||||
nextTick as _,
|
||||
} from "vue";
|
||||
const h = { class: "content-wrapper" },
|
||||
x = { key: 0, class: "empty-state" },
|
||||
w = { class: "header" },
|
||||
k = { class: "header-left" },
|
||||
V = { class: "title" },
|
||||
C = { class: "subtitle" },
|
||||
M = { class: "header-right" },
|
||||
F = { class: "user-cell" },
|
||||
N = { class: "user-name" },
|
||||
z = { key: 0, class: "status-date" },
|
||||
E = { key: 0, class: "drawer-content" },
|
||||
U = { class: "form-section" },
|
||||
S = { class: "field" },
|
||||
A = { class: "drawer-actions" },
|
||||
T = { key: 0, class: "drawer-content" },
|
||||
Z = { class: "form-section" },
|
||||
j = { class: "field" },
|
||||
$ = { class: "field" },
|
||||
D = { class: "field" },
|
||||
O = { key: 1, class: "field" },
|
||||
W = { class: "drawer-actions" };
|
||||
var q = t({
|
||||
__name: "module",
|
||||
setup(a) {
|
||||
const t = e(),
|
||||
q = l([]),
|
||||
B = l(null),
|
||||
K = l([]),
|
||||
L = l(!1),
|
||||
P = l(!1),
|
||||
G = l(null),
|
||||
I = l(null),
|
||||
H = l(!1),
|
||||
J = l(!1),
|
||||
Q = l({ id: "", name: "" }),
|
||||
R = l(!1),
|
||||
X = l(!1),
|
||||
Y = l({
|
||||
id: "",
|
||||
first_name: "",
|
||||
last_name: "",
|
||||
email: "",
|
||||
temporary_password: "",
|
||||
}),
|
||||
ee = [
|
||||
{ text: "Name", value: "name", sortable: !0 },
|
||||
{ text: "E-Mail", value: "email", sortable: !0 },
|
||||
{ text: "Zuletzt eingeladen", value: "last_invited", sortable: !0 },
|
||||
];
|
||||
async function ae() {
|
||||
const e = await t.get("/items/companies", {
|
||||
params: { fields: ["id", "name"], sort: "name" },
|
||||
});
|
||||
q.value = e.data.data;
|
||||
}
|
||||
async function te(e) {
|
||||
((B.value = e), (L.value = !0));
|
||||
try {
|
||||
const a = await t.get("/items/client_users", {
|
||||
params: {
|
||||
filter: { company: { _eq: e.id } },
|
||||
fields: ["*"],
|
||||
sort: "first_name",
|
||||
},
|
||||
});
|
||||
K.value = a.data.data;
|
||||
} finally {
|
||||
L.value = !1;
|
||||
}
|
||||
}
|
||||
function le() {
|
||||
((J.value = !1), (Q.value = { id: "", name: "" }), (H.value = !0));
|
||||
}
|
||||
async function ne() {
|
||||
B.value &&
|
||||
((Q.value = { id: B.value.id, name: B.value.name }),
|
||||
(J.value = !0),
|
||||
await _(),
|
||||
(H.value = !0));
|
||||
}
|
||||
async function ie() {
|
||||
var e;
|
||||
if (Q.value.name) {
|
||||
P.value = !0;
|
||||
try {
|
||||
(J.value
|
||||
? (await t.patch(`/items/companies/${Q.value.id}`, {
|
||||
name: Q.value.name,
|
||||
}),
|
||||
(I.value = { type: "success", message: "Firma aktualisiert!" }))
|
||||
: (await t.post("/items/companies", { name: Q.value.name }),
|
||||
(I.value = { type: "success", message: "Firma angelegt!" })),
|
||||
(H.value = !1),
|
||||
await ae(),
|
||||
(null == (e = B.value) ? void 0 : e.id) === Q.value.id &&
|
||||
(B.value.name = Q.value.name));
|
||||
} catch (e) {
|
||||
I.value = { type: "danger", message: e.message };
|
||||
} finally {
|
||||
P.value = !1;
|
||||
}
|
||||
}
|
||||
}
|
||||
function se() {
|
||||
((X.value = !1),
|
||||
(Y.value = {
|
||||
id: "",
|
||||
first_name: "",
|
||||
last_name: "",
|
||||
email: "",
|
||||
temporary_password: "",
|
||||
}),
|
||||
(R.value = !0));
|
||||
}
|
||||
async function de() {
|
||||
if (Y.value.email && B.value) {
|
||||
P.value = !0;
|
||||
try {
|
||||
(X.value
|
||||
? (await t.patch(`/items/client_users/${Y.value.id}`, {
|
||||
first_name: Y.value.first_name,
|
||||
last_name: Y.value.last_name,
|
||||
email: Y.value.email,
|
||||
}),
|
||||
(I.value = {
|
||||
type: "success",
|
||||
message: "Mitarbeiter aktualisiert!",
|
||||
}))
|
||||
: (await t.post("/items/client_users", {
|
||||
first_name: Y.value.first_name,
|
||||
last_name: Y.value.last_name,
|
||||
email: Y.value.email,
|
||||
company: B.value.id,
|
||||
}),
|
||||
(I.value = {
|
||||
type: "success",
|
||||
message: "Mitarbeiter angelegt!",
|
||||
})),
|
||||
(R.value = !1),
|
||||
await te(B.value));
|
||||
} catch (e) {
|
||||
I.value = { type: "danger", message: e.message };
|
||||
} finally {
|
||||
P.value = !1;
|
||||
}
|
||||
}
|
||||
}
|
||||
function re(e) {
|
||||
const a = (null == e ? void 0 : e.item) || e;
|
||||
a &&
|
||||
a.id &&
|
||||
(async function (e) {
|
||||
((Y.value = {
|
||||
id: e.id || "",
|
||||
first_name: e.first_name || "",
|
||||
last_name: e.last_name || "",
|
||||
email: e.email || "",
|
||||
temporary_password: e.temporary_password || "",
|
||||
}),
|
||||
(X.value = !0),
|
||||
await _(),
|
||||
(R.value = !0));
|
||||
})(a);
|
||||
}
|
||||
return (
|
||||
n(() => {
|
||||
ae();
|
||||
}),
|
||||
(e, a) => {
|
||||
const l = i("v-icon"),
|
||||
n = i("v-list-item-icon"),
|
||||
_ = i("v-text-overflow"),
|
||||
ae = i("v-list-item-content"),
|
||||
ue = i("v-list-item"),
|
||||
oe = i("v-divider"),
|
||||
me = i("v-list"),
|
||||
ce = i("v-notice"),
|
||||
ve = i("v-button"),
|
||||
pe = i("v-info"),
|
||||
fe = i("v-avatar"),
|
||||
ge = i("v-chip"),
|
||||
ye = i("v-table"),
|
||||
be = i("v-input"),
|
||||
_e = i("v-drawer"),
|
||||
he = i("private-view"),
|
||||
xe = s("tooltip");
|
||||
return (
|
||||
d(),
|
||||
r(
|
||||
he,
|
||||
{ title: "Customer Manager" },
|
||||
{
|
||||
navigation: u(() => [
|
||||
o(
|
||||
me,
|
||||
{ nav: "" },
|
||||
{
|
||||
default: u(() => [
|
||||
o(
|
||||
ue,
|
||||
{ onClick: le, clickable: "" },
|
||||
{
|
||||
default: u(() => [
|
||||
o(n, null, {
|
||||
default: u(() => [
|
||||
o(l, {
|
||||
name: "add",
|
||||
color: "var(--theme--primary)",
|
||||
}),
|
||||
]),
|
||||
_: 1,
|
||||
}),
|
||||
o(ae, null, {
|
||||
default: u(() => [
|
||||
o(_, { text: "Neue Firma anlegen" }),
|
||||
]),
|
||||
_: 1,
|
||||
}),
|
||||
]),
|
||||
_: 1,
|
||||
},
|
||||
),
|
||||
o(oe),
|
||||
(d(!0),
|
||||
m(
|
||||
c,
|
||||
null,
|
||||
v(q.value, (e) => {
|
||||
var a;
|
||||
return (
|
||||
d(),
|
||||
r(
|
||||
ue,
|
||||
{
|
||||
key: e.id,
|
||||
active:
|
||||
(null == (a = B.value) ? void 0 : a.id) ===
|
||||
e.id,
|
||||
class: "company-item",
|
||||
clickable: "",
|
||||
onClick: (a) => te(e),
|
||||
},
|
||||
{
|
||||
default: u(() => [
|
||||
o(n, null, {
|
||||
default: u(() => [
|
||||
o(l, { name: "business" }),
|
||||
]),
|
||||
_: 1,
|
||||
}),
|
||||
o(
|
||||
ae,
|
||||
null,
|
||||
{
|
||||
default: u(() => [
|
||||
o(_, { text: e.name }, null, 8, [
|
||||
"text",
|
||||
]),
|
||||
]),
|
||||
_: 2,
|
||||
},
|
||||
1024,
|
||||
),
|
||||
]),
|
||||
_: 2,
|
||||
},
|
||||
1032,
|
||||
["active", "onClick"],
|
||||
)
|
||||
);
|
||||
}),
|
||||
128,
|
||||
)),
|
||||
]),
|
||||
_: 1,
|
||||
},
|
||||
),
|
||||
]),
|
||||
"title-outer:after": u(() => [
|
||||
I.value
|
||||
? (d(),
|
||||
r(
|
||||
ce,
|
||||
{
|
||||
key: 0,
|
||||
type: I.value.type,
|
||||
onClose: a[0] || (a[0] = (e) => (I.value = null)),
|
||||
dismissible: "",
|
||||
},
|
||||
{ default: u(() => [p(f(I.value.message), 1)]), _: 1 },
|
||||
8,
|
||||
["type"],
|
||||
))
|
||||
: g("v-if", !0),
|
||||
]),
|
||||
default: u(() => [
|
||||
y("div", h, [
|
||||
B.value
|
||||
? (d(),
|
||||
m(
|
||||
c,
|
||||
{ key: 1 },
|
||||
[
|
||||
y("header", w, [
|
||||
y("div", k, [
|
||||
y("h1", V, f(B.value.name), 1),
|
||||
y(
|
||||
"p",
|
||||
C,
|
||||
f(K.value.length) + " Kunden-Mitarbeiter",
|
||||
1,
|
||||
),
|
||||
]),
|
||||
y("div", M, [
|
||||
b(
|
||||
(d(),
|
||||
r(
|
||||
ve,
|
||||
{
|
||||
secondary: "",
|
||||
rounded: "",
|
||||
icon: "",
|
||||
onClick: ne,
|
||||
},
|
||||
{
|
||||
default: u(() => [
|
||||
o(l, { name: "edit" }),
|
||||
]),
|
||||
_: 1,
|
||||
},
|
||||
)),
|
||||
[
|
||||
[
|
||||
xe,
|
||||
"Firma bearbeiten",
|
||||
void 0,
|
||||
{ bottom: !0 },
|
||||
],
|
||||
],
|
||||
),
|
||||
o(
|
||||
ve,
|
||||
{ primary: "", onClick: se },
|
||||
{
|
||||
default: u(() => [
|
||||
...(a[14] ||
|
||||
(a[14] = [
|
||||
p(" Mitarbeiter hinzufügen ", -1),
|
||||
])),
|
||||
]),
|
||||
_: 1,
|
||||
},
|
||||
),
|
||||
]),
|
||||
]),
|
||||
o(
|
||||
ye,
|
||||
{
|
||||
headers: ee,
|
||||
items: K.value,
|
||||
loading: L.value,
|
||||
class: "clickable-table",
|
||||
"fixed-header": "",
|
||||
"onClick:row": re,
|
||||
},
|
||||
{
|
||||
"item.name": u(({ item: e }) => [
|
||||
y("div", F, [
|
||||
o(
|
||||
fe,
|
||||
{ name: e.first_name, "x-small": "" },
|
||||
null,
|
||||
8,
|
||||
["name"],
|
||||
),
|
||||
y(
|
||||
"span",
|
||||
N,
|
||||
f(e.first_name) + " " + f(e.last_name),
|
||||
1,
|
||||
),
|
||||
]),
|
||||
]),
|
||||
"item.last_invited": u(({ item: e }) => {
|
||||
return [
|
||||
e.last_invited
|
||||
? (d(),
|
||||
m(
|
||||
"span",
|
||||
z,
|
||||
f(
|
||||
((t = e.last_invited),
|
||||
new Date(t).toLocaleString(
|
||||
"de-DE",
|
||||
{
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
year: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
},
|
||||
)),
|
||||
),
|
||||
1,
|
||||
))
|
||||
: (d(),
|
||||
r(
|
||||
ge,
|
||||
{ key: 1, "x-small": "" },
|
||||
{
|
||||
default: u(() => [
|
||||
...(a[15] ||
|
||||
(a[15] = [p("Noch nie", -1)])),
|
||||
]),
|
||||
_: 1,
|
||||
},
|
||||
)),
|
||||
];
|
||||
var t;
|
||||
}),
|
||||
_: 2,
|
||||
},
|
||||
1032,
|
||||
["items", "loading"],
|
||||
),
|
||||
],
|
||||
64,
|
||||
))
|
||||
: (d(),
|
||||
m("div", x, [
|
||||
o(
|
||||
pe,
|
||||
{
|
||||
title: "Firmen auswählen",
|
||||
icon: "business",
|
||||
center: "",
|
||||
},
|
||||
{
|
||||
default: u(() => [
|
||||
a[12] ||
|
||||
(a[12] = p(
|
||||
" Wähle eine Firma in der Navigation aus oder ",
|
||||
-1,
|
||||
)),
|
||||
o(
|
||||
ve,
|
||||
{ "x-small": "", onClick: le },
|
||||
{
|
||||
default: u(() => [
|
||||
...(a[11] ||
|
||||
(a[11] = [
|
||||
p("erstelle eine neue Firma", -1),
|
||||
])),
|
||||
]),
|
||||
_: 1,
|
||||
},
|
||||
),
|
||||
a[13] || (a[13] = p(". ", -1)),
|
||||
]),
|
||||
_: 1,
|
||||
},
|
||||
),
|
||||
])),
|
||||
]),
|
||||
o(
|
||||
_e,
|
||||
{
|
||||
modelValue: H.value,
|
||||
"onUpdate:modelValue":
|
||||
a[2] || (a[2] = (e) => (H.value = e)),
|
||||
title: J.value
|
||||
? "Firma bearbeiten"
|
||||
: "Neue Firma anlegen",
|
||||
icon: "business",
|
||||
onCancel: a[3] || (a[3] = (e) => (H.value = !1)),
|
||||
},
|
||||
{
|
||||
default: u(() => [
|
||||
H.value
|
||||
? (d(),
|
||||
m("div", E, [
|
||||
y("div", U, [
|
||||
y("div", S, [
|
||||
a[16] ||
|
||||
(a[16] = y(
|
||||
"span",
|
||||
{ class: "label" },
|
||||
"Firmenname",
|
||||
-1,
|
||||
)),
|
||||
o(
|
||||
be,
|
||||
{
|
||||
modelValue: Q.value.name,
|
||||
"onUpdate:modelValue":
|
||||
a[1] ||
|
||||
(a[1] = (e) => (Q.value.name = e)),
|
||||
placeholder: "z.B. KLZ Cables",
|
||||
autofocus: "",
|
||||
},
|
||||
null,
|
||||
8,
|
||||
["modelValue"],
|
||||
),
|
||||
]),
|
||||
]),
|
||||
y("div", A, [
|
||||
o(
|
||||
ve,
|
||||
{
|
||||
primary: "",
|
||||
block: "",
|
||||
loading: P.value,
|
||||
onClick: ie,
|
||||
},
|
||||
{
|
||||
default: u(() => [
|
||||
...(a[17] ||
|
||||
(a[17] = [p("Speichern", -1)])),
|
||||
]),
|
||||
_: 1,
|
||||
},
|
||||
8,
|
||||
["loading"],
|
||||
),
|
||||
]),
|
||||
]))
|
||||
: g("v-if", !0),
|
||||
]),
|
||||
_: 1,
|
||||
},
|
||||
8,
|
||||
["modelValue", "title"],
|
||||
),
|
||||
o(
|
||||
_e,
|
||||
{
|
||||
modelValue: R.value,
|
||||
"onUpdate:modelValue":
|
||||
a[9] || (a[9] = (e) => (R.value = e)),
|
||||
title: X.value
|
||||
? "Mitarbeiter bearbeiten"
|
||||
: "Neuen Mitarbeiter anlegen",
|
||||
icon: "person",
|
||||
onCancel: a[10] || (a[10] = (e) => (R.value = !1)),
|
||||
},
|
||||
{
|
||||
default: u(() => [
|
||||
R.value
|
||||
? (d(),
|
||||
m("div", T, [
|
||||
y("div", Z, [
|
||||
y("div", j, [
|
||||
a[18] ||
|
||||
(a[18] = y(
|
||||
"span",
|
||||
{ class: "label" },
|
||||
"Vorname",
|
||||
-1,
|
||||
)),
|
||||
o(
|
||||
be,
|
||||
{
|
||||
modelValue: Y.value.first_name,
|
||||
"onUpdate:modelValue":
|
||||
a[4] ||
|
||||
(a[4] = (e) =>
|
||||
(Y.value.first_name = e)),
|
||||
placeholder: "Vorname",
|
||||
autofocus: "",
|
||||
},
|
||||
null,
|
||||
8,
|
||||
["modelValue"],
|
||||
),
|
||||
]),
|
||||
y("div", $, [
|
||||
a[19] ||
|
||||
(a[19] = y(
|
||||
"span",
|
||||
{ class: "label" },
|
||||
"Nachname",
|
||||
-1,
|
||||
)),
|
||||
o(
|
||||
be,
|
||||
{
|
||||
modelValue: Y.value.last_name,
|
||||
"onUpdate:modelValue":
|
||||
a[5] ||
|
||||
(a[5] = (e) => (Y.value.last_name = e)),
|
||||
placeholder: "Nachname",
|
||||
},
|
||||
null,
|
||||
8,
|
||||
["modelValue"],
|
||||
),
|
||||
]),
|
||||
y("div", D, [
|
||||
a[20] ||
|
||||
(a[20] = y(
|
||||
"span",
|
||||
{ class: "label" },
|
||||
"E-Mail",
|
||||
-1,
|
||||
)),
|
||||
o(
|
||||
be,
|
||||
{
|
||||
modelValue: Y.value.email,
|
||||
"onUpdate:modelValue":
|
||||
a[6] ||
|
||||
(a[6] = (e) => (Y.value.email = e)),
|
||||
placeholder: "E-Mail Adresse",
|
||||
type: "email",
|
||||
},
|
||||
null,
|
||||
8,
|
||||
["modelValue"],
|
||||
),
|
||||
]),
|
||||
X.value
|
||||
? (d(), r(oe, { key: 0 }))
|
||||
: g("v-if", !0),
|
||||
X.value
|
||||
? (d(),
|
||||
m("div", O, [
|
||||
a[21] ||
|
||||
(a[21] = y(
|
||||
"span",
|
||||
{ class: "label" },
|
||||
"Temporäres Passwort",
|
||||
-1,
|
||||
)),
|
||||
o(
|
||||
be,
|
||||
{
|
||||
modelValue:
|
||||
Y.value.temporary_password,
|
||||
"onUpdate:modelValue":
|
||||
a[7] ||
|
||||
(a[7] = (e) =>
|
||||
(Y.value.temporary_password = e)),
|
||||
readonly: "",
|
||||
class: "password-input",
|
||||
},
|
||||
null,
|
||||
8,
|
||||
["modelValue"],
|
||||
),
|
||||
a[22] ||
|
||||
(a[22] = y(
|
||||
"p",
|
||||
{ class: "field-note" },
|
||||
"Wird beim Senden der Zugangsdaten automatisch generiert.",
|
||||
-1,
|
||||
)),
|
||||
]))
|
||||
: g("v-if", !0),
|
||||
]),
|
||||
y("div", W, [
|
||||
o(
|
||||
ve,
|
||||
{
|
||||
primary: "",
|
||||
block: "",
|
||||
loading: P.value,
|
||||
onClick: de,
|
||||
},
|
||||
{
|
||||
default: u(() => [
|
||||
...(a[23] ||
|
||||
(a[23] = [p("Daten speichern", -1)])),
|
||||
]),
|
||||
_: 1,
|
||||
},
|
||||
8,
|
||||
["loading"],
|
||||
),
|
||||
X.value
|
||||
? (d(),
|
||||
m(
|
||||
c,
|
||||
{ key: 0 },
|
||||
[
|
||||
o(oe),
|
||||
b(
|
||||
(d(),
|
||||
r(
|
||||
ve,
|
||||
{
|
||||
secondary: "",
|
||||
block: "",
|
||||
loading: G.value === Y.value.id,
|
||||
onClick:
|
||||
a[8] ||
|
||||
(a[8] = (e) =>
|
||||
(async function (e) {
|
||||
G.value = e.id;
|
||||
try {
|
||||
if (
|
||||
(await t.post(
|
||||
"/flows/trigger/33443f6b-cec7-4668-9607-f33ea674d501",
|
||||
[e.id],
|
||||
),
|
||||
(I.value = {
|
||||
type: "success",
|
||||
message: `Zugangsdaten für ${e.first_name} versendet. 📧`,
|
||||
}),
|
||||
await te(B.value),
|
||||
R.value &&
|
||||
Y.value.id === e.id)
|
||||
) {
|
||||
const a = K.value.find(
|
||||
(a) => a.id === e.id,
|
||||
);
|
||||
a &&
|
||||
(Y.value.temporary_password =
|
||||
a.temporary_password);
|
||||
}
|
||||
} catch (e) {
|
||||
I.value = {
|
||||
type: "danger",
|
||||
message: `Fehler: ${e.message}`,
|
||||
};
|
||||
} finally {
|
||||
G.value = null;
|
||||
}
|
||||
})(Y.value)),
|
||||
},
|
||||
{
|
||||
default: u(() => [
|
||||
o(l, {
|
||||
name: "send",
|
||||
left: "",
|
||||
}),
|
||||
a[24] ||
|
||||
(a[24] = p(
|
||||
" Zugangsdaten senden ",
|
||||
-1,
|
||||
)),
|
||||
]),
|
||||
_: 1,
|
||||
},
|
||||
8,
|
||||
["loading"],
|
||||
)),
|
||||
[
|
||||
[
|
||||
xe,
|
||||
"Generiert PW, speichert es und sendet E-Mail",
|
||||
void 0,
|
||||
{ bottom: !0 },
|
||||
],
|
||||
],
|
||||
),
|
||||
],
|
||||
64,
|
||||
))
|
||||
: g("v-if", !0),
|
||||
]),
|
||||
]))
|
||||
: g("v-if", !0),
|
||||
]),
|
||||
_: 1,
|
||||
},
|
||||
8,
|
||||
["modelValue", "title"],
|
||||
),
|
||||
]),
|
||||
_: 1,
|
||||
},
|
||||
)
|
||||
);
|
||||
}
|
||||
);
|
||||
},
|
||||
}),
|
||||
B = [],
|
||||
K = [];
|
||||
!(function (e, a) {
|
||||
if (e && "undefined" != typeof document) {
|
||||
var t,
|
||||
l = !0 === a.prepend ? "prepend" : "append",
|
||||
n = !0 === a.singleTag,
|
||||
i =
|
||||
"string" == typeof a.container
|
||||
? document.querySelector(a.container)
|
||||
: document.getElementsByTagName("head")[0];
|
||||
if (n) {
|
||||
var s = B.indexOf(i);
|
||||
(-1 === s && ((s = B.push(i) - 1), (K[s] = {})),
|
||||
(t = K[s] && K[s][l] ? K[s][l] : (K[s][l] = d())));
|
||||
} else t = d();
|
||||
(65279 === e.charCodeAt(0) && (e = e.substring(1)),
|
||||
t.styleSheet
|
||||
? (t.styleSheet.cssText += e)
|
||||
: t.appendChild(document.createTextNode(e)));
|
||||
}
|
||||
function d() {
|
||||
var e = document.createElement("style");
|
||||
if ((e.setAttribute("type", "text/css"), a.attributes))
|
||||
for (var t = Object.keys(a.attributes), n = 0; n < t.length; n++)
|
||||
e.setAttribute(t[n], a.attributes[t[n]]);
|
||||
var s = "prepend" === l ? "afterbegin" : "beforeend";
|
||||
return (i.insertAdjacentElement(s, e), e);
|
||||
}
|
||||
})(
|
||||
"\n.content-wrapper[data-v-3fd11e72] { padding: 32px; height: 100%; display: flex; flex-direction: column;\n}\n.company-item[data-v-3fd11e72] { cursor: pointer;\n}\n.header[data-v-3fd11e72] { margin-bottom: 24px; display: flex; justify-content: space-between; align-items: flex-end;\n}\n.header-right[data-v-3fd11e72] { display: flex; gap: 12px;\n}\n.title[data-v-3fd11e72] { font-size: 24px; font-weight: 800; margin-bottom: 4px;\n}\n.subtitle[data-v-3fd11e72] { color: var(--theme--foreground-subdued); font-size: 14px;\n}\n.empty-state[data-v-3fd11e72] { height: 100%; display: flex; align-items: center; justify-content: center;\n}\n.user-cell[data-v-3fd11e72] { display: flex; align-items: center; gap: 12px;\n}\n.user-name[data-v-3fd11e72] { font-weight: 600;\n}\n.status-date[data-v-3fd11e72] { font-size: 12px; color: var(--theme--foreground-subdued);\n}\n.drawer-content[data-v-3fd11e72] { padding: 24px; display: flex; flex-direction: column; gap: 32px;\n}\n.form-section[data-v-3fd11e72] { display: flex; flex-direction: column; gap: 20px;\n}\n.field[data-v-3fd11e72] { display: flex; flex-direction: column; gap: 8px;\n}\n.label[data-v-3fd11e72] { font-size: 12px; font-weight: 700; text-transform: uppercase; color: var(--theme--foreground-subdued); letter-spacing: 0.5px;\n}\n.field-note[data-v-3fd11e72] { font-size: 11px; color: var(--theme--foreground-subdued); margin-top: 4px;\n}\n.drawer-actions[data-v-3fd11e72] { margin-top: 24px; display: flex; flex-direction: column; gap: 12px;\n}\n.password-input[data-v-3fd11e72] textarea {\n\tfont-family: var(--family-monospace);\n\tfont-weight: 800;\n\tcolor: var(--theme--primary) !important;\n\tbackground: var(--theme--background-subdued) !important;\n}\n.clickable-table[data-v-3fd11e72] tbody tr { cursor: pointer; transition: background-color 0.2s ease;\n}\n.clickable-table[data-v-3fd11e72] tbody tr:hover { background-color: var(--theme--background-subdued) !important;\n}\n[data-v-3fd11e72] .v-list-item { cursor: pointer !important;\n}\n",
|
||||
{},
|
||||
);
|
||||
var L = a({
|
||||
id: "customer-manager",
|
||||
name: "Customer Manager",
|
||||
icon: "supervisor_account",
|
||||
routes: [
|
||||
{
|
||||
path: "",
|
||||
component: ((e, a) => {
|
||||
const t = e.__vccOpts || e;
|
||||
for (const [e, l] of a) t[e] = l;
|
||||
return t;
|
||||
})(q, [
|
||||
["__scopeId", "data-v-3fd11e72"],
|
||||
["__file", "module.vue"],
|
||||
]),
|
||||
},
|
||||
],
|
||||
});
|
||||
export { L as default };
|
||||
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>
|
||||
@@ -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",
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/* global module */
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
|
||||
@@ -9,16 +9,74 @@ export async function GET(req: NextRequest) {
|
||||
|
||||
const session = cookieStore.get(authCookieName);
|
||||
|
||||
if (session?.value === password) {
|
||||
return new NextResponse("OK", { status: 200 });
|
||||
}
|
||||
|
||||
// Traefik ForwardAuth headers
|
||||
// 1. URL Parameter Bypass (for automated tests/staging)
|
||||
const originalUrl = req.headers.get("x-forwarded-uri") || "/";
|
||||
const host =
|
||||
req.headers.get("x-forwarded-host") || req.headers.get("host") || "";
|
||||
const proto = req.headers.get("x-forwarded-proto") || "https";
|
||||
|
||||
try {
|
||||
const url = new URL(originalUrl, `${proto}://${host}`);
|
||||
if (url.searchParams.get("gk_bypass") === password) {
|
||||
// Remove the bypass parameter from the redirect URL
|
||||
url.searchParams.delete("gk_bypass");
|
||||
const cleanUrl = url.pathname + url.search;
|
||||
const absoluteCleanUrl = `${proto}://${host}${cleanUrl}`;
|
||||
|
||||
const response = NextResponse.redirect(absoluteCleanUrl);
|
||||
|
||||
// Set the session cookie so the bypass is persistent
|
||||
const isDev = process.env.NODE_ENV === "development";
|
||||
const cookieDomain = process.env.COOKIE_DOMAIN;
|
||||
const sessionValue = JSON.stringify({
|
||||
identity: "Bypass",
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
|
||||
response.cookies.set(authCookieName, sessionValue, {
|
||||
httpOnly: true,
|
||||
secure: !isDev,
|
||||
path: "/",
|
||||
maxAge: 30 * 24 * 60 * 60, // 30 days
|
||||
sameSite: "lax",
|
||||
...(cookieDomain ? { domain: cookieDomain } : {}),
|
||||
});
|
||||
|
||||
return response;
|
||||
}
|
||||
} catch (_e) {
|
||||
// URL parsing failed, proceed with normal logic
|
||||
}
|
||||
|
||||
let isAuthenticated = false;
|
||||
let identity = "Guest";
|
||||
|
||||
if (session?.value) {
|
||||
if (session.value === password) {
|
||||
isAuthenticated = true;
|
||||
} else {
|
||||
try {
|
||||
const payload = JSON.parse(session.value);
|
||||
if (payload.identity) {
|
||||
isAuthenticated = true;
|
||||
identity = payload.identity;
|
||||
}
|
||||
} catch (_e) {
|
||||
// Fallback or old format
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (isAuthenticated) {
|
||||
return new NextResponse("OK", {
|
||||
status: 200,
|
||||
headers: {
|
||||
"X-Auth-User": identity,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Traefik ForwardAuth headers
|
||||
const gatekeeperUrl =
|
||||
process.env.NEXT_PUBLIC_BASE_URL || `${proto}://gatekeeper.${host}`;
|
||||
const absoluteOriginalUrl = `${proto}://${host}${originalUrl}`;
|
||||
|
||||
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,
|
||||
});
|
||||
}
|
||||
@@ -17,18 +17,118 @@ export default async function LoginPage({ searchParams }: LoginPageProps) {
|
||||
async function login(formData: FormData) {
|
||||
"use server";
|
||||
|
||||
const password = formData.get("password");
|
||||
const expectedPassword = process.env.GATEKEEPER_PASSWORD || "mintel";
|
||||
const email = formData.get("email") as string;
|
||||
const password = formData.get("password") as string;
|
||||
|
||||
const expectedCode = process.env.GATEKEEPER_PASSWORD || "mintel";
|
||||
const adminEmail = process.env.DIRECTUS_ADMIN_EMAIL;
|
||||
const adminPassword = process.env.DIRECTUS_ADMIN_PASSWORD;
|
||||
const authCookieName =
|
||||
process.env.AUTH_COOKIE_NAME || "mintel_gatekeeper_session";
|
||||
const targetRedirect = formData.get("redirect") as string;
|
||||
const cookieDomain = process.env.COOKIE_DOMAIN;
|
||||
|
||||
if (password === expectedPassword) {
|
||||
let userIdentity = "";
|
||||
let userCompany: any = null;
|
||||
|
||||
// 1. Check Global Admin (from ENV)
|
||||
if (
|
||||
adminEmail &&
|
||||
adminPassword &&
|
||||
email === adminEmail &&
|
||||
password === adminPassword
|
||||
) {
|
||||
userIdentity = "Admin";
|
||||
}
|
||||
// 2. Check Generic Code (Guest)
|
||||
else if (!email && password === expectedCode) {
|
||||
userIdentity = "Guest";
|
||||
}
|
||||
// 3. Check Lightweight Client Users (dedicated collection)
|
||||
if (email && password && process.env.INFRA_DIRECTUS_URL) {
|
||||
try {
|
||||
const clientUsersRes = await fetch(
|
||||
`${process.env.INFRA_DIRECTUS_URL}/items/client_users?filter[email][_eq]=${encodeURIComponent(
|
||||
email,
|
||||
)}&fields=*,company.*`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${process.env.INFRA_DIRECTUS_TOKEN}`,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
if (clientUsersRes.ok) {
|
||||
const { data: users } = await clientUsersRes.json();
|
||||
const clientUser = users[0];
|
||||
|
||||
// ⚠️ NOTE: Plain text check for demo/dev, should use argon2 in production
|
||||
if (
|
||||
clientUser &&
|
||||
(clientUser.password === password ||
|
||||
clientUser.temporary_password === password)
|
||||
) {
|
||||
userIdentity = clientUser.first_name || clientUser.email;
|
||||
userCompany = {
|
||||
id: clientUser.company?.id,
|
||||
name: clientUser.company?.name,
|
||||
};
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Client User Auth Error:", e);
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Fallback to Directus Staff Auth if still not identified
|
||||
if (!userIdentity && email && password && process.env.DIRECTUS_URL) {
|
||||
try {
|
||||
const loginRes = await fetch(`${process.env.DIRECTUS_URL}/auth/login`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ email, password }),
|
||||
});
|
||||
|
||||
if (loginRes.ok) {
|
||||
const { data } = await loginRes.json();
|
||||
const accessToken = data.access_token;
|
||||
|
||||
// Fetch user info with company depth
|
||||
const userRes = await fetch(
|
||||
`${process.env.DIRECTUS_URL}/users/me?fields=*,company.*`,
|
||||
{
|
||||
headers: { Authorization: `Bearer ${accessToken}` },
|
||||
},
|
||||
);
|
||||
|
||||
if (userRes.ok) {
|
||||
const { data: user } = await userRes.json();
|
||||
userIdentity = user.first_name || user.email;
|
||||
userCompany = {
|
||||
id: user.company?.id,
|
||||
name: user.company?.name,
|
||||
};
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Directus Auth Error:", e);
|
||||
}
|
||||
}
|
||||
|
||||
if (userIdentity) {
|
||||
const cookieStore = await cookies();
|
||||
cookieStore.set(authCookieName, expectedPassword, {
|
||||
// Store identity in the cookie (simplified for now, ideally signed)
|
||||
const sessionValue = JSON.stringify({
|
||||
identity: userIdentity,
|
||||
company: userCompany,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
|
||||
const isDev = process.env.NODE_ENV === "development";
|
||||
|
||||
cookieStore.set(authCookieName, sessionValue, {
|
||||
httpOnly: true,
|
||||
secure: true,
|
||||
secure: !isDev,
|
||||
path: "/",
|
||||
maxAge: 30 * 24 * 60 * 60, // 30 days
|
||||
sameSite: "lax",
|
||||
@@ -85,24 +185,35 @@ export default async function LoginPage({ searchParams }: LoginPageProps) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form action={login} className="space-y-6">
|
||||
<form action={login} className="space-y-4">
|
||||
<input type="hidden" name="redirect" value={redirectUrl} />
|
||||
|
||||
<div className="relative group">
|
||||
<input
|
||||
type="password"
|
||||
name="password"
|
||||
required
|
||||
autoFocus
|
||||
autoComplete="current-password"
|
||||
placeholder="GATEKEEPER CODE"
|
||||
className="w-full bg-slate-50/50 border border-slate-200 rounded-2xl px-6 py-4 focus:outline-none focus:border-slate-900 focus:bg-white transition-all text-sm font-sans font-bold tracking-[0.3em] uppercase placeholder:text-slate-300 placeholder:tracking-widest shadow-sm shadow-slate-50"
|
||||
/>
|
||||
<div className="space-y-2">
|
||||
<div className="relative group">
|
||||
<input
|
||||
type="email"
|
||||
name="email"
|
||||
placeholder="EMAIL (OPTIONAL)"
|
||||
className="w-full bg-slate-50/50 border border-slate-200 rounded-2xl px-6 py-4 focus:outline-none focus:border-slate-900 focus:bg-white transition-all text-[10px] font-sans font-bold tracking-[0.2em] uppercase placeholder:text-slate-300 shadow-sm shadow-slate-50"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="relative group">
|
||||
<input
|
||||
type="password"
|
||||
name="password"
|
||||
required
|
||||
autoFocus
|
||||
autoComplete="current-password"
|
||||
placeholder="ACCESS CODE"
|
||||
className="w-full bg-slate-50/50 border border-slate-200 rounded-2xl px-6 py-4 focus:outline-none focus:border-slate-900 focus:bg-white transition-all text-sm font-sans font-bold tracking-[0.3em] uppercase placeholder:text-slate-300 placeholder:tracking-widest shadow-sm shadow-slate-50"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
className="btn btn-primary w-full py-5 rounded-2xl text-[10px] shadow-lg shadow-slate-100"
|
||||
className="btn btn-primary w-full py-5 rounded-2xl text-[10px] shadow-lg shadow-slate-100 flex items-center justify-center"
|
||||
>
|
||||
Unlock Access
|
||||
<ArrowRight className="ml-3 w-3 h-3 group-hover:translate-x-1 transition-transform" />
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/* global module, require */
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
module.exports = {
|
||||
content: [
|
||||
@@ -55,5 +56,6 @@ module.exports = {
|
||||
},
|
||||
},
|
||||
},
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
plugins: [require("@tailwindcss/typography")],
|
||||
};
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import path from "path";
|
||||
/* global process */
|
||||
import path from "node:path";
|
||||
|
||||
const buildLintCommand = (filenames) => {
|
||||
const isNext =
|
||||
@@ -11,7 +12,7 @@ const buildLintCommand = (filenames) => {
|
||||
.join(" --file ")}`;
|
||||
}
|
||||
|
||||
return "eslint --fix";
|
||||
return "eslint --fix --no-warn-ignored";
|
||||
};
|
||||
|
||||
const config = {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@mintel/husky-config",
|
||||
"version": "1.0.0",
|
||||
"version": "1.7.0",
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
"registry": "https://npm.infra.mintel.me"
|
||||
|
||||
@@ -3,18 +3,36 @@ FROM node:20-alpine AS builder
|
||||
RUN apk add --no-cache libc6-compat curl
|
||||
WORKDIR /app
|
||||
RUN corepack enable pnpm
|
||||
ENV CI=true
|
||||
|
||||
# Copy source (honoring .dockerignore)
|
||||
COPY . .
|
||||
# Copy manifest files specifically for better layer caching
|
||||
COPY pnpm-lock.yaml pnpm-workspace.yaml package.json .npmrc ./
|
||||
COPY packages/gatekeeper/package.json ./packages/gatekeeper/package.json
|
||||
COPY packages/next-utils/package.json ./packages/next-utils/package.json
|
||||
COPY packages/eslint-config/package.json ./packages/eslint-config/package.json
|
||||
COPY packages/next-config/package.json ./packages/next-config/package.json
|
||||
COPY packages/tsconfig/package.json ./packages/tsconfig/package.json
|
||||
COPY packages/infra/package.json ./packages/infra/package.json
|
||||
COPY packages/cms-infra/package.json ./packages/cms-infra/package.json
|
||||
COPY packages/mail/package.json ./packages/mail/package.json
|
||||
COPY packages/cli/package.json ./packages/cli/package.json
|
||||
COPY packages/observability/package.json ./packages/observability/package.json
|
||||
COPY packages/next-observability/package.json ./packages/next-observability/package.json
|
||||
COPY packages/husky-config/package.json ./packages/husky-config/package.json
|
||||
|
||||
# Use a secret for NPM_TOKEN to authenticate with private registry
|
||||
RUN --mount=type=cache,target=/root/.local/share/pnpm/store/v3 \
|
||||
# Use a secret for NPM_TOKEN and a cache mount for the pnpm store
|
||||
RUN --mount=type=cache,id=pnpm,target=/pnpm/store \
|
||||
--mount=type=secret,id=NPM_TOKEN \
|
||||
export NPM_TOKEN=$(cat /run/secrets/NPM_TOKEN) && \
|
||||
pnpm config set store-dir /pnpm/store && \
|
||||
pnpm i --frozen-lockfile
|
||||
|
||||
# Copy the rest of the source
|
||||
COPY . .
|
||||
|
||||
# Build Gatekeeper and its dependencies
|
||||
RUN pnpm --filter @mintel/gatekeeper... build
|
||||
RUN --mount=type=cache,target=/app/packages/gatekeeper/.next/cache \
|
||||
pnpm --filter @mintel/gatekeeper... build
|
||||
RUN mkdir -p packages/gatekeeper/public
|
||||
|
||||
# Step 2: Runner stage
|
||||
@@ -25,9 +43,12 @@ ENV NODE_ENV=production
|
||||
RUN addgroup --system --gid 1001 nodejs
|
||||
RUN adduser --system --uid 1001 nextjs
|
||||
|
||||
COPY --from=builder /app/packages/gatekeeper/public ./packages/gatekeeper/public
|
||||
COPY --from=builder /app/packages/gatekeeper/.next/standalone ./
|
||||
COPY --from=builder /app/packages/gatekeeper/.next/static ./packages/gatekeeper/.next/static
|
||||
# Set the correct permission for prerender cache
|
||||
RUN mkdir -p packages/gatekeeper/.next && chown nextjs:nodejs packages/gatekeeper/.next
|
||||
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/packages/gatekeeper/public ./packages/gatekeeper/public
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/packages/gatekeeper/.next/standalone ./
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/packages/gatekeeper/.next/static ./packages/gatekeeper/.next/static
|
||||
|
||||
USER nextjs
|
||||
EXPOSE 3000
|
||||
|
||||
@@ -5,15 +5,34 @@ WORKDIR /app
|
||||
RUN corepack enable pnpm
|
||||
|
||||
# Step 2: Install dependencies
|
||||
# We copy everything first because we have a .dockerignore
|
||||
# and we need the workspace structure for pnpm to work correctly
|
||||
COPY . .
|
||||
ENV NPM_TOKEN=placeholder
|
||||
# Copy manifest files specifically for better layer caching
|
||||
COPY pnpm-lock.yaml pnpm-workspace.yaml package.json .npmrc ./
|
||||
# Copy package manifest files individually to preserve directory structure
|
||||
COPY packages/cli/package.json ./packages/cli/
|
||||
COPY packages/cms-infra/package.json ./packages/cms-infra/
|
||||
COPY packages/customer-manager/package.json ./packages/customer-manager/
|
||||
COPY packages/eslint-config/package.json ./packages/eslint-config/
|
||||
COPY packages/feedback-commander/package.json ./packages/feedback-commander/
|
||||
COPY packages/gatekeeper/package.json ./packages/gatekeeper/
|
||||
COPY packages/husky-config/package.json ./packages/husky-config/
|
||||
COPY packages/infra/package.json ./packages/infra/
|
||||
COPY packages/mail/package.json ./packages/mail/
|
||||
COPY packages/next-config/package.json ./packages/next-config/
|
||||
COPY packages/next-feedback/package.json ./packages/next-feedback/
|
||||
COPY packages/next-observability/package.json ./packages/next-observability/
|
||||
COPY packages/next-utils/package.json ./packages/next-utils/
|
||||
COPY packages/observability/package.json ./packages/observability/
|
||||
COPY packages/tsconfig/package.json ./packages/tsconfig/
|
||||
# packages/ui does not have a package.json
|
||||
|
||||
# Use a secret for NPM_TOKEN to authenticate with private registry
|
||||
RUN --mount=type=cache,target=/root/.local/share/pnpm/store/v3 \
|
||||
# Use a secret for NPM_TOKEN and a standardized cache mount
|
||||
RUN --mount=type=cache,id=pnpm,target=/pnpm/store \
|
||||
--mount=type=secret,id=NPM_TOKEN \
|
||||
export NPM_TOKEN=$(cat /run/secrets/NPM_TOKEN) && \
|
||||
pnpm config set store-dir /pnpm/store && \
|
||||
pnpm i --frozen-lockfile
|
||||
|
||||
# Step 3: Build shared packages
|
||||
COPY . .
|
||||
RUN pnpm --filter "./packages/*" -r build
|
||||
|
||||
@@ -1,19 +1,22 @@
|
||||
FROM node:20-alpine
|
||||
FROM node:20-alpine AS runner
|
||||
RUN apk add --no-cache libc6-compat curl
|
||||
|
||||
# Install essential production utilities
|
||||
RUN apk add --no-cache curl libc6-compat
|
||||
WORKDIR /app
|
||||
|
||||
# Set standard production environment
|
||||
ENV NODE_ENV=production
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
ENV PORT=3000
|
||||
ENV HOSTNAME="0.0.0.0"
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Create non-root user for security
|
||||
RUN addgroup --system --gid 1001 nodejs && \
|
||||
adduser --system --uid 1001 nextjs
|
||||
|
||||
# Expose the default Next.js port
|
||||
# Set correct permissions
|
||||
RUN chown -R nextjs:nodejs /app
|
||||
|
||||
USER nextjs
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
CMD ["node", "server.js"]
|
||||
|
||||
@@ -53,7 +53,7 @@ services:
|
||||
- "traefik.http.services.${PROJECT_NAME}-gatekeeper.loadbalancer.server.port=3000"
|
||||
|
||||
directus:
|
||||
image: registry.infra.mintel.me/mintel/directus:latest
|
||||
image: registry.infra.mintel.me/mintel/directus:${IMAGE_TAG:-latest}
|
||||
restart: always
|
||||
networks:
|
||||
- infra
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@mintel/infra",
|
||||
"version": "1.0.1",
|
||||
"version": "1.7.0",
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
"registry": "https://npm.infra.mintel.me"
|
||||
|
||||
7
packages/mail/CHANGELOG.md
Normal file
7
packages/mail/CHANGELOG.md
Normal file
@@ -0,0 +1,7 @@
|
||||
# @mintel/mail
|
||||
|
||||
## 1.7.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- 96ec2c7: Initial release of the branded email system package.
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@mintel/mail",
|
||||
"version": "1.2.0",
|
||||
"version": "1.7.0",
|
||||
"private": false,
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
@@ -38,6 +38,7 @@
|
||||
"@mintel/tsconfig": "workspace:*",
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
"prettier": "^3.8.1",
|
||||
"tsup": "^8.3.5",
|
||||
"typescript": "^5.0.0",
|
||||
"vitest": "^3.0.4"
|
||||
|
||||
@@ -14,11 +14,7 @@ export interface BaseLayoutProps {
|
||||
brandColor?: string;
|
||||
}
|
||||
|
||||
export const BaseLayout = ({
|
||||
preview,
|
||||
children,
|
||||
brandColor = "#82ed20",
|
||||
}: BaseLayoutProps) => {
|
||||
export const BaseLayout = ({ preview, children }: BaseLayoutProps) => {
|
||||
return (
|
||||
<Html>
|
||||
<Head />
|
||||
|
||||
@@ -10,7 +10,7 @@ export interface MintelLayoutProps {
|
||||
|
||||
export const MintelLayout = ({ preview, children }: MintelLayoutProps) => {
|
||||
return (
|
||||
<BaseLayout preview={preview} brandColor="#82ed20">
|
||||
<BaseLayout preview={preview}>
|
||||
<Section style={header}>
|
||||
<MintelLogo />
|
||||
</Section>
|
||||
|
||||
23
packages/mail/vitest.config.ts
Normal file
23
packages/mail/vitest.config.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
import path from "path";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: "node",
|
||||
alias: {
|
||||
"prettier/plugins/html": path.resolve(
|
||||
process.cwd(),
|
||||
"../../node_modules/prettier/plugins/html.js",
|
||||
),
|
||||
"prettier/parser-html": path.resolve(
|
||||
process.cwd(),
|
||||
"../../node_modules/prettier/plugins/html.js",
|
||||
),
|
||||
},
|
||||
server: {
|
||||
deps: {
|
||||
inline: [/@react-email/],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -1,5 +1,17 @@
|
||||
# @mintel/next-config
|
||||
|
||||
## 1.6.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Add `turbopack: {}` to support Next.js 16 default Turbopack behavior when a webpack config is present.
|
||||
|
||||
## 1.6.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Add `turbopack: {}` to support Next.js 16 default Turbopack behavior when a webpack config is present.
|
||||
|
||||
## 1.0.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/* global process, URL */
|
||||
import createNextIntlPlugin from "next-intl/plugin";
|
||||
import { withSentryConfig } from "@sentry/nextjs";
|
||||
import fs from "node:fs";
|
||||
@@ -6,6 +7,7 @@ import path from "node:path";
|
||||
/** @type {import('next').NextConfig} */
|
||||
export const baseNextConfig = {
|
||||
output: "standalone",
|
||||
turbopack: {},
|
||||
images: {
|
||||
dangerouslyAllowSVG: true,
|
||||
contentDispositionType: "attachment",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@mintel/next-config",
|
||||
"version": "1.0.1",
|
||||
"version": "1.7.0",
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
"registry": "https://npm.infra.mintel.me"
|
||||
@@ -16,6 +16,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"next-intl": "^4.8.2",
|
||||
"@sentry/nextjs": "^8.0.0"
|
||||
"@sentry/nextjs": "^10.38.0",
|
||||
"next": "16.1.6"
|
||||
}
|
||||
}
|
||||
|
||||
51
packages/next-feedback/package.json
Normal file
51
packages/next-feedback/package.json
Normal file
@@ -0,0 +1,51 @@
|
||||
{
|
||||
"name": "@mintel/next-feedback",
|
||||
"version": "1.7.0",
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
"registry": "https://npm.infra.mintel.me"
|
||||
},
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/index.mjs",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.mjs",
|
||||
"require": "./dist/index.js"
|
||||
},
|
||||
"./FeedbackOverlay": {
|
||||
"types": "./dist/components/FeedbackOverlay.d.ts",
|
||||
"import": "./dist/components/FeedbackOverlay.mjs",
|
||||
"require": "./dist/components/FeedbackOverlay.js"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsup",
|
||||
"dev": "tsup --watch",
|
||||
"lint": "eslint src/"
|
||||
},
|
||||
"dependencies": {
|
||||
"@directus/sdk": "^21.0.0",
|
||||
"clsx": "^2.1.1",
|
||||
"framer-motion": "^11.5.4",
|
||||
"html2canvas": "^1.4.1",
|
||||
"lucide-react": "^0.441.0",
|
||||
"next": "16.1.6",
|
||||
"tailwind-merge": "^2.5.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@mintel/eslint-config": "workspace:*",
|
||||
"@mintel/tsconfig": "workspace:*",
|
||||
"@types/node": "^20.17.16",
|
||||
"@types/react": "^19.2.10",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"eslint": "^9.39.2",
|
||||
"tsup": "^8.0.0",
|
||||
"typescript": "^5.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0"
|
||||
}
|
||||
}
|
||||
621
packages/next-feedback/src/components/FeedbackOverlay.tsx
Normal file
621
packages/next-feedback/src/components/FeedbackOverlay.tsx
Normal file
@@ -0,0 +1,621 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useEffect, useMemo } from "react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { MessageSquare, X, Check, Plus, List, Send, User } from "lucide-react";
|
||||
import { clsx } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import html2canvas from "html2canvas";
|
||||
|
||||
function cn(...inputs: any[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
|
||||
interface FeedbackComment {
|
||||
id: string;
|
||||
userName: string;
|
||||
text: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
interface Feedback {
|
||||
id: string;
|
||||
x: number;
|
||||
y: number;
|
||||
selector: string;
|
||||
text: string;
|
||||
type: "design" | "content";
|
||||
elementRect: DOMRect | null;
|
||||
userName: string;
|
||||
comments: FeedbackComment[];
|
||||
}
|
||||
|
||||
export function FeedbackOverlay() {
|
||||
const [isActive, setIsActive] = useState(false);
|
||||
const [hoveredElement, setHoveredElement] = useState<HTMLElement | null>(
|
||||
null,
|
||||
);
|
||||
const [selectedElement, setSelectedElement] = useState<HTMLElement | null>(
|
||||
null,
|
||||
);
|
||||
const [feedbacks, setFeedbacks] = useState<Feedback[]>([]);
|
||||
const [currentComment, setCurrentComment] = useState("");
|
||||
const [currentType, setCurrentType] = useState<"design" | "content">(
|
||||
"design",
|
||||
);
|
||||
const [showList, setShowList] = useState(false);
|
||||
const [currentUser, setCurrentUser] = useState<{
|
||||
identity: string;
|
||||
isDevFallback?: boolean;
|
||||
} | null>(null);
|
||||
const [newCommentTexts, setNewCommentTexts] = useState<{
|
||||
[feedbackId: string]: string;
|
||||
}>({});
|
||||
const [isCapturing, setIsCapturing] = useState(false);
|
||||
|
||||
// 1. Fetch Identity and Existing Feedback
|
||||
useEffect(() => {
|
||||
const checkAuth = async () => {
|
||||
try {
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const bypass = urlParams.get("gatekeeper_bypass");
|
||||
const apiUrl = bypass
|
||||
? `/api/whoami?gatekeeper_bypass=${bypass}`
|
||||
: "/api/whoami";
|
||||
|
||||
const res = await fetch(apiUrl);
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setCurrentUser(data);
|
||||
} else {
|
||||
setCurrentUser({ identity: "Guest" });
|
||||
}
|
||||
} catch (_e) {
|
||||
setCurrentUser({ identity: "Guest" });
|
||||
}
|
||||
};
|
||||
|
||||
const fetchFeedback = async () => {
|
||||
try {
|
||||
const res = await fetch("/api/feedback");
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
const mapped = data.map((fb: any) => ({
|
||||
id: fb.id,
|
||||
x: fb.x,
|
||||
y: fb.y,
|
||||
selector: fb.selector,
|
||||
text: fb.text,
|
||||
type: fb.type,
|
||||
userName: fb.user_name,
|
||||
comments: (fb.comments || []).map((c: any) => ({
|
||||
id: c.id,
|
||||
userName: c.user_name,
|
||||
text: c.text,
|
||||
createdAt: c.date_created,
|
||||
})),
|
||||
}));
|
||||
setFeedbacks(mapped);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to fetch feedbacks", e);
|
||||
}
|
||||
};
|
||||
|
||||
checkAuth();
|
||||
fetchFeedback();
|
||||
}, []);
|
||||
|
||||
const getSelector = (el: HTMLElement): string => {
|
||||
if (el.id) return `#${el.id}`;
|
||||
const path = [];
|
||||
let curr: HTMLElement | null = el;
|
||||
while (curr && curr.parentElement) {
|
||||
const index = Array.from(curr.parentElement.children).indexOf(curr) + 1;
|
||||
path.unshift(`${curr.tagName.toLowerCase()}:nth-child(${index})`);
|
||||
curr = curr.parentElement;
|
||||
}
|
||||
return path.join(" > ");
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!isActive) {
|
||||
setHoveredElement(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
if (selectedElement) return;
|
||||
|
||||
const target = e.target as HTMLElement;
|
||||
if (target.closest(".feedback-ui-ignore")) {
|
||||
setHoveredElement(null);
|
||||
return;
|
||||
}
|
||||
setHoveredElement(target);
|
||||
};
|
||||
|
||||
const handleClick = (e: MouseEvent) => {
|
||||
if (selectedElement) return;
|
||||
|
||||
const target = e.target as HTMLElement;
|
||||
if (target.closest(".feedback-ui-ignore")) return;
|
||||
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
setSelectedElement(target);
|
||||
setHoveredElement(null);
|
||||
};
|
||||
|
||||
window.addEventListener("mousemove", handleMouseMove);
|
||||
window.addEventListener("click", handleClick, true);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("mousemove", handleMouseMove);
|
||||
window.removeEventListener("click", handleClick, true);
|
||||
};
|
||||
}, [isActive, selectedElement]);
|
||||
|
||||
const captureScreenshot = async (): Promise<string | null> => {
|
||||
try {
|
||||
setIsCapturing(true);
|
||||
const canvas = await html2canvas(document.body, {
|
||||
useCORS: true,
|
||||
scale: 1,
|
||||
ignoreElements: (el) => el.classList.contains("feedback-ui-ignore"),
|
||||
});
|
||||
return canvas.toDataURL("image/png");
|
||||
} catch (e) {
|
||||
console.error("Screenshot failed", e);
|
||||
return null;
|
||||
} finally {
|
||||
setIsCapturing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const saveFeedback = async () => {
|
||||
if (!selectedElement || !currentComment) return;
|
||||
|
||||
const rect = selectedElement.getBoundingClientRect();
|
||||
const screenshot = await captureScreenshot();
|
||||
|
||||
const feedbackData = {
|
||||
url: window.location.href,
|
||||
x: rect.left + rect.width / 2 + window.scrollX,
|
||||
y: rect.top + rect.height / 2 + window.scrollY,
|
||||
selector: getSelector(selectedElement),
|
||||
text: currentComment,
|
||||
type: currentType,
|
||||
userName: currentUser?.identity || "Unknown",
|
||||
userIdentity: currentUser?.identity === "Admin" ? "admin" : "user",
|
||||
screenshot_base64: screenshot,
|
||||
};
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/feedback", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(feedbackData),
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
const savedFb = await res.json();
|
||||
const newFeedback: Feedback = {
|
||||
id: savedFb.id,
|
||||
x: savedFb.x,
|
||||
y: savedFb.y,
|
||||
selector: savedFb.selector,
|
||||
text: savedFb.text,
|
||||
type: savedFb.type,
|
||||
elementRect: rect,
|
||||
userName: savedFb.user_name,
|
||||
comments: [],
|
||||
};
|
||||
setFeedbacks([...feedbacks, newFeedback]);
|
||||
setSelectedElement(null);
|
||||
setCurrentComment("");
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to save feedback", e);
|
||||
}
|
||||
};
|
||||
|
||||
const addReply = async (feedbackId: string) => {
|
||||
const text = newCommentTexts[feedbackId];
|
||||
if (!text) return;
|
||||
|
||||
if (!currentUser?.identity || currentUser.identity === "Guest") {
|
||||
alert("Nur angemeldete Benutzer können antworten.");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/feedback", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
action: "reply",
|
||||
feedbackId,
|
||||
userName: currentUser?.identity || "Unknown",
|
||||
text,
|
||||
}),
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
const savedReply = await res.json();
|
||||
setFeedbacks(
|
||||
feedbacks.map((f) => {
|
||||
if (f.id === feedbackId) {
|
||||
return {
|
||||
...f,
|
||||
comments: [
|
||||
...f.comments,
|
||||
{
|
||||
id: savedReply.id,
|
||||
userName: savedReply.user_name,
|
||||
text: savedReply.text,
|
||||
createdAt: savedReply.date_created,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
return f;
|
||||
}),
|
||||
);
|
||||
setNewCommentTexts({ ...newCommentTexts, [feedbackId]: "" });
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to save reply", e);
|
||||
}
|
||||
};
|
||||
|
||||
const hoveredRect = useMemo(
|
||||
() => hoveredElement?.getBoundingClientRect(),
|
||||
[hoveredElement],
|
||||
);
|
||||
const selectedRect = useMemo(
|
||||
() => selectedElement?.getBoundingClientRect(),
|
||||
[selectedElement],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="feedback-ui-ignore">
|
||||
{/* 1. Global Toolbar */}
|
||||
<div className="fixed bottom-6 left-1/2 -translate-x-1/2 z-[9999]">
|
||||
<div className="bg-black/80 backdrop-blur-xl border border-white/10 p-2 rounded-2xl shadow-2xl flex items-center gap-2">
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-2 px-3 py-2 rounded-xl transition-all",
|
||||
currentUser?.isDevFallback
|
||||
? "bg-orange-500/20 text-orange-400"
|
||||
: "bg-white/5 text-white/40",
|
||||
)}
|
||||
>
|
||||
<User size={14} />
|
||||
<span className="text-[10px] font-bold uppercase tracking-wider">
|
||||
{currentUser?.identity || "Loading..."}
|
||||
{currentUser?.isDevFallback && " (Local Dev Bypass)"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="w-px h-6 bg-white/10 mx-1" />
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
if (!currentUser?.identity || currentUser.identity === "Guest") {
|
||||
alert("Bitte logge dich ein, um Feedback zu geben.");
|
||||
return;
|
||||
}
|
||||
setIsActive(!isActive);
|
||||
}}
|
||||
disabled={
|
||||
!currentUser?.identity || currentUser.identity === "Guest"
|
||||
}
|
||||
className={cn(
|
||||
"flex items-center gap-2 px-4 py-2 rounded-xl transition-all font-medium disabled:opacity-30 disabled:cursor-not-allowed",
|
||||
isActive
|
||||
? "bg-blue-500 text-white shadow-lg shadow-blue-500/20"
|
||||
: "text-white/70 hover:text-white hover:bg-white/10",
|
||||
)}
|
||||
>
|
||||
{isActive ? <X size={18} /> : <MessageSquare size={18} />}
|
||||
{isActive ? "Modus beenden" : "Feedback geben"}
|
||||
</button>
|
||||
|
||||
<div className="w-px h-6 bg-white/10 mx-1" />
|
||||
|
||||
<button
|
||||
onClick={() => setShowList(!showList)}
|
||||
className="p-2 text-white/70 hover:text-white hover:bg-white/10 rounded-xl relative"
|
||||
>
|
||||
<List size={20} />
|
||||
{feedbacks.length > 0 && (
|
||||
<span className="absolute -top-1 -right-1 w-5 h-5 bg-blue-500 text-[10px] flex items-center justify-center rounded-full text-white font-bold border-2 border-[#1a1a1a]">
|
||||
{feedbacks.length}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 2. Feedback Markers & Highlights */}
|
||||
<AnimatePresence>
|
||||
{isActive && (
|
||||
<>
|
||||
{/* Fixed Overlay for real-time highlights */}
|
||||
<div className="fixed inset-0 pointer-events-none z-[9998]">
|
||||
{hoveredRect && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="absolute border-2 border-blue-400 bg-blue-400/10 rounded-sm transition-all duration-200"
|
||||
style={{
|
||||
top: hoveredRect.top,
|
||||
left: hoveredRect.left,
|
||||
width: hoveredRect.width,
|
||||
height: hoveredRect.height,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{selectedRect && (
|
||||
<motion.div
|
||||
initial={{ scale: 0.9, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
className="absolute border-2 border-yellow-400 bg-yellow-400/20 rounded-sm"
|
||||
style={{
|
||||
top: selectedRect.top,
|
||||
left: selectedRect.left,
|
||||
width: selectedRect.width,
|
||||
height: selectedRect.height,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Absolute Overlay for persistent pins */}
|
||||
<div className="absolute inset-0 pointer-events-none z-[9997]">
|
||||
{feedbacks.map((fb) => (
|
||||
<div
|
||||
key={fb.id}
|
||||
className="absolute"
|
||||
style={{ top: fb.y, left: fb.x }}
|
||||
>
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowList(true);
|
||||
}}
|
||||
className={cn(
|
||||
"w-6 h-6 -translate-x-1/2 -translate-y-1/2 rounded-full border-2 border-white shadow-lg flex items-center justify-center text-white cursor-pointer pointer-events-auto transition-transform hover:scale-110",
|
||||
fb.type === "design" ? "bg-purple-500" : "bg-orange-500",
|
||||
)}
|
||||
>
|
||||
<Plus size={14} className="rotate-45" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* 3. Feedback Modal */}
|
||||
<AnimatePresence>
|
||||
{selectedElement && (
|
||||
<div className="fixed inset-0 flex items-center justify-center z-[10000] bg-black/40 backdrop-blur-sm">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20, scale: 0.95 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, y: 20, scale: 0.95 }}
|
||||
className="bg-[#1c1c1e] border border-white/10 rounded-3xl p-6 w-[400px] shadow-2xl"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h3 className="text-white font-bold text-lg">Feedback geben</h3>
|
||||
<button
|
||||
onClick={() => setSelectedElement(null)}
|
||||
className="text-white/40 hover:text-white"
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 mb-6">
|
||||
{(["design", "content"] as const).map((type) => (
|
||||
<button
|
||||
key={type}
|
||||
onClick={() => setCurrentType(type)}
|
||||
className={cn(
|
||||
"flex-1 py-3 px-4 rounded-xl text-sm font-medium transition-all capitalize",
|
||||
currentType === type
|
||||
? "bg-white text-black shadow-lg"
|
||||
: "bg-white/5 text-white/40 hover:bg-white/10",
|
||||
)}
|
||||
>
|
||||
{type === "design" ? "🎨 Design" : "✍️ Content"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<textarea
|
||||
autoFocus
|
||||
value={currentComment}
|
||||
onChange={(e) => setCurrentComment(e.target.value)}
|
||||
placeholder="Was möchtest du anmerken?"
|
||||
className="w-full h-32 bg-white/5 border border-white/5 rounded-2xl p-4 text-white placeholder:text-white/20 focus:outline-none focus:border-blue-500/50 transition-colors resize-none mb-6"
|
||||
/>
|
||||
|
||||
<button
|
||||
disabled={!currentComment || isCapturing}
|
||||
onClick={saveFeedback}
|
||||
className="w-full bg-blue-500 hover:bg-blue-400 disabled:opacity-50 disabled:cursor-not-allowed text-white font-bold py-4 rounded-2xl flex items-center justify-center gap-2 transition-all shadow-lg shadow-blue-500/20"
|
||||
>
|
||||
{isCapturing ? (
|
||||
"Erfasse Screenshot..."
|
||||
) : (
|
||||
<>
|
||||
<Check size={20} />
|
||||
Feedback speichern
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</motion.div>
|
||||
</div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* 4. Feedback List Sidebar */}
|
||||
<AnimatePresence>
|
||||
{showList && (
|
||||
<>
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
onClick={() => setShowList(false)}
|
||||
className="fixed inset-0 bg-black/60 backdrop-blur-sm z-[10001]"
|
||||
/>
|
||||
<motion.div
|
||||
initial={{ x: "100%" }}
|
||||
animate={{ x: 0 }}
|
||||
exit={{ x: "100%" }}
|
||||
transition={{ type: "spring", damping: 25, stiffness: 200 }}
|
||||
className="fixed top-0 right-0 h-full w-[400px] bg-[#1c1c1e] border-l border-white/10 z-[10002] shadow-2xl flex flex-col"
|
||||
>
|
||||
<div className="p-8 border-b border-white/10 flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-white mb-1">
|
||||
Feedback
|
||||
</h2>
|
||||
<p className="text-white/40 text-sm">
|
||||
{feedbacks.length} Anmerkungen live
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowList(false)}
|
||||
className="p-2 text-white/40 hover:text-white bg-white/5 rounded-xl transition-colors"
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-6 space-y-6">
|
||||
{feedbacks.length === 0 ? (
|
||||
<div className="h-full flex flex-col items-center justify-center text-center px-8 opacity-40">
|
||||
<MessageSquare size={48} className="mb-4" />
|
||||
<p>
|
||||
Noch kein Feedback vorhanden. Aktiviere den Modus um
|
||||
Stellen auf der Seite zu markieren.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
feedbacks.map((fb) => (
|
||||
<div
|
||||
key={fb.id}
|
||||
className="bg-white/5 border border-white/5 rounded-3xl overflow-hidden hover:border-white/20 transition-all flex flex-col"
|
||||
>
|
||||
<div className="p-5 border-b border-white/5 bg-white/[0.02]">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-8 h-8 rounded-full bg-blue-500/20 flex items-center justify-center text-blue-400">
|
||||
<User size={14} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-white text-[11px] font-bold uppercase tracking-wider">
|
||||
{fb.userName}
|
||||
</p>
|
||||
<p className="text-white/20 text-[9px] uppercase tracking-widest">
|
||||
Original Poster
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
className={cn(
|
||||
"px-3 py-1 rounded-full text-[9px] font-bold uppercase tracking-wider",
|
||||
fb.type === "design"
|
||||
? "bg-purple-500/20 text-purple-400"
|
||||
: "bg-orange-500/20 text-orange-400",
|
||||
)}
|
||||
>
|
||||
{fb.type}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-white/80 whitespace-pre-wrap text-sm leading-relaxed">
|
||||
{fb.text}
|
||||
</p>
|
||||
<div className="mt-3 flex items-center gap-2">
|
||||
<div className="w-1 h-1 bg-white/10 rounded-full" />
|
||||
<span className="text-white/20 text-[9px] truncate tracking-wider italic">
|
||||
{fb.selector}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{fb.comments.length > 0 && (
|
||||
<div className="bg-black/20 p-5 space-y-4">
|
||||
{fb.comments.map((comment) => (
|
||||
<div key={comment.id} className="flex gap-3">
|
||||
<div className="w-6 h-6 rounded-full bg-white/10 flex items-center justify-center text-white/40 shrink-0">
|
||||
<User size={10} />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="text-[10px] font-bold text-white/60 uppercase">
|
||||
{comment.userName}
|
||||
</p>
|
||||
<p className="text-[10px] text-white/20">
|
||||
{new Date(
|
||||
comment.createdAt,
|
||||
).toLocaleTimeString([], {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-white/80 text-xs leading-snug">
|
||||
{comment.text}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="p-4 bg-white/[0.01] mt-auto border-t border-white/5">
|
||||
<div className="relative">
|
||||
<input
|
||||
type="text"
|
||||
value={newCommentTexts[fb.id] || ""}
|
||||
onChange={(e) =>
|
||||
setNewCommentTexts({
|
||||
...newCommentTexts,
|
||||
[fb.id]: e.target.value,
|
||||
})
|
||||
}
|
||||
placeholder="Antworten..."
|
||||
className="w-full bg-black/40 border border-white/5 rounded-2xl py-3 pl-4 pr-12 text-xs text-white placeholder:text-white/20 focus:outline-none focus:border-blue-500/50 transition-colors"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") addReply(fb.id);
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
onClick={() => addReply(fb.id)}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 p-2 text-blue-500 hover:text-blue-400 transition-colors disabled:opacity-30"
|
||||
disabled={!newCommentTexts[fb.id]}
|
||||
>
|
||||
<Send size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
</>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
131
packages/next-feedback/src/handlers/index.ts
Normal file
131
packages/next-feedback/src/handlers/index.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import {
|
||||
createDirectus,
|
||||
rest,
|
||||
staticToken,
|
||||
createItem,
|
||||
readItems,
|
||||
} from "@directus/sdk";
|
||||
|
||||
export interface CMSConfig {
|
||||
url: string;
|
||||
token: string;
|
||||
}
|
||||
|
||||
export function createCMSClient(config: CMSConfig) {
|
||||
return createDirectus(config.url)
|
||||
.with(staticToken(config.token))
|
||||
.with(rest());
|
||||
}
|
||||
|
||||
export async function handleFeedbackRequest(
|
||||
req: NextRequest,
|
||||
config: CMSConfig,
|
||||
) {
|
||||
const client = createCMSClient(config);
|
||||
|
||||
if (req.method === "GET") {
|
||||
try {
|
||||
const items = await client.request(
|
||||
readItems("visual_feedback", {
|
||||
fields: ["*", { comments: ["*"] }],
|
||||
sort: ["-date_created"],
|
||||
}),
|
||||
);
|
||||
return NextResponse.json(items);
|
||||
} catch (error: any) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
if (req.method === "POST") {
|
||||
try {
|
||||
const body = await req.json();
|
||||
const { action, screenshot_base64, ...data } = body;
|
||||
|
||||
if (action === "reply") {
|
||||
const reply = await client.request(
|
||||
createItem("visual_feedback_comments", {
|
||||
feedback_id: data.feedbackId,
|
||||
user_name: data.userName,
|
||||
text: data.text,
|
||||
}),
|
||||
);
|
||||
return NextResponse.json(reply);
|
||||
}
|
||||
|
||||
let screenshotId = null;
|
||||
|
||||
if (screenshot_base64) {
|
||||
try {
|
||||
const base64Data = screenshot_base64.split(";base64,").pop();
|
||||
const buffer = Buffer.from(base64Data, "base64");
|
||||
const formData = new FormData();
|
||||
const blob = new Blob([buffer], { type: "image/png" });
|
||||
formData.append("file", blob, `feedback-${Date.now()}.png`);
|
||||
|
||||
const fileRes = await fetch(`${config.url}/files`, {
|
||||
method: "POST",
|
||||
headers: { Authorization: `Bearer ${config.token}` },
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (fileRes.ok) {
|
||||
const fileData = await fileRes.json();
|
||||
screenshotId = fileData.data.id;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to upload screenshot:", e);
|
||||
}
|
||||
}
|
||||
|
||||
const feedback = await client.request(
|
||||
createItem("visual_feedback", {
|
||||
project: data.project || req.headers.get("host") || "unknown",
|
||||
url: data.url,
|
||||
selector: data.selector,
|
||||
x: data.x,
|
||||
y: data.y,
|
||||
type: data.type,
|
||||
text: data.text,
|
||||
user_name: data.userName,
|
||||
user_identity: data.userIdentity,
|
||||
status: "open",
|
||||
screenshot: screenshotId,
|
||||
company: data.companyId,
|
||||
}),
|
||||
);
|
||||
|
||||
return NextResponse.json(feedback);
|
||||
} catch (error: any) {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({ error: "Method not allowed" }, { status: 405 });
|
||||
}
|
||||
|
||||
export async function handleWhoAmIRequest(
|
||||
req: NextRequest,
|
||||
gatekeeperUrl: string,
|
||||
) {
|
||||
try {
|
||||
const bypass = req.nextUrl.searchParams.get("gatekeeper_bypass");
|
||||
const targetUrl = bypass
|
||||
? `${gatekeeperUrl}/api/whoami?gatekeeper_bypass=${bypass}`
|
||||
: `${gatekeeperUrl}/api/whoami`;
|
||||
|
||||
// Forward cookies
|
||||
const cookieHeader = req.headers.get("cookie") || "";
|
||||
const res = await fetch(targetUrl, {
|
||||
headers: { Cookie: cookieHeader },
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
return NextResponse.json(await res.json());
|
||||
}
|
||||
return NextResponse.json({ identity: "Guest" });
|
||||
} catch (_e) {
|
||||
return NextResponse.json({ identity: "Guest" });
|
||||
}
|
||||
}
|
||||
2
packages/next-feedback/src/index.ts
Normal file
2
packages/next-feedback/src/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from "./handlers";
|
||||
export * from "./components/FeedbackOverlay";
|
||||
10
packages/next-feedback/tsconfig.json
Normal file
10
packages/next-feedback/tsconfig.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "@mintel/tsconfig/nextjs.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"baseUrl": "."
|
||||
},
|
||||
"include": ["src"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
12
packages/next-feedback/tsup.config.ts
Normal file
12
packages/next-feedback/tsup.config.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { defineConfig } from "tsup";
|
||||
|
||||
export default defineConfig({
|
||||
entry: ["src/index.ts", "src/components/FeedbackOverlay.tsx"],
|
||||
format: ["cjs", "esm"],
|
||||
dts: true,
|
||||
clean: true,
|
||||
sourcemap: true,
|
||||
banner: {
|
||||
js: "'use client';",
|
||||
},
|
||||
});
|
||||
49
packages/next-observability/package.json
Normal file
49
packages/next-observability/package.json
Normal file
@@ -0,0 +1,49 @@
|
||||
{
|
||||
"name": "@mintel/next-observability",
|
||||
"version": "1.7.0",
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
"registry": "https://npm.infra.mintel.me"
|
||||
},
|
||||
"main": "./dist/index.cjs",
|
||||
"module": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js",
|
||||
"require": "./dist/index.cjs"
|
||||
},
|
||||
"./client": {
|
||||
"types": "./dist/client.d.ts",
|
||||
"import": "./dist/client.js",
|
||||
"require": "./dist/client.cjs"
|
||||
}
|
||||
},
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "tsup src/index.ts src/client.ts --format cjs,esm --dts --splitting",
|
||||
"dev": "tsup src/index.ts src/client.ts --format cjs,esm --watch --dts --splitting",
|
||||
"lint": "eslint src/"
|
||||
},
|
||||
"dependencies": {
|
||||
"@mintel/observability": "workspace:*",
|
||||
"@sentry/nextjs": "^10.38.0",
|
||||
"next": "16.1.6"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^18.0.0 || ^19.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@mintel/eslint-config": "workspace:*",
|
||||
"@mintel/tsconfig": "workspace:*",
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
"eslint": "^9.39.2",
|
||||
"tsup": "^8.0.0",
|
||||
"typescript": "^5.0.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0"
|
||||
}
|
||||
}
|
||||
23
packages/next-observability/src/analytics/auto-tracker.tsx
Normal file
23
packages/next-observability/src/analytics/auto-tracker.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { usePathname, useSearchParams } from "next/navigation";
|
||||
import { useAnalytics } from "./context";
|
||||
|
||||
/**
|
||||
* Automatically tracks pageviews on client-side route changes in Next.js.
|
||||
*/
|
||||
export function AnalyticsAutoTracker() {
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
const analytics = useAnalytics();
|
||||
|
||||
useEffect(() => {
|
||||
if (!pathname) return;
|
||||
|
||||
const url = `${pathname}${searchParams?.size ? `?${searchParams.toString()}` : ""}`;
|
||||
analytics.trackPageview(url);
|
||||
}, [pathname, searchParams, analytics]);
|
||||
|
||||
return null;
|
||||
}
|
||||
50
packages/next-observability/src/analytics/context.tsx
Normal file
50
packages/next-observability/src/analytics/context.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
"use client";
|
||||
|
||||
import { createContext, useContext, ReactNode, useMemo } from "react";
|
||||
import type { AnalyticsService } from "@mintel/observability";
|
||||
import {
|
||||
NoopAnalyticsService,
|
||||
UmamiAnalyticsService,
|
||||
} from "@mintel/observability";
|
||||
|
||||
const AnalyticsContext = createContext<AnalyticsService>(
|
||||
new NoopAnalyticsService(),
|
||||
);
|
||||
|
||||
export interface AnalyticsContextProviderProps {
|
||||
service?: AnalyticsService;
|
||||
config?: {
|
||||
enabled: boolean;
|
||||
websiteId?: string;
|
||||
apiEndpoint: string;
|
||||
};
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function AnalyticsContextProvider({
|
||||
service,
|
||||
config,
|
||||
children,
|
||||
}: AnalyticsContextProviderProps) {
|
||||
const activeService = useMemo(() => {
|
||||
if (service) return service;
|
||||
if (config) return new UmamiAnalyticsService(config);
|
||||
return new NoopAnalyticsService();
|
||||
}, [service, config]);
|
||||
|
||||
return (
|
||||
<AnalyticsContext.Provider value={activeService}>
|
||||
{children}
|
||||
</AnalyticsContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useAnalytics() {
|
||||
const context = useContext(AnalyticsContext);
|
||||
if (!context) {
|
||||
throw new Error(
|
||||
"useAnalytics must be used within an AnalyticsContextProvider",
|
||||
);
|
||||
}
|
||||
return context;
|
||||
}
|
||||
4
packages/next-observability/src/client.ts
Normal file
4
packages/next-observability/src/client.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
"use client";
|
||||
|
||||
export * from "./analytics/context";
|
||||
export * from "./analytics/auto-tracker";
|
||||
26
packages/next-observability/src/errors/sentry.ts
Normal file
26
packages/next-observability/src/errors/sentry.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import * as Sentry from "@sentry/nextjs";
|
||||
|
||||
export interface SentryConfig {
|
||||
dsn?: string;
|
||||
enabled?: boolean;
|
||||
tracesSampleRate?: number;
|
||||
tunnel?: string;
|
||||
replaysOnErrorSampleRate?: number;
|
||||
replaysSessionSampleRate?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Standardized Sentry initialization for Mintel projects.
|
||||
*/
|
||||
export function initSentry(config: SentryConfig) {
|
||||
Sentry.init({
|
||||
dsn: config.dsn,
|
||||
enabled: config.enabled ?? Boolean(config.dsn || config.tunnel),
|
||||
tracesSampleRate: config.tracesSampleRate ?? 0,
|
||||
tunnel: config.tunnel,
|
||||
replaysOnErrorSampleRate: config.replaysOnErrorSampleRate ?? 1.0,
|
||||
replaysSessionSampleRate: config.replaysSessionSampleRate ?? 0.1,
|
||||
});
|
||||
}
|
||||
|
||||
export { Sentry };
|
||||
90
packages/next-observability/src/handlers/index.ts
Normal file
90
packages/next-observability/src/handlers/index.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
/**
|
||||
* Logic for Umami Smart Proxy Route Handler.
|
||||
*/
|
||||
export function createUmamiProxyHandler(config: {
|
||||
websiteId?: string;
|
||||
apiEndpoint: string;
|
||||
}) {
|
||||
return async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { type, payload } = body;
|
||||
|
||||
if (!config.websiteId) {
|
||||
return NextResponse.json({ status: "ignored" }, { status: 200 });
|
||||
}
|
||||
|
||||
const enhancedPayload = {
|
||||
...payload,
|
||||
website: config.websiteId,
|
||||
};
|
||||
|
||||
const response = await fetch(`${config.apiEndpoint}/api/send`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"User-Agent": request.headers.get("user-agent") || "Mintel-Proxy",
|
||||
"X-Forwarded-For": request.headers.get("x-forwarded-for") || "",
|
||||
},
|
||||
body: JSON.stringify({ type, payload: enhancedPayload }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
return new NextResponse(errorText, { status: response.status });
|
||||
}
|
||||
|
||||
return NextResponse.json({ status: "ok" });
|
||||
} catch (_error) {
|
||||
return NextResponse.json(
|
||||
{ error: "Internal Server Error" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Logic for Sentry/GlitchTip Relay Route Handler.
|
||||
*/
|
||||
export function createSentryRelayHandler(config: { dsn?: string }) {
|
||||
return async function POST(request: NextRequest) {
|
||||
try {
|
||||
const envelope = await request.text();
|
||||
const lines = envelope.split("\n");
|
||||
if (lines.length < 1) {
|
||||
return NextResponse.json({ error: "Empty envelope" }, { status: 400 });
|
||||
}
|
||||
|
||||
if (!config.dsn) {
|
||||
return NextResponse.json({ status: "ignored" }, { status: 200 });
|
||||
}
|
||||
|
||||
const dsnUrl = new URL(config.dsn);
|
||||
const projectId = dsnUrl.pathname.replace("/", "");
|
||||
const relayUrl = `${dsnUrl.protocol}//${dsnUrl.host}/api/${projectId}/envelope/`;
|
||||
|
||||
const response = await fetch(relayUrl, {
|
||||
method: "POST",
|
||||
body: envelope,
|
||||
headers: {
|
||||
"Content-Type": "application/x-sentry-envelope",
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
return new NextResponse(errorText, { status: response.status });
|
||||
}
|
||||
|
||||
return NextResponse.json({ status: "ok" });
|
||||
} catch (_error) {
|
||||
return NextResponse.json(
|
||||
{ error: "Internal Server Error" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
};
|
||||
}
|
||||
2
packages/next-observability/src/index.ts
Normal file
2
packages/next-observability/src/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from "./handlers/index";
|
||||
export * from "./errors/sentry";
|
||||
10
packages/next-observability/tsconfig.json
Normal file
10
packages/next-observability/tsconfig.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "../tsconfig/base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "dist",
|
||||
"jsx": "react-jsx",
|
||||
"esModuleInterop": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@mintel/next-utils",
|
||||
"version": "1.0.1",
|
||||
"version": "1.7.0",
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
"registry": "https://npm.infra.mintel.me"
|
||||
@@ -16,7 +16,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@directus/sdk": "^21.0.0",
|
||||
"next": "15.1.6",
|
||||
"next": "16.1.6",
|
||||
"next-intl": "^4.8.2",
|
||||
"zod": "^3.0.0"
|
||||
},
|
||||
|
||||
@@ -1,20 +1,36 @@
|
||||
import { z } from 'zod';
|
||||
import { z } from "zod";
|
||||
|
||||
export const mintelEnvSchema = {
|
||||
NODE_ENV: z.enum(['development', 'production', 'test']).default('development'),
|
||||
NODE_ENV: z
|
||||
.enum(["development", "production", "test"])
|
||||
.default("development"),
|
||||
NEXT_PUBLIC_BASE_URL: z.string().url(),
|
||||
NEXT_PUBLIC_UMAMI_WEBSITE_ID: z.string().optional(),
|
||||
NEXT_PUBLIC_UMAMI_SCRIPT_URL: z.string().url().default('https://analytics.infra.mintel.me/script.js'),
|
||||
|
||||
// Analytics (Proxy Pattern)
|
||||
UMAMI_WEBSITE_ID: z.string().optional(),
|
||||
UMAMI_API_ENDPOINT: z
|
||||
.string()
|
||||
.url()
|
||||
.default("https://analytics.infra.mintel.me"),
|
||||
|
||||
// Error Tracking
|
||||
SENTRY_DSN: z.string().optional(),
|
||||
LOG_LEVEL: z.enum(['debug', 'info', 'warn', 'error']).default('info'),
|
||||
|
||||
// Notifications
|
||||
GOTIFY_URL: z.string().url().optional(),
|
||||
GOTIFY_TOKEN: z.string().optional(),
|
||||
|
||||
LOG_LEVEL: z
|
||||
.enum(["trace", "debug", "info", "warn", "error", "fatal"])
|
||||
.default("info"),
|
||||
MAIL_HOST: z.string().optional(),
|
||||
MAIL_PORT: z.coerce.number().default(587),
|
||||
MAIL_USERNAME: z.string().optional(),
|
||||
MAIL_PASSWORD: z.string().optional(),
|
||||
MAIL_FROM: z.string().optional(),
|
||||
MAIL_RECIPIENTS: z.preprocess(
|
||||
(val) => (typeof val === 'string' ? val.split(',').filter(Boolean) : val),
|
||||
z.array(z.string()).default([])
|
||||
(val) => (typeof val === "string" ? val.split(",").filter(Boolean) : val),
|
||||
z.array(z.string()).default([]),
|
||||
),
|
||||
};
|
||||
|
||||
@@ -24,11 +40,26 @@ export function validateMintelEnv(schemaExtension = {}) {
|
||||
...schemaExtension,
|
||||
});
|
||||
|
||||
const isBuildTime =
|
||||
process.env.NEXT_PHASE === "phase-production-build" ||
|
||||
process.env.SKIP_ENV_VALIDATION === "true";
|
||||
|
||||
const result = fullSchema.safeParse(process.env);
|
||||
|
||||
if (!result.success) {
|
||||
console.error('❌ Invalid environment variables:', result.error.flatten().fieldErrors);
|
||||
throw new Error('Invalid environment variables');
|
||||
if (isBuildTime) {
|
||||
console.warn(
|
||||
"⚠️ Some environment variables are missing during build, but skipping strict validation.",
|
||||
);
|
||||
// Return partial data to allow build to continue
|
||||
return process.env as unknown as z.infer<typeof fullSchema>;
|
||||
}
|
||||
|
||||
console.error(
|
||||
"❌ Invalid environment variables:",
|
||||
result.error.flatten().fieldErrors,
|
||||
);
|
||||
throw new Error("Invalid environment variables");
|
||||
}
|
||||
|
||||
return result.data;
|
||||
|
||||
123
packages/observability/README.md
Normal file
123
packages/observability/README.md
Normal file
@@ -0,0 +1,123 @@
|
||||
# @mintel/observability
|
||||
|
||||
Standardized observability package for the Mintel ecosystem, providing Umami analytics and Sentry/GlitchTip error tracking with a focus on privacy and ad-blocker resilience.
|
||||
|
||||
## Features
|
||||
|
||||
- **Umami Smart Proxy**: Track analytics without external scripts and hide your Website ID.
|
||||
- **Sentry Relay**: Bypass ad-blockers for error tracking by relaying envelopes through your own server.
|
||||
- **Unified API**: consistent interface for tracking across multiple projects.
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
pnpm add @mintel/observability @sentry/nextjs
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
### 1. Unified Environment (via @mintel/next-utils)
|
||||
|
||||
Define the following environment variables:
|
||||
|
||||
```bash
|
||||
# Analytics
|
||||
UMAMI_WEBSITE_ID=your-website-id
|
||||
UMAMI_API_ENDPOINT=https://analytics.infra.mintel.me
|
||||
|
||||
# Error Tracking
|
||||
SENTRY_DSN=your-sentry-dsn
|
||||
```
|
||||
|
||||
Note: No `NEXT_PUBLIC_` prefix is required for these anymore, as they are handled by server-side proxies.
|
||||
|
||||
### 2. Analytics Setup
|
||||
|
||||
In your root layout:
|
||||
|
||||
```tsx
|
||||
import {
|
||||
AnalyticsContextProvider,
|
||||
AnalyticsAutoTracker,
|
||||
UmamiAnalyticsService,
|
||||
} from "@mintel/observability";
|
||||
|
||||
const analytics = new UmamiAnalyticsService({
|
||||
enabled: true,
|
||||
websiteId: process.env.UMAMI_WEBSITE_ID, // Server-side
|
||||
apiEndpoint:
|
||||
typeof window === "undefined" ? process.env.UMAMI_API_ENDPOINT : "/stats",
|
||||
});
|
||||
|
||||
export default function Layout({ children }) {
|
||||
return (
|
||||
<AnalyticsContextProvider service={analytics}>
|
||||
<AnalyticsAutoTracker />
|
||||
{children}
|
||||
</AnalyticsContextProvider>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Route Handlers
|
||||
|
||||
Create a proxy for Umami:
|
||||
`app/stats/api/send/route.ts`
|
||||
|
||||
```ts
|
||||
import { createUmamiProxyHandler } from "@mintel/observability";
|
||||
export const POST = await createUmamiProxyHandler({
|
||||
websiteId: process.env.UMAMI_WEBSITE_ID,
|
||||
apiEndpoint: process.env.UMAMI_API_ENDPOINT,
|
||||
});
|
||||
```
|
||||
|
||||
Create a relay for Sentry:
|
||||
`app/errors/api/relay/route.ts`
|
||||
|
||||
```ts
|
||||
import { createSentryRelayHandler } from "@mintel/observability";
|
||||
export const POST = await createSentryRelayHandler({
|
||||
dsn: process.env.SENTRY_DSN,
|
||||
});
|
||||
```
|
||||
|
||||
### 4. Notification Setup (Server-side)
|
||||
|
||||
```ts
|
||||
import { GotifyNotificationService } from "@mintel/observability";
|
||||
|
||||
const notifications = new GotifyNotificationService({
|
||||
enabled: true,
|
||||
url: process.env.GOTIFY_URL,
|
||||
token: process.env.GOTIFY_TOKEN,
|
||||
});
|
||||
|
||||
await notifications.notify({
|
||||
title: "Lead Capture",
|
||||
message: "New contact form submission",
|
||||
priority: 5,
|
||||
});
|
||||
```
|
||||
|
||||
### 5. Sentry Configuration
|
||||
|
||||
Use `initSentry` in your `sentry.server.config.ts` and `sentry.client.config.ts`.
|
||||
|
||||
On the client, use the tunnel:
|
||||
|
||||
```ts
|
||||
initSentry({
|
||||
dsn: "https://public@errors.infra.mintel.me/1", // Placeholder
|
||||
tunnel: "/errors/api/relay",
|
||||
});
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
This package implements the **Smart Proxy** pattern:
|
||||
|
||||
- The client NEVER knows the real `UMAMI_WEBSITE_ID`.
|
||||
- Tracking events are sent to your own domain (`/stats/api/send`).
|
||||
- Your server injects the secret ID and forwards to Umami.
|
||||
- This bypasses ad-blockers and keeps your configuration secure.
|
||||
34
packages/observability/package.json
Normal file
34
packages/observability/package.json
Normal file
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"name": "@mintel/observability",
|
||||
"version": "1.7.0",
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
"registry": "https://npm.infra.mintel.me"
|
||||
},
|
||||
"main": "./dist/index.cjs",
|
||||
"module": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js",
|
||||
"require": "./dist/index.cjs"
|
||||
}
|
||||
},
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "tsup src/index.ts --format cjs,esm --dts",
|
||||
"dev": "tsup src/index.ts --format cjs,esm --watch --dts",
|
||||
"lint": "eslint src/",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {},
|
||||
"devDependencies": {
|
||||
"@mintel/eslint-config": "workspace:*",
|
||||
"@mintel/tsconfig": "workspace:*",
|
||||
"eslint": "^9.39.2",
|
||||
"tsup": "^8.0.0",
|
||||
"typescript": "^5.0.0",
|
||||
"vitest": "^2.0.0"
|
||||
}
|
||||
}
|
||||
14
packages/observability/src/analytics/noop.test.ts
Normal file
14
packages/observability/src/analytics/noop.test.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { NoopAnalyticsService } from "./noop";
|
||||
|
||||
describe("NoopAnalyticsService", () => {
|
||||
it("should not throw on track", () => {
|
||||
const service = new NoopAnalyticsService();
|
||||
expect(() => service.track("test")).not.toThrow();
|
||||
});
|
||||
|
||||
it("should not throw on trackPageview", () => {
|
||||
const service = new NoopAnalyticsService();
|
||||
expect(() => service.trackPageview()).not.toThrow();
|
||||
});
|
||||
});
|
||||
15
packages/observability/src/analytics/noop.ts
Normal file
15
packages/observability/src/analytics/noop.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import type { AnalyticsService, AnalyticsEventProperties } from "./service";
|
||||
|
||||
/**
|
||||
* No-operation analytics service.
|
||||
* Used when analytics are disabled or for local development.
|
||||
*/
|
||||
export class NoopAnalyticsService implements AnalyticsService {
|
||||
track(_eventName: string, _props?: AnalyticsEventProperties): void {
|
||||
// Do nothing
|
||||
}
|
||||
|
||||
trackPageview(_url?: string): void {
|
||||
// Do nothing
|
||||
}
|
||||
}
|
||||
31
packages/observability/src/analytics/service.ts
Normal file
31
packages/observability/src/analytics/service.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* Type definition for analytics event properties.
|
||||
*/
|
||||
export type AnalyticsEventProperties = Record<
|
||||
string,
|
||||
string | number | boolean | null | undefined
|
||||
>;
|
||||
|
||||
/**
|
||||
* Interface for analytics service implementations.
|
||||
*
|
||||
* This interface defines the contract for all analytics services,
|
||||
* allowing for different implementations (Umami, Google Analytics, etc.)
|
||||
* while maintaining a consistent API.
|
||||
*/
|
||||
export interface AnalyticsService {
|
||||
/**
|
||||
* Track a custom event with optional properties.
|
||||
*
|
||||
* @param eventName - The name of the event to track
|
||||
* @param props - Optional event properties (metadata)
|
||||
*/
|
||||
track(eventName: string, props?: AnalyticsEventProperties): void;
|
||||
|
||||
/**
|
||||
* Track a pageview.
|
||||
*
|
||||
* @param url - The URL to track (defaults to current location)
|
||||
*/
|
||||
trackPageview(url?: string): void;
|
||||
}
|
||||
74
packages/observability/src/analytics/umami.test.ts
Normal file
74
packages/observability/src/analytics/umami.test.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { UmamiAnalyticsService } from "./umami";
|
||||
|
||||
describe("UmamiAnalyticsService", () => {
|
||||
const mockConfig = {
|
||||
websiteId: "test-website-id",
|
||||
apiEndpoint: "https://analytics.test",
|
||||
enabled: true,
|
||||
};
|
||||
|
||||
const mockLogger = {
|
||||
debug: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
trace: vi.fn(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
global.fetch = vi.fn();
|
||||
});
|
||||
|
||||
it("should not send payload if disabled", async () => {
|
||||
const service = new UmamiAnalyticsService({
|
||||
...mockConfig,
|
||||
enabled: false,
|
||||
});
|
||||
service.track("test-event");
|
||||
expect(global.fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should send payload with correct data for track", async () => {
|
||||
const service = new UmamiAnalyticsService(mockConfig, mockLogger);
|
||||
|
||||
(global.fetch as any).mockResolvedValue({ ok: true });
|
||||
|
||||
service.track("test-event", { foo: "bar" });
|
||||
|
||||
// Wait for async sendPayload
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
expect(global.fetch).toHaveBeenCalledWith(
|
||||
"https://analytics.test/api/send",
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
body: expect.stringContaining('"type":"event"'),
|
||||
}),
|
||||
);
|
||||
|
||||
const callBody = JSON.parse((global.fetch as any).mock.calls[0][1].body);
|
||||
expect(callBody.payload.name).toBe("test-event");
|
||||
expect(callBody.payload.data.foo).toBe("bar");
|
||||
expect(callBody.payload.website).toBe("test-website-id");
|
||||
});
|
||||
|
||||
it("should log warning if send fails", async () => {
|
||||
const service = new UmamiAnalyticsService(mockConfig, mockLogger);
|
||||
|
||||
(global.fetch as any).mockResolvedValue({
|
||||
ok: false,
|
||||
status: 500,
|
||||
text: () => Promise.resolve("Internal error"),
|
||||
});
|
||||
|
||||
service.track("test-event");
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
|
||||
expect(mockLogger.warn).toHaveBeenCalledWith(
|
||||
"Umami API responded with error",
|
||||
expect.objectContaining({ status: 500 }),
|
||||
);
|
||||
});
|
||||
});
|
||||
115
packages/observability/src/analytics/umami.ts
Normal file
115
packages/observability/src/analytics/umami.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
import type { AnalyticsService, AnalyticsEventProperties } from "./service";
|
||||
|
||||
export interface UmamiConfig {
|
||||
websiteId?: string;
|
||||
apiEndpoint: string; // The endpoint to send to (proxied or direct)
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export interface Logger {
|
||||
debug(msg: string, data?: any): void;
|
||||
warn(msg: string, data?: any): void;
|
||||
error(msg: string, data?: any): void;
|
||||
trace(msg: string, data?: any): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Umami Analytics Service Implementation (Script-less/Proxy edition).
|
||||
*/
|
||||
export class UmamiAnalyticsService implements AnalyticsService {
|
||||
private logger?: Logger;
|
||||
|
||||
constructor(
|
||||
private config: UmamiConfig,
|
||||
logger?: Logger,
|
||||
) {
|
||||
this.logger = logger;
|
||||
}
|
||||
|
||||
private async sendPayload(type: "event", data: Record<string, any>) {
|
||||
if (!this.config.enabled) return;
|
||||
|
||||
const isClient = typeof window !== "undefined";
|
||||
const websiteId = this.config.websiteId;
|
||||
|
||||
if (!isClient && !websiteId) {
|
||||
this.logger?.warn(
|
||||
"Umami tracking called on server but no Website ID configured",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const payload = {
|
||||
website: websiteId,
|
||||
hostname: isClient ? window.location.hostname : "server",
|
||||
screen: isClient
|
||||
? `${window.screen.width}x${window.screen.height}`
|
||||
: undefined,
|
||||
language: isClient ? navigator.language : undefined,
|
||||
referrer: isClient ? document.referrer : undefined,
|
||||
...data,
|
||||
};
|
||||
|
||||
this.logger?.trace("Sending analytics payload", { type, url: data.url });
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 10000);
|
||||
|
||||
try {
|
||||
const response = await fetch(`${this.config.apiEndpoint}/api/send`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"User-Agent": isClient ? navigator.userAgent : "Mintel-Server",
|
||||
},
|
||||
body: JSON.stringify({ type, payload }),
|
||||
keepalive: true,
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
this.logger?.warn("Umami API responded with error", {
|
||||
status: response.status,
|
||||
error: errorText.slice(0, 100),
|
||||
});
|
||||
}
|
||||
} catch (fetchError) {
|
||||
clearTimeout(timeoutId);
|
||||
if ((fetchError as Error).name === "AbortError") {
|
||||
this.logger?.error("Umami request timed out");
|
||||
} else {
|
||||
throw fetchError;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger?.error("Failed to send analytics", {
|
||||
error: (error as Error).message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
track(eventName: string, props?: AnalyticsEventProperties) {
|
||||
this.sendPayload("event", {
|
||||
name: eventName,
|
||||
data: props,
|
||||
url:
|
||||
typeof window !== "undefined"
|
||||
? window.location.pathname + window.location.search
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
trackPageview(url?: string) {
|
||||
this.sendPayload("event", {
|
||||
url:
|
||||
url ||
|
||||
(typeof window !== "undefined"
|
||||
? window.location.pathname + window.location.search
|
||||
: undefined),
|
||||
});
|
||||
}
|
||||
}
|
||||
9
packages/observability/src/index.ts
Normal file
9
packages/observability/src/index.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
// Analytics
|
||||
export * from "./analytics/service";
|
||||
export * from "./analytics/umami";
|
||||
export * from "./analytics/noop";
|
||||
|
||||
// Notifications
|
||||
export * from "./notifications/service";
|
||||
export * from "./notifications/gotify";
|
||||
export * from "./notifications/noop";
|
||||
82
packages/observability/src/notifications/gotify.test.ts
Normal file
82
packages/observability/src/notifications/gotify.test.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { GotifyNotificationService } from "./gotify";
|
||||
|
||||
describe("GotifyNotificationService", () => {
|
||||
const mockConfig = {
|
||||
url: "https://gotify.test",
|
||||
token: "test-token",
|
||||
enabled: true,
|
||||
};
|
||||
|
||||
const mockLogger = {
|
||||
error: vi.fn(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
global.fetch = vi.fn();
|
||||
});
|
||||
|
||||
it("should not notify if disabled", async () => {
|
||||
const service = new GotifyNotificationService({
|
||||
...mockConfig,
|
||||
enabled: false,
|
||||
});
|
||||
await service.notify({ title: "test", message: "test" });
|
||||
expect(global.fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should send correct payload to Gotify", async () => {
|
||||
const service = new GotifyNotificationService(mockConfig, mockLogger);
|
||||
(global.fetch as any).mockResolvedValue({ ok: true });
|
||||
|
||||
await service.notify({
|
||||
title: "Alert",
|
||||
message: "Critical issue",
|
||||
priority: 8,
|
||||
});
|
||||
|
||||
expect(global.fetch).toHaveBeenCalledWith(
|
||||
"https://gotify.test/message?token=test-token",
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
title: "Alert",
|
||||
message: "Critical issue",
|
||||
priority: 8,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("should handle missing trailing slash in URL", async () => {
|
||||
const service = new GotifyNotificationService({
|
||||
...mockConfig,
|
||||
url: "https://gotify.test",
|
||||
});
|
||||
(global.fetch as any).mockResolvedValue({ ok: true });
|
||||
|
||||
await service.notify({ title: "test", message: "test" });
|
||||
|
||||
expect((global.fetch as any).mock.calls[0][0]).toBe(
|
||||
"https://gotify.test/message?token=test-token",
|
||||
);
|
||||
});
|
||||
|
||||
it("should log error if notify fails", async () => {
|
||||
const service = new GotifyNotificationService(mockConfig, mockLogger);
|
||||
(global.fetch as any).mockResolvedValue({
|
||||
ok: false,
|
||||
status: 401,
|
||||
text: () => Promise.resolve("Unauthorized"),
|
||||
});
|
||||
|
||||
await service.notify({ title: "test", message: "test" });
|
||||
|
||||
expect(mockLogger.error).toHaveBeenCalledWith(
|
||||
"Gotify notification failed",
|
||||
expect.objectContaining({ status: 401 }),
|
||||
);
|
||||
});
|
||||
});
|
||||
56
packages/observability/src/notifications/gotify.ts
Normal file
56
packages/observability/src/notifications/gotify.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { NotificationOptions, NotificationService } from "./service";
|
||||
|
||||
export interface GotifyConfig {
|
||||
url: string;
|
||||
token: string;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gotify Notification Service implementation.
|
||||
*/
|
||||
export class GotifyNotificationService implements NotificationService {
|
||||
constructor(
|
||||
private config: GotifyConfig,
|
||||
private logger?: { error(msg: string, data?: any): void },
|
||||
) {}
|
||||
|
||||
async notify(options: NotificationOptions): Promise<void> {
|
||||
if (!this.config.enabled) return;
|
||||
|
||||
try {
|
||||
const { title, message, priority = 4 } = options;
|
||||
|
||||
// Ensure we have a trailing slash for base URL, then append 'message'
|
||||
const baseUrl = this.config.url.endsWith("/")
|
||||
? this.config.url
|
||||
: `${this.config.url}/`;
|
||||
const url = new URL("message", baseUrl);
|
||||
url.searchParams.set("token", this.config.token);
|
||||
|
||||
const response = await fetch(url.toString(), {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
title,
|
||||
message,
|
||||
priority,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
this.logger?.error("Gotify notification failed", {
|
||||
status: response.status,
|
||||
error: errorText.slice(0, 100),
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger?.error("Gotify notification error", {
|
||||
error: (error as Error).message,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
11
packages/observability/src/notifications/noop.test.ts
Normal file
11
packages/observability/src/notifications/noop.test.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { NoopNotificationService } from "./noop";
|
||||
|
||||
describe("NoopNotificationService", () => {
|
||||
it("should not throw on notify", async () => {
|
||||
const service = new NoopNotificationService();
|
||||
await expect(
|
||||
service.notify({ title: "test", message: "test" }),
|
||||
).resolves.not.toThrow();
|
||||
});
|
||||
});
|
||||
10
packages/observability/src/notifications/noop.ts
Normal file
10
packages/observability/src/notifications/noop.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { NotificationService } from "./service";
|
||||
|
||||
/**
|
||||
* No-operation notification service.
|
||||
*/
|
||||
export class NoopNotificationService implements NotificationService {
|
||||
async notify(): Promise<void> {
|
||||
// Do nothing
|
||||
}
|
||||
}
|
||||
16
packages/observability/src/notifications/service.ts
Normal file
16
packages/observability/src/notifications/service.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
export interface NotificationOptions {
|
||||
title: string;
|
||||
message: string;
|
||||
priority?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Interface for notification service implementations.
|
||||
* Allows for different implementations (Gotify, Slack, Email, etc.)
|
||||
*/
|
||||
export interface NotificationService {
|
||||
/**
|
||||
* Send a notification.
|
||||
*/
|
||||
notify(options: NotificationOptions): Promise<void>;
|
||||
}
|
||||
11
packages/observability/tsconfig.json
Normal file
11
packages/observability/tsconfig.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"extends": "../tsconfig/base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "dist",
|
||||
"jsx": "react-jsx",
|
||||
"esModuleInterop": true
|
||||
},
|
||||
"include": ["src"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@mintel/tsconfig",
|
||||
"version": "1.0.1",
|
||||
"version": "1.7.0",
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
"registry": "https://npm.infra.mintel.me"
|
||||
|
||||
11537
pnpm-lock.yaml
generated
11537
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user