Compare commits

..

1 Commits

Author SHA1 Message Date
9e4e296e3b feat: adds aquisition extension to cms
Some checks failed
Monorepo Pipeline / 🧪 Quality Assurance (push) Failing after 11s
Monorepo Pipeline / 🚀 Release (push) Has been skipped
Monorepo Pipeline / 🐳 Build Directus (Base) (push) Has been skipped
Monorepo Pipeline / 🐳 Build Gatekeeper (Product) (push) Has been skipped
Monorepo Pipeline / 🐳 Build Build-Base (push) Has been skipped
Monorepo Pipeline / 🐳 Build Production Runtime (push) Has been skipped
2026-02-10 21:30:23 +01:00
130 changed files with 7984 additions and 8363 deletions

View File

@@ -1,82 +0,0 @@
---
description: How to manage and deploy Directus CMS infrastructure changes.
---
# Directus CMS Infrastructure Workflow
This workflow ensures "Industrial Grade" consistency and stability across local, testing, and production environments for the `at-mintel` Directus CMS.
## 1. Local Development Lifecycle
### Starting the CMS
To start the local Directus instance with extensions:
```bash
cd packages/cms-infra
npm run up
```
### Modifying Schema
1. **Directus UI**: Make your changes directly in the local Directus Admin UI (Collections, Fields, Relations).
2. **Take Snapshot**:
```bash
cd packages/cms-infra
npm run snapshot:local
```
This updates `packages/cms-infra/schema/snapshot.yaml`.
3. **Commit**: Commit the updated `snapshot.yaml`.
## 2. Deploying Schema Changes
### To Local Environment (Reconciliation)
If you pull changes from Git and need to apply them to your local database:
```bash
cd packages/cms-infra
npm run schema:apply:local
```
> [!IMPORTANT]
> This command automatically runs `scripts/cms-reconcile.sh` to prevent "Field already exists" errors by registering database columns in Directus metadata first.
### To Production (Infra)
To deploy the local snapshot to the production server:
```bash
cd packages/cms-infra
npm run schema:apply:infra
```
This script:
1. Syncs built extensions via rsync.
2. Injects the `snapshot.yaml` into the remote container.
3. Runs `directus schema apply`.
4. Restarts Directus to clear the schema cache.
## 3. Data Synchronization
### Pulling from Production
To update your local environment with production data and assets:
```bash
cd packages/cms-infra
npm run sync:pull
```
### Pushing to Production
> [!CAUTION]
> This will overwrite production data. Use with extreme care.
```bash
cd packages/cms-infra
npm run sync:push
```
## 4. Extension Management
When modifying extensions in `packages/*-manager`:
1. Extensions are automatically built and synced when running `npm run up`.
2. To sync manually without restarting the stack:
```bash
cd packages/cms-infra
npm run build:extensions
```
## 5. Troubleshooting "Field already exists"
If `schema:apply` fails with "Field already exists", run:
```bash
./scripts/cms-reconcile.sh
```
This script ensures the database state matches Directus's internal field registry (`directus_fields`).

View File

@@ -1,7 +0,0 @@
---
"@mintel/monorepo": patch
"acquisition-manager": patch
"feedback-commander": patch
---
fix: make directus extension build scripts more resilient

36
.env
View File

@@ -1,36 +0,0 @@
# Project
IMAGE_TAG=v1.8.2
PROJECT_NAME=at-mintel
PROJECT_COLOR=#82ed20
GITEA_TOKEN=ccce002e30fe16a31a6c9d5a414740af2f72a582
# Authentication
GATEKEEPER_PASSWORD=mintel
AUTH_COOKIE_NAME=mintel_gatekeeper_session
# Host Config (Local)
TRAEFIK_HOST=at-mintel.localhost
DIRECTUS_HOST=cms.localhost
# Next.js
NEXT_PUBLIC_BASE_URL=http://at-mintel.localhost
# Directus
DIRECTUS_URL=http://cms.localhost
DIRECTUS_KEY=F9IIfahEjPq6NZhKyRLw516D8GotuFj79EGK7pGfIWg=
DIRECTUS_SECRET=OZfxMu8lBxzaEnFGRKreNBoJpRiRu58U+HsVg2yWk4o=
CORS_ENABLED=true
CORS_ORIGIN=true
LOG_LEVEL=debug
DIRECTUS_ADMIN_EMAIL=mmintel@mintel.me
DIRECTUS_ADMIN_PASSWORD=Tim300493.
DIRECTUS_DB_NAME=directus
DIRECTUS_DB_USER=directus
DIRECTUS_DB_PASSWORD=mintel-db-pass
# Sentry / Glitchtip
SENTRY_DSN=
# Analytics (Umami)
NEXT_PUBLIC_UMAMI_WEBSITE_ID=
NEXT_PUBLIC_UMAMI_SCRIPT_URL=https://analytics.infra.mintel.me/script.js

View File

@@ -1,5 +1,5 @@
# Project
IMAGE_TAG=v1.8.2
IMAGE_TAG=v1.7.0
PROJECT_NAME=sample-website
PROJECT_COLOR=#82ed20

View File

@@ -1,4 +0,0 @@
**/index.js
**/dist/**
packages/cms-infra/extensions/**
packages/cms-infra/extensions/**

View File

@@ -1,44 +0,0 @@
name: 🏥 Server Maintenance
on:
schedule:
- cron: '0 3 * * *' # Every day at 3:00 AM
workflow_dispatch: # Allow manual trigger
jobs:
maintenance:
name: 🧹 Prune & Clean
runs-on: docker
container:
image: catthehacker/ubuntu:act-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: 🚀 Execute Maintenance via SSH
run: |
mkdir -p ~/.ssh
echo "${{ secrets.SSH_KEY }}" > ~/.ssh/id_ed25519
chmod 600 ~/.ssh/id_ed25519
ssh-keyscan -H ${{ secrets.SSH_HOST }} >> ~/.ssh/known_hosts 2>/dev/null
# Run the prune script on the host
# We transfer the script and execute it to ensure it matches the repo version
scp packages/infra/scripts/prune-registry.sh root@${{ secrets.SSH_HOST }}:/tmp/prune-registry.sh
ssh root@${{ secrets.SSH_HOST }} "bash /tmp/prune-registry.sh && rm /tmp/prune-registry.sh"
- name: 🔔 Notification - Success
if: success()
run: |
curl -s -k -X POST "${{ secrets.GOTIFY_URL }}/message?token=${{ secrets.GOTIFY_TOKEN }}" \
-F "title=🏥 Maintenance Complete" \
-F "message=Server-Wartung erfolgreich ausgeführt.\nRegistry & Docker Ressourcen bereinigt." \
-F "priority=2" || true
- name: 🔔 Notification - Failure
if: failure()
run: |
curl -s -k -X POST "${{ secrets.GOTIFY_URL }}/message?token=${{ secrets.GOTIFY_TOKEN }}" \
-F "title=❌ Maintenance FAILED" \
-F "message=Die automatische Server-Wartung ist fehlgeschlagen!\nBitte Logs prüfen." \
-F "priority=8" || true

View File

@@ -12,56 +12,8 @@ concurrency:
cancel-in-progress: true
jobs:
prioritize:
name: ⚡ Prioritize Release
runs-on: docker
container:
image: catthehacker/ubuntu:act-latest
steps:
- name: 🛑 Cancel Redundant Runs
env:
GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }}
REPO: ${{ github.repository }}
RUN_ID: ${{ github.run_id }}
REF: ${{ github.ref }}
REF_NAME: ${{ github.ref_name }}
EVENT: ${{ github.event_name }}
run: |
echo "🔎 Debug: Event=$EVENT, Ref=$REF, RefName=$REF_NAME, RunId=$RUN_ID"
case "$REF" in
refs/tags/v*)
echo "🚀 Release detected ($REF_NAME). Cancelling non-tag runs..."
# Fetch all runs
RUNS=$(curl -s -H "Authorization: token $GITEA_TOKEN" "https://git.infra.mintel.me/api/v1/repos/$REPO/actions/runs")
# Identify runs to cancel: in_progress/queued, NOT this run, and NOT a tag run
echo "$RUNS" | jq -c '.workflow_runs[] | select(.status == "in_progress" or .status == "queued") | select(.id | tostring != "'$RUN_ID'")' | while read -r run; do
ID=$(echo "$run" | jq -r '.id')
RUN_REF=$(echo "$run" | jq -r '.ref')
TITLE=$(echo "$run" | jq -r '.display_title')
case "$RUN_REF" in
refs/tags/v*)
echo "⏭️ Skipping parallel release run $ID ($TITLE) on $RUN_REF"
;;
*)
echo "🛑 Cancelling redundant branch run $ID ($TITLE) on $RUN_REF..."
curl -X POST -s -H "Authorization: token $GITEA_TOKEN" "https://git.infra.mintel.me/api/v1/repos/$REPO/actions/runs/$ID/cancel"
;;
esac
done
;;
*)
echo " Regular push. No prioritization needed."
;;
esac
lint:
name: 🧹 Lint
needs: prioritize
if: always() && !cancelled() && (needs.prioritize.result == 'success' || needs.prioritize.result == 'skipped')
qa:
name: 🧪 Quality Assurance
runs-on: docker
container:
image: catthehacker/ubuntu:act-latest
@@ -70,66 +22,36 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Install pnpm
uses: pnpm/action-setup@v4
with:
version: 10
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node_version: 20
- name: Enable pnpm
run: corepack enable && corepack prepare pnpm@10.2.0 --activate
- name: Install dependencies
run: pnpm install --frozen-lockfile --prefer-offline --ignore-scripts --no-color
run: pnpm install --frozen-lockfile
- name: 🏷️ Sync Versions (if Tagged)
if: startsWith(github.ref, 'refs/tags/v')
run: pnpm sync-versions
- name: Lint
run: pnpm lint
test:
name: 🧪 Test
needs: prioritize
if: always() && !cancelled() && (needs.prioritize.result == 'success' || needs.prioritize.result == 'skipped')
runs-on: docker
container:
image: catthehacker/ubuntu:act-latest
env:
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node_version: 20
- name: Enable pnpm
run: corepack enable && corepack prepare pnpm@10.2.0 --activate
- name: Install dependencies
run: pnpm install --frozen-lockfile --prefer-offline --ignore-scripts --no-color
- name: Test
run: pnpm test
build:
name: 🏗️ Build
needs: prioritize
if: always() && !cancelled() && (needs.prioritize.result == 'success' || needs.prioritize.result == 'skipped')
runs-on: docker
container:
image: catthehacker/ubuntu:act-latest
env:
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node_version: 20
- name: Enable pnpm
run: corepack enable && corepack prepare pnpm@10.2.0 --activate
- name: Install dependencies
run: pnpm install --frozen-lockfile --prefer-offline --ignore-scripts --no-color
- name: Build
run: pnpm build
release:
name: 🚀 Release
needs: [lint, test, build]
needs: qa
if: startsWith(github.ref, 'refs/tags/v')
runs-on: docker
container:
@@ -142,16 +64,20 @@ jobs:
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Install pnpm
uses: pnpm/action-setup@v4
with:
version: 10
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node_version: 20
- name: Enable pnpm
run: corepack enable && corepack prepare pnpm@10.2.0 --activate
- name: Install dependencies
run: pnpm install --frozen-lockfile --prefer-offline --ignore-scripts --no-color
- name: 🏷️ Sync Versions (if Tagged)
run: pnpm sync-versions
run: pnpm install --frozen-lockfile
- name: 🏷️ Release Packages (Tag-Driven)
run: |
echo "🏷️ Tag detected [${{ github.ref_name }}], performing sync release..."
@@ -159,7 +85,7 @@ jobs:
build-images:
name: 🐳 Build ${{ matrix.name }}
needs: [lint, test, build]
needs: qa
if: startsWith(github.ref, 'refs/tags/v')
runs-on: docker
container:

View File

@@ -1,37 +1,16 @@
# Check if we are pushing a tag
while read local_ref local_sha remote_ref remote_sha
do
if [[ "$remote_ref" == refs/tags/v* ]]; then
TAG=${remote_ref#refs/tags/}
echo "🏷️ Tag detected: $TAG, ensuring versions are synced..."
# Run sync script
echo "🏷️ Tag detected: $TAG, syncing versions..."
pnpm sync-versions "$TAG"
# Check for changes in relevant files
SYNC_FILES="package.json packages/*/package.json apps/*/package.json .env.example"
CHANGES=$(git status --porcelain $SYNC_FILES)
# Stage the changed files
git add package.json packages/*/package.json apps/*/package.json .env .env.example
if [[ -n "$CHANGES" ]]; then
echo "📝 Version sync made changes. Integrating into tag..."
# Stage and commit
git add $SYNC_FILES
git commit -m "chore: sync versions to $TAG" --no-verify
# Force update the local tag to point to the new commit
git tag -f "$TAG" > /dev/null
echo "✅ Tag $TAG has been updated locally with synced versions."
echo "🚀 Auto-pushing updated tag..."
# Push the updated tag directly (using --no-verify to avoid recursion)
git push origin "$TAG" --force --no-verify
echo "✨ All done! Hook integrated the sync and pushed for you."
exit 1 # Still exit 1 to abort the original (now outdated) push attempt
else
echo "✨ Versions already in sync for $TAG."
fi
echo "⚠️ package.json and .env files updated to match tag $TAG."
echo "⚠️ Note: You might need to push again if these changes were not already in your commit/tag."
fi
done

View File

@@ -1,56 +0,0 @@
# Stage 1: Builder
FROM registry.infra.mintel.me/mintel/nextjs:latest AS builder
WORKDIR /app
# Clean the workspace in case the base image is dirty
RUN rm -rf ./*
# Arguments for build-time configuration
ARG NEXT_PUBLIC_BASE_URL
ARG NEXT_PUBLIC_TARGET
ARG DIRECTUS_URL
ARG NPM_TOKEN
# Environment variables for Next.js build
ENV NEXT_PUBLIC_BASE_URL=$NEXT_PUBLIC_BASE_URL
ENV NEXT_PUBLIC_TARGET=$NEXT_PUBLIC_TARGET
ENV DIRECTUS_URL=$DIRECTUS_URL
ENV SKIP_RUNTIME_ENV_VALIDATION=true
ENV CI=true
# Enable pnpm
RUN corepack enable
# Copy lockfile and manifest for dependency installation caching
COPY pnpm-lock.yaml package.json .npmrc* ./
# Install dependencies with cache mount
RUN --mount=type=cache,id=pnpm,target=/pnpm/store \
--mount=type=secret,id=NPM_TOKEN \
export NPM_TOKEN=$(cat /run/secrets/NPM_TOKEN 2>/dev/null || echo $NPM_TOKEN) && \
pnpm install --frozen-lockfile
# Copy source code
COPY . .
# Build application
RUN pnpm build
# Stage 2: Runner
FROM registry.infra.mintel.me/mintel/runtime:latest AS runner
WORKDIR /app
ENV HOSTNAME="0.0.0.0"
ENV PORT=3000
ENV NODE_ENV=production
# Copy standalone output and static files
# Adjust paths if using a monorepo structure (e.g., /app/apps/web/public)
COPY --from=builder --chown=nextjs:nodejs /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
COPY --from=builder --chown=nextjs:nodejs /app/.next/cache ./.next/cache
USER nextjs
CMD ["node", "server.js"]

View File

@@ -80,4 +80,3 @@ Client websites scaffolded via the CLI use a **tag-based deployment** strategy:
- **Git Tag `v*.*.*`**: Deploys to the `production` environment.
See the [`@mintel/infra`](packages/infra/README.md) package for detailed template documentation.

View File

@@ -1,5 +1,6 @@
# Start from the pre-built Nextjs Base image
FROM registry.infra.mintel.me/mintel/nextjs:latest AS builder
ARG IMAGE_TAG=latest
FROM registry.infra.mintel.me/mintel/nextjs:${IMAGE_TAG} AS builder
WORKDIR /app
@@ -20,7 +21,7 @@ ENV DIRECTUS_URL=$DIRECTUS_URL
RUN pnpm --filter sample-website build
# Production runner image
FROM registry.infra.mintel.me/mintel/runtime:latest AS runner
FROM registry.infra.mintel.me/mintel/runtime:${IMAGE_TAG} AS runner
WORKDIR /app
COPY --from=builder /app/apps/sample-website/public ./apps/sample-website/public

View File

@@ -1,6 +1,6 @@
{
"name": "sample-website",
"version": "1.8.2",
"version": "1.7.0",
"private": true,
"type": "module",
"scripts": {

View File

@@ -1 +0,0 @@
S9WsV

View File

@@ -24,12 +24,6 @@ services:
directus:
image: registry.infra.mintel.me/mintel/directus:${IMAGE_TAG:-latest}
healthcheck:
test: [ "CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://127.0.0.1:8055/server/health" ]
interval: 30s
timeout: 10s
retries: 3
start_period: 5s
restart: always
networks:
- infra
@@ -41,7 +35,7 @@ services:
ADMIN_EMAIL: ${DIRECTUS_ADMIN_EMAIL:-admin@mintel.me}
ADMIN_PASSWORD: ${DIRECTUS_ADMIN_PASSWORD:-mintel-admin}
DB_CLIENT: 'pg'
DB_HOST: 'at-mintel-directus-db'
DB_HOST: 'directus-db'
DB_PORT: '5432'
DB_DATABASE: ${DIRECTUS_DB_NAME:-directus}
DB_USER: ${DIRECTUS_DB_USER:-directus}
@@ -59,7 +53,7 @@ services:
- "traefik.http.routers.sample-website-directus.rule=Host(`${DIRECTUS_HOST:-cms.sample-website.localhost}`)"
- "traefik.http.services.sample-website-directus.loadbalancer.server.port=8055"
at-mintel-directus-db:
directus-db:
image: postgres:15-alpine
restart: always
networks:

View File

@@ -5,7 +5,7 @@ export default [
{
ignores: [
"packages/cms-infra/extensions/**",
"**/index.js",
"packages/customer-manager/index.js",
"**/*.db",
"**/build/**",
"**/data/**",

View File

@@ -15,9 +15,6 @@
"cms:schema:snapshot": "./scripts/cms-snapshot.sh",
"cms:schema:apply": "./scripts/cms-apply.sh local",
"cms:schema:apply:infra": "./scripts/cms-apply.sh infra",
"cms:up": "./scripts/sync-extensions.sh && cd packages/cms-infra && npm run up -- --force-recreate",
"cms:down": "cd packages/cms-infra && npm run down",
"cms:logs": "cd packages/cms-infra && npm run logs",
"dev:infra": "docker-compose up -d directus directus-db",
"release": "pnpm build && changeset publish",
"release:tag": "pnpm build && pnpm -r publish --no-git-checks --access public",
@@ -55,7 +52,7 @@
"pino-pretty": "^13.1.3",
"require-in-the-middle": "^8.0.1"
},
"version": "1.8.2",
"version": "1.7.0",
"pnpm": {
"overrides": {
"next": "16.1.6",

File diff suppressed because one or more lines are too long

View File

@@ -1,27 +0,0 @@
{
"name": "acquisition-manager",
"description": "Custom High-Fidelity Acquisition Management for Directus",
"icon": "account_balance_wallet",
"version": "1.8.2",
"type": "module",
"keywords": [
"directus",
"directus-extension",
"directus-extension-module"
],
"directus:extension": {
"type": "module",
"path": "index.js",
"source": "src/index.ts",
"host": "*",
"name": "Acquisition Manager"
},
"scripts": {
"build": "directus-extension build && cp -f dist/index.js index.js 2>/dev/null || true",
"dev": "directus-extension build -w"
},
"devDependencies": {
"@directus/extensions-sdk": "11.0.2",
"vue": "^3.4.0"
}
}

View File

@@ -1,27 +1,32 @@
{
"name": "acquisition",
"version": "1.8.2",
"type": "module",
"directus:extension": {
"type": "endpoint",
"path": "dist/index.js",
"source": "src/index.ts",
"host": "^11.0.0"
},
"scripts": {
"build": "node build.mjs",
"dev": "node build.mjs --watch"
},
"devDependencies": {
"@directus/extensions-sdk": "11.0.2",
"@mintel/pdf": "workspace:*",
"@mintel/mail": "workspace:*",
"esbuild": "^0.25.0",
"typescript": "^5.6.3"
},
"dependencies": {
"jquery": "^3.7.1",
"react": "^19.2.4",
"react-dom": "^19.2.4"
}
}
"name": "@mintel/acquisition",
"version": "1.0.0",
"private": true,
"type": "module",
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"scripts": {
"build": "tsup src/index.ts --format esm --dts --clean",
"dev": "tsup src/index.ts --format esm --watch --dts",
"lint": "eslint src",
"test": "vitest run",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"axios": "^1.7.9",
"crawlee": "^3.12.2",
"cheerio": "^1.0.0",
"react": "^19.0.0",
"@react-pdf/renderer": "^4.3.0",
"framer-motion": "^12.4.2"
},
"devDependencies": {
"@mintel/tsconfig": "workspace:*",
"@mintel/eslint-config": "workspace:*",
"tsup": "^8.3.5",
"typescript": "^5.0.0",
"vitest": "^3.0.4",
"@types/node": "^20.17.16"
}
}

View File

@@ -18,8 +18,6 @@ import { calculatePositions } from "../logic/pricing/calculator.js";
interface PDFProps {
state: any;
totalPrice: number;
monthlyPrice?: number;
totalPagesCount?: number;
pricing: any;
headerIcon?: string;
footerLogo?: string;

View File

@@ -0,0 +1,401 @@
"use client";
import * as React from "react";
import {
View as PDFView,
Text as PDFText,
StyleSheet,
Image as PDFImage,
} from "@react-pdf/renderer";
// INDUSTRIAL DESIGN SYSTEM TOKENS
export const COLORS = {
CHARCOAL: "#0f172a", // Slate 900
TEXT_MAIN: "#334155", // Slate 700
TEXT_DIM: "#64748b", // Slate 500
TEXT_LIGHT: "#94a3b8", // Slate 400
DIVIDER: "#cbd5e1", // Slate 300
GRID: "#f1f5f9", // Slate 100
BLUEPRINT: "#e2e8f0", // Slate 200
WHITE: "#ffffff",
};
export const FONT_SIZES = {
HERO: 24, // Main Page Titles
HEADING: 14, // Section Headers
BODY: 11, // Standard Content
LABEL: 10, // Bold Labels / Keys
SMALL: 9, // Descriptions / Footnotes
TINY: 8, // Metadata / Unit prices
};
export const pdfStyles = StyleSheet.create({
page: {
paddingTop: 45, // DIN 5008
paddingLeft: 70, // ~25mm
paddingRight: 57, // ~20mm
paddingBottom: 80, // Safe buffer for absolute footer
backgroundColor: COLORS.WHITE,
fontFamily: "Helvetica",
fontSize: FONT_SIZES.BODY,
color: COLORS.CHARCOAL,
},
titlePage: {
width: "100%",
height: "100%",
backgroundColor: COLORS.WHITE,
fontFamily: "Helvetica",
color: COLORS.CHARCOAL,
padding: 0,
},
header: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "flex-start",
marginBottom: 20,
minHeight: 120,
},
addressBlock: {
width: "55%",
marginTop: 45,
},
senderLine: {
fontSize: FONT_SIZES.TINY,
textDecoration: "underline",
color: COLORS.TEXT_DIM,
marginBottom: 8,
},
recipientAddress: {
fontSize: FONT_SIZES.BODY,
lineHeight: 1.4,
},
brandLogoContainer: {
width: "40%",
alignItems: "flex-end",
},
brandIconContainer: {
width: 40,
height: 40,
backgroundColor: "#0f172a",
borderRadius: 8,
alignItems: "center",
justifyContent: "center",
marginBottom: 12,
},
brandIconText: {
color: COLORS.WHITE,
fontSize: 20,
fontWeight: "bold",
},
titleInfo: {
marginBottom: 24,
},
mainTitle: {
fontSize: FONT_SIZES.HEADING,
fontWeight: "bold",
marginBottom: 4,
color: COLORS.CHARCOAL,
letterSpacing: 0.5,
},
subTitle: {
fontSize: FONT_SIZES.BODY,
color: COLORS.TEXT_DIM,
marginTop: 2,
lineHeight: 1.4,
},
section: {
marginBottom: 32,
},
sectionTitle: {
fontSize: FONT_SIZES.LABEL,
fontWeight: "bold",
textTransform: "uppercase",
letterSpacing: 1,
color: COLORS.TEXT_LIGHT,
marginBottom: 8,
},
footer: {
position: "absolute",
bottom: 32,
left: 70,
right: 57,
borderTopWidth: 1,
borderTopColor: COLORS.GRID,
paddingTop: 16,
flexDirection: "row",
justifyContent: "space-between",
alignItems: "flex-start",
},
footerColumn: {
flex: 1,
alignItems: "flex-start",
},
footerLogo: {
height: 20,
width: "auto",
objectFit: "contain",
marginBottom: 8,
},
footerText: {
fontSize: FONT_SIZES.TINY,
color: COLORS.TEXT_LIGHT,
lineHeight: 1.4,
},
asymmetryContainer: {
flexDirection: "row",
gap: 32,
},
asymmetryLeft: {
width: "32%",
},
asymmetryRight: {
width: "63%",
},
specRow: {
flexDirection: "row",
justifyContent: "space-between",
paddingVertical: 6,
borderBottomWidth: 1,
borderBottomColor: COLORS.GRID,
},
specLabel: {
fontSize: FONT_SIZES.TINY,
fontWeight: "bold",
color: COLORS.TEXT_LIGHT,
textTransform: "uppercase",
letterSpacing: 0.5,
},
specValue: {
fontSize: FONT_SIZES.SMALL,
color: COLORS.CHARCOAL,
fontWeight: "bold",
},
blueprintBox: {
borderWidth: 1,
borderColor: COLORS.GRID,
padding: 16,
backgroundColor: "#fafafa",
},
footerLabel: {
fontWeight: "bold",
color: COLORS.TEXT_DIM,
},
pageNumber: {
fontSize: FONT_SIZES.TINY,
color: COLORS.DIVIDER,
fontWeight: "bold",
marginTop: 8,
textAlign: "right",
},
foldingMark: {
position: "absolute",
left: 20,
width: 10,
borderTopWidth: 0.5,
borderTopColor: COLORS.DIVIDER,
},
divider: {
width: "100%",
height: 1,
backgroundColor: COLORS.DIVIDER,
marginVertical: 12,
},
industrialListItem: {
flexDirection: "row",
alignItems: "flex-start",
marginBottom: 6,
},
industrialBulletBox: {
width: 6,
height: 6,
backgroundColor: COLORS.DIVIDER,
marginRight: 8,
marginTop: 5,
},
industrialTitle: {
fontSize: FONT_SIZES.HERO,
fontWeight: "bold",
color: COLORS.CHARCOAL,
marginBottom: 6,
letterSpacing: 0,
},
});
export const IndustrialListItem = ({
children,
}: {
children: React.ReactNode;
}) => (
<PDFView style={pdfStyles.industrialListItem}>
<PDFView style={pdfStyles.industrialBulletBox} />
{children}
</PDFView>
);
export const Divider = ({ style = {} }: { style?: any }) => (
<PDFView style={[pdfStyles.divider, style]} />
);
export const Footer = ({
logo,
companyData,
showDetails = true,
showPageNumber = true,
}: {
logo?: string;
companyData: any;
showDetails?: boolean;
showPageNumber?: boolean;
}) => (
<PDFView style={pdfStyles.footer}>
<PDFView style={pdfStyles.footerColumn}>
{logo ? (
<PDFImage src={logo} style={pdfStyles.footerLogo} />
) : (
<PDFText style={{ fontSize: 12, fontWeight: "bold", marginBottom: 8 }}>
marc mintel
</PDFText>
)}
</PDFView>
{showDetails && (
<>
<PDFView style={pdfStyles.footerColumn}>
<PDFText style={pdfStyles.footerText}>
<PDFText style={pdfStyles.footerLabel}>{companyData.name}</PDFText>
{"\n"}
{companyData.address1}
{"\n"}
{companyData.address2}
{"\n"}UST: {companyData.ustId}
</PDFText>
</PDFView>
<PDFView style={[pdfStyles.footerColumn, { alignItems: "flex-end" }]}>
{showPageNumber && (
<PDFText
style={pdfStyles.pageNumber}
render={({ pageNumber, totalPages }) =>
`${pageNumber} / ${totalPages}`
}
fixed
/>
)}
</PDFView>
</>
)}
</PDFView>
);
export const Header = ({
sender,
recipient,
icon,
showAddress = true,
}: {
sender?: string;
recipient?: {
title: string;
subtitle?: string;
email?: string;
address?: string;
phone?: string;
taxId?: string;
};
icon?: string;
showAddress?: boolean;
}) => (
<PDFView
style={[
pdfStyles.header,
showAddress ? {} : { minHeight: 40, marginBottom: 0 },
]}
>
<PDFView style={pdfStyles.addressBlock}>
{showAddress && sender && (
<>
<PDFText style={pdfStyles.senderLine}>{sender}</PDFText>
{recipient && (
<PDFView style={pdfStyles.recipientAddress}>
<PDFText style={{ fontWeight: "bold" }}>
{recipient.title}
</PDFText>
{recipient.subtitle && <PDFText>{recipient.subtitle}</PDFText>}
{recipient.address && <PDFText>{recipient.address}</PDFText>}
{recipient.phone && <PDFText>{recipient.phone}</PDFText>}
{recipient.email && <PDFText>{recipient.email}</PDFText>}
{recipient.taxId && <PDFText>USt-ID: {recipient.taxId}</PDFText>}
</PDFView>
)}
</>
)}
</PDFView>
<PDFView style={pdfStyles.brandLogoContainer}>
<PDFView style={pdfStyles.brandIconContainer}>
{icon ? (
<PDFImage src={icon} style={{ width: 24, height: 24 }} />
) : (
<PDFText style={pdfStyles.brandIconText}>M</PDFText>
)}
</PDFView>
</PDFView>
</PDFView>
);
export const DocumentTitle = ({
title,
subLines,
isHero = false,
}: {
title: string;
subLines?: string[];
isHero?: boolean;
}) => (
<PDFView style={pdfStyles.titleInfo}>
<PDFText
style={[
pdfStyles.mainTitle,
{ fontSize: isHero ? FONT_SIZES.HERO : FONT_SIZES.HEADING },
]}
>
{title}
</PDFText>
{subLines?.map((line, i) => (
<PDFText
key={i}
style={[
pdfStyles.subTitle,
i === 1 ? { fontWeight: "bold", color: COLORS.CHARCOAL } : {},
]}
>
{line}
</PDFText>
))}
</PDFView>
);
export const TechnicalSpec = ({
label,
value,
}: {
label: string;
value: string;
}) => (
<PDFView style={pdfStyles.specRow}>
<PDFText style={pdfStyles.specLabel}>{label}</PDFText>
<PDFText style={pdfStyles.specValue}>{value}</PDFText>
</PDFView>
);
export const AsymmetryView = ({
left,
right,
style = {},
}: {
left: React.ReactNode;
right: React.ReactNode;
style?: any;
}) => (
<PDFView style={[pdfStyles.asymmetryContainer, style]}>
<PDFView style={pdfStyles.asymmetryLeft}>{left}</PDFView>
<PDFView style={pdfStyles.asymmetryRight}>{right}</PDFView>
</PDFView>
);

View File

@@ -33,7 +33,6 @@ interface SimpleLayoutProps {
icon?: string;
footerLogo?: string;
companyData: any;
bankData?: any;
showPageNumber?: boolean;
}
@@ -43,7 +42,6 @@ export const SimpleLayout = ({
icon,
footerLogo,
companyData,
bankData,
showPageNumber = true
}: SimpleLayoutProps) => {
return (
@@ -58,7 +56,6 @@ export const SimpleLayout = ({
<Footer
logo={footerLogo}
companyData={companyData}
bankData={bankData}
showDetails={false}
showPageNumber={showPageNumber}
/>

View File

@@ -0,0 +1,69 @@
"use client";
import * as React from "react";
import {
View as PDFView,
Text as PDFText,
StyleSheet,
} from "@react-pdf/renderer";
import { DocumentTitle, COLORS, FONT_SIZES } from "../SharedUI.js";
const styles = StyleSheet.create({
section: { marginBottom: 24 },
sectionTitle: {
fontSize: FONT_SIZES.LABEL,
fontWeight: "bold",
marginBottom: 8,
color: COLORS.CHARCOAL,
},
visionText: {
fontSize: FONT_SIZES.BODY,
color: COLORS.TEXT_MAIN,
lineHeight: 1.4,
textAlign: "justify",
},
});
export const BriefingModule = ({ state }: any) => (
<>
<DocumentTitle title="Projektdetails" isHero={true} />
{state.briefingSummary && (
<PDFView style={styles.section}>
<PDFText style={styles.sectionTitle}>Briefing Analyse</PDFText>
<PDFText
style={{
fontSize: FONT_SIZES.BODY,
color: COLORS.TEXT_MAIN,
lineHeight: 1.6,
textAlign: "justify",
}}
>
{state.briefingSummary}
</PDFText>
</PDFView>
)}
{state.designVision && (
<PDFView
style={[
styles.section,
{
padding: 12,
borderLeftWidth: 2,
borderLeftColor: COLORS.DIVIDER,
backgroundColor: COLORS.GRID,
},
]}
>
<PDFText
style={[
styles.sectionTitle,
{ color: COLORS.CHARCOAL, marginBottom: 4 },
]}
>
Strategische Vision
</PDFText>
<PDFText style={styles.visionText}>{state.designVision}</PDFText>
</PDFView>
)}
</>
);

View File

@@ -0,0 +1,56 @@
"use client";
import * as React from "react";
import { View as PDFView, Text as PDFText, StyleSheet } from "@react-pdf/renderer";
import { DocumentTitle, COLORS, FONT_SIZES, IndustrialListItem } from "../SharedUI.js";
const styles = StyleSheet.create({
section: { marginBottom: 24 },
categoryBox: {
marginBottom: 20,
padding: 12,
backgroundColor: COLORS.GRID,
borderLeftWidth: 2,
borderLeftColor: COLORS.DIVIDER,
},
categoryTitle: {
fontSize: FONT_SIZES.TINY,
fontWeight: "bold",
color: COLORS.TEXT_LIGHT,
textTransform: "uppercase",
marginBottom: 10,
letterSpacing: 1,
},
pageTitle: {
fontSize: FONT_SIZES.LABEL,
fontWeight: "bold",
color: COLORS.CHARCOAL,
marginBottom: 2,
},
pageDesc: {
fontSize: FONT_SIZES.TINY,
color: COLORS.TEXT_DIM,
lineHeight: 1.4,
},
});
export const SitemapModule = ({ state }: any) => (
<>
<DocumentTitle title="Informations-Architektur" isHero={true} />
<PDFView style={styles.section}>
{state.sitemap?.map((cat: any, i: number) => (
<PDFView key={i} style={styles.categoryBox}>
<PDFText style={styles.categoryTitle}>{cat.category}</PDFText>
{cat.pages?.map((p: any, j: number) => (
<IndustrialListItem key={j}>
<PDFView style={{ marginBottom: 8 }}>
<PDFText style={styles.pageTitle}>{p.title}</PDFText>
<PDFText style={styles.pageDesc}>{p.desc}</PDFText>
</PDFView>
</IndustrialListItem>
))}
</PDFView>
))}
</PDFView>
</>
);

View File

@@ -1,176 +1,6 @@
import { defineEndpoint } from "@directus/extensions-sdk";
import { AcquisitionService, PdfEngine } from "@mintel/pdf/server";
import { render, SiteAuditTemplate, ProjectEstimateTemplate } from "@mintel/mail";
import { createElement } from "react";
import * as path from "path";
import * as fs from "fs";
export default defineEndpoint((router, { services, env }) => {
const { ItemsService, MailService } = services;
router.get("/ping", (req, res) => res.send("pong"));
router.post("/audit/:id", async (req: any, res: any) => {
const { id } = req.params;
const leadsService = new ItemsService("leads", { schema: req.schema, accountability: req.accountability });
try {
const lead = await leadsService.readOne(id);
if (!lead) return res.status(404).send({ error: "Lead not found" });
await leadsService.updateOne(id, { status: "auditing" });
const acqService = new AcquisitionService(env.OPENROUTER_API_KEY);
const result = await acqService.runFullSequence(lead.website_url, lead.briefing, lead.comments);
await leadsService.updateOne(id, {
status: "audit_ready",
ai_state: result.state,
audit_context: JSON.stringify(result.usage),
});
res.send({ success: true, result });
} catch (error: any) {
console.error("Audit failed:", error);
await leadsService.updateOne(id, { status: "new", comments: `Audit failed: ${error.message}` });
res.status(500).send({ error: error.message });
}
});
router.post("/audit-email/:id", async (req: any, res: any) => {
const { id } = req.params;
const { ItemsService, MailService } = services;
const leadsService = new ItemsService("leads", { schema: req.schema, accountability: req.accountability });
const peopleService = new ItemsService("people", { schema: req.schema, accountability: req.accountability });
const companiesService = new ItemsService("companies", { schema: req.schema, accountability: req.accountability });
const mailService = new MailService({ schema: req.schema, accountability: req.accountability });
try {
const lead = await leadsService.readOne(id, { fields: ["*", "company.*", "contact_person.*"] });
if (!lead || !lead.ai_state) return res.status(400).send({ error: "Lead or Audit not ready" });
let recipientEmail = lead.contact_email;
let companyName = lead.company?.name || lead.company_name;
if (lead.contact_person) {
recipientEmail = lead.contact_person.email || recipientEmail;
if (lead.contact_person.company) {
const personCompany = await companiesService.readOne(lead.contact_person.company);
companyName = personCompany?.name || companyName;
}
}
if (!recipientEmail) return res.status(400).send({ error: "No recipient email found" });
const auditHighlights = [
`Projekt-Typ: ${lead.ai_state.projectType === "website" ? "Website" : "Web App"}`,
...(lead.ai_state.sitemap || []).slice(0, 3).map((item: any) => `Potenzial in: ${item.category}`),
];
const html = await render(createElement(SiteAuditTemplate, {
companyName: companyName,
websiteUrl: lead.website_url,
auditHighlights
}));
await mailService.send({
to: recipientEmail,
subject: `Analyse Ihrer Webpräsenz: ${companyName}`,
html
});
await leadsService.updateOne(id, {
status: "contacted",
last_contacted_at: new Date().toISOString(),
});
res.send({ success: true });
} catch (error: any) {
console.error("Audit Email failed:", error);
res.status(500).send({ error: error.message });
}
});
router.post("/estimate/:id", async (req: any, res: any) => {
const { id } = req.params;
const leadsService = new ItemsService("leads", { schema: req.schema, accountability: req.accountability });
try {
const lead = await leadsService.readOne(id);
if (!lead || !lead.ai_state) return res.status(400).send({ error: "Lead or AI state not found" });
const pdfEngine = new PdfEngine();
const filename = `estimate_${id}_${Date.now()}.pdf`;
const storageRoot = env.STORAGE_LOCAL_ROOT || "./storage";
const outputPath = path.join(storageRoot, filename);
await pdfEngine.generateEstimatePdf(lead.ai_state, outputPath);
await leadsService.updateOne(id, {
audit_pdf_path: filename,
});
res.send({ success: true, filename });
} catch (error: any) {
console.error("PDF Generation failed:", error);
res.status(500).send({ error: error.message });
}
});
router.post("/estimate-email/:id", async (req: any, res: any) => {
const { id } = req.params;
const leadsService = new ItemsService("leads", { schema: req.schema, accountability: req.accountability });
const peopleService = new ItemsService("people", { schema: req.schema, accountability: req.accountability });
const companiesService = new ItemsService("companies", { schema: req.schema, accountability: req.accountability });
const mailService = new MailService({ schema: req.schema, accountability: req.accountability });
try {
const lead = await leadsService.readOne(id, { fields: ["*", "company.*", "contact_person.*"] });
if (!lead || !lead.audit_pdf_path) return res.status(400).send({ error: "PDF not generated" });
let recipientEmail = lead.contact_email;
let companyName = lead.company?.name || lead.company_name;
if (lead.contact_person) {
recipientEmail = lead.contact_person.email || recipientEmail;
if (lead.contact_person.company) {
const personCompany = await companiesService.readOne(lead.contact_person.company);
companyName = personCompany?.name || companyName;
}
}
if (!recipientEmail) return res.status(400).send({ error: "No recipient email found" });
const html = await render(createElement(ProjectEstimateTemplate, {
companyName: companyName,
}));
const storageRoot = env.STORAGE_LOCAL_ROOT || "./storage";
const attachmentPath = path.join(storageRoot, lead.audit_pdf_path);
await mailService.send({
to: recipientEmail,
subject: `Ihre Projekt-Schätzung: ${companyName}`,
html,
attachments: [
{
filename: `Angebot_${companyName}.pdf`,
content: fs.readFileSync(attachmentPath)
}
]
});
await leadsService.updateOne(id, {
status: "contacted",
last_contacted_at: new Date().toISOString(),
});
res.send({ success: true });
} catch (error: any) {
console.error("Estimate Email failed:", error);
res.status(500).send({ error: error.message });
}
});
});
export * from "./logic/pricing/types.js";
export * from "./logic/pricing/constants.js";
export * from "./logic/pricing/calculator.js";
export * from "./services/AcquisitionService.js";
export * from "./services/PdfEngine.js";
export * from "./components/EstimationPDF.js";

View File

@@ -1,4 +1,4 @@
import { CheerioCrawler } from "@crawlee/cheerio";
import { CheerioCrawler } from "crawlee";
import axios from "axios";
import { FileCacheAdapter } from "../utils/cache/FileCacheAdapter.js";
import { initialState } from "../logic/pricing/constants.js";

View File

@@ -0,0 +1,59 @@
import { describe, it, expect } from "vitest";
import { calculateTotals, calculatePositions } from "../src/logic/pricing/calculator.js";
import { PRICING, initialState } from "../src/logic/pricing/constants.js";
import { FormState } from "../src/logic/pricing/types.js";
describe("Pricing Logic", () => {
it("should calculate base website price correctly", () => {
const state: FormState = {
...initialState,
projectType: "website",
selectedPages: [] // Clear for base test
};
const totals = calculateTotals(state, PRICING);
expect(totals.totalPrice).toBe(PRICING.BASE_WEBSITE);
});
it("should add page costs correctly", () => {
const state: FormState = {
...initialState,
projectType: "website",
selectedPages: [], // Clear for clean test
otherPagesCount: 5
};
const totals = calculateTotals(state, PRICING);
expect(totals.totalPrice).toBe(PRICING.BASE_WEBSITE + (5 * PRICING.PAGE));
});
it("should apply multi-language multiplier", () => {
const state: FormState = {
...initialState,
projectType: "website",
selectedPages: [], // Clear for clean test
languagesList: ["Deutsch", "Englisch"]
};
const totals = calculateTotals(state, PRICING);
expect(totals.totalPrice).toBe(Math.round(PRICING.BASE_WEBSITE * 1.2));
});
it("should generate correct positions for a website", () => {
const state: FormState = {
...initialState,
projectType: "website",
selectedPages: ["Home"],
otherPagesCount: 2
};
const positions = calculatePositions(state, PRICING);
// Find "Fundament" position (Das technische Fundament)
const basePos = positions.find(p => p.title.includes("Fundament"));
expect(basePos).toBeDefined();
expect(basePos?.price).toBe(PRICING.BASE_WEBSITE);
// Find "Individuelle Seiten" position
const pagesPos = positions.find(p => p.title.includes("Seiten"));
expect(pagesPos).toBeDefined();
expect(pagesPos?.qty).toBe(3); // 1 selected + 2 other
expect(pagesPos?.price).toBe(3 * PRICING.PAGE);
});
});

View File

@@ -0,0 +1,15 @@
{
"extends": "@mintel/tsconfig/base.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src",
"jsx": "react-jsx"
},
"include": [
"src/**/*"
],
"exclude": [
"node_modules",
"dist"
]
}

View File

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

View File

@@ -1,41 +0,0 @@
import { build } from 'esbuild';
import { resolve, dirname } from 'path';
import { mkdirSync } from 'fs';
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const entryPoints = [
resolve(__dirname, 'src/index.ts')
];
try {
mkdirSync(resolve(__dirname, 'dist'), { recursive: true });
} catch (e) { }
console.log(`Building entry point...`);
build({
entryPoints: entryPoints,
bundle: true,
platform: 'node',
target: 'node18',
outdir: resolve(__dirname, 'dist'),
format: 'esm',
loader: {
'.ts': 'ts',
'.js': 'js',
},
external: ["playwright", "crawlee", "axios", "cheerio", "fs", "path", "os", "http", "https", "url", "stream", "util", "child_process"],
}).then(() => {
console.log("Build succeeded!");
}).catch((e) => {
if (e.errors) {
console.error("Build failed with errors:");
e.errors.forEach(err => console.error(` ${err.text} at ${err.location?.file}:${err.location?.line}`));
} else {
console.error("Build failed:", e);
}
process.exit(1);
});

View File

@@ -1,30 +0,0 @@
{
"name": "@mintel/cloner",
"version": "1.8.2",
"type": "module",
"main": "dist/index.js",
"module": "dist/index.js",
"types": "dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js",
"default": "./dist/index.js"
}
},
"scripts": {
"build": "node build.mjs",
"dev": "node build.mjs --watch"
},
"devDependencies": {
"esbuild": "^0.25.0",
"typescript": "^5.6.3",
"@types/node": "^22.0.0"
},
"dependencies": {
"playwright": "^1.40.0",
"crawlee": "^3.7.0",
"axios": "^1.6.0",
"cheerio": "^1.0.0-rc.12"
}
}

View File

@@ -1,93 +0,0 @@
import axios from "axios";
import fs from "node:fs";
import path from "node:path";
export interface AssetMap {
[originalUrl: string]: string;
}
export class AssetManager {
private userAgent: string;
constructor(userAgent: string = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36") {
this.userAgent = userAgent;
}
public sanitizePath(rawPath: string): string {
return rawPath
.split("/")
.map((p) => p.replace(/[^a-z0-9._-]/gi, "_"))
.join("/");
}
public async downloadFile(url: string, assetsDir: string): Promise<string | null> {
if (url.startsWith("//")) url = `https:${url}`;
if (!url.startsWith("http")) return null;
try {
const u = new URL(url);
const relPath = this.sanitizePath(u.hostname + u.pathname);
const dest = path.join(assetsDir, relPath);
if (fs.existsSync(dest)) return `./assets/${relPath}`;
const res = await axios.get(url, {
responseType: "arraybuffer",
headers: { "User-Agent": this.userAgent },
timeout: 15000,
validateStatus: () => true,
});
if (res.status !== 200) return null;
if (!fs.existsSync(path.dirname(dest)))
fs.mkdirSync(path.dirname(dest), { recursive: true });
fs.writeFileSync(dest, Buffer.from(res.data));
return `./assets/${relPath}`;
} catch {
return null;
}
}
public async processCssRecursively(
cssContent: string,
cssUrl: string,
assetsDir: string,
urlMap: AssetMap,
depth = 0,
): Promise<string> {
if (depth > 5) return cssContent;
const urlRegex = /(?:url\(["']?|@import\s+["'])([^"'\)]+)["']?\)?/gi;
let match;
let newContent = cssContent;
while ((match = urlRegex.exec(cssContent)) !== null) {
const originalUrl = match[1];
if (originalUrl.startsWith("data:") || originalUrl.startsWith("blob:"))
continue;
try {
const absUrl = new URL(originalUrl, cssUrl).href;
const local = await this.downloadFile(absUrl, assetsDir);
if (local) {
const u = new URL(cssUrl);
const cssPath = u.hostname + u.pathname;
const assetPath = new URL(absUrl).hostname + new URL(absUrl).pathname;
const rel = path.relative(
path.dirname(this.sanitizePath(cssPath)),
this.sanitizePath(assetPath),
);
newContent = newContent.split(originalUrl).join(rel);
urlMap[absUrl] = local;
}
} catch {
// Ignore
}
}
return newContent;
}
}

View File

@@ -1,184 +0,0 @@
import { chromium, Browser, BrowserContext, Page } from "playwright";
import fs from "node:fs";
import path from "node:path";
import axios from "axios";
import { AssetManager, AssetMap } from "./AssetManager.js";
export interface PageClonerOptions {
outputDir: string;
userAgent?: string;
}
export class PageCloner {
private options: PageClonerOptions;
private assetManager: AssetManager;
private userAgent: string;
constructor(options: PageClonerOptions) {
this.options = options;
this.userAgent = options.userAgent || "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36";
this.assetManager = new AssetManager(this.userAgent);
}
public async clone(targetUrl: string): Promise<string> {
const urlObj = new URL(targetUrl);
const domainSlug = urlObj.hostname.replace("www.", "");
const domainDir = path.resolve(this.options.outputDir, domainSlug);
const assetsDir = path.join(domainDir, "assets");
if (!fs.existsSync(assetsDir)) fs.mkdirSync(assetsDir, { recursive: true });
let pageSlug = urlObj.pathname.split("/").filter(Boolean).join("-");
if (!pageSlug) pageSlug = "index";
const htmlFilename = `${pageSlug}.html`;
console.log(`🚀 INDUSTRIAL CLONE: ${targetUrl}`);
const browser = await chromium.launch({ headless: true });
const context = await browser.newContext({
userAgent: this.userAgent,
viewport: { width: 1920, height: 1080 },
});
const page = await context.newPage();
const urlMap: AssetMap = {};
const foundAssets = new Set<string>();
page.on("response", (response) => {
if (response.status() === 200) {
const url = response.url();
if (url.match(/\.(css|js|png|jpg|jpeg|gif|svg|woff2?|ttf|otf|mp4|webm|webp|ico)/i)) {
foundAssets.add(url);
}
}
});
try {
await page.goto(targetUrl, { waitUntil: "networkidle", timeout: 90000 });
// Scroll Wave
await page.evaluate(async () => {
await new Promise((resolve) => {
let totalHeight = 0;
const distance = 400;
const timer = setInterval(() => {
const scrollHeight = document.body.scrollHeight;
window.scrollBy(0, distance);
totalHeight += distance;
if (totalHeight >= scrollHeight) {
clearInterval(timer);
window.scrollTo(0, 0);
resolve(true);
}
}, 100);
});
});
const fullHeight = await page.evaluate(() => document.body.scrollHeight);
await page.setViewportSize({ width: 1920, height: fullHeight + 1000 });
await page.waitForTimeout(3000);
// Sanitization
await page.evaluate(() => {
const assetPattern = /\.(jpg|jpeg|png|gif|svg|webp|mp4|webm|woff2?|ttf|otf)/i;
document.querySelectorAll("*").forEach((el) => {
if (["META", "LINK", "HEAD", "SCRIPT", "STYLE", "SVG", "PATH"].includes(el.tagName)) return;
const htmlEl = el as HTMLElement;
const style = window.getComputedStyle(htmlEl);
if (style.opacity === "0" || style.visibility === "hidden") {
htmlEl.style.setProperty("opacity", "1", "important");
htmlEl.style.setProperty("visibility", "visible", "important");
}
for (const attr of Array.from(el.attributes)) {
const name = attr.name.toLowerCase();
const val = attr.value;
if (assetPattern.test(val) || name.includes("src") || name.includes("image")) {
if (el.tagName === "IMG") {
const img = el as HTMLImageElement;
if (name.includes("srcset")) img.srcset = val;
else if (!img.src || img.src.includes("data:")) img.src = val;
}
if (el.tagName === "SOURCE") (el as HTMLSourceElement).srcset = val;
if (el.tagName === "VIDEO" || el.tagName === "AUDIO") (el as HTMLMediaElement).src = val;
if (val.match(/^(https?:\/\/|\/\/|\/)/) && !name.includes("href")) {
const bg = htmlEl.style.backgroundImage;
if (!bg || bg === "none") htmlEl.style.backgroundImage = `url('${val}')`;
}
}
}
});
if (document.body) {
document.body.style.setProperty("opacity", "1", "important");
document.body.style.setProperty("visibility", "visible", "important");
}
});
await page.waitForLoadState("networkidle");
await page.waitForTimeout(1000);
let content = await page.content();
const regexPatterns = [
/(?:src|href|url|data-[a-z-]+|srcset)=["']([^"'<>\s]+?\.(?:css|js|png|jpg|jpeg|gif|svg|woff2?|ttf|otf|mp4|webm|webp|ico)(?:\?[^"']*)?)["']/gi,
/url\(["']?([^"'\)]+)["']?\)/gi,
];
for (const pattern of regexPatterns) {
let match;
while ((match = pattern.exec(content)) !== null) {
try { foundAssets.add(new URL(match[1], targetUrl).href); } catch { }
}
}
for (const url of foundAssets) {
const local = await this.assetManager.downloadFile(url, assetsDir);
if (local) {
urlMap[url] = local;
const clean = url.split("?")[0];
urlMap[clean] = local;
if (clean.endsWith(".css")) {
try {
const { data } = await axios.get(url, { headers: { "User-Agent": this.userAgent } });
const processedCss = await this.assetManager.processCssRecursively(data, url, assetsDir, urlMap);
const relPath = this.assetManager.sanitizePath(new URL(url).hostname + new URL(url).pathname);
fs.writeFileSync(path.join(assetsDir, relPath), processedCss);
} catch { }
}
}
}
let finalContent = content;
const sortedUrls = Object.keys(urlMap).sort((a, b) => b.length - a.length);
if (sortedUrls.length > 0) {
const escaped = sortedUrls.map((u) => u.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"));
const masterRegex = new RegExp(`(${escaped.join("|")})`, "g");
finalContent = finalContent.replace(masterRegex, (match) => urlMap[match] || match);
}
const commonDirs = ["/wp-content/", "/wp-includes/", "/assets/", "/static/", "/images/"];
for (const dir of commonDirs) {
const localDir = `./assets/${urlObj.hostname}${dir}`;
finalContent = finalContent.split(`"${dir}`).join(`"${localDir}`).split(`'${dir}`).join(`'${localDir}`).split(`(${dir}`).join(`(${localDir}`);
}
const domainPattern = new RegExp(`https?://(www\\.)?${urlObj.hostname.replace(/\./g, "\\.")}[^"']*`, "gi");
finalContent = finalContent.replace(domainPattern, () => "./");
finalContent = finalContent.replace(/<script\b[^>]*>([\s\S]*?)<\/script>/gi, (match, scriptContent) => {
const lower = scriptContent.toLowerCase();
return (lower.includes("google-analytics") || lower.includes("gtag") || lower.includes("fbq") || lower.includes("lazy") || lower.includes("tracker")) ? "" : match;
});
const headEnd = finalContent.indexOf("</head>");
if (headEnd > -1) {
const stabilityCss = `\n<style>* { transition: none !important; animation: none !important; scroll-behavior: auto !important; } [data-aos], .reveal, .lazypath, .lazy-load, [data-src] { opacity: 1 !important; visibility: visible !important; transform: none !important; clip-path: none !important; } img, video, iframe { max-width: 100%; display: block; } a { pointer-events: none; cursor: default; } </style>`;
finalContent = finalContent.slice(0, headEnd) + stabilityCss + finalContent.slice(headEnd);
}
const finalPath = path.join(domainDir, htmlFilename);
fs.writeFileSync(finalPath, finalContent);
return finalPath;
} finally {
await browser.close();
}
}
}

View File

@@ -1,123 +0,0 @@
import { PlaywrightCrawler, RequestQueue } from 'crawlee';
import * as path from 'node:path';
import * as fs from 'node:fs';
import { execSync } from 'node:child_process';
export interface WebsiteClonerOptions {
baseOutputDir: string;
maxRequestsPerCrawl?: number;
maxConcurrency?: number;
}
export class WebsiteCloner {
private options: WebsiteClonerOptions;
constructor(options: WebsiteClonerOptions) {
this.options = {
maxRequestsPerCrawl: 100,
maxConcurrency: 3,
...options
};
}
public async clone(targetUrl: string, outputDirName?: string): Promise<string> {
const urlObj = new URL(targetUrl);
const domain = urlObj.hostname;
const finalOutputDirName = outputDirName || domain.replace(/\./g, '-');
const baseOutputDir = path.resolve(this.options.baseOutputDir, finalOutputDirName);
if (fs.existsSync(baseOutputDir)) {
fs.rmSync(baseOutputDir, { recursive: true, force: true });
}
fs.mkdirSync(baseOutputDir, { recursive: true });
console.log(`🚀 Starting perfect recursive clone of ${targetUrl}...`);
console.log(`📂 Output: ${baseOutputDir}`);
const requestQueue = await RequestQueue.open();
await requestQueue.addRequest({ url: targetUrl });
const crawler = new PlaywrightCrawler({
requestQueue,
maxRequestsPerCrawl: this.options.maxRequestsPerCrawl,
maxConcurrency: this.options.maxConcurrency,
async requestHandler({ request, enqueueLinks, log }) {
const url = request.url;
log.info(`Capturing ${url}...`);
const u = new URL(url);
let relPath = u.pathname;
if (relPath === '/' || relPath === '') relPath = '/index.html';
if (!relPath.endsWith('.html') && !path.extname(relPath)) relPath += '/index.html';
if (relPath.startsWith('/')) relPath = relPath.substring(1);
const fullPath = path.join(baseOutputDir, relPath);
fs.mkdirSync(path.dirname(fullPath), { recursive: true });
try {
// Note: This assumes single-file-cli is available in the environment
execSync(`npx single-file-cli "${url}" "${fullPath}" --browser-headless=true --browser-wait-until=networkidle0`, {
stdio: 'inherit'
});
} catch (e) {
log.error(`Failed to capture ${url} with SingleFile`);
}
await enqueueLinks({
strategy: 'same-domain',
transformRequestFunction: (req) => {
if (/\.(download|pdf|zip|gz|exe|png|jpg|jpeg|gif|svg|css|js)$/i.test(req.url)) return false;
return req;
}
});
},
});
await crawler.run();
console.log('🔗 Rewriting internal links for offline navigation...');
const allFiles = this.getFiles(baseOutputDir).filter(f => f.endsWith('.html'));
for (const file of allFiles) {
let content = fs.readFileSync(file, 'utf8');
const fileRelToRoot = path.relative(baseOutputDir, file);
content = content.replace(/href="([^"]+)"/g, (match, href) => {
if (href.startsWith(targetUrl) || href.startsWith('/') || (!href.includes('://') && !href.startsWith('data:'))) {
try {
const linkUrl = new URL(href, targetUrl);
if (linkUrl.hostname === domain) {
let linkPath = linkUrl.pathname;
if (linkPath === '/' || linkPath === '') linkPath = '/index.html';
if (!linkPath.endsWith('.html') && !path.extname(linkPath)) linkPath += '/index.html';
if (linkPath.startsWith('/')) linkPath = linkPath.substring(1);
const relativeLink = path.relative(path.dirname(fileRelToRoot), linkPath);
return `href="${relativeLink}"`;
}
} catch (e) { }
}
return match;
});
fs.writeFileSync(file, content);
}
console.log(`\n✅ Done! Perfect clone complete in: ${baseOutputDir}`);
return baseOutputDir;
}
private getFiles(dir: string, fileList: string[] = []) {
const files = fs.readdirSync(dir);
for (const file of files) {
const name = path.join(dir, file);
if (fs.statSync(name).isDirectory()) {
this.getFiles(name, fileList);
} else {
fileList.push(name);
}
}
return fileList;
}
}

View File

@@ -1,3 +0,0 @@
export * from "./AssetManager.js";
export * from "./PageCloner.js";
export * from "./WebsiteCloner.js";

View File

@@ -1,17 +0,0 @@
{
"extends": "../tsconfig/base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src",
"declaration": true,
"emitDeclarationOnly": true,
"module": "ESNext",
"target": "ESNext",
"moduleResolution": "Bundler",
"allowImportingTsExtensions": true,
"noEmit": false
},
"include": [
"src/**/*"
]
}

View File

@@ -0,0 +1,42 @@
# Build Stage
FROM node:20-slim AS builder
WORKDIR /app
# Core environment for pnpm
RUN corepack enable && corepack prepare pnpm@latest --activate
# Copy root configurations
COPY pnpm-lock.yaml pnpm-workspace.yaml package.json ./
# Copy all packages for extensions build
COPY packages ./packages
# Install dependencies (only what's needed for extensions)
RUN pnpm install --no-frozen-lockfile \
--filter "@mintel/directus-extension-*" \
--filter "acquisition" \
--filter "acquisition-manager" \
--filter "customer-manager" \
--filter "feedback-commander" \
--filter "people-manager" \
--filter "./packages/acquisition" \
--filter "./packages/mail"
# Runtime Stage
FROM directus/directus:11
WORKDIR /directus
# Copy built extensions
COPY --from=builder /app/packages/cms-infra/extensions ./extensions
# Environment defaults (can be overridden)
ENV KEY="infra-cms-key"
ENV SECRET="infra-cms-secret"
ENV DB_CLIENT="sqlite3"
ENV DB_FILENAME="/directus/database/data.db"
# Expose port
EXPOSE 8055

BIN
packages/cms-infra/database/data.db Executable file → Normal file

Binary file not shown.

View File

@@ -1,6 +1,9 @@
services:
infra-cms:
image: directus/directus:11
build:
context: ../../
dockerfile: packages/cms-infra/Dockerfile
image: mintel/cms-infra:latest
ports:
- "8059:8055"
networks:
@@ -21,6 +24,7 @@ services:
EMAIL_SMTP_PASSWORD: "4592fcb94599ee1a45b4ac2386fd0a64-102c75d8-ca2870e6"
EMAIL_SMTP_SECURE: "false"
EMAIL_FROM: "postmaster@mg.mintel.me"
LOG_LEVEL: "trace"
volumes:
- ./database:/directus/database
- ./uploads:/directus/uploads

View File

@@ -1,30 +1,20 @@
{
"name": "acquisition-manager",
"description": "Custom High-Fidelity Acquisition Management for Directus",
"icon": "account_balance_wallet",
"version": "1.7.12",
"type": "module",
"keywords": [
"directus",
"directus-extension",
"directus-extension-module"
],
"files": [
"dist"
],
"directus:extension": {
"name": "acquisition-manager",
"version": "1.0.0",
"type": "module",
"path": "index.js",
"source": "src/index.ts",
"host": "*",
"name": "Acquisition Manager"
},
"scripts": {
"build": "directus-extension build && (cp -f dist/index.js index.js 2>/dev/null || true)",
"dev": "directus-extension build -w"
},
"devDependencies": {
"@directus/extensions-sdk": "11.0.2",
"vue": "^3.4.0"
}
}
"directus:extension": {
"type": "module",
"path": "dist/index.js",
"source": "src/index.ts",
"host": "^11.0.0"
},
"scripts": {
"build": "directus-extension build",
"dev": "directus-extension build -w"
},
"devDependencies": {
"@directus/extensions-sdk": "11.0.2",
"vue": "^3.4.34",
"typescript": "^5.6.3"
}
}

View File

@@ -23,7 +23,7 @@
<v-icon :name="getStatusIcon(lead.status)" :color="getStatusColor(lead.status)" />
</v-list-item-icon>
<v-list-item-content>
<v-text-overflow :text="getCompanyName(lead)" />
<v-text-overflow :text="lead.company_name" />
</v-list-item-content>
</v-list-item>
</v-list>
@@ -50,7 +50,7 @@
<template v-else>
<header class="header">
<div class="header-left">
<h1 class="title">{{ getCompanyName(selectedLead) }}</h1>
<h1 class="title">{{ selectedLead.company_name }}</h1>
<p class="subtitle">
<v-icon name="language" x-small />
<a :href="selectedLead.website_url" target="_blank" class="url-link">
@@ -156,15 +156,7 @@
<div class="drawer-content">
<div class="form-section">
<div class="field">
<span class="label">Organisation / Firma (Zentral)</span>
<v-select
v-model="newLead.company"
:items="companyOptions"
placeholder="Bestehende Firma auswählen..."
/>
</div>
<div class="field">
<span class="label">Organisation / Firma (Legacy / Neu)</span>
<span class="label">Firma</span>
<v-input v-model="newLead.company_name" placeholder="z.B. Schmidt GmbH" autofocus />
</div>
<div class="field">
@@ -216,26 +208,17 @@ const savingLead = ref(false);
const notice = ref<{ type: string; message: string } | null>(null);
const newLead = ref({
company_name: '', // Legacy
company: null,
company_name: '',
website_url: '',
contact_name: '', // Legacy
contact_email: '', // Legacy
contact_name: '',
contact_email: '',
contact_person: null,
briefing: '',
status: 'new'
});
const companies = ref<any[]>([]);
const people = ref<any[]>([]);
const companyOptions = computed(() =>
companies.value.map(c => ({
text: c.name,
value: c.id
}))
);
const peopleOptions = computed(() =>
people.value.map(p => ({
text: `${p.first_name} ${p.last_name}`,
@@ -243,16 +226,7 @@ const peopleOptions = computed(() =>
}))
);
function getCompanyName(lead: any) {
if (lead.company) {
return typeof lead.company === 'object' ? lead.company.name : (companies.value.find(c => c.id === lead.company)?.name || lead.company_name);
}
return lead.company_name;
}
function getPersonName(id: string | any) {
if (!id) return '';
if (typeof id === 'object') return `${id.first_name} ${id.last_name}`;
function getPersonName(id: string) {
const person = people.value.find(p => p.id === id);
return person ? `${person.first_name} ${person.last_name}` : id;
}
@@ -264,32 +238,20 @@ function goToPerson(id: string) {
const selectedLead = computed(() => leads.value.find(l => l.id === selectedLeadId.value));
onMounted(fetchData);
onMounted(fetchLeads);
async function fetchData() {
const [leadsResp, peopleResp, companiesResp] = await Promise.all([
api.get('/items/leads', {
params: {
sort: '-date_created',
fields: '*.*'
}
}),
api.get('/items/people', { params: { sort: 'last_name' } }),
api.get('/items/companies', { params: { sort: 'name' } })
async function fetchLeads() {
const [leadsResp, peopleResp] = await Promise.all([
api.get('/items/leads', { params: { sort: '-date_created' } }),
api.get('/items/people', { params: { sort: 'last_name' } })
]);
leads.value = leadsResp.data.data;
people.value = peopleResp.data.data;
companies.value = companiesResp.data.data;
if (!selectedLeadId.value && leads.value.length > 0) {
selectedLeadId.value = leads.value[0].id;
}
}
async function fetchLeads() {
await fetchData();
}
function selectLead(id: string) {
selectedLeadId.value = id;
}
@@ -356,10 +318,7 @@ function openPdf() {
}
async function saveLead() {
if (!newLead.value.company_name && !newLead.value.company) {
notice.value = { type: 'danger', message: 'Firma oder Firmenname erforderlich.' };
return;
}
if (!newLead.value.company_name) return;
savingLead.value = true;
try {
const payload = {
@@ -373,7 +332,6 @@ async function saveLead() {
selectedLeadId.value = payload.id;
newLead.value = {
company_name: '',
company: null,
website_url: '',
contact_name: '',
contact_email: '',

View File

@@ -21,24 +21,25 @@ build({
platform: 'node',
target: 'node18',
outfile: outfile,
jsx: 'automatic',
loader: {
'.tsx': 'tsx',
'.ts': 'ts',
'.js': 'js',
},
external: ["@react-pdf/renderer", "react", "react-dom", "jsdom", "jsdom/*", "jquery", "jquery/*", "canvas", "fs", "path", "os", "http", "https", "zlib", "stream", "util", "url", "net", "tls", "crypto"],
format: 'esm',
// Bundle everything, including Directus SDK, to avoid resolution issues in Docker
external: [],
plugins: [{
name: 'mock-jquery',
setup(build) {
build.onResolve({ filter: /^jquery$/ }, args => ({ path: args.path, namespace: 'mock-jquery' }));
build.onLoad({ filter: /.*/, namespace: 'mock-jquery' }, () => ({ contents: 'export default {};', loader: 'js' }));
}
}, {
name: 'mock-canvas',
setup(build) {
build.onResolve({ filter: /^canvas/ }, args => ({ path: args.path, namespace: 'mock-canvas' }));
build.onResolve({ filter: /^canvas$/ }, args => ({ path: args.path, namespace: 'mock-canvas' }));
build.onLoad({ filter: /.*/, namespace: 'mock-canvas' }, () => ({ contents: 'export default {};', loader: 'js' }));
}
}, {
name: 'mock-jsdom',
setup(build) {
build.onResolve({ filter: /^jsdom/ }, args => ({ path: args.path, namespace: 'mock-jsdom' }));
build.onLoad({ filter: /.*/, namespace: 'mock-jsdom' }, () => ({ contents: 'export default {};', loader: 'js' }));
return;
}
}]
}).then(() => {

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,27 @@
{
"name": "acquisition",
"version": "1.0.0",
"type": "module",
"directus:extension": {
"type": "endpoint",
"path": "dist/index.js",
"source": "src/index.ts",
"host": "^11.0.0"
},
"scripts": {
"build": "node build.js",
"dev": "node build.js --watch"
},
"devDependencies": {
"@directus/extensions-sdk": "11.0.2",
"@mintel/acquisition": "workspace:*",
"@mintel/mail": "workspace:*",
"esbuild": "^0.25.0",
"typescript": "^5.6.3"
},
"dependencies": {
"jquery": "^3.7.1",
"react": "^19.2.4",
"react-dom": "^19.2.4"
}
}

View File

@@ -0,0 +1,172 @@
import "./shim";
import { defineEndpoint } from "@directus/extensions-sdk";
import { AcquisitionService, PdfEngine } from "@mintel/acquisition";
import { render, SiteAuditTemplate, ProjectEstimateTemplate } from "@mintel/mail";
import { createElement } from "react";
import * as path from "path";
import * as fs from "fs";
export default defineEndpoint((router, { services, env }) => {
const { ItemsService, MailService } = services;
router.get("/ping", (req, res) => res.send("pong"));
router.post("/audit/:id", async (req: any, res: any) => {
const { id } = req.params;
const leadsService = new ItemsService("leads", { schema: req.schema, accountability: req.accountability });
try {
const lead = await leadsService.readOne(id);
if (!lead) return res.status(404).send({ error: "Lead not found" });
await leadsService.updateOne(id, { status: "auditing" });
const acqService = new AcquisitionService(env.OPENROUTER_API_KEY);
const result = await acqService.runFullSequence(lead.website_url, lead.briefing, lead.comments);
await leadsService.updateOne(id, {
status: "audit_ready",
ai_state: result.state,
audit_context: JSON.stringify(result.usage),
});
res.send({ success: true, result });
} catch (error: any) {
console.error("Audit failed:", error);
await leadsService.updateOne(id, { status: "new", comments: `Audit failed: ${error.message}` });
res.status(500).send({ error: error.message });
}
});
router.post("/audit-email/:id", async (req: any, res: any) => {
const { id } = req.params;
const leadsService = new ItemsService("leads", { schema: req.schema, accountability: req.accountability });
const peopleService = new ItemsService("people", { schema: req.schema, accountability: req.accountability });
const mailService = new MailService({ schema: req.schema, accountability: req.accountability });
try {
const lead = await leadsService.readOne(id);
if (!lead || !lead.ai_state) return res.status(400).send({ error: "Lead or Audit not ready" });
let recipientEmail = lead.contact_email;
let companyName = lead.company_name;
if (lead.contact_person) {
const person = await peopleService.readOne(lead.contact_person);
if (person && person.email) {
recipientEmail = person.email;
companyName = person.company || lead.company_name;
}
}
if (!recipientEmail) return res.status(400).send({ error: "No recipient email found" });
const auditHighlights = [
`Projekt-Typ: ${lead.ai_state.projectType === "website" ? "Website" : "Web App"}`,
...(lead.ai_state.sitemap || []).slice(0, 3).map((item: any) => `Potenzial in: ${item.category}`),
];
const html = await render(createElement(SiteAuditTemplate, {
companyName: companyName,
websiteUrl: lead.website_url,
auditHighlights
}));
await mailService.send({
to: recipientEmail,
subject: `Analyse Ihrer Webpräsenz: ${companyName}`,
html
});
await leadsService.updateOne(id, {
status: "contacted",
last_contacted_at: new Date().toISOString(),
});
res.send({ success: true });
} catch (error: any) {
console.error("Audit Email failed:", error);
res.status(500).send({ error: error.message });
}
});
router.post("/estimate/:id", async (req: any, res: any) => {
const { id } = req.params;
const leadsService = new ItemsService("leads", { schema: req.schema, accountability: req.accountability });
try {
const lead = await leadsService.readOne(id);
if (!lead || !lead.ai_state) return res.status(400).send({ error: "Lead or AI state not found" });
const pdfEngine = new PdfEngine();
const filename = `estimate_${id}_${Date.now()}.pdf`;
const storageRoot = env.STORAGE_LOCAL_ROOT || "./storage";
const outputPath = path.join(storageRoot, filename);
await pdfEngine.generateEstimatePdf(lead.ai_state, outputPath);
await leadsService.updateOne(id, {
audit_pdf_path: filename,
});
res.send({ success: true, filename });
} catch (error: any) {
console.error("PDF Generation failed:", error);
res.status(500).send({ error: error.message });
}
});
router.post("/estimate-email/:id", async (req: any, res: any) => {
const { id } = req.params;
const leadsService = new ItemsService("leads", { schema: req.schema, accountability: req.accountability });
const peopleService = new ItemsService("people", { schema: req.schema, accountability: req.accountability });
const mailService = new MailService({ schema: req.schema, accountability: req.accountability });
try {
const lead = await leadsService.readOne(id);
if (!lead || !lead.audit_pdf_path) return res.status(400).send({ error: "PDF not generated" });
let recipientEmail = lead.contact_email;
let companyName = lead.company_name;
if (lead.contact_person) {
const person = await peopleService.readOne(lead.contact_person);
if (person && person.email) {
recipientEmail = person.email;
companyName = person.company || lead.company_name;
}
}
if (!recipientEmail) return res.status(400).send({ error: "No recipient email found" });
const html = await render(createElement(ProjectEstimateTemplate, {
companyName: companyName,
}));
const storageRoot = env.STORAGE_LOCAL_ROOT || "./storage";
const attachmentPath = path.join(storageRoot, lead.audit_pdf_path);
await mailService.send({
to: recipientEmail,
subject: `Ihre Projekt-Schätzung: ${companyName}`,
html,
attachments: [
{
filename: `Angebot_${companyName}.pdf`,
content: fs.readFileSync(attachmentPath)
}
]
});
await leadsService.updateOne(id, {
status: "contacted",
last_contacted_at: new Date().toISOString(),
});
res.send({ success: true });
} catch (error: any) {
console.error("Estimate Email failed:", error);
res.status(500).send({ error: error.message });
}
});
});

View File

@@ -0,0 +1,22 @@
import { fileURLToPath } from 'url';
import { dirname } from 'path';
import { createRequire } from 'module';
try {
const url = import.meta?.url;
// Hardcode fallback path for Directus Docker environment
const fallbackPath = '/directus/extensions/acquisition/dist/index.js';
const filename = url ? fileURLToPath(url) : fallbackPath;
const dir = dirname(filename);
// @ts-ignore
globalThis.__filename = filename;
// @ts-ignore
globalThis.__dirname = dir;
// @ts-ignore
globalThis.require = createRequire(url || `file://${fallbackPath}`);
console.log(`[Shim] Loaded. __dirname: ${dir}`);
} catch (e) {
console.warn("[Shim] Failed to shim __dirname/require", e);
}

File diff suppressed because one or more lines are too long

View File

@@ -1,30 +1,20 @@
{
"name": "customer-manager",
"description": "Custom High-Fidelity Customer & Company Management for Directus",
"icon": "supervisor_account",
"version": "1.7.12",
"type": "module",
"keywords": [
"directus",
"directus-extension",
"directus-extension-module"
],
"files": [
"dist"
],
"directus:extension": {
"name": "customer-manager",
"version": "1.0.0",
"type": "module",
"path": "index.js",
"source": "src/index.ts",
"host": "*",
"name": "Customer Manager"
},
"scripts": {
"build": "directus-extension build && (cp -f dist/index.js index.js 2>/dev/null || true)",
"dev": "directus-extension build -w"
},
"devDependencies": {
"@directus/extensions-sdk": "11.0.2",
"vue": "^3.4.0"
}
}
"directus:extension": {
"type": "module",
"path": "dist/index.js",
"source": "src/index.ts",
"host": "^11.0.0"
},
"scripts": {
"build": "directus-extension build",
"dev": "directus-extension build -w"
},
"devDependencies": {
"@directus/extensions-sdk": "11.0.2",
"vue": "^3.4.34",
"typescript": "^5.6.3"
}
}

View File

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

View File

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

File diff suppressed because one or more lines are too long

View File

@@ -1,30 +1,20 @@
{
"name": "feedback-commander",
"description": "Custom High-Fidelity Feedback Management Extension for Directus",
"icon": "view_kanban",
"version": "1.7.12",
"type": "module",
"keywords": [
"directus",
"directus-extension",
"directus-extension-module"
],
"files": [
"dist"
],
"directus:extension": {
"name": "feedback-commander",
"version": "1.0.0",
"type": "module",
"path": "index.js",
"source": "src/index.ts",
"host": "*",
"name": "Feedback Commander"
},
"scripts": {
"build": "directus-extension build && (cp -f dist/index.js index.js 2>/dev/null || true)",
"dev": "directus-extension build -w"
},
"devDependencies": {
"@directus/extensions-sdk": "11.0.2",
"vue": "^3.4.0"
}
}
"directus:extension": {
"type": "module",
"path": "dist/index.js",
"source": "src/index.ts",
"host": "^11.0.0"
},
"scripts": {
"build": "directus-extension build",
"dev": "directus-extension build -w"
},
"devDependencies": {
"@directus/extensions-sdk": "11.0.2",
"vue": "^3.4.34",
"typescript": "^5.6.3"
}
}

View File

@@ -0,0 +1,14 @@
import { defineModule } from '@directus/extensions-sdk';
import ModuleComponent from './module.vue';
export default defineModule({
id: 'feedback-commander',
name: 'Feedback Commander',
icon: 'view_kanban',
routes: [
{
path: '',
component: ModuleComponent,
},
],
});

View File

@@ -0,0 +1,746 @@
<template>
<private-view title="Feedback Commander">
<template #headline>
<v-breadcrumb :items="[{ name: 'Feedback', to: '/feedback-commander' }]" />
</template>
<template #title-outer:after>
<v-chip v-if="loading" label color="blue" small>Loading...</v-chip>
<v-chip v-else-if="fetchError" label color="red" small>Fetch Error</v-chip>
<v-chip v-else label color="green" small>{{ items.length }} Items</v-chip>
</template>
<template #navigation>
<div class="sidebar-header">
<v-text-overflow text="Websites" class="header-text" />
</div>
<v-list nav>
<v-list-item
:active="currentProject === 'all'"
@click="currentProject = 'all'"
clickable
>
<v-list-item-icon><v-icon name="language" /></v-list-item-icon>
<v-list-item-content><v-text-overflow text="All Projects" /></v-list-item-content>
</v-list-item>
<v-list-item
v-for="project in projects"
:key="project"
:active="currentProject === project"
@click="currentProject = project"
clickable
>
<v-list-item-icon><v-icon name="public" color="var(--primary)" /></v-list-item-icon>
<v-list-item-content><v-text-overflow :text="project || 'Unknown'" /></v-list-item-content>
</v-list-item>
</v-list>
</template>
<div class="feedback-container">
<div v-if="!items.length && !loading && !fetchError" class="empty-state">
<v-info icon="inbox" title="Clean Inbox" center>
All feedback has been processed. Great job!
</v-info>
</div>
<div v-if="fetchError" class="empty-state">
<v-info icon="error" title="Fetch Failed" :description="fetchError" center />
<v-button @click="fetchData" secondary small>Retry</v-button>
</div>
<div class="operational-layout" v-else-if="items.length">
<!-- Detailed Triage Lane -->
<aside class="triage-lane">
<div class="lane-header">
<v-select
v-model="currentStatusFilter"
:items="statusOptions"
small
placeholder="Status Filter"
/>
</div>
<div class="lane-content scrollbar">
<TransitionGroup name="list">
<div
v-for="item in filteredItems"
:key="item.id"
class="feedback-card"
:class="{ active: selectedItem?.id === item.id }"
@click="selectItem(item)"
>
<div class="card-status-bar" :style="{ background: getStatusColor(item.status || 'open') }"></div>
<div class="card-body">
<header class="card-header">
<span class="card-user">{{ item.user_name }}</span>
<span class="card-date">{{ formatDate(item.date_created || item.id) }}</span>
</header>
<div class="card-text">{{ item.text }}</div>
<footer class="card-footer">
<div class="meta-tags">
<v-chip x-small outline>{{ item.project }}</v-chip>
<v-icon :name="item.type === 'bug' ? 'bug_report' : 'lightbulb'" :color="item.type === 'bug' ? '#E91E63' : '#FFC107'" small />
</div>
<v-icon v-if="selectedItem?.id === item.id" name="chevron_right" small />
</footer>
</div>
</div>
</TransitionGroup>
</div>
</aside>
<!-- Elaborated Master-Detail Desk -->
<main class="processing-desk scrollbar">
<Transition name="fade" mode="out-in">
<div v-if="selectedItem" :key="selectedItem.id" class="desk-content">
<header class="desk-header">
<div class="headline-group">
<div class="status-indicator">
<div class="status-dot" :style="{ background: getStatusColor(selectedItem.status || 'open') }"></div>
<span class="status-text">{{ capitalize(selectedItem.status || 'open') }}</span>
</div>
<h2>{{ selectedItem.user_name }}'s Submission</h2>
</div>
<div class="header-actions">
<v-select
v-model="selectedItem.contact_person"
:items="peopleOptions"
inline
placeholder="Bezugsperson..."
show-deselect
@update:model-value="updatePerson"
/>
<v-button primary @click="openDeepLink(selectedItem)">
<v-icon name="open_in_new" left /> Open & Highlight
</v-button>
<v-select
v-model="selectedItem.status"
:items="statuses"
inline
@update:model-value="updateStatus"
/>
</div>
</header>
<div class="desk-grid">
<!-- Message Container -->
<div class="main-column">
<v-card class="content-card">
<v-card-title>
<v-icon name="format_quote" left />
Feedback Content
</v-card-title>
<v-card-text class="feedback-body">
<div v-if="selectedItem.screenshot" class="visual-proof">
<label class="proof-label"><v-icon name="photo" x-small /> Element Snapshot</label>
<img :src="getAssetUrl(selectedItem.screenshot)" class="screenshot-img" />
</div>
<div class="main-text">{{ selectedItem.text }}</div>
</v-card-text>
</v-card>
<section class="reply-section">
<div class="section-divider">
<v-divider />
<span class="divider-label">Internal Communication</span>
<v-divider />
</div>
<div class="thread">
<TransitionGroup name="thread-list">
<div v-for="reply in comments" :key="reply.id" class="reply-bubble">
<header class="reply-header">
<span class="reply-user">{{ reply.user_name }}</span>
<span class="reply-date">{{ formatDate(reply.date_created || reply.id) }}</span>
</header>
<div class="reply-text">{{ reply.text }}</div>
</div>
</TransitionGroup>
<div v-if="!comments.length" class="empty-state-mini">
<v-icon name="auto_awesome" small /> No replies yet. Start the thread.
</div>
</div>
<div class="composer">
<v-textarea v-model="replyText" placeholder="Compose internal response..." auto-grow />
<div class="composer-actions">
<v-button secondary :loading="sending" @click="sendReply">Post Reply</v-button>
</div>
</div>
</section>
</div>
<!-- Technical Sidebar -->
<aside class="meta-column">
<v-card class="meta-card">
<v-card-title>Context</v-card-title>
<v-card-text class="meta-list">
<div class="meta-item">
<label><v-icon name="public" x-small /> Website</label>
<strong>{{ selectedItem.project }}</strong>
</div>
<div class="meta-item">
<label><v-icon name="link" x-small /> Source Path</label>
<span class="truncate-path" :title="selectedItem.url">{{ formatUrl(selectedItem.url) }}</span>
<v-button icon small @click="openExternal(selectedItem.url)"><v-icon name="launch" /></v-button>
</div>
<v-divider />
<div class="meta-item">
<label><v-icon name="layers" x-small /> Element Trace</label>
<code class="trace-code">{{ selectedItem.selector || 'Body' }}</code>
</div>
<div class="meta-item">
<label><v-icon name="location_searching" x-small /> Precise Mark</label>
<span class="coords">X: {{ Math.round(selectedItem.x) }}px / Y: {{ Math.round(selectedItem.y) }}px</span>
</div>
<div class="meta-item">
<label><v-icon name="fingerprint" x-small /> Reference ID</label>
<code class="id-code">{{ selectedItem.id }}</code>
</div>
</v-card-text>
</v-card>
<div class="help-box">
<v-icon name="help_outline" x-small />
<span>Click "Open & Highlight" to jump directly to this element on the live site.</span>
</div>
</aside>
</div>
</div>
<div v-else class="no-selection-desk">
<v-info icon="touch_app" title="Select Feedback" center>
Choose an entry from the triage list to view details and process.
</v-info>
</div>
</Transition>
</main>
</div>
</div>
</private-view>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue';
import { useApi } from '@directus/extensions-sdk';
const api = useApi();
const items = ref([]);
const comments = ref([]);
const people = ref([]);
const loading = ref(true);
const fetchError = ref(null);
const sending = ref(false);
const selectedItem = ref(null);
const currentProject = ref('all');
const currentStatusFilter = ref('open');
const replyText = ref('');
const statuses = [
{ text: 'Open', value: 'open', icon: 'warning', color: '#E91E63' },
{ text: 'In Progress', value: 'in_progress', icon: 'play_arrow', color: '#2196F3' },
{ text: 'Resolved', value: 'resolved', icon: 'check_circle', color: '#4CAF50' }
];
const statusOptions = [
{ text: 'All Statuses', value: 'all' },
...statuses
];
const projects = computed(() => {
const projSet = new Set(items.value.map(i => i.project).filter(Boolean));
return Array.from(projSet).sort();
});
const peopleOptions = computed(() =>
people.value.map(p => ({
text: `${p.first_name} ${p.last_name}`,
value: p.id
}))
);
async function fetchData() {
loading.value = true;
fetchError.value = null;
try {
const [feedbackRes, peopleRes] = await Promise.all([
api.get('/items/visual_feedback', {
params: {
sort: '-date_created,-id',
limit: 300
}
}),
api.get('/items/people', { params: { sort: 'last_name' } })
]);
items.value = feedbackRes.data.data;
people.value = peopleRes.data.data;
} catch (e: any) {
fetchError.value = e.message;
} finally {
loading.value = false;
}
}
async function selectItem(item) {
selectedItem.value = null;
setTimeout(async () => {
selectedItem.value = item;
comments.value = [];
try {
const response = await api.get('/items/visual_feedback_comments', {
params: {
filter: { feedback_id: { _eq: item.id } },
sort: '-date_created,-id'
}
});
comments.value = response.data.data;
} catch (e) {
console.error(e);
}
}, 10);
}
async function updateStatus(val) {
if (!selectedItem.value) return;
try {
await api.patch(`/items/visual_feedback/${selectedItem.value.id}`, {
status: val
});
fetchData();
} catch (e) {
console.error(e);
}
}
async function updatePerson(val) {
if (!selectedItem.value) return;
try {
await api.patch(`/items/visual_feedback/${selectedItem.value.id}`, {
contact_person: val
});
fetchData();
} catch (e) {
console.error(e);
}
}
async function sendReply() {
if (!replyText.value.trim() || !selectedItem.value) return;
sending.value = true;
try {
const response = await api.post('/items/visual_feedback_comments', {
feedback_id: selectedItem.value.id,
user_name: 'Operator',
text: replyText.value
});
comments.value.unshift(response.data.data);
replyText.value = '';
} catch (e) {
console.error(e);
} finally {
sending.value = false;
}
}
function formatDate(dateStr) {
if (!dateStr || typeof dateStr === 'number') return 'Legacy';
return new Date(dateStr).toLocaleDateString() + ' ' + new Date(dateStr).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
}
function formatUrl(url) {
if (!url) return '';
return url.replace(/^https?:\/\//, '');
}
function capitalize(s) {
return s.charAt(0).toUpperCase() + s.slice(1).replace('_', ' ');
}
function getDeepLinkUrl(item) {
if (!item || !item.url) return '';
try {
const url = new URL(item.url);
url.searchParams.set('fb_id', item.id);
return url.toString();
} catch (e) {
return item.url + '?fb_id=' + item.id;
}
}
function openDeepLink(item) {
const url = getDeepLinkUrl(item);
if (url) window.open(url, '_blank');
}
function openExternal(url) {
if (url) window.open(url, '_blank');
}
function getAssetUrl(id) {
if (!id) return '';
return `/assets/${id}`;
}
function getStatusColor(status) {
const s = statuses.find(st => st.value === status);
return s ? s.color : 'var(--foreground-subdued)';
}
onMounted(() => {
fetchData();
});
</script>
<style scoped>
.feedback-container {
height: calc(100vh - 64px);
display: flex;
flex-direction: column;
background: var(--background-subdued);
}
.operational-layout {
display: flex;
height: 100%;
}
/* Triage Lane Polish */
.triage-lane {
width: 360px;
height: 100%;
display: flex;
flex-direction: column;
background: var(--background-normal);
border-right: 1px solid var(--border-normal);
box-shadow: 2px 0 8px rgba(0,0,0,0.02);
}
.lane-header {
padding: 16px;
background: var(--background-normal);
border-bottom: 1px solid var(--border-normal);
}
.lane-content {
flex: 1;
overflow-y: auto;
padding: 12px;
display: flex;
flex-direction: column;
gap: 12px;
}
.feedback-card {
background: var(--background-normal);
border: 1px solid var(--border-subdued);
border-radius: 8px;
display: flex;
overflow: hidden;
cursor: pointer;
transition: all var(--transition);
}
.feedback-card:hover {
border-color: var(--border-normal);
background: var(--background-subdued);
transform: translateY(-1px);
box-shadow: 0 4px 8px rgba(0,0,0,0.04);
}
.feedback-card.active {
border-color: var(--primary);
background: var(--background-accent);
box-shadow: 0 4px 12px rgba(var(--primary-rgb), 0.1);
}
.card-status-bar {
width: 4px;
}
.card-body {
flex: 1;
padding: 12px;
display: flex;
flex-direction: column;
gap: 8px;
}
.card-header {
display: flex;
justify-content: space-between;
font-size: 11px;
}
.card-user { font-weight: bold; color: var(--foreground-normal); }
.card-date { color: var(--foreground-subdued); }
.card-text {
font-size: 13px;
line-height: 1.5;
color: var(--foreground-normal);
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.card-footer {
display: flex;
justify-content: space-between;
align-items: center;
}
.meta-tags {
display: flex;
gap: 8px;
align-items: center;
}
/* Processing Desk Refinement */
.processing-desk {
flex: 1;
height: 100%;
overflow-y: auto;
padding: 32px;
}
.desk-content {
max-width: 1100px;
margin: 0 auto;
}
.desk-header {
display: flex;
justify-content: space-between;
align-items: flex-end;
margin-bottom: 32px;
border-bottom: 2px solid var(--border-normal);
padding-bottom: 20px;
}
.headline-group {
display: flex;
flex-direction: column;
gap: 8px;
}
.status-indicator {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
font-weight: bold;
text-transform: uppercase;
color: var(--foreground-subdued);
}
.status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
}
.status-text { letter-spacing: 0.5px; }
.header-actions {
display: flex;
gap: 16px;
align-items: center;
}
.desk-grid {
display: grid;
grid-template-columns: 1fr 300px;
gap: 24px;
align-items: start;
}
.content-card {
border-radius: 12px;
overflow: hidden;
}
.feedback-body {
font-size: 18px;
line-height: 1.6;
padding: 24px;
color: var(--foreground-normal);
display: flex;
flex-direction: column;
gap: 20px;
}
.visual-proof {
display: flex;
flex-direction: column;
gap: 8px;
}
.proof-label {
font-size: 10px;
text-transform: uppercase;
font-weight: 800;
color: var(--foreground-subdued);
letter-spacing: 0.5px;
}
.screenshot-img {
width: 100%;
border-radius: 8px;
border: 1px solid var(--border-normal);
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
background: var(--background-subdued);
}
.main-text {
white-space: pre-wrap;
}
.reply-section {
margin-top: 40px;
}
.section-divider {
display: flex;
align-items: center;
gap: 16px;
margin-bottom: 24px;
}
.divider-label {
font-size: 11px;
text-transform: uppercase;
font-weight: 800;
color: var(--foreground-subdued);
white-space: nowrap;
letter-spacing: 1px;
}
.thread {
display: flex;
flex-direction: column;
gap: 16px;
margin-bottom: 32px;
}
.reply-bubble {
padding: 16px;
border-radius: 12px;
background: var(--background-normal);
border: 1px solid var(--border-subdued);
}
.reply-header {
display: flex;
justify-content: space-between;
font-size: 11px;
margin-bottom: 8px;
}
.reply-user { font-weight: 800; color: var(--primary); }
.reply-date { color: var(--foreground-subdued); }
.reply-text { font-size: 14px; line-height: 1.5; }
.composer {
background: var(--background-normal);
border: 1px solid var(--border-normal);
border-radius: 12px;
padding: 16px;
}
.composer-actions {
display: flex;
justify-content: flex-end;
margin-top: 12px;
}
.meta-card {
border-radius: 12px;
}
.meta-list {
display: flex;
flex-direction: column;
gap: 16px;
padding: 16px;
}
.meta-item {
display: flex;
flex-direction: column;
gap: 4px;
font-size: 13px;
}
.meta-item label {
font-size: 10px;
text-transform: uppercase;
font-weight: bold;
color: var(--foreground-subdued);
display: flex;
align-items: center;
gap: 4px;
}
.truncate-path {
color: var(--primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.trace-code, .id-code {
background: var(--background-subdued);
padding: 4px 8px;
border-radius: 4px;
font-size: 11px;
word-break: break-all;
}
.coords { font-weight: bold; font-family: var(--family-monospace); }
.help-box {
margin-top: 20px;
padding: 16px;
background: rgba(var(--primary-rgb), 0.05);
border-radius: 12px;
font-size: 12px;
color: var(--primary);
display: flex;
gap: 8px;
line-height: 1.4;
}
.no-selection-desk {
height: 100%;
display: flex;
align-items: center;
justify-content: center;
}
.empty-state-mini {
text-align: center;
padding: 24px;
font-size: 12px;
color: var(--foreground-subdued);
background: var(--background-subdued);
border-radius: 12px;
border: 1px dashed var(--border-normal);
}
/* Animations */
.list-enter-active, .list-leave-active { transition: all 0.3s ease; }
.list-enter-from, .list-leave-to { opacity: 0; transform: translateX(-20px); }
.fade-enter-active, .fade-leave-active { transition: opacity 0.2s ease, transform 0.2s ease; }
.fade-enter-from { opacity: 0; transform: translateY(10px); }
.fade-leave-to { opacity: 0; transform: translateY(-10px); }
.thread-list-enter-active { transition: all 0.4s ease; transform-origin: top; }
.thread-list-enter-from { opacity: 0; transform: scaleY(0.9); }
.scrollbar::-webkit-scrollbar { width: 6px; }
.scrollbar::-webkit-scrollbar-track { background: transparent; }
.scrollbar::-webkit-scrollbar-thumb { background: var(--border-subdued); border-radius: 3px; }
.scrollbar::-webkit-scrollbar-thumb:hover { background: var(--border-normal); }
</style>

View File

@@ -1,30 +1,20 @@
{
"name": "people-manager",
"description": "Custom High-Fidelity People Management for Directus",
"icon": "person",
"version": "1.7.12",
"type": "module",
"keywords": [
"directus",
"directus-extension",
"directus-extension-module"
],
"files": [
"dist"
],
"directus:extension": {
"name": "people-manager",
"version": "1.0.0",
"type": "module",
"path": "index.js",
"source": "src/index.ts",
"host": "*",
"name": "People Manager"
},
"scripts": {
"build": "directus-extension build && (cp -f dist/index.js index.js 2>/dev/null || true)",
"dev": "directus-extension build -w"
},
"devDependencies": {
"@directus/extensions-sdk": "11.0.2",
"vue": "^3.4.0"
}
}
"directus:extension": {
"type": "module",
"path": "dist/index.js",
"source": "src/index.ts",
"host": "^11.0.0"
},
"scripts": {
"build": "directus-extension build",
"dev": "directus-extension build -w"
},
"devDependencies": {
"@directus/extensions-sdk": "11.0.2",
"vue": "^3.4.34",
"typescript": "^5.6.3"
}
}

View File

@@ -64,8 +64,8 @@
<div class="details-grid">
<div class="detail-item">
<span class="label">Organisation / Firma</span>
<p class="value">{{ getCompanyName(selectedPerson) }}</p>
<span class="label">Organisation</span>
<p class="value">{{ selectedPerson.company || '---' }}</p>
</div>
<div class="detail-item">
<span class="label">Telefon</span>
@@ -98,16 +98,8 @@
<v-input v-model="form.email" placeholder="email@beispiel.de" type="email" />
</div>
<div class="field">
<span class="label">Zentrale Firma</span>
<v-select
v-model="form.company"
:items="companyOptions"
placeholder="Bestehende Firma auswählen..."
/>
</div>
<div class="field">
<span class="label">Firma (Legacy / Neu)</span>
<v-input v-model="form.company_name" placeholder="z.B. Mintel" />
<span class="label">Organisation / Firma</span>
<v-input v-model="form.company" placeholder="z.B. Mintel" />
</div>
<div class="field">
<span class="label">Telefon</span>
@@ -127,12 +119,11 @@
</template>
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue';
import { ref, onMounted } from 'vue';
import { useApi } from '@directus/extensions-sdk';
const api = useApi();
const people = ref([]);
const companies = ref([]);
const selectedPerson = ref(null);
const feedback = ref(null);
const saving = ref(false);
@@ -144,43 +135,18 @@ const form = ref({
first_name: '',
last_name: '',
email: '',
company: null,
company_name: '',
company: '',
phone: ''
});
const companyOptions = computed(() =>
companies.value.map(c => ({
text: c.name,
value: c.id
}))
);
function getCompanyName(person: any) {
if (!person) return '---';
if (person.company) {
return typeof person.company === 'object' ? person.company.name : (companies.value.find(c => c.id === person.company)?.name || person.company_name);
}
return person.company_name || '---';
}
async function fetchData() {
async function fetchPeople() {
try {
const [peopleResp, companiesResp] = await Promise.all([
api.get('/items/people', {
params: {
sort: 'last_name',
fields: '*.*'
}
}),
api.get('/items/companies', {
params: { sort: 'name' }
})
]);
people.value = peopleResp.data.data;
companies.value = companiesResp.data.data;
const response = await api.get('/items/people', {
params: { sort: 'last_name' }
});
people.ref = response.data.data;
} catch (error) {
console.error('Failed to fetch data:', error);
console.error('Failed to fetch people:', error);
}
}
@@ -190,39 +156,13 @@ function selectPerson(person) {
function openCreateDrawer() {
isEditing.value = false;
form.value = {
id: null,
first_name: '',
last_name: '',
email: '',
company: null,
company_name: '',
phone: ''
};
form.value = { id: null, first_name: '', last_name: '', email: '', company: '', phone: '' };
drawerActive.value = true;
}
function openEditDrawer() {
isEditing.value = true;
const person = selectedPerson.value;
let companyId = null;
let companyName = person.company_name || '';
if (person.company) {
if (typeof person.company === 'object') {
companyId = person.company.id;
} else if (person.company.length === 36) { // Assume UUID
companyId = person.company;
} else {
companyName = person.company;
}
}
form.value = {
...person,
company: companyId,
company_name: companyName
};
form.value = { ...selectedPerson.value };
drawerActive.value = true;
}
@@ -242,9 +182,9 @@ async function savePerson() {
feedback.value = { type: 'success', message: 'Person angelegt!' };
}
drawerActive.value = false;
await fetchData();
await fetchPeople();
if (isEditing.value) {
selectedPerson.value = people.value.find(p => p.id === form.value.id);
selectedPerson.value = form.value;
}
} catch (error) {
feedback.value = { type: 'danger', message: error.message };
@@ -260,13 +200,13 @@ async function deletePerson() {
await api.delete(`/items/people/${selectedPerson.value.id}`);
feedback.value = { type: 'success', message: 'Person gelöscht.' };
selectedPerson.value = null;
await fetchData();
await fetchPeople();
} catch (error) {
feedback.value = { type: 'danger', message: error.message };
}
}
onMounted(fetchData);
onMounted(fetchPeople);
</script>
<style scoped>

View File

@@ -1,17 +1,14 @@
{
"name": "@mintel/cms-infra",
"version": "1.8.2",
"version": "1.7.0",
"private": true,
"type": "module",
"scripts": {
"up": "npm run build:extensions && docker compose up -d",
"build": "pnpm --filter \"./extensions/**\" build",
"dev": "pnpm --filter \"./extensions/**\" dev",
"up": "docker compose up -d",
"up:build": "docker compose up -d --build",
"down": "docker compose down",
"logs": "docker compose logs -f",
"build:extensions": "../../scripts/sync-extensions.sh",
"schema:apply:local": "../../scripts/cms-apply.sh local",
"schema:apply:infra": "../../scripts/cms-apply.sh infra",
"snapshot:local": "../../scripts/cms-snapshot.sh",
"sync:push": "../../scripts/sync-directus.sh push infra",
"sync:pull": "../../scripts/sync-directus.sh pull infra"
"logs": "docker compose logs -f"
}
}
}

View File

@@ -0,0 +1,3 @@
export default (router) => {
router.get('/ping', (req, res) => res.send('pong'));
};

View File

@@ -0,0 +1,10 @@
{
"name": "test-extension",
"version": "1.0.0",
"type": "module",
"directus:extension": {
"type": "endpoint",
"path": "index.js",
"host": "^11.0.0"
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,75 @@
#!/bin/bash
# Configuration
API_URL="http://localhost:8059"
EMAIL="marc@mintel.me"
PASSWORD="Tim300493."
echo "Logging in to Directus..."
TOKEN=$(curl -s -X POST "${API_URL}/auth/login" \
-H "Content-Type: application/json" \
-d "{\"email\":\"${EMAIL}\", \"password\":\"${PASSWORD}\"}" | grep -o '"access_token":"[^"]*' | cut -d'"' -f4)
if [ -z "$TOKEN" ]; then
echo "Login failed!"
exit 1
fi
echo "Hiding 'leads' collection..."
curl -s -X PATCH "${API_URL}/collections/leads" \
-H "Authorization: Bearer ${TOKEN}" \
-H "Content-Type: application/json" \
-d '{"meta": {"hidden": true}}'
echo "Creating 'people' collection..."
curl -s -X POST "${API_URL}/collections" \
-H "Authorization: Bearer ${TOKEN}" \
-H "Content-Type: application/json" \
-d '{
"collection": "people",
"schema": {},
"meta": {
"icon": "person",
"display_template": "{{first_name}} {{last_name}}",
"show_status_indicator": true
}
}'
echo "Adding fields to 'people'..."
FIELDS='[
{"field": "first_name", "type": "string", "meta": {"interface": "input", "width": "half"}},
{"field": "last_name", "type": "string", "meta": {"interface": "input", "width": "half"}},
{"field": "email", "type": "string", "meta": {"interface": "input", "width": "half"}},
{"field": "phone", "type": "string", "meta": {"interface": "input", "width": "half"}},
{"field": "company", "type": "string", "meta": {"interface": "input", "width": "full"}}
]'
for field in $(echo "${FIELDS}" | jq -c '.[]'); do
curl -s -X POST "${API_URL}/fields/people" \
-H "Authorization: Bearer ${TOKEN}" \
-H "Content-Type: application/json" \
-d "${field}"
done
echo "Adding 'contact_person' to 'leads', 'client_users', and 'visual_feedback'..."
for collection in leads client_users visual_feedback; do
curl -s -X POST "${API_URL}/fields/${collection}" \
-H "Authorization: Bearer ${TOKEN}" \
-H "Content-Type: application/json" \
-d '{
"field": "contact_person",
"type": "uuid",
"meta": {
"interface": "select-dropdown-m2o",
"options": {
"template": "{{first_name}} {{last_name}}"
}
},
"schema": {
"foreign_key_column": "id",
"foreign_key_table": "people"
}
}'
done
echo "Done!"

View File

@@ -1,27 +0,0 @@
{
"name": "company-manager",
"description": "Central Company Management for Directus",
"icon": "business",
"version": "1.8.2",
"type": "module",
"keywords": [
"directus",
"directus-extension",
"directus-extension-module"
],
"directus:extension": {
"type": "module",
"path": "index.js",
"source": "src/index.ts",
"host": "*",
"name": "Company Manager"
},
"scripts": {
"build": "directus-extension build && cp -f dist/index.js index.js 2>/dev/null || true",
"dev": "directus-extension build -w"
},
"devDependencies": {
"@directus/extensions-sdk": "11.0.2",
"vue": "^3.4.0"
}
}

View File

@@ -2,13 +2,15 @@
"name": "customer-manager",
"description": "Custom High-Fidelity Customer & Company Management for Directus",
"icon": "supervisor_account",
"version": "1.8.2",
"type": "module",
"version": "1.7.0",
"keywords": [
"directus",
"directus-extension",
"directus-extension-module"
],
"files": [
"dist"
],
"directus:extension": {
"type": "module",
"path": "index.js",
@@ -17,7 +19,7 @@
"name": "Customer Manager"
},
"scripts": {
"build": "directus-extension build && cp -f dist/index.js index.js 2>/dev/null || true",
"build": "directus-extension build",
"dev": "directus-extension build -w"
},
"devDependencies": {

View File

@@ -127,15 +127,6 @@
<v-input v-model="employeeForm.email" placeholder="E-Mail Adresse" type="email" />
</div>
<div class="field">
<span class="label">Zentrale Person (Verknüpfung)</span>
<v-select
v-model="employeeForm.person"
:items="peopleOptions"
placeholder="Person aus dem People Manager auswählen..."
/>
</div>
<v-divider v-if="isEditingEmployee" />
<div v-if="isEditingEmployee" class="field">
@@ -167,7 +158,7 @@
</template>
<script setup lang="ts">
import { ref, onMounted, nextTick, computed } from 'vue';
import { ref, onMounted, nextTick } from 'vue';
import { useApi } from '@directus/extensions-sdk';
const api = useApi();
@@ -192,7 +183,6 @@ const employeeForm = ref({
first_name: '',
last_name: '',
email: '',
person: null,
temporary_password: ''
});
@@ -202,22 +192,14 @@ const tableHeaders = [
{ text: 'Zuletzt eingeladen', value: 'last_invited', sortable: true }
];
const people = ref<any[]>([]);
const peopleOptions = computed(() =>
people.value.map(p => ({
text: `${p.first_name} ${p.last_name} (${p.email})`,
value: p.id
}))
);
async function fetchData() {
const [companiesResp, peopleResp] = await Promise.all([
api.get('/items/companies', { params: { sort: 'name', fields: ['id', 'name'] } }),
api.get('/items/people', { params: { sort: 'last_name' } })
]);
companies.value = companiesResp.data.data;
people.value = peopleResp.data.data;
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) {
@@ -227,7 +209,7 @@ async function selectCompany(company: any) {
const res = await api.get('/items/client_users', {
params: {
filter: { company: { _eq: company.id } },
fields: ['*', 'person.*'],
fields: ['*'],
sort: 'first_name',
},
});
@@ -291,7 +273,6 @@ async function openEditEmployee(item: any) {
first_name: item.first_name || '',
last_name: item.last_name || '',
email: item.email || '',
person: item.person?.id || item.person || null,
temporary_password: item.temporary_password || ''
};
isEditingEmployee.value = true;
@@ -307,8 +288,7 @@ async function saveEmployee() {
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,
person: employeeForm.value.person
email: employeeForm.value.email
});
notice.value = { type: 'success', message: 'Mitarbeiter aktualisiert!' };
} else {
@@ -316,8 +296,7 @@ async function saveEmployee() {
first_name: employeeForm.value.first_name,
last_name: employeeForm.value.last_name,
email: employeeForm.value.email,
company: selectedCompany.value.id,
person: employeeForm.value.person
company: selectedCompany.value.id
});
notice.value = { type: 'success', message: 'Mitarbeiter angelegt!' };
}
@@ -364,7 +343,7 @@ function formatDate(dateStr: string) {
}
onMounted(() => {
fetchData();
fetchCompanies();
});
</script>

View File

@@ -1,31 +0,0 @@
{
"name": "@mintel/directus-extension-toolkit",
"version": "1.8.2",
"description": "Shared toolkit for Directus extensions in the Mintel ecosystem",
"type": "module",
"main": "./dist/index.js",
"module": "./dist/index.js",
"files": [
"dist"
],
"exports": {
".": {
"import": "./dist/index.js"
}
},
"scripts": {
"build": "vite build",
"dev": "vite build --watch"
},
"devDependencies": {
"@directus/extensions-sdk": "11.0.2",
"@vitejs/plugin-vue": "^6.0.4",
"typescript": "^5.0.0",
"vite": "^5.0.0",
"vue": "^3.4.0"
},
"peerDependencies": {
"@directus/extensions-sdk": "*",
"vue": "^3.4.0"
}
}

View File

@@ -1,102 +0,0 @@
<template>
<private-view :title="title">
<template #navigation>
<slot name="navigation" />
</template>
<template #title-outer:after>
<v-notice v-if="notice" :type="notice.type" @close="$emit('close-notice')" dismissible>
{{ notice.message }}
</v-notice>
</template>
<div class="mintel-manager-layout">
<div v-if="isEmpty" class="empty-state">
<v-info :title="emptyTitle" :icon="emptyIcon" center>
<slot name="empty-state" />
</v-info>
</div>
<template v-else>
<header class="mintel-header">
<div class="header-left">
<h1 class="mintel-title">{{ itemTitle }}</h1>
<p class="mintel-subtitle">
<slot name="subtitle" />
</p>
</div>
<div class="header-right">
<slot name="actions" />
</div>
</header>
<v-divider />
<div class="mintel-content">
<slot />
</div>
</template>
</div>
</private-view>
</template>
<script setup lang="ts">
defineProps<{
title: string;
itemTitle?: string;
isEmpty?: boolean;
emptyTitle?: string;
emptyIcon?: string;
notice?: { type: string; message: string } | null;
}>();
defineEmits(['close-notice']);
</script>
<style scoped>
.mintel-manager-layout {
padding: 32px;
height: 100%;
display: flex;
flex-direction: column;
overflow-y: auto;
}
.mintel-header {
margin-bottom: 24px;
display: flex;
justify-content: space-between;
align-items: flex-end;
}
.mintel-title {
font-size: 24px;
font-weight: 800;
margin-bottom: 4px;
color: var(--theme--foreground);
}
.mintel-subtitle {
color: var(--theme--foreground-subdued);
font-size: 14px;
display: flex;
align-items: center;
gap: 8px;
}
.header-right {
display: flex;
gap: 12px;
}
.empty-state {
height: 100%;
display: flex;
align-items: center;
justify-content: center;
}
.mintel-content {
margin-top: 32px;
}
</style>

View File

@@ -1,62 +0,0 @@
<template>
<div class="mintel-select">
<v-select
:model-value="modelValue"
:items="items"
:placeholder="placeholder"
:searchable="searchable"
:show-deselect="showDeselect"
@update:model-value="$emit('update:modelValue', $event)"
/>
<v-button v-if="allowAdd" secondary rounded icon x-small class="add-button" @click="$emit('add')">
<v-icon name="add" />
</v-button>
</div>
</template>
<script setup lang="ts">
const props = defineProps({
modelValue: {
type: [String, Number],
default: null
},
items: {
type: Array as () => Array<{ text: string; value: string | number }>,
required: true
},
placeholder: {
type: String,
default: 'Auswählen...'
},
searchable: {
type: Boolean,
default: true
},
showDeselect: {
type: Boolean,
default: true
},
allowAdd: {
type: Boolean,
default: false
}
});
defineEmits(['update:modelValue', 'add']);
</script>
<style scoped>
.mintel-select {
display: flex;
align-items: center;
gap: 8px;
}
.mintel-select :deep(.v-select) {
flex: 1;
}
.add-button {
flex-shrink: 0;
}
</style>

View File

@@ -1,84 +0,0 @@
<template>
<div class="mintel-stat-card" @click="$emit('click')">
<div class="stat-icon">
<v-icon :name="icon" large />
</div>
<div class="stat-content">
<span class="stat-label">{{ label }}</span>
<span class="stat-value">{{ value }}</span>
</div>
<v-icon name="chevron_right" class="arrow" />
</div>
</template>
<script setup lang="ts">
defineProps<{
label: string;
value: string | number;
icon: string;
}>();
defineEmits(['click']);
</script>
<style scoped>
.mintel-stat-card {
background: var(--theme--background-normal);
border: 1px solid var(--theme--border);
padding: 24px;
border-radius: 12px;
display: flex;
align-items: center;
gap: 20px;
cursor: pointer;
transition: all 0.2s ease;
position: relative;
}
.mintel-stat-card:hover {
border-color: var(--theme--primary);
transform: translateY(-2px);
box-shadow: 0 8px 16px rgba(0,0,0,0.1);
}
.stat-icon {
width: 56px;
height: 56px;
background: var(--theme--background-subdued);
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
color: var(--theme--primary);
}
.stat-content {
display: flex;
flex-direction: column;
}
.stat-label {
font-size: 12px;
font-weight: 700;
text-transform: uppercase;
color: var(--theme--foreground-subdued);
letter-spacing: 0.5px;
}
.stat-value {
font-size: 28px;
font-weight: 800;
color: var(--theme--foreground);
}
.arrow {
position: absolute;
right: 24px;
opacity: 0.2;
}
.mintel-stat-card:hover .arrow {
opacity: 1;
color: var(--theme--primary);
}
</style>

View File

@@ -1,3 +0,0 @@
export { default as MintelSelect } from './MintelSelect.vue';
export { default as MintelManagerLayout } from './MintelManagerLayout.vue';
export { default as MintelStatCard } from './MintelStatCard.vue';

View File

@@ -1,24 +0,0 @@
import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';
import { resolve } from 'path';
export default defineConfig({
plugins: [vue()],
build: {
lib: {
entry: resolve('src/index.ts'),
name: 'MintelDirectusToolkit',
fileName: 'index',
formats: ['es']
},
rollupOptions: {
external: ['vue', '@directus/extensions-sdk'],
output: {
globals: {
vue: 'Vue',
'@directus/extensions-sdk': 'DirectusExtensionsSDK'
}
}
}
}
});

View File

@@ -1,6 +1,6 @@
{
"name": "@mintel/eslint-config",
"version": "1.8.2",
"version": "1.7.0",
"publishConfig": {
"access": "public",
"registry": "https://npm.infra.mintel.me"

File diff suppressed because one or more lines are too long

View File

@@ -1,23 +1,25 @@
{
"name": "feedback-commander",
"name": "@mintel/extension-feedback-commander",
"description": "Custom High-Fidelity Feedback Management Extension for Directus",
"icon": "view_kanban",
"version": "1.8.2",
"type": "module",
"version": "1.7.0",
"keywords": [
"directus",
"directus-extension",
"directus-extension-module"
],
"files": [
"dist"
],
"directus:extension": {
"type": "module",
"path": "index.js",
"path": "dist/index.js",
"source": "src/index.ts",
"host": "*",
"name": "Feedback Commander"
},
"scripts": {
"build": "directus-extension build && cp -f dist/index.js index.js 2>/dev/null || true",
"build": "directus-extension build",
"dev": "directus-extension build -w"
},
"devDependencies": {

View File

@@ -78,7 +78,7 @@
<div class="card-text">{{ item.text }}</div>
<footer class="card-footer">
<div class="meta-tags">
<v-chip x-small outline>{{ item.company?.name || item.project }}</v-chip>
<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 />
@@ -142,8 +142,7 @@
<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 || 'System' }}</span>
<span v-if="reply.person" class="reply-person">({{ reply.person.first_name }} {{ reply.person.last_name }})</span>
<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>
@@ -169,12 +168,8 @@
<v-card-title>Context</v-card-title>
<v-card-text class="meta-list">
<div class="meta-item">
<label><v-icon name="business" x-small /> Organisation / Firma</label>
<strong>{{ selectedItem.company?.name || selectedItem.project }}</strong>
</div>
<div v-if="selectedItem.person" class="meta-item">
<label><v-icon name="person" x-small /> Zentrale Person</label>
<strong>{{ selectedItem.person.first_name }} {{ selectedItem.person.last_name }}</strong>
<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>
@@ -243,14 +238,13 @@ const statusOptions = [
];
const projects = computed(() => {
const projSet = new Set(items.value.map(i => i.company?.name || i.project).filter(Boolean));
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 projectName = item.company?.name || item.project;
const matchProject = currentProject.value === 'all' || projectName === currentProject.value;
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;
@@ -264,8 +258,7 @@ async function fetchData() {
const response = await api.get('/items/visual_feedback', {
params: {
sort: '-date_created,-id',
limit: 300,
fields: ['*', 'company.*', 'person.*']
limit: 300
}
});
items.value = response.data.data;
@@ -285,8 +278,7 @@ async function selectItem(item) {
const response = await api.get('/items/visual_feedback_comments', {
params: {
filter: { feedback_id: { _eq: item.id } },
sort: '-date_created,-id',
fields: ['*', 'person.*']
sort: '-date_created,-id'
}
});
comments.value = response.data.data;

View File

@@ -1,6 +1,6 @@
{
"name": "@mintel/gatekeeper",
"version": "1.8.2",
"version": "1.7.0",
"private": true,
"type": "module",
"scripts": {

View File

@@ -11,8 +11,6 @@ export async function GET(req: NextRequest) {
// 1. URL Parameter Bypass (for automated tests/staging)
const originalUrl = req.headers.get("x-forwarded-uri") || "/";
console.log(`[Verify] Check: ${originalUrl} | Cookie: ${session ? "Found" : "Missing"}`);
const host =
req.headers.get("x-forwarded-host") || req.headers.get("host") || "";
const proto = req.headers.get("x-forwarded-proto") || "https";
@@ -56,17 +54,15 @@ export async function GET(req: NextRequest) {
if (session?.value) {
if (session.value === password) {
isAuthenticated = true;
console.log(`[Verify] Legacy password match`);
} else {
try {
const payload = JSON.parse(session.value);
if (payload.identity) {
isAuthenticated = true;
identity = payload.identity;
console.log(`[Verify] Identity verified: ${identity}`);
}
} catch (_e) {
console.log(`[Verify] JSON Parse failed for cookie: ${session.value.substring(0, 10)}...`);
// Fallback or old format
}
}
}

View File

@@ -17,8 +17,8 @@ export default async function LoginPage({ searchParams }: LoginPageProps) {
async function login(formData: FormData) {
"use server";
const email = (formData.get("email") as string || "").trim();
const password = (formData.get("password") as string || "").trim();
const email = formData.get("email") as string;
const password = formData.get("password") as string;
const expectedCode = process.env.GATEKEEPER_PASSWORD || "mintel";
const adminEmail = process.env.DIRECTUS_ADMIN_EMAIL;
@@ -31,19 +31,19 @@ export default async function LoginPage({ searchParams }: LoginPageProps) {
let userIdentity = "";
let userCompany: any = null;
// 1. Check Generic Code (Guest) - High Priority to prevent autofill traps
if (password === expectedCode) {
userIdentity = "Guest";
}
// 2. Check Global Admin (from ENV)
else if (
// 1. Check Global Admin (from ENV)
if (
adminEmail &&
adminPassword &&
email === adminEmail.trim() &&
password === adminPassword.trim()
email === adminEmail &&
password === adminPassword
) {
userIdentity = "Admin";
}
// 2. Check Generic Code (Guest)
else if (!email && password === expectedCode) {
userIdentity = "Guest";
}
// 3. Check Lightweight Client Users (dedicated collection)
if (email && password && process.env.INFRA_DIRECTUS_URL) {
try {
@@ -116,7 +116,6 @@ export default async function LoginPage({ searchParams }: LoginPageProps) {
}
if (userIdentity) {
console.log(`[Login] Success: ${userIdentity} | Redirect: ${targetRedirect}`);
const cookieStore = await cookies();
// Store identity in the cookie (simplified for now, ideally signed)
const sessionValue = JSON.stringify({
@@ -127,8 +126,6 @@ export default async function LoginPage({ searchParams }: LoginPageProps) {
const isDev = process.env.NODE_ENV === "development";
console.log(`[Login] Setting Cookie: ${authCookieName} | Domain: ${cookieDomain || "Default"}`);
cookieStore.set(authCookieName, sessionValue, {
httpOnly: true,
secure: !isDev,
@@ -139,7 +136,6 @@ export default async function LoginPage({ searchParams }: LoginPageProps) {
});
redirect(targetRedirect);
} else {
console.log(`[Login] Failed for inputs. Redirecting back with error.`);
redirect(`/login?error=1&redirect=${encodeURIComponent(targetRedirect)}`);
}
}

View File

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

View File

@@ -1,5 +1,6 @@
# Start from the pre-built Nextjs Base image
FROM registry.infra.mintel.me/mintel/nextjs:latest AS builder
ARG IMAGE_TAG=latest
FROM registry.infra.mintel.me/mintel/nextjs:${IMAGE_TAG} AS builder
WORKDIR /app
@@ -20,7 +21,7 @@ ENV DIRECTUS_URL=$DIRECTUS_URL
RUN pnpm --filter ${APP_NAME:-app} build
# Production runner image
FROM registry.infra.mintel.me/mintel/runtime:latest AS runner
FROM registry.infra.mintel.me/mintel/runtime:${IMAGE_TAG} AS runner
WORKDIR /app
# Copy standalone output and static files

View File

@@ -1,13 +1,38 @@
# Step 1: Base image for Next.js builds
FROM node:20-alpine
# Step 1: Builder image
FROM node:20-alpine AS builder
RUN apk add --no-cache libc6-compat curl
# Enable pnpm
RUN corepack enable pnpm && \
corepack prepare pnpm@10.2.0 --activate
WORKDIR /app
RUN corepack enable pnpm
# Final environment
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
# Step 2: Install dependencies
ENV NPM_TOKEN=placeholder
# Copy manifest files specifically for better layer caching
COPY pnpm-lock.yaml pnpm-workspace.yaml package.json .npmrc ./
# Copy package manifest files individually to preserve directory structure
COPY packages/cli/package.json ./packages/cli/
COPY packages/cms-infra/package.json ./packages/cms-infra/
COPY packages/customer-manager/package.json ./packages/customer-manager/
COPY packages/eslint-config/package.json ./packages/eslint-config/
COPY packages/feedback-commander/package.json ./packages/feedback-commander/
COPY packages/gatekeeper/package.json ./packages/gatekeeper/
COPY packages/husky-config/package.json ./packages/husky-config/
COPY packages/infra/package.json ./packages/infra/
COPY packages/mail/package.json ./packages/mail/
COPY packages/next-config/package.json ./packages/next-config/
COPY packages/next-feedback/package.json ./packages/next-feedback/
COPY packages/next-observability/package.json ./packages/next-observability/
COPY packages/next-utils/package.json ./packages/next-utils/
COPY packages/observability/package.json ./packages/observability/
COPY packages/tsconfig/package.json ./packages/tsconfig/
# packages/ui does not have a package.json
# Use a secret for NPM_TOKEN and a standardized cache mount
RUN --mount=type=cache,id=pnpm,target=/pnpm/store \
--mount=type=secret,id=NPM_TOKEN \
export NPM_TOKEN=$(cat /run/secrets/NPM_TOKEN) && \
pnpm config set store-dir /pnpm/store && \
pnpm i --frozen-lockfile
# Step 3: Build shared packages
COPY . .
RUN pnpm --filter "./packages/*" -r build

View File

@@ -275,10 +275,6 @@ jobs:
docker system prune -f --filter "until=24h"
EOF
- name: 🧹 Post-Deploy Cleanup (Runner)
if: always()
run: docker builder prune -f --filter "until=1h"
# ──────────────────────────────────────────────────────────────────────────────
# JOB 5: Notifications
# ──────────────────────────────────────────────────────────────────────────────

View File

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

View File

@@ -2,7 +2,7 @@
set -e
# Configuration
REGISTRY_DATA="/mnt/HC_Volume_104575103/registry-data/docker/registry/v2"
REGISTRY_DATA="/opt/infra/registry/data/docker/registry/v2"
KEEP_TAGS=3
echo "🏥 Starting Aggressive Registry & Docker Maintenance..."
@@ -15,26 +15,31 @@ for repo_dir in "$REGISTRY_DATA/repositories/mintel/"*; do
if [ -d "$tags_dir" ]; then
echo "🔍 Processing repository: mintel/$repo_name"
# Prune various tag patterns
PATTERNS=("main-*" "testing-*" "branch-*" "v*" "rc*" "[0-9a-f]*")
for pattern in "${PATTERNS[@]}"; do
echo " 📦 Pruning $pattern tags..."
tags=$(ls -dt "$tags_dir"/${pattern} 2>/dev/null || true)
count=0
for tag_path in $tags; do
tag_name=$(basename "$tag_path")
if [[ "$tag_name" == "latest" ]]; then continue; fi
((++count))
if [ $count -gt $KEEP_TAGS ]; then
echo " 🗑️ Deleting old tag: $tag_name"
rm -rf "$tag_path"
fi
done
# Prune main-* tags
echo " 📦 Pruning main tags..."
main_tags=$(ls -dt "$tags_dir"/main-* 2>/dev/null || true)
count=0
for tag_path in $main_tags; do
((++count))
if [ $count -gt $KEEP_TAGS ]; then
echo " 🗑️ Deleting old main tag: $(basename "$tag_path")"
rm -rf "$tag_path"
fi
done
# Always prune buildcache
# Prune version tags (v* and rc*)
echo " 🏷️ Pruning version tags..."
version_tags=$(ls -dt "$tags_dir"/v1* 2>/dev/null || true)
count=0
for tag_path in $version_tags; do
((++count))
if [ $count -gt $KEEP_TAGS ]; then
echo " 🗑️ Deleting old version tag: $(basename "$tag_path")"
rm -rf "$tag_path"
fi
done
# Always prune buildcache (as it rebuilds quickly)
if [ -d "$tags_dir/buildcache" ]; then
echo " 🧹 Deleting buildcache tag"
rm -rf "$tags_dir/buildcache"
@@ -43,15 +48,8 @@ for repo_dir in "$REGISTRY_DATA/repositories/mintel/"*; do
done
# 2. Run Garbage Collection
echo "♻️ Detecting Registry Container..."
REGISTRY_CONTAINER=$(docker ps --format "{{.Names}}" | grep registry | head -1 || true)
if [ -n "$REGISTRY_CONTAINER" ]; then
echo "♻️ Running Registry Garbage Collection on $REGISTRY_CONTAINER..."
docker exec "$REGISTRY_CONTAINER" bin/registry garbage-collect /etc/docker/registry/config.yml --delete-untagged
else
echo "⚠️ Registry container not found. Skipping GC."
fi
echo "♻️ Running Registry Garbage Collection..."
docker exec registry-registry-1 bin/registry garbage-collect /etc/docker/registry/config.yml
# 3. Prune Host Docker resources (Shorter window: 24h)
echo "🧹 Pruning Host Docker resources..."

View File

@@ -1,93 +0,0 @@
#!/bin/bash
set -e
# wait-for-upstream.sh
# Usage: ./wait-for-upstream.sh <org/repo> <version_tag> [poll_interval_sec]
REPO=$1
TAG=$2
INTERVAL=${3:-30}
MAX_RETRIES=40 # ~20 minutes default
if [[ -z "$REPO" || -z "$TAG" ]]; then
echo "❌ Error: REPO and TAG are required."
echo "Usage: $0 <org/repo> <version_tag>"
exit 1
fi
if [[ -z "$GITEA_TOKEN" ]]; then
echo "❌ Error: GITEA_TOKEN is not set."
exit 1
fi
GITEA_API="https://git.infra.mintel.me/api/v1"
echo "🔎 Searching for upstream release $TAG in $REPO..."
# 1. Get the SHA of the tag to be more precise
TAG_INFO=$(curl -s -H "Authorization: token $GITEA_TOKEN" "$GITEA_API/repos/$REPO/tags/$TAG")
TARGET_SHA=$(echo "$TAG_INFO" | jq -r '.commit.sha // empty')
if [[ -z "$TARGET_SHA" || "$TARGET_SHA" == "null" ]]; then
echo "⚠️ Warning: Tag $TAG not found yet. Upstream might be lagging."
echo " Waiting 15s for tag to appear..."
sleep 15
TAG_INFO=$(curl -s -H "Authorization: token $GITEA_TOKEN" "$GITEA_API/repos/$REPO/tags/$TAG")
TARGET_SHA=$(echo "$TAG_INFO" | jq -r '.commit.sha // empty')
if [[ -z "$TARGET_SHA" || "$TARGET_SHA" == "null" ]]; then
echo "❌ Error: Tag $TAG does not exist in $REPO."
exit 1
fi
fi
echo "✅ Target SHA for $TAG is $TARGET_SHA"
# 2. Find the run for the specific SHA
# We list recent runs and filter by head_sha
RUN_QUERY=$(curl -s -H "Authorization: token $GITEA_TOKEN" "$GITEA_API/repos/$REPO/actions/runs?limit=30")
RUN_ID=$(echo "$RUN_QUERY" | jq -r ".workflow_runs[] | select(.head_sha == \"$TARGET_SHA\") | .id" | head -n 1)
if [[ -z "$RUN_ID" || "$RUN_ID" == "null" ]]; then
echo " No recent action run found for SHA $TARGET_SHA yet."
echo " Checking if we should wait or if it was already successful..."
# Fallback: wait a bit more for new tags
echo "⏳ waiting for run to appear..."
sleep 20
RUN_QUERY=$(curl -s -H "Authorization: token $GITEA_TOKEN" "$GITEA_API/repos/$REPO/actions/runs?limit=30")
RUN_ID=$(echo "$RUN_QUERY" | jq -r ".workflow_runs[] | select(.head_sha == \"$TARGET_SHA\") | .id" | head -n 1)
if [[ -z "$RUN_ID" || "$RUN_ID" == "null" ]]; then
echo "✅ No run found but Tag exists. Assuming manual release or already completed. Proceeding."
exit 0
fi
fi
echo "⏳ Waiting for upstream run $RUN_ID status..."
RETRY_COUNT=0
while [ $RETRY_COUNT -lt $MAX_RETRIES ]; do
STATUS_QUERY=$(curl -s -H "Authorization: token $GITEA_TOKEN" "$GITEA_API/repos/$REPO/actions/runs/$RUN_ID")
STATUS=$(echo "$STATUS_QUERY" | jq -r '.status')
CONCLUSION=$(echo "$STATUS_QUERY" | jq -r '.conclusion')
echo " - Current Status: $STATUS (Conclusion: $CONCLUSION)"
if [[ "$STATUS" == "success" || "$CONCLUSION" == "success" ]]; then
echo "✅ Upstream release $TAG is READY."
exit 0
fi
if [[ "$STATUS" == "failure" || "$CONCLUSION" == "failure" || "$CONCLUSION" == "cancelled" ]]; then
echo "❌ Error: Upstream release $TAG FAILED or was CANCELLED."
exit 1
fi
echo " - Still working... waiting $INTERVAL seconds (Attempt $((RETRY_COUNT+1))/$MAX_RETRIES)"
sleep $INTERVAL
RETRY_COUNT=$((RETRY_COUNT+1))
done
echo "❌ Error: Timeout waiting for upstream release $TAG."
exit 1

View File

@@ -1,6 +1,6 @@
{
"name": "@mintel/mail",
"version": "1.8.2",
"version": "1.7.0",
"private": false,
"publishConfig": {
"access": "public",
@@ -24,7 +24,8 @@
"build": "tsup src/index.ts src/templates/*.tsx --format esm --dts --clean",
"dev": "tsup src/index.ts src/templates/*.tsx --format esm --watch --dts",
"lint": "eslint src",
"test": "vitest run"
"test": "vitest run",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@react-email/components": "^0.0.33"
@@ -43,4 +44,4 @@
"typescript": "^5.0.0",
"vitest": "^3.0.4"
}
}
}

View File

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

View File

@@ -1,6 +1,6 @@
{
"name": "@mintel/next-config",
"version": "1.8.2",
"version": "1.7.0",
"publishConfig": {
"access": "public",
"registry": "https://npm.infra.mintel.me"

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