Compare commits
27 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 |
@@ -1,5 +0,0 @@
|
|||||||
---
|
|
||||||
"@mintel/mail": minor
|
|
||||||
---
|
|
||||||
|
|
||||||
Initial release of the branded email system package.
|
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
node_modules
|
node_modules
|
||||||
.next
|
.next
|
||||||
.git
|
.git
|
||||||
.npmrc
|
# .npmrc is allowed as it contains the registry template
|
||||||
dist
|
dist
|
||||||
build
|
build
|
||||||
out
|
out
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ name: Monorepo Pipeline
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
|
branches:
|
||||||
|
- '**'
|
||||||
tags:
|
tags:
|
||||||
- 'v*'
|
- 'v*'
|
||||||
|
|
||||||
@@ -15,6 +17,8 @@ jobs:
|
|||||||
runs-on: docker
|
runs-on: docker
|
||||||
container:
|
container:
|
||||||
image: catthehacker/ubuntu:act-latest
|
image: catthehacker/ubuntu:act-latest
|
||||||
|
env:
|
||||||
|
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
@@ -28,12 +32,13 @@ jobs:
|
|||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node_version: 20
|
node_version: 20
|
||||||
cache: 'pnpm'
|
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: pnpm install --frozen-lockfile
|
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
|
- name: Lint
|
||||||
run: pnpm lint
|
run: pnpm lint
|
||||||
@@ -69,7 +74,6 @@ jobs:
|
|||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node_version: 20
|
node_version: 20
|
||||||
cache: 'pnpm'
|
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: pnpm install --frozen-lockfile
|
run: pnpm install --frozen-lockfile
|
||||||
@@ -77,7 +81,6 @@ jobs:
|
|||||||
- name: 🏷️ Release Packages (Tag-Driven)
|
- name: 🏷️ Release Packages (Tag-Driven)
|
||||||
run: |
|
run: |
|
||||||
echo "🏷️ Tag detected [${{ github.ref_name }}], performing sync release..."
|
echo "🏷️ Tag detected [${{ github.ref_name }}], performing sync release..."
|
||||||
pnpm sync-versions
|
|
||||||
pnpm release:tag
|
pnpm release:tag
|
||||||
|
|
||||||
build-images:
|
build-images:
|
||||||
@@ -130,6 +133,6 @@ jobs:
|
|||||||
tags: |
|
tags: |
|
||||||
registry.infra.mintel.me/mintel/${{ matrix.image }}:${{ github.ref_name }}
|
registry.infra.mintel.me/mintel/${{ matrix.image }}:${{ github.ref_name }}
|
||||||
registry.infra.mintel.me/mintel/${{ matrix.image }}:latest
|
registry.infra.mintel.me/mintel/${{ matrix.image }}:latest
|
||||||
cache-from: type=gha
|
cache-from: type=registry,ref=registry.infra.mintel.me/mintel/${{ matrix.image }}:buildcache
|
||||||
cache-to: type=gha,mode=max
|
cache-to: type=registry,ref=registry.infra.mintel.me/mintel/${{ matrix.image }}:buildcache,mode=max
|
||||||
|
|
||||||
|
|||||||
13
.husky/pre-push
Executable file
13
.husky/pre-push
Executable file
@@ -0,0 +1,13 @@
|
|||||||
|
|
||||||
|
# Check if we are pushing a tag
|
||||||
|
if echo "$*" | grep -q "refs/tags/v"; then
|
||||||
|
echo "🏷️ Tag detected in push, syncing versions..."
|
||||||
|
pnpm sync-versions
|
||||||
|
|
||||||
|
# Stage the changed package.json files
|
||||||
|
git add "package.json" "packages/*/package.json" "apps/*/package.json"
|
||||||
|
|
||||||
|
# Amend the tag if it's on the current commit, but this is complex in pre-push.
|
||||||
|
# Better: Just warn the user that they might need to update the tag if package.json changed.
|
||||||
|
echo "⚠️ package.json files updated to match tag. Please ensure these changes are part of your tag/commit."
|
||||||
|
fi
|
||||||
3
.npmrc
3
.npmrc
@@ -2,3 +2,6 @@
|
|||||||
registry=https://npm.infra.mintel.me/
|
registry=https://npm.infra.mintel.me/
|
||||||
//npm.infra.mintel.me/:_authToken=${NPM_TOKEN}
|
//npm.infra.mintel.me/:_authToken=${NPM_TOKEN}
|
||||||
always-auth=true
|
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",
|
"name": "sample-website",
|
||||||
"version": "0.1.1",
|
"version": "1.6.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -8,15 +8,9 @@
|
|||||||
"dev:local": "mintel dev --local",
|
"dev:local": "mintel dev --local",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "next lint",
|
"lint": "eslint src/",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"test": "vitest run --passWithNoTests",
|
"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",
|
"cms:pull:prod": "mintel directus sync pull production",
|
||||||
"pagespeed:test": "mintel pagespeed"
|
"pagespeed:test": "mintel pagespeed"
|
||||||
},
|
},
|
||||||
@@ -24,8 +18,8 @@
|
|||||||
"@mintel/next-utils": "workspace:*",
|
"@mintel/next-utils": "workspace:*",
|
||||||
"@mintel/observability": "workspace:*",
|
"@mintel/observability": "workspace:*",
|
||||||
"@mintel/next-observability": "workspace:*",
|
"@mintel/next-observability": "workspace:*",
|
||||||
"@sentry/nextjs": "^8.55.0",
|
"@sentry/nextjs": "10.38.0",
|
||||||
"next": "15.1.6",
|
"next": "16.1.6",
|
||||||
"next-intl": "^4.8.2",
|
"next-intl": "^4.8.2",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0"
|
"react-dom": "^19.0.0"
|
||||||
|
|||||||
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:
|
services:
|
||||||
app:
|
app:
|
||||||
build:
|
build:
|
||||||
context: .
|
context: ./apps/sample-website
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
args:
|
args:
|
||||||
NEXT_PUBLIC_BASE_URL: ${NEXT_PUBLIC_BASE_URL:-http://localhost:3000}
|
NEXT_PUBLIC_BASE_URL: ${NEXT_PUBLIC_BASE_URL:-http://localhost:3000}
|
||||||
NEXT_PUBLIC_UMAMI_WEBSITE_ID: ${NEXT_PUBLIC_UMAMI_WEBSITE_ID}
|
NEXT_PUBLIC_UMAMI_WEBSITE_ID: ${NEXT_PUBLIC_UMAMI_WEBSITE_ID}
|
||||||
NEXT_PUBLIC_UMAMI_SCRIPT_URL: ${NEXT_PUBLIC_UMAMI_SCRIPT_URL}
|
NEXT_PUBLIC_UMAMI_SCRIPT_URL: ${NEXT_PUBLIC_UMAMI_SCRIPT_URL}
|
||||||
NEXT_PUBLIC_TARGET: ${TARGET:-development}
|
NEXT_PUBLIC_TARGET: ${TARGET:-development}
|
||||||
DIRECTUS_URL: ${DIRECTUS_URL:-http://directus:8055}
|
|
||||||
restart: always
|
restart: always
|
||||||
networks:
|
networks:
|
||||||
- infra
|
- infra
|
||||||
|
environment:
|
||||||
|
- DIRECTUS_URL=${DIRECTUS_URL:-http://directus:8055}
|
||||||
env_file:
|
env_file:
|
||||||
- .env
|
- .env
|
||||||
ports:
|
ports:
|
||||||
@@ -46,6 +47,7 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- ./directus/uploads:/directus/uploads
|
- ./directus/uploads:/directus/uploads
|
||||||
- ./directus/extensions:/directus/extensions
|
- ./directus/extensions:/directus/extensions
|
||||||
|
- ./directus/schema:/directus/schema
|
||||||
labels:
|
labels:
|
||||||
- "traefik.enable=true"
|
- "traefik.enable=true"
|
||||||
- "traefik.http.routers.sample-website-directus.rule=Host(`${DIRECTUS_HOST:-cms.sample-website.localhost}`)"
|
- "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";
|
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}",
|
||||||
|
],
|
||||||
|
})),
|
||||||
|
];
|
||||||
|
|||||||
23
package.json
23
package.json
@@ -5,11 +5,17 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "pnpm -r build",
|
"build": "pnpm -r build",
|
||||||
"dev": "pnpm -r dev",
|
"dev": "pnpm -r dev",
|
||||||
"lint": "pnpm -r lint",
|
"lint": "pnpm -r --filter='./packages/**' --filter='./apps/**' lint",
|
||||||
"test": "pnpm -r test",
|
"test": "pnpm -r test",
|
||||||
"changeset": "changeset",
|
"changeset": "changeset",
|
||||||
"version-packages": "changeset version",
|
"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": "pnpm build && changeset publish",
|
||||||
"release:tag": "pnpm build && pnpm -r publish --no-git-checks --access public",
|
"release:tag": "pnpm build && pnpm -r publish --no-git-checks --access public",
|
||||||
"prepare": "husky"
|
"prepare": "husky"
|
||||||
@@ -27,7 +33,7 @@
|
|||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
"@vitejs/plugin-react": "^5.1.2",
|
"@vitejs/plugin-react": "^5.1.2",
|
||||||
"eslint": "^9.39.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": "^7.37.5",
|
||||||
"eslint-plugin-react-hooks": "^7.0.1",
|
"eslint-plugin-react-hooks": "^7.0.1",
|
||||||
"happy-dom": "^20.4.0",
|
"happy-dom": "^20.4.0",
|
||||||
@@ -39,5 +45,18 @@
|
|||||||
"typescript": "^5.0.0",
|
"typescript": "^5.0.0",
|
||||||
"typescript-eslint": "^8.54.0",
|
"typescript-eslint": "^8.54.0",
|
||||||
"vitest": "^4.0.18"
|
"vitest": "^4.0.18"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"import-in-the-middle": "^3.0.0",
|
||||||
|
"pino": "^10.3.1",
|
||||||
|
"pino-pretty": "^13.1.3",
|
||||||
|
"require-in-the-middle": "^8.0.1"
|
||||||
|
},
|
||||||
|
"version": "1.6.0",
|
||||||
|
"pnpm": {
|
||||||
|
"overrides": {
|
||||||
|
"next": "16.1.6",
|
||||||
|
"@sentry/nextjs": "10.38.0"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@mintel/cli",
|
"name": "@mintel/cli",
|
||||||
"version": "1.0.1",
|
"version": "1.6.0",
|
||||||
"publishConfig": {
|
"publishConfig": {
|
||||||
"access": "public",
|
"access": "public",
|
||||||
"registry": "https://npm.infra.mintel.me"
|
"registry": "https://npm.infra.mintel.me"
|
||||||
@@ -28,4 +28,4 @@
|
|||||||
"@types/prompts": "^2.4.4",
|
"@types/prompts": "^2.4.4",
|
||||||
"@mintel/tsconfig": "workspace:*"
|
"@mintel/tsconfig": "workspace:*"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,24 +25,25 @@ program
|
|||||||
console.log(chalk.cyan("Running Next.js locally..."));
|
console.log(chalk.cyan("Running Next.js locally..."));
|
||||||
execSync("next dev", { stdio: "inherit" });
|
execSync("next dev", { stdio: "inherit" });
|
||||||
} else {
|
} else {
|
||||||
console.log(chalk.cyan("Starting Docker stack (App, Directus, DB)..."));
|
|
||||||
// Ensure network exists
|
|
||||||
try {
|
try {
|
||||||
execSync("docker network create infra", { stdio: "ignore" });
|
console.log(chalk.cyan("Starting Docker stack (App, Directus, DB)..."));
|
||||||
} catch (e) {}
|
// Ensure network exists
|
||||||
|
} catch (_e) {
|
||||||
|
// Network already exists or docker is not running
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
console.log(
|
console.log(
|
||||||
chalk.yellow(`
|
chalk.yellow(`
|
||||||
📱 App: http://localhost:3000
|
📱 App: http://localhost:3000
|
||||||
🗄️ CMS: http://localhost:8055/admin
|
🗄️ CMS: http://localhost:8055/admin
|
||||||
🚦 Traefik: http://localhost:8080
|
🚦 Traefik: http://localhost:8080
|
||||||
`),
|
`),
|
||||||
);
|
);
|
||||||
execSync(
|
execSync(
|
||||||
"docker-compose down --remove-orphans && docker-compose up app directus directus-db",
|
"docker-compose down --remove-orphans && docker-compose up app directus directus-db",
|
||||||
{ stdio: "inherit" },
|
{ stdio: "inherit" },
|
||||||
);
|
);
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const directus = program
|
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
|
directus
|
||||||
.command("sync <action> <env>")
|
.command("sync <action> <env>")
|
||||||
.description("Sync Directus data (push/pull) for a specific environment")
|
.description("Sync Directus data (push/pull) for a specific environment")
|
||||||
@@ -121,7 +231,7 @@ program
|
|||||||
"pagespeed:test": "mintel pagespeed",
|
"pagespeed:test": "mintel pagespeed",
|
||||||
},
|
},
|
||||||
dependencies: {
|
dependencies: {
|
||||||
next: "15.1.6",
|
next: "16.1.6",
|
||||||
react: "^19.0.0",
|
react: "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
"@mintel/next-utils": "workspace:*",
|
"@mintel/next-utils": "workspace:*",
|
||||||
|
|||||||
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.6.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"up": "docker compose up -d",
|
||||||
|
"down": "docker compose down",
|
||||||
|
"logs": "docker compose logs -f"
|
||||||
|
}
|
||||||
|
}
|
||||||
1203
packages/cms-infra/schema/snapshot.yaml
Normal file
1203
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.6.0",
|
||||||
|
"keywords": [
|
||||||
|
"directus",
|
||||||
|
"directus-extension",
|
||||||
|
"directus-extension-module"
|
||||||
|
],
|
||||||
|
"files": [
|
||||||
|
"dist"
|
||||||
|
],
|
||||||
|
"directus:extension": {
|
||||||
|
"type": "module",
|
||||||
|
"path": "index.js",
|
||||||
|
"source": "src/index.ts",
|
||||||
|
"host": "*",
|
||||||
|
"name": "Customer Manager"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"build": "directus-extension build",
|
||||||
|
"dev": "directus-extension build -w"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@directus/extensions-sdk": "11.0.2",
|
||||||
|
"vue": "^3.4.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
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(
|
export default tseslint.config(
|
||||||
{
|
{
|
||||||
ignores: ["**/dist/**", "**/node_modules/**", "**/.next/**"],
|
ignores: ["**/dist/**", "**/node_modules/**", "**/.next/**", "**/build/**"],
|
||||||
},
|
},
|
||||||
js.configs.recommended,
|
js.configs.recommended,
|
||||||
...tseslint.configs.recommended,
|
...tseslint.configs.recommended,
|
||||||
{
|
{
|
||||||
rules: {
|
rules: {
|
||||||
"no-unused-vars": "warn",
|
"no-unused-vars": "off",
|
||||||
|
"@typescript-eslint/no-unused-vars": [
|
||||||
|
"warn",
|
||||||
|
{
|
||||||
|
argsIgnorePattern: "^_",
|
||||||
|
varsIgnorePattern: "^_",
|
||||||
|
caughtErrorsIgnorePattern: "^_",
|
||||||
|
},
|
||||||
|
],
|
||||||
"no-console": "off",
|
"no-console": "off",
|
||||||
"@typescript-eslint/no-explicit-any": "off",
|
"@typescript-eslint/no-explicit-any": "off",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@mintel/eslint-config",
|
"name": "@mintel/eslint-config",
|
||||||
"version": "1.0.1",
|
"version": "1.6.0",
|
||||||
"publishConfig": {
|
"publishConfig": {
|
||||||
"access": "public",
|
"access": "public",
|
||||||
"registry": "https://npm.infra.mintel.me"
|
"registry": "https://npm.infra.mintel.me"
|
||||||
@@ -20,8 +20,8 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@eslint/eslintrc": "^3.0.0",
|
"@eslint/eslintrc": "^3.0.0",
|
||||||
"@eslint/js": "^9.39.2",
|
"@eslint/js": "^9.39.2",
|
||||||
"@next/eslint-plugin-next": "15.1.6",
|
"@next/eslint-plugin-next": "16.1.6",
|
||||||
"eslint-config-next": "15.1.6",
|
"eslint-config-next": "16.1.6",
|
||||||
"eslint-plugin-react": "^7.37.5",
|
"eslint-plugin-react": "^7.37.5",
|
||||||
"eslint-plugin-react-hooks": "^7.0.1",
|
"eslint-plugin-react-hooks": "^7.0.1",
|
||||||
"typescript-eslint": "^8.54.0"
|
"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.6.0",
|
||||||
|
"keywords": [
|
||||||
|
"directus",
|
||||||
|
"directus-extension",
|
||||||
|
"directus-extension-module"
|
||||||
|
],
|
||||||
|
"files": [
|
||||||
|
"dist"
|
||||||
|
],
|
||||||
|
"directus:extension": {
|
||||||
|
"type": "module",
|
||||||
|
"path": "dist/index.js",
|
||||||
|
"source": "src/index.ts",
|
||||||
|
"host": "*",
|
||||||
|
"name": "Feedback Commander"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"build": "directus-extension build",
|
||||||
|
"dev": "directus-extension build -w"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@directus/extensions-sdk": "11.0.2",
|
||||||
|
"vue": "^3.4.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
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>
|
||||||
@@ -1,20 +1,20 @@
|
|||||||
{
|
{
|
||||||
"name": "@mintel/gatekeeper",
|
"name": "@mintel/gatekeeper",
|
||||||
"version": "1.0.0",
|
"version": "1.6.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "next lint",
|
"lint": "eslint src/",
|
||||||
"test": "vitest run"
|
"test": "vitest run"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@mintel/next-utils": "workspace:*",
|
"@mintel/next-utils": "workspace:*",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"lucide-react": "^0.474.0",
|
"lucide-react": "^0.474.0",
|
||||||
"next": "15.1.6",
|
"next": "16.1.6",
|
||||||
"next-intl": "^4.8.2",
|
"next-intl": "^4.8.2",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
/* global module */
|
||||||
module.exports = {
|
module.exports = {
|
||||||
plugins: {
|
plugins: {
|
||||||
tailwindcss: {},
|
tailwindcss: {},
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ export async function GET(req: NextRequest) {
|
|||||||
|
|
||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (_e) {
|
||||||
// URL parsing failed, proceed with normal logic
|
// URL parsing failed, proceed with normal logic
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -61,7 +61,7 @@ export async function GET(req: NextRequest) {
|
|||||||
isAuthenticated = true;
|
isAuthenticated = true;
|
||||||
identity = payload.identity;
|
identity = payload.identity;
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (_e) {
|
||||||
// Fallback or old format
|
// Fallback or old format
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { cookies } from "next/headers";
|
import { cookies } from "next/headers";
|
||||||
|
|
||||||
export async function GET(req: NextRequest) {
|
export async function GET(_req: NextRequest) {
|
||||||
const cookieStore = await cookies();
|
const cookieStore = await cookies();
|
||||||
const authCookieName =
|
const authCookieName =
|
||||||
process.env.AUTH_COOKIE_NAME || "mintel_gatekeeper_session";
|
process.env.AUTH_COOKIE_NAME || "mintel_gatekeeper_session";
|
||||||
@@ -12,15 +12,18 @@ export async function GET(req: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let identity = "Guest";
|
let identity = "Guest";
|
||||||
|
let company = null;
|
||||||
try {
|
try {
|
||||||
const payload = JSON.parse(session.value);
|
const payload = JSON.parse(session.value);
|
||||||
identity = payload.identity || "Guest";
|
identity = payload.identity || "Guest";
|
||||||
} catch (e) {
|
company = payload.company || null;
|
||||||
|
} catch (_e) {
|
||||||
// Old format probably just the password
|
// Old format probably just the password
|
||||||
}
|
}
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
authenticated: true,
|
authenticated: true,
|
||||||
identity: identity,
|
identity: identity,
|
||||||
|
company: company,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ export default async function LoginPage({ searchParams }: LoginPageProps) {
|
|||||||
const cookieDomain = process.env.COOKIE_DOMAIN;
|
const cookieDomain = process.env.COOKIE_DOMAIN;
|
||||||
|
|
||||||
let userIdentity = "";
|
let userIdentity = "";
|
||||||
|
let userCompany: any = null;
|
||||||
|
|
||||||
// 1. Check Global Admin (from ENV)
|
// 1. Check Global Admin (from ENV)
|
||||||
if (
|
if (
|
||||||
@@ -43,8 +44,44 @@ export default async function LoginPage({ searchParams }: LoginPageProps) {
|
|||||||
else if (!email && password === expectedCode) {
|
else if (!email && password === expectedCode) {
|
||||||
userIdentity = "Guest";
|
userIdentity = "Guest";
|
||||||
}
|
}
|
||||||
// 3. Check Directus if email is provided
|
// 3. Check Lightweight Client Users (dedicated collection)
|
||||||
if (email && password && process.env.DIRECTUS_URL) {
|
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 {
|
try {
|
||||||
const loginRes = await fetch(`${process.env.DIRECTUS_URL}/auth/login`, {
|
const loginRes = await fetch(`${process.env.DIRECTUS_URL}/auth/login`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -56,14 +93,21 @@ export default async function LoginPage({ searchParams }: LoginPageProps) {
|
|||||||
const { data } = await loginRes.json();
|
const { data } = await loginRes.json();
|
||||||
const accessToken = data.access_token;
|
const accessToken = data.access_token;
|
||||||
|
|
||||||
// Fetch user info to get a nice display name
|
// Fetch user info with company depth
|
||||||
const userRes = await fetch(`${process.env.DIRECTUS_URL}/users/me`, {
|
const userRes = await fetch(
|
||||||
headers: { Authorization: `Bearer ${accessToken}` },
|
`${process.env.DIRECTUS_URL}/users/me?fields=*,company.*`,
|
||||||
});
|
{
|
||||||
|
headers: { Authorization: `Bearer ${accessToken}` },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
if (userRes.ok) {
|
if (userRes.ok) {
|
||||||
const { data: user } = await userRes.json();
|
const { data: user } = await userRes.json();
|
||||||
userIdentity = user.first_name || user.email;
|
userIdentity = user.first_name || user.email;
|
||||||
|
userCompany = {
|
||||||
|
id: user.company?.id,
|
||||||
|
name: user.company?.name,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -76,6 +120,7 @@ export default async function LoginPage({ searchParams }: LoginPageProps) {
|
|||||||
// Store identity in the cookie (simplified for now, ideally signed)
|
// Store identity in the cookie (simplified for now, ideally signed)
|
||||||
const sessionValue = JSON.stringify({
|
const sessionValue = JSON.stringify({
|
||||||
identity: userIdentity,
|
identity: userIdentity,
|
||||||
|
company: userCompany,
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
/* global module, require */
|
||||||
/** @type {import('tailwindcss').Config} */
|
/** @type {import('tailwindcss').Config} */
|
||||||
module.exports = {
|
module.exports = {
|
||||||
content: [
|
content: [
|
||||||
@@ -55,5 +56,6 @@ module.exports = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||||
plugins: [require("@tailwindcss/typography")],
|
plugins: [require("@tailwindcss/typography")],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import path from "path";
|
/* global process */
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
const buildLintCommand = (filenames) => {
|
const buildLintCommand = (filenames) => {
|
||||||
const isNext =
|
const isNext =
|
||||||
@@ -11,7 +12,7 @@ const buildLintCommand = (filenames) => {
|
|||||||
.join(" --file ")}`;
|
.join(" --file ")}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
return "eslint --fix";
|
return "eslint --fix --no-warn-ignored";
|
||||||
};
|
};
|
||||||
|
|
||||||
const config = {
|
const config = {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@mintel/husky-config",
|
"name": "@mintel/husky-config",
|
||||||
"version": "1.0.0",
|
"version": "1.6.0",
|
||||||
"publishConfig": {
|
"publishConfig": {
|
||||||
"access": "public",
|
"access": "public",
|
||||||
"registry": "https://npm.infra.mintel.me"
|
"registry": "https://npm.infra.mintel.me"
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ FROM node:20-alpine AS builder
|
|||||||
RUN apk add --no-cache libc6-compat curl
|
RUN apk add --no-cache libc6-compat curl
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
RUN corepack enable pnpm
|
RUN corepack enable pnpm
|
||||||
|
ENV CI=true
|
||||||
|
|
||||||
# Copy manifest files specifically for better layer caching
|
# Copy manifest files specifically for better layer caching
|
||||||
COPY pnpm-lock.yaml pnpm-workspace.yaml package.json .npmrc ./
|
COPY pnpm-lock.yaml pnpm-workspace.yaml package.json .npmrc ./
|
||||||
@@ -11,6 +12,13 @@ COPY packages/next-utils/package.json ./packages/next-utils/package.json
|
|||||||
COPY packages/eslint-config/package.json ./packages/eslint-config/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/next-config/package.json ./packages/next-config/package.json
|
||||||
COPY packages/tsconfig/package.json ./packages/tsconfig/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 and a cache mount for the pnpm store
|
# Use a secret for NPM_TOKEN and a cache mount for the pnpm store
|
||||||
RUN --mount=type=cache,id=pnpm,target=/pnpm/store \
|
RUN --mount=type=cache,id=pnpm,target=/pnpm/store \
|
||||||
@@ -23,7 +31,8 @@ RUN --mount=type=cache,id=pnpm,target=/pnpm/store \
|
|||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
# Build Gatekeeper and its dependencies
|
# 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
|
RUN mkdir -p packages/gatekeeper/public
|
||||||
|
|
||||||
# Step 2: Runner stage
|
# Step 2: Runner stage
|
||||||
|
|||||||
@@ -5,15 +5,34 @@ WORKDIR /app
|
|||||||
RUN corepack enable pnpm
|
RUN corepack enable pnpm
|
||||||
|
|
||||||
# Step 2: Install dependencies
|
# Step 2: Install dependencies
|
||||||
# We copy everything first because we have a .dockerignore
|
ENV NPM_TOKEN=placeholder
|
||||||
# and we need the workspace structure for pnpm to work correctly
|
# Copy manifest files specifically for better layer caching
|
||||||
COPY . .
|
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
|
# Use a secret for NPM_TOKEN and a standardized cache mount
|
||||||
RUN --mount=type=cache,target=/root/.local/share/pnpm/store/v3 \
|
RUN --mount=type=cache,id=pnpm,target=/pnpm/store \
|
||||||
--mount=type=secret,id=NPM_TOKEN \
|
--mount=type=secret,id=NPM_TOKEN \
|
||||||
export NPM_TOKEN=$(cat /run/secrets/NPM_TOKEN) && \
|
export NPM_TOKEN=$(cat /run/secrets/NPM_TOKEN) && \
|
||||||
|
pnpm config set store-dir /pnpm/store && \
|
||||||
pnpm i --frozen-lockfile
|
pnpm i --frozen-lockfile
|
||||||
|
|
||||||
# Step 3: Build shared packages
|
# Step 3: Build shared packages
|
||||||
|
COPY . .
|
||||||
RUN pnpm --filter "./packages/*" -r build
|
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
|
WORKDIR /app
|
||||||
RUN apk add --no-cache curl libc6-compat
|
|
||||||
|
|
||||||
# Set standard production environment
|
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
ENV NEXT_TELEMETRY_DISABLED=1
|
ENV NEXT_TELEMETRY_DISABLED=1
|
||||||
ENV PORT=3000
|
ENV PORT=3000
|
||||||
ENV HOSTNAME="0.0.0.0"
|
ENV HOSTNAME="0.0.0.0"
|
||||||
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
# Create non-root user for security
|
# Create non-root user for security
|
||||||
RUN addgroup --system --gid 1001 nodejs && \
|
RUN addgroup --system --gid 1001 nodejs && \
|
||||||
adduser --system --uid 1001 nextjs
|
adduser --system --uid 1001 nextjs
|
||||||
|
|
||||||
# Expose the default Next.js port
|
# Set correct permissions
|
||||||
|
RUN chown -R nextjs:nodejs /app
|
||||||
|
|
||||||
|
USER nextjs
|
||||||
|
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
|
|
||||||
|
CMD ["node", "server.js"]
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@mintel/infra",
|
"name": "@mintel/infra",
|
||||||
"version": "1.0.1",
|
"version": "1.6.0",
|
||||||
"publishConfig": {
|
"publishConfig": {
|
||||||
"access": "public",
|
"access": "public",
|
||||||
"registry": "https://npm.infra.mintel.me"
|
"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",
|
"name": "@mintel/mail",
|
||||||
"version": "1.2.0",
|
"version": "1.7.0",
|
||||||
"private": false,
|
"private": false,
|
||||||
"publishConfig": {
|
"publishConfig": {
|
||||||
"access": "public",
|
"access": "public",
|
||||||
@@ -38,6 +38,7 @@
|
|||||||
"@mintel/tsconfig": "workspace:*",
|
"@mintel/tsconfig": "workspace:*",
|
||||||
"@types/react": "^19.0.0",
|
"@types/react": "^19.0.0",
|
||||||
"@types/react-dom": "^19.0.0",
|
"@types/react-dom": "^19.0.0",
|
||||||
|
"prettier": "^3.8.1",
|
||||||
"tsup": "^8.3.5",
|
"tsup": "^8.3.5",
|
||||||
"typescript": "^5.0.0",
|
"typescript": "^5.0.0",
|
||||||
"vitest": "^3.0.4"
|
"vitest": "^3.0.4"
|
||||||
|
|||||||
@@ -14,11 +14,7 @@ export interface BaseLayoutProps {
|
|||||||
brandColor?: string;
|
brandColor?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const BaseLayout = ({
|
export const BaseLayout = ({ preview, children }: BaseLayoutProps) => {
|
||||||
preview,
|
|
||||||
children,
|
|
||||||
brandColor = "#82ed20",
|
|
||||||
}: BaseLayoutProps) => {
|
|
||||||
return (
|
return (
|
||||||
<Html>
|
<Html>
|
||||||
<Head />
|
<Head />
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ export interface MintelLayoutProps {
|
|||||||
|
|
||||||
export const MintelLayout = ({ preview, children }: MintelLayoutProps) => {
|
export const MintelLayout = ({ preview, children }: MintelLayoutProps) => {
|
||||||
return (
|
return (
|
||||||
<BaseLayout preview={preview} brandColor="#82ed20">
|
<BaseLayout preview={preview}>
|
||||||
<Section style={header}>
|
<Section style={header}>
|
||||||
<MintelLogo />
|
<MintelLogo />
|
||||||
</Section>
|
</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
|
# @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
|
## 1.0.1
|
||||||
|
|
||||||
### Patch Changes
|
### Patch Changes
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
/* global process, URL */
|
||||||
import createNextIntlPlugin from "next-intl/plugin";
|
import createNextIntlPlugin from "next-intl/plugin";
|
||||||
import { withSentryConfig } from "@sentry/nextjs";
|
import { withSentryConfig } from "@sentry/nextjs";
|
||||||
import fs from "node:fs";
|
import fs from "node:fs";
|
||||||
@@ -6,6 +7,7 @@ import path from "node:path";
|
|||||||
/** @type {import('next').NextConfig} */
|
/** @type {import('next').NextConfig} */
|
||||||
export const baseNextConfig = {
|
export const baseNextConfig = {
|
||||||
output: "standalone",
|
output: "standalone",
|
||||||
|
turbopack: {},
|
||||||
images: {
|
images: {
|
||||||
dangerouslyAllowSVG: true,
|
dangerouslyAllowSVG: true,
|
||||||
contentDispositionType: "attachment",
|
contentDispositionType: "attachment",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@mintel/next-config",
|
"name": "@mintel/next-config",
|
||||||
"version": "1.0.1",
|
"version": "1.7.0",
|
||||||
"publishConfig": {
|
"publishConfig": {
|
||||||
"access": "public",
|
"access": "public",
|
||||||
"registry": "https://npm.infra.mintel.me"
|
"registry": "https://npm.infra.mintel.me"
|
||||||
@@ -16,6 +16,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"next-intl": "^4.8.2",
|
"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.6.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';",
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@mintel/next-observability",
|
"name": "@mintel/next-observability",
|
||||||
"version": "1.0.0",
|
"version": "1.6.0",
|
||||||
"publishConfig": {
|
"publishConfig": {
|
||||||
"access": "public",
|
"access": "public",
|
||||||
"registry": "https://npm.infra.mintel.me"
|
"registry": "https://npm.infra.mintel.me"
|
||||||
@@ -28,8 +28,8 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@mintel/observability": "workspace:*",
|
"@mintel/observability": "workspace:*",
|
||||||
"@sentry/nextjs": "^8.55.0",
|
"@sentry/nextjs": "^10.38.0",
|
||||||
"next": "15.1.6"
|
"next": "16.1.6"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"react": "^18.0.0 || ^19.0.0",
|
"react": "^18.0.0 || ^19.0.0",
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ export function createUmamiProxyHandler(config: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return NextResponse.json({ status: "ok" });
|
return NextResponse.json({ status: "ok" });
|
||||||
} catch (error) {
|
} catch (_error) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: "Internal Server Error" },
|
{ error: "Internal Server Error" },
|
||||||
{ status: 500 },
|
{ status: 500 },
|
||||||
@@ -80,7 +80,7 @@ export function createSentryRelayHandler(config: { dsn?: string }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return NextResponse.json({ status: "ok" });
|
return NextResponse.json({ status: "ok" });
|
||||||
} catch (error) {
|
} catch (_error) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: "Internal Server Error" },
|
{ error: "Internal Server Error" },
|
||||||
{ status: 500 },
|
{ status: 500 },
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@mintel/next-utils",
|
"name": "@mintel/next-utils",
|
||||||
"version": "1.0.1",
|
"version": "1.6.0",
|
||||||
"publishConfig": {
|
"publishConfig": {
|
||||||
"access": "public",
|
"access": "public",
|
||||||
"registry": "https://npm.infra.mintel.me"
|
"registry": "https://npm.infra.mintel.me"
|
||||||
@@ -16,7 +16,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@directus/sdk": "^21.0.0",
|
"@directus/sdk": "^21.0.0",
|
||||||
"next": "15.1.6",
|
"next": "16.1.6",
|
||||||
"next-intl": "^4.8.2",
|
"next-intl": "^4.8.2",
|
||||||
"zod": "^3.0.0"
|
"zod": "^3.0.0"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@mintel/observability",
|
"name": "@mintel/observability",
|
||||||
"version": "1.0.0",
|
"version": "1.6.0",
|
||||||
"publishConfig": {
|
"publishConfig": {
|
||||||
"access": "public",
|
"access": "public",
|
||||||
"registry": "https://npm.infra.mintel.me"
|
"registry": "https://npm.infra.mintel.me"
|
||||||
|
|||||||
@@ -5,11 +5,11 @@ import type { AnalyticsService, AnalyticsEventProperties } from "./service";
|
|||||||
* Used when analytics are disabled or for local development.
|
* Used when analytics are disabled or for local development.
|
||||||
*/
|
*/
|
||||||
export class NoopAnalyticsService implements AnalyticsService {
|
export class NoopAnalyticsService implements AnalyticsService {
|
||||||
track(eventName: string, props?: AnalyticsEventProperties): void {
|
track(_eventName: string, _props?: AnalyticsEventProperties): void {
|
||||||
// Do nothing
|
// Do nothing
|
||||||
}
|
}
|
||||||
|
|
||||||
trackPageview(url?: string): void {
|
trackPageview(_url?: string): void {
|
||||||
// Do nothing
|
// Do nothing
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@mintel/tsconfig",
|
"name": "@mintel/tsconfig",
|
||||||
"version": "1.0.1",
|
"version": "1.6.0",
|
||||||
"publishConfig": {
|
"publishConfig": {
|
||||||
"access": "public",
|
"access": "public",
|
||||||
"registry": "https://npm.infra.mintel.me"
|
"registry": "https://npm.infra.mintel.me"
|
||||||
|
|||||||
11021
pnpm-lock.yaml
generated
11021
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -1,3 +1,4 @@
|
|||||||
packages:
|
packages:
|
||||||
- 'packages/*'
|
- 'packages/*'
|
||||||
- 'apps/*'
|
- 'apps/*'
|
||||||
|
- '../klz-2026'
|
||||||
|
|||||||
71
scripts/cms-apply.sh
Executable file
71
scripts/cms-apply.sh
Executable file
@@ -0,0 +1,71 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# Configuration
|
||||||
|
PROJECT="infra-cms"
|
||||||
|
LOCAL_SCHEMA_PATH="./packages/cms-infra/schema/snapshot.yaml"
|
||||||
|
REMOTE_HOST="root@infra.mintel.me"
|
||||||
|
REMOTE_DIR="/opt/infra/directus"
|
||||||
|
|
||||||
|
ENV=$1
|
||||||
|
|
||||||
|
if [ -z "$ENV" ]; then
|
||||||
|
echo "Usage: ./scripts/cms-apply.sh [local|infra]"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
case $ENV in
|
||||||
|
local)
|
||||||
|
PROJECT="infra-cms"
|
||||||
|
CMD_PREFIX="docker-compose -f packages/cms-infra/docker-compose.yml"
|
||||||
|
|
||||||
|
LOCAL_CONTAINER=$($CMD_PREFIX ps -q $PROJECT)
|
||||||
|
if [ -z "$LOCAL_CONTAINER" ]; then
|
||||||
|
echo "❌ Local $PROJECT container not found. Is it running?"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "🚀 Applying schema to LOCAL $PROJECT..."
|
||||||
|
docker exec "$LOCAL_CONTAINER" npx directus schema apply -y /directus/schema/snapshot.yaml
|
||||||
|
;;
|
||||||
|
infra)
|
||||||
|
# 'infra' is the remote production server for at-mintel
|
||||||
|
PROJECT="directus" # Remote project name
|
||||||
|
|
||||||
|
echo "🔍 Detecting remote container..."
|
||||||
|
REMOTE_CONTAINER=$(ssh "$REMOTE_HOST" "docker ps --filter label=com.docker.compose.project=$PROJECT --filter label=com.docker.compose.service=directus -q")
|
||||||
|
|
||||||
|
if [ -z "$REMOTE_CONTAINER" ]; then
|
||||||
|
# Fallback to older name if labels fail
|
||||||
|
REMOTE_CONTAINER=$(ssh "$REMOTE_HOST" "docker ps -f name=directus-directus-1 -q")
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -z "$REMOTE_CONTAINER" ]; then
|
||||||
|
echo "❌ Remote container for $ENV not found."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "📦 Syncing extensions to REMOTE $ENV..."
|
||||||
|
# Ensure remote directory exists
|
||||||
|
ssh "$REMOTE_HOST" "mkdir -p $REMOTE_DIR/extensions"
|
||||||
|
rsync -avz --delete ./packages/cms-infra/extensions/ "$REMOTE_HOST:$REMOTE_DIR/extensions/"
|
||||||
|
|
||||||
|
echo "📤 Injecting snapshot directly into container $REMOTE_CONTAINER..."
|
||||||
|
# Inject file via stdin to avoid needing a host-side mount or scp path matching
|
||||||
|
ssh "$REMOTE_HOST" "docker exec -i $REMOTE_CONTAINER sh -c 'cat > /tmp/snapshot.yaml'" < "$LOCAL_SCHEMA_PATH"
|
||||||
|
|
||||||
|
echo "🚀 Applying schema to REMOTE $ENV..."
|
||||||
|
ssh "$REMOTE_HOST" "docker exec $REMOTE_CONTAINER npx directus schema apply -y /tmp/snapshot.yaml"
|
||||||
|
|
||||||
|
echo "🔄 Restarting remote Directus to clear cache..."
|
||||||
|
ssh "$REMOTE_HOST" "cd $REMOTE_DIR && docker compose restart directus"
|
||||||
|
|
||||||
|
# Cleanup
|
||||||
|
ssh "$REMOTE_HOST" "docker exec $REMOTE_CONTAINER rm /tmp/snapshot.yaml"
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "❌ Invalid environment: $ENV. Supported: local, infra."
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
echo "✨ Schema apply complete!"
|
||||||
23
scripts/cms-snapshot.sh
Executable file
23
scripts/cms-snapshot.sh
Executable file
@@ -0,0 +1,23 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# Configuration
|
||||||
|
PROJECT="infra-cms"
|
||||||
|
SCHEMA_PATH="./packages/cms-infra/schema/snapshot.yaml"
|
||||||
|
CMD_PREFIX="docker-compose -f packages/cms-infra/docker-compose.yml"
|
||||||
|
|
||||||
|
# Detect local container
|
||||||
|
LOCAL_CONTAINER=$($CMD_PREFIX ps -q $PROJECT)
|
||||||
|
|
||||||
|
if [ -z "$LOCAL_CONTAINER" ]; then
|
||||||
|
echo "❌ Local $PROJECT container not found. Is it running?"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "📸 Creating schema snapshot for local $PROJECT..."
|
||||||
|
# Note: we save it to the mounted volume path inside the container
|
||||||
|
docker exec "$LOCAL_CONTAINER" npx directus schema snapshot -y /directus/schema/snapshot.yaml
|
||||||
|
|
||||||
|
echo "🛠️ Repairing snapshot for Postgres compatibility..."
|
||||||
|
python3 ./scripts/fix_snapshot_v3.py
|
||||||
|
|
||||||
|
echo "✅ Snapshot saved and repaired at $SCHEMA_PATH"
|
||||||
96
scripts/fix_snapshot_v3.py
Normal file
96
scripts/fix_snapshot_v3.py
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
import sys
|
||||||
|
import os
|
||||||
|
|
||||||
|
path = '/Users/marcmintel/Projects/at-mintel/packages/cms-infra/schema/snapshot.yaml'
|
||||||
|
if not os.path.exists(path):
|
||||||
|
print(f"File not found: {path}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
with open(path, 'r') as f:
|
||||||
|
lines = f.readlines()
|
||||||
|
|
||||||
|
new_lines = []
|
||||||
|
current_collection = None
|
||||||
|
current_field = None
|
||||||
|
in_schema = False
|
||||||
|
|
||||||
|
fix_fields = {'id', 'company', 'user_created', 'user_updated', 'screenshot', 'logo', 'feedback_id'}
|
||||||
|
uuid_fields = {'id', 'company', 'user_created', 'user_updated'}
|
||||||
|
|
||||||
|
# For multi-pass logic
|
||||||
|
snapshot_has_feedback_id = False
|
||||||
|
|
||||||
|
for line in lines:
|
||||||
|
stripped = line.strip()
|
||||||
|
|
||||||
|
if stripped.startswith('- collection:'):
|
||||||
|
current_collection = stripped.split(':')[-1].strip()
|
||||||
|
in_schema = False
|
||||||
|
elif stripped.startswith('field:'):
|
||||||
|
current_field = stripped.split(':')[-1].strip()
|
||||||
|
if current_collection == 'visual_feedback_comments' and current_field == 'feedback_id':
|
||||||
|
snapshot_has_feedback_id = True
|
||||||
|
elif stripped == 'schema:':
|
||||||
|
in_schema = True
|
||||||
|
elif stripped == 'meta:' or stripped.startswith('- collection:') or (not line.startswith(' ') and line.strip() and not line.startswith('-')):
|
||||||
|
in_schema = False
|
||||||
|
|
||||||
|
# Top-level field type
|
||||||
|
if not in_schema and stripped.startswith('type:') and current_field in uuid_fields:
|
||||||
|
line = line.replace('type: string', 'type: uuid')
|
||||||
|
|
||||||
|
# Schema data type
|
||||||
|
if in_schema and current_field in fix_fields:
|
||||||
|
if 'data_type: char' in line or 'data_type: varchar' in line:
|
||||||
|
line = line.replace('data_type: char', 'data_type: uuid').replace('data_type: varchar', 'data_type: uuid')
|
||||||
|
if 'max_length:' in line:
|
||||||
|
line = ' max_length: null\n'
|
||||||
|
|
||||||
|
new_lines.append(line)
|
||||||
|
|
||||||
|
# Handle Missing feedback_id Injection
|
||||||
|
if not snapshot_has_feedback_id:
|
||||||
|
# We find systemFields and inject before it
|
||||||
|
injected = False
|
||||||
|
final_lines = []
|
||||||
|
feedback_id_block = """ - collection: visual_feedback_comments
|
||||||
|
field: feedback_id
|
||||||
|
type: integer
|
||||||
|
meta:
|
||||||
|
collection: visual_feedback_comments
|
||||||
|
field: feedback_id
|
||||||
|
interface: select-dropdown-m2o
|
||||||
|
required: true
|
||||||
|
sort: 4
|
||||||
|
width: full
|
||||||
|
schema:
|
||||||
|
name: feedback_id
|
||||||
|
table: visual_feedback_comments
|
||||||
|
data_type: integer
|
||||||
|
is_nullable: false
|
||||||
|
is_indexed: true
|
||||||
|
foreign_key_table: visual_feedback
|
||||||
|
foreign_key_column: id
|
||||||
|
"""
|
||||||
|
for line in new_lines:
|
||||||
|
if 'systemFields:' in line and not injected:
|
||||||
|
final_lines.append(feedback_id_block)
|
||||||
|
injected = True
|
||||||
|
final_lines.append(line)
|
||||||
|
new_lines = final_lines
|
||||||
|
|
||||||
|
# Second pass for primary key nullability
|
||||||
|
final_lines = []
|
||||||
|
for i in range(len(new_lines)):
|
||||||
|
line = new_lines[i]
|
||||||
|
if 'is_primary_key: true' in line:
|
||||||
|
# Search backwards and forwards
|
||||||
|
for j in range(max(0, i-10), min(len(new_lines), i+10)):
|
||||||
|
if 'is_nullable: true' in new_lines[j]:
|
||||||
|
new_lines[j] = new_lines[j].replace('is_nullable: true', 'is_nullable: false')
|
||||||
|
final_lines.append(line)
|
||||||
|
|
||||||
|
with open(path, 'w') as f:
|
||||||
|
f.writelines(new_lines)
|
||||||
|
|
||||||
|
print("SUCCESS: Full normalization and field injection complete.")
|
||||||
123
scripts/sync-directus.sh
Executable file
123
scripts/sync-directus.sh
Executable file
@@ -0,0 +1,123 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# Configuration
|
||||||
|
REMOTE_HOST="root@infra.mintel.me"
|
||||||
|
REMOTE_DIR="/opt/infra/directus"
|
||||||
|
|
||||||
|
# DB Details (matching docker-compose defaults)
|
||||||
|
DB_USER="directus"
|
||||||
|
DB_NAME="directus"
|
||||||
|
|
||||||
|
ACTION=$1
|
||||||
|
ENV=$2
|
||||||
|
|
||||||
|
# Help
|
||||||
|
if [ -z "$ACTION" ] || [ -z "$ENV" ]; then
|
||||||
|
echo "Usage: ./scripts/sync-directus.sh [push|pull] [infra|testing|staging|production]"
|
||||||
|
echo ""
|
||||||
|
echo "Commands:"
|
||||||
|
echo " push Sync LOCAL data -> REMOTE"
|
||||||
|
echo " pull Sync REMOTE data -> LOCAL"
|
||||||
|
echo ""
|
||||||
|
echo "Environments:"
|
||||||
|
echo " infra (infra.mintel.me)"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Map Environment
|
||||||
|
case $ENV in
|
||||||
|
infra)
|
||||||
|
PROJECT_NAME="directus"
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "❌ Invalid environment: $ENV. Only 'infra' is currently configured for monorepo sync."
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
# Detect local containers
|
||||||
|
echo "🔍 Detecting local database..."
|
||||||
|
LOCAL_DB_CONTAINER=$(docker compose ps -q directus-db)
|
||||||
|
if [ -z "$LOCAL_DB_CONTAINER" ]; then
|
||||||
|
echo "❌ Local directus-db container not found. Is it running? (npm run dev)"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$ACTION" == "push" ]; then
|
||||||
|
echo "🚀 Pushing Local Data to $ENV..."
|
||||||
|
|
||||||
|
# 1. DB Dump
|
||||||
|
echo "📦 Dumping local database..."
|
||||||
|
docker exec "$LOCAL_DB_CONTAINER" pg_dump -U "$DB_USER" --clean --if-exists --no-owner --no-privileges "$DB_NAME" > dump.sql
|
||||||
|
|
||||||
|
# 2. Upload Dump
|
||||||
|
echo "📤 Uploading dump to remote server..."
|
||||||
|
scp dump.sql "$REMOTE_HOST:$REMOTE_DIR/dump.sql"
|
||||||
|
|
||||||
|
# 3. Restore on Remote
|
||||||
|
echo "🔄 Restoring dump on $ENV..."
|
||||||
|
REMOTE_DB_CONTAINER=$(ssh "$REMOTE_HOST" "cd $REMOTE_DIR && docker compose -p $PROJECT_NAME ps -q directus-postgres")
|
||||||
|
|
||||||
|
if [ -z "$REMOTE_DB_CONTAINER" ]; then
|
||||||
|
echo "❌ Remote $ENV-db container not found!"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Wipe remote DB clean before restore to avoid constraint errors
|
||||||
|
echo "🧹 Wiping remote database schema..."
|
||||||
|
ssh "$REMOTE_HOST" "docker exec $REMOTE_DB_CONTAINER psql -U $DB_USER $DB_NAME -c 'DROP SCHEMA public CASCADE; CREATE SCHEMA public;'"
|
||||||
|
|
||||||
|
echo "⚡ Restoring database..."
|
||||||
|
ssh "$REMOTE_HOST" "docker exec -i $REMOTE_DB_CONTAINER psql -U $DB_USER $DB_NAME < $REMOTE_DIR/dump.sql"
|
||||||
|
|
||||||
|
# 4. Sync Uploads
|
||||||
|
echo "📁 Syncing uploads (Local -> $ENV)..."
|
||||||
|
rsync -avz --progress ./directus/uploads/ "$REMOTE_HOST:$REMOTE_DIR/uploads/"
|
||||||
|
|
||||||
|
# Clean up
|
||||||
|
rm dump.sql
|
||||||
|
ssh "$REMOTE_HOST" "rm $REMOTE_DIR/dump.sql"
|
||||||
|
|
||||||
|
# 5. Restart Directus to trigger migrations and refresh schema cache
|
||||||
|
echo "🔄 Restarting remote Directus to apply migrations..."
|
||||||
|
ssh "$REMOTE_HOST" "cd $REMOTE_DIR && docker compose -p $PROJECT_NAME restart directus"
|
||||||
|
|
||||||
|
echo "✨ Push to $ENV complete!"
|
||||||
|
|
||||||
|
elif [ "$ACTION" == "pull" ]; then
|
||||||
|
echo "📥 Pulling $ENV Data to Local..."
|
||||||
|
|
||||||
|
# 1. DB Dump on Remote
|
||||||
|
echo "📦 Dumping remote database ($ENV)..."
|
||||||
|
REMOTE_DB_CONTAINER=$(ssh "$REMOTE_HOST" "cd $REMOTE_DIR && docker compose -p $PROJECT_NAME ps -q directus-postgres")
|
||||||
|
|
||||||
|
if [ -z "$REMOTE_DB_CONTAINER" ]; then
|
||||||
|
echo "❌ Remote $ENV-db container not found!"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
ssh "$REMOTE_HOST" "docker exec $REMOTE_DB_CONTAINER pg_dump -U $DB_USER --clean --if-exists --no-owner --no-privileges $DB_NAME > $REMOTE_DIR/dump.sql"
|
||||||
|
|
||||||
|
# 2. Download Dump
|
||||||
|
echo "📥 Downloading dump..."
|
||||||
|
scp "$REMOTE_HOST:$REMOTE_DIR/dump.sql" dump.sql
|
||||||
|
|
||||||
|
# Wipe local DB clean before restore to avoid constraint errors
|
||||||
|
echo "🧹 Wiping local database schema..."
|
||||||
|
docker exec "$LOCAL_DB_CONTAINER" psql -U "$DB_USER" "$DB_NAME" -c 'DROP SCHEMA public CASCADE; CREATE SCHEMA public;'
|
||||||
|
|
||||||
|
echo "⚡ Restoring database locally..."
|
||||||
|
docker exec -i "$LOCAL_DB_CONTAINER" psql -U "$DB_USER" "$DB_NAME" < dump.sql
|
||||||
|
|
||||||
|
# 4. Sync Uploads
|
||||||
|
echo "📁 Syncing uploads ($ENV -> Local)..."
|
||||||
|
rsync -avz --progress "$REMOTE_HOST:$REMOTE_DIR/uploads/" ./directus/uploads/
|
||||||
|
|
||||||
|
# Clean up
|
||||||
|
rm dump.sql
|
||||||
|
ssh "$REMOTE_HOST" "rm $REMOTE_DIR/dump.sql"
|
||||||
|
|
||||||
|
echo "✨ Pull to Local complete!"
|
||||||
|
else
|
||||||
|
echo "Invalid action: $ACTION. Use push or pull."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
@@ -1,11 +1,45 @@
|
|||||||
import * as fs from "fs";
|
import * as fs from "fs";
|
||||||
import * as path from "path";
|
import * as path from "path";
|
||||||
|
|
||||||
const tag = process.env.GITHUB_REF_NAME || process.env.TAG;
|
import { execSync } from "child_process";
|
||||||
|
|
||||||
if (!tag || !tag.startsWith("v")) {
|
/**
|
||||||
console.error("❌ No valid tag found (must start with v, e.g., v1.0.0)");
|
* Gets the current version tag from environment or git.
|
||||||
process.exit(1);
|
*/
|
||||||
|
function getVersionTag() {
|
||||||
|
// 1. Check CI environment variables
|
||||||
|
if (
|
||||||
|
process.env.GITHUB_REF_NAME &&
|
||||||
|
process.env.GITHUB_REF_NAME.startsWith("v")
|
||||||
|
) {
|
||||||
|
return process.env.GITHUB_REF_NAME;
|
||||||
|
}
|
||||||
|
if (process.env.TAG && process.env.TAG.startsWith("v")) {
|
||||||
|
return process.env.TAG;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Try to get it from local git
|
||||||
|
try {
|
||||||
|
const gitTag = execSync("git describe --tags --abbrev=0", {
|
||||||
|
encoding: "utf8",
|
||||||
|
}).trim();
|
||||||
|
if (gitTag && gitTag.startsWith("v")) {
|
||||||
|
return gitTag;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// Fallback or silence
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tag = getVersionTag();
|
||||||
|
|
||||||
|
if (!tag) {
|
||||||
|
console.log(
|
||||||
|
"ℹ️ No version tag found (starting with v). Skipping version sync.",
|
||||||
|
);
|
||||||
|
process.exit(0); // Exit gracefully if no tag is present
|
||||||
}
|
}
|
||||||
|
|
||||||
const version = tag.replace(/^v/, "");
|
const version = tag.replace(/^v/, "");
|
||||||
|
|||||||
Reference in New Issue
Block a user